mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
Merge branch 'feat/playground-search-foundation-F1' into chore/playground-search-audit-F10
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
// src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
|
||||
interface MarkdownMessageProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MarkdownMessage — renders markdown safely in the Playground chat.
|
||||
*
|
||||
* Security notes:
|
||||
* - react-markdown does NOT render raw HTML by default, so <script> and other
|
||||
* dangerous tags appear as literal text — no XSS possible via markdown content.
|
||||
* - Code blocks are rendered as <pre><code> (no syntax highlighter — react-syntax-highlighter
|
||||
* is not installed; install it if needed in a future iteration).
|
||||
* - remark-gfm enables tables, strikethrough, task lists (GFM extensions).
|
||||
*/
|
||||
export default function MarkdownMessage({ content, className }: MarkdownMessageProps) {
|
||||
const components: Components = {
|
||||
// Code blocks and inline code — rendered as <pre><code> without syntax highlighting
|
||||
code({ className: codeClassName, children, ...props }) {
|
||||
// Extract language from className (e.g., "language-js" → "js")
|
||||
const match = /language-(\w+)/.exec(codeClassName ?? "");
|
||||
const language = match ? match[1] : undefined;
|
||||
const isBlock = codeClassName != null;
|
||||
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre
|
||||
className="overflow-x-auto rounded bg-neutral-900 p-3 text-sm text-neutral-100 my-2"
|
||||
data-language={language}
|
||||
>
|
||||
<code className={codeClassName ?? ""} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline code
|
||||
return (
|
||||
<code
|
||||
className="rounded bg-neutral-200 dark:bg-neutral-800 px-1 py-0.5 text-sm font-mono text-neutral-800 dark:text-neutral-200"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
|
||||
// Tables (GFM)
|
||||
table({ children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-2">
|
||||
<table className="min-w-full border-collapse text-sm">{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }) {
|
||||
return <thead className="bg-neutral-100 dark:bg-neutral-800">{children}</thead>;
|
||||
},
|
||||
th({ children }) {
|
||||
return (
|
||||
<th className="border border-neutral-300 dark:border-neutral-600 px-3 py-1.5 text-left font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children }) {
|
||||
return (
|
||||
<td className="border border-neutral-300 dark:border-neutral-600 px-3 py-1.5">
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
},
|
||||
|
||||
// Links — open in new tab with rel noopener for security
|
||||
a({ href, children }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 underline hover:no-underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// Lists
|
||||
ul({ children }) {
|
||||
return <ul className="list-disc list-inside my-1 space-y-0.5">{children}</ul>;
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ol className="list-decimal list-inside my-1 space-y-0.5">{children}</ol>;
|
||||
},
|
||||
li({ children }) {
|
||||
return <li className="leading-relaxed">{children}</li>;
|
||||
},
|
||||
|
||||
// Paragraphs
|
||||
p({ children }) {
|
||||
return <p className="my-1 leading-relaxed">{children}</p>;
|
||||
},
|
||||
|
||||
// Headings
|
||||
h1({ children }) {
|
||||
return <h1 className="text-2xl font-bold my-2">{children}</h1>;
|
||||
},
|
||||
h2({ children }) {
|
||||
return <h2 className="text-xl font-bold my-2">{children}</h2>;
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="text-lg font-semibold my-1.5">{children}</h3>;
|
||||
},
|
||||
h4({ children }) {
|
||||
return <h4 className="text-base font-semibold my-1">{children}</h4>;
|
||||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote({ children }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-neutral-400 pl-3 italic text-neutral-600 dark:text-neutral-400 my-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
|
||||
// Horizontal rule
|
||||
hr() {
|
||||
return <hr className="my-3 border-neutral-300 dark:border-neutral-600" />;
|
||||
},
|
||||
|
||||
// Strong / emphasis
|
||||
strong({ children }) {
|
||||
return <strong className="font-semibold">{children}</strong>;
|
||||
},
|
||||
em({ children }) {
|
||||
return <em className="italic">{children}</em>;
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
364
src/lib/playground/codeExport.ts
Normal file
364
src/lib/playground/codeExport.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
// src/lib/playground/codeExport.ts
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Endpoint suportado pelo Playground Studio. Reflete os 10 endpoints da aba API
|
||||
* + os endpoints consumidos pelas tabs Chat/Compare/Build/Search/Scrape.
|
||||
*/
|
||||
export type PlaygroundEndpoint =
|
||||
| "chat.completions"
|
||||
| "completions"
|
||||
| "embeddings"
|
||||
| "images"
|
||||
| "audio.transcriptions"
|
||||
| "audio.speech"
|
||||
| "moderations"
|
||||
| "rerank"
|
||||
| "search"
|
||||
| "web.fetch";
|
||||
|
||||
/** Linguagens de export suportadas. */
|
||||
export type ExportLanguage = "curl" | "python" | "typescript";
|
||||
|
||||
/** Mensagem chat single-turn ou multi-turn. */
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string | Array<{ type: string; [k: string]: unknown }>;
|
||||
name?: string;
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
/** Tool definition no formato OpenAI Function Calling. */
|
||||
export interface ToolDefinition {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/** Estado completo capturável pelo Playground (subset de campos por endpoint). */
|
||||
export interface PlaygroundState {
|
||||
endpoint: PlaygroundEndpoint;
|
||||
baseUrl: string; // ex.: "http://localhost:20128"
|
||||
model?: string; // não aplicável a web.fetch
|
||||
systemPrompt?: string;
|
||||
messages?: ChatMessage[]; // chat/completions
|
||||
prompt?: string; // completions/embeddings
|
||||
query?: string; // search/rerank
|
||||
url?: string; // web.fetch
|
||||
params?: Partial<{
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
top_p: number;
|
||||
presence_penalty: number;
|
||||
frequency_penalty: number;
|
||||
seed: number;
|
||||
stop: string | string[];
|
||||
response_format: { type: "text" | "json_object" | "json_schema"; json_schema?: unknown };
|
||||
}>;
|
||||
tools?: ToolDefinition[];
|
||||
stream?: boolean;
|
||||
// Search-specific
|
||||
searchProvider?: string;
|
||||
searchType?: "web" | "news";
|
||||
maxResults?: number;
|
||||
// Scrape-specific
|
||||
fetchProvider?: "firecrawl" | "jina-reader" | "tavily-search";
|
||||
fetchFormat?: "markdown" | "html" | "links" | "screenshot";
|
||||
fetchDepth?: 0 | 1 | 2;
|
||||
// Rerank-specific
|
||||
rerankModel?: string;
|
||||
documents?: string[];
|
||||
}
|
||||
|
||||
export const PlaygroundStateSchema = z.object({
|
||||
endpoint: z.enum([
|
||||
"chat.completions",
|
||||
"completions",
|
||||
"embeddings",
|
||||
"images",
|
||||
"audio.transcriptions",
|
||||
"audio.speech",
|
||||
"moderations",
|
||||
"rerank",
|
||||
"search",
|
||||
"web.fetch",
|
||||
]),
|
||||
baseUrl: z.string().min(1),
|
||||
model: z.string().optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
messages: z.array(z.any()).optional(),
|
||||
prompt: z.string().optional(),
|
||||
query: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
params: z.record(z.string(), z.any()).optional(),
|
||||
tools: z.array(z.any()).optional(),
|
||||
stream: z.boolean().optional(),
|
||||
searchProvider: z.string().optional(),
|
||||
searchType: z.enum(["web", "news"]).optional(),
|
||||
maxResults: z.number().int().optional(),
|
||||
fetchProvider: z.enum(["firecrawl", "jina-reader", "tavily-search"]).optional(),
|
||||
fetchFormat: z.enum(["markdown", "html", "links", "screenshot"]).optional(),
|
||||
fetchDepth: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(),
|
||||
rerankModel: z.string().optional(),
|
||||
documents: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/** Constante: placeholder de API key — NUNCA embutir key real. */
|
||||
export const API_KEY_PLACEHOLDER = "$OMNIROUTE_API_KEY";
|
||||
|
||||
/**
|
||||
* Resolve o path HTTP a partir do endpoint (ex.: "chat.completions" → "/v1/chat/completions").
|
||||
* Exportado para reuso em testes.
|
||||
*/
|
||||
export function endpointToPath(endpoint: PlaygroundEndpoint): string {
|
||||
const map: Record<PlaygroundEndpoint, string> = {
|
||||
"chat.completions": "/v1/chat/completions",
|
||||
completions: "/v1/completions",
|
||||
embeddings: "/v1/embeddings",
|
||||
images: "/v1/images/generations",
|
||||
"audio.transcriptions": "/v1/audio/transcriptions",
|
||||
"audio.speech": "/v1/audio/speech",
|
||||
moderations: "/v1/moderations",
|
||||
rerank: "/v1/rerank",
|
||||
search: "/v1/search",
|
||||
"web.fetch": "/v1/web/fetch",
|
||||
};
|
||||
return map[endpoint];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request body for a given endpoint+state.
|
||||
* Internal helper — returns plain object suitable for JSON.stringify.
|
||||
*/
|
||||
function buildBody(state: PlaygroundState): Record<string, unknown> {
|
||||
const { endpoint, model, params, tools, stream } = state;
|
||||
|
||||
switch (endpoint) {
|
||||
case "chat.completions": {
|
||||
let messages: ChatMessage[];
|
||||
if (state.messages && state.messages.length > 0) {
|
||||
messages = state.messages;
|
||||
} else if (state.systemPrompt) {
|
||||
messages = [
|
||||
{ role: "system", content: state.systemPrompt },
|
||||
{ role: "user", content: state.prompt ?? "Hello!" },
|
||||
];
|
||||
} else {
|
||||
messages = [{ role: "user", content: state.prompt ?? "Hello!" }];
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
model: model ?? "gpt-4o-mini",
|
||||
messages,
|
||||
stream: stream ?? false,
|
||||
};
|
||||
if (params) Object.assign(body, params);
|
||||
if (tools && tools.length > 0) body.tools = tools;
|
||||
return body;
|
||||
}
|
||||
|
||||
case "completions": {
|
||||
const body: Record<string, unknown> = {
|
||||
model: model ?? "gpt-3.5-turbo-instruct",
|
||||
prompt: state.prompt ?? "Hello,",
|
||||
stream: stream ?? false,
|
||||
};
|
||||
if (params) Object.assign(body, params);
|
||||
return body;
|
||||
}
|
||||
|
||||
case "embeddings": {
|
||||
return {
|
||||
model: model ?? "text-embedding-3-small",
|
||||
input: state.prompt ?? "Hello world",
|
||||
};
|
||||
}
|
||||
|
||||
case "images": {
|
||||
return {
|
||||
model: model ?? "dall-e-3",
|
||||
prompt: state.prompt ?? "A beautiful sunset",
|
||||
n: 1,
|
||||
size: "1024x1024",
|
||||
};
|
||||
}
|
||||
|
||||
case "audio.transcriptions": {
|
||||
// Note: actual usage needs multipart/form-data; shown as JSON for documentation
|
||||
return {
|
||||
model: model ?? "whisper-1",
|
||||
file: "<audio-file-binary>",
|
||||
language: "en",
|
||||
};
|
||||
}
|
||||
|
||||
case "audio.speech": {
|
||||
return {
|
||||
model: model ?? "tts-1",
|
||||
input: state.prompt ?? "Hello, world!",
|
||||
voice: "alloy",
|
||||
};
|
||||
}
|
||||
|
||||
case "moderations": {
|
||||
return {
|
||||
model: model ?? "text-moderation-latest",
|
||||
input: state.prompt ?? "Hello world",
|
||||
};
|
||||
}
|
||||
|
||||
case "rerank": {
|
||||
return {
|
||||
model: state.rerankModel ?? model ?? "rerank-english-v3.0",
|
||||
query: state.query ?? "search query",
|
||||
documents: state.documents ?? ["Document 1 text", "Document 2 text"],
|
||||
top_n: 3,
|
||||
};
|
||||
}
|
||||
|
||||
case "search": {
|
||||
const body: Record<string, unknown> = {
|
||||
query: state.query ?? "search query",
|
||||
};
|
||||
if (model) body.model = model;
|
||||
if (state.searchProvider) body.provider = state.searchProvider;
|
||||
if (state.searchType) body.search_type = state.searchType;
|
||||
if (state.maxResults) body.max_results = state.maxResults;
|
||||
return body;
|
||||
}
|
||||
|
||||
case "web.fetch": {
|
||||
const body: Record<string, unknown> = {
|
||||
url: state.url ?? "https://example.com",
|
||||
};
|
||||
if (state.fetchProvider) body.provider = state.fetchProvider;
|
||||
if (state.fetchFormat) body.format = state.fetchFormat;
|
||||
if (state.fetchDepth != null) body.depth = state.fetchDepth;
|
||||
return body;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe embedding inside single-quoted shell literals.
|
||||
*/
|
||||
function escSingleQuote(s: string): string {
|
||||
return s.replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate curl snippet for a given endpoint.
|
||||
*/
|
||||
function buildCurlSnippet(state: PlaygroundState): string {
|
||||
const path = endpointToPath(state.endpoint);
|
||||
const url = `${state.baseUrl}${path}`;
|
||||
const body = buildBody(state);
|
||||
const bodyJson = JSON.stringify(body, null, 2);
|
||||
|
||||
const lines: string[] = [
|
||||
`# Set your API key: export OMNIROUTE_API_KEY="your-key-here"`,
|
||||
`curl -s -X POST \\`,
|
||||
` "${url}" \\`,
|
||||
` -H "Authorization: Bearer ${API_KEY_PLACEHOLDER}" \\`,
|
||||
` -H "Content-Type: application/json" \\`,
|
||||
` -d '${escSingleQuote(JSON.stringify(body))}'`,
|
||||
];
|
||||
|
||||
// Also show pretty body as a comment for readability
|
||||
const prettyLines = bodyJson.split("\n");
|
||||
const commentBlock = prettyLines.map((l) => `# ${l}`).join("\n");
|
||||
|
||||
return `${lines.join("\n")}\n\n# Request body (pretty-printed for reference):\n${commentBlock}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Python (requests) snippet for a given endpoint.
|
||||
*/
|
||||
function buildPythonSnippet(state: PlaygroundState): string {
|
||||
const path = endpointToPath(state.endpoint);
|
||||
const url = `${state.baseUrl}${path}`;
|
||||
const body = buildBody(state);
|
||||
const bodyJson = JSON.stringify(body, null, 2);
|
||||
|
||||
const lines: string[] = [
|
||||
`# Set your API key: export ${API_KEY_PLACEHOLDER}="your-key-here"`,
|
||||
`import os`,
|
||||
`import json`,
|
||||
`import requests`,
|
||||
``,
|
||||
`api_key = os.environ["OMNIROUTE_API_KEY"]`,
|
||||
``,
|
||||
`url = "${url}"`,
|
||||
`headers = {`,
|
||||
` "Authorization": f"Bearer {api_key}",`,
|
||||
` "Content-Type": "application/json",`,
|
||||
`}`,
|
||||
``,
|
||||
`data = json.loads("""`,
|
||||
bodyJson,
|
||||
`""")`,
|
||||
``,
|
||||
`response = requests.post(url, headers=headers, json=data)`,
|
||||
`print(response.json())`,
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate TypeScript (fetch) snippet for a given endpoint.
|
||||
*/
|
||||
function buildTypescriptSnippet(state: PlaygroundState): string {
|
||||
const path = endpointToPath(state.endpoint);
|
||||
const url = `${state.baseUrl}${path}`;
|
||||
const body = buildBody(state);
|
||||
const bodyJson = JSON.stringify(body, null, 2);
|
||||
|
||||
const lines: string[] = [
|
||||
`// Set your API key: export ${API_KEY_PLACEHOLDER}="your-key-here"`,
|
||||
`const apiKey = process.env.OMNIROUTE_API_KEY ?? "";`,
|
||||
``,
|
||||
`const url = "${url}";`,
|
||||
`const body = ${bodyJson};`,
|
||||
``,
|
||||
`const response = await fetch(url, {`,
|
||||
` method: "POST",`,
|
||||
` headers: {`,
|
||||
` "Authorization": \`Bearer \${apiKey}\`,`,
|
||||
` "Content-Type": "application/json",`,
|
||||
` },`,
|
||||
` body: JSON.stringify(body),`,
|
||||
`});`,
|
||||
``,
|
||||
`const data = await response.json();`,
|
||||
`console.log(data);`,
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera código para uma linguagem específica a partir do estado atual.
|
||||
* Sempre usa `API_KEY_PLACEHOLDER` para a API key (D11).
|
||||
*/
|
||||
export function exportCode(state: PlaygroundState, language: ExportLanguage): string {
|
||||
switch (language) {
|
||||
case "curl":
|
||||
return buildCurlSnippet(state);
|
||||
case "python":
|
||||
return buildPythonSnippet(state);
|
||||
case "typescript":
|
||||
return buildTypescriptSnippet(state);
|
||||
}
|
||||
}
|
||||
|
||||
/** Gera os 3 snippets de uma vez (atalho para o ExportCodeModal de UI). */
|
||||
export function exportAllLanguages(state: PlaygroundState): Record<ExportLanguage, string> {
|
||||
return {
|
||||
curl: exportCode(state, "curl"),
|
||||
python: exportCode(state, "python"),
|
||||
typescript: exportCode(state, "typescript"),
|
||||
};
|
||||
}
|
||||
147
src/lib/playground/promptImprover.ts
Normal file
147
src/lib/playground/promptImprover.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// src/lib/playground/promptImprover.ts
|
||||
import { z } from "zod";
|
||||
|
||||
export const ImprovePromptRequestSchema = z
|
||||
.object({
|
||||
/** Conteúdo atual a melhorar; pelo menos um dos dois deve ser não-vazio. */
|
||||
system: z.string().max(50_000).optional(),
|
||||
prompt: z.string().max(50_000).optional(),
|
||||
/** Modelo configurado no painel Config — escolha do usuário (D8). */
|
||||
model: z.string().min(1),
|
||||
/** Tom: "concise" (padrão) ou "detailed" (mais verboso). */
|
||||
tone: z.enum(["concise", "detailed"]).default("concise"),
|
||||
})
|
||||
.refine((d) => Boolean(d.system?.trim() || d.prompt?.trim()), {
|
||||
message: "At least one of `system` or `prompt` must be non-empty",
|
||||
path: ["system"],
|
||||
});
|
||||
|
||||
export type ImprovePromptRequest = z.infer<typeof ImprovePromptRequestSchema>;
|
||||
|
||||
export interface ImprovePromptResult {
|
||||
/** Versões melhoradas (apenas as fornecidas no request retornam). */
|
||||
improvedSystem?: string;
|
||||
improvedPrompt?: string;
|
||||
/** Tokens consumidos no call de melhoria (do usage). */
|
||||
tokensIn: number;
|
||||
tokensOut: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta-prompt fixado (D8). NÃO alterar sem aprovação do orquestrador.
|
||||
* Inspirado em Anthropic Console Prompt Improver.
|
||||
*/
|
||||
export const META_SYSTEM_PROMPT = `\
|
||||
You are an expert prompt engineer. Rewrite the user-supplied prompts to be:
|
||||
- Clear, specific, and unambiguous
|
||||
- Free of redundancy and filler
|
||||
- Structured (numbered steps, bullets) only when it materially helps the model
|
||||
- Preserving the user's original intent — never invent new constraints
|
||||
|
||||
Return ONLY the rewritten text. No explanations, no markdown wrappers.
|
||||
If you receive both a system message and a user prompt, output them on separate
|
||||
lines prefixed with "<<SYSTEM>>" and "<<PROMPT>>" respectively.`;
|
||||
|
||||
/**
|
||||
* Monta o body de /v1/chat/completions para o improve call.
|
||||
* Exportado para reuso em testes + na rota.
|
||||
*/
|
||||
export function buildImproveChatBody(req: ImprovePromptRequest): {
|
||||
model: string;
|
||||
messages: Array<{ role: "system" | "user"; content: string }>;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
stream: false;
|
||||
} {
|
||||
const userContent: string[] = [];
|
||||
if (req.system?.trim()) userContent.push(`<<SYSTEM>>\n${req.system}`);
|
||||
if (req.prompt?.trim()) userContent.push(`<<PROMPT>>\n${req.prompt}`);
|
||||
const tonePrefix =
|
||||
req.tone === "detailed"
|
||||
? "Be detailed and explicit in the rewrite.\n\n"
|
||||
: "Be concise and direct.\n\n";
|
||||
|
||||
return {
|
||||
model: req.model,
|
||||
messages: [
|
||||
{ role: "system", content: META_SYSTEM_PROMPT },
|
||||
{ role: "user", content: tonePrefix + userContent.join("\n\n") },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2048,
|
||||
stream: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse da resposta do meta-LLM em improvedSystem/improvedPrompt.
|
||||
* Retorna ambos quando ambos foram enviados; senão retorna só o presente.
|
||||
*
|
||||
* Edge cases:
|
||||
* - Se raw não contém marcadores <<SYSTEM>> ou <<PROMPT>>:
|
||||
* - Se hadSystem e !hadPrompt → trata todo o conteúdo como improvedSystem
|
||||
* - Se !hadSystem e hadPrompt → trata todo o conteúdo como improvedPrompt
|
||||
* - Se hadSystem e hadPrompt → trata todo o conteúdo como improvedPrompt (fallback)
|
||||
* - Whitespace é trimado antes de retornar
|
||||
*/
|
||||
export function parseImprovedContent(
|
||||
raw: string,
|
||||
hadSystem: boolean,
|
||||
hadPrompt: boolean,
|
||||
): { improvedSystem?: string; improvedPrompt?: string } {
|
||||
const result: { improvedSystem?: string; improvedPrompt?: string } = {};
|
||||
|
||||
const systemMarker = "<<SYSTEM>>";
|
||||
const promptMarker = "<<PROMPT>>";
|
||||
|
||||
const hasSystemMarker = raw.includes(systemMarker);
|
||||
const hasPromptMarker = raw.includes(promptMarker);
|
||||
|
||||
if (hasSystemMarker || hasPromptMarker) {
|
||||
// Parse by markers
|
||||
if (hasSystemMarker && hasPromptMarker) {
|
||||
const sysStart = raw.indexOf(systemMarker) + systemMarker.length;
|
||||
const promptStart = raw.indexOf(promptMarker);
|
||||
|
||||
let sysContent: string;
|
||||
if (sysStart < promptStart) {
|
||||
sysContent = raw.substring(sysStart, promptStart).trim();
|
||||
} else {
|
||||
sysContent = raw.substring(sysStart).trim();
|
||||
}
|
||||
|
||||
const promptContentStart = promptStart + promptMarker.length;
|
||||
let promptContent: string;
|
||||
if (hasSystemMarker && raw.indexOf(systemMarker) > promptStart) {
|
||||
promptContent = raw.substring(promptContentStart, raw.indexOf(systemMarker)).trim();
|
||||
} else {
|
||||
promptContent = raw.substring(promptContentStart).trim();
|
||||
}
|
||||
|
||||
if (hadSystem && sysContent) result.improvedSystem = sysContent;
|
||||
if (hadPrompt && promptContent) result.improvedPrompt = promptContent;
|
||||
} else if (hasSystemMarker) {
|
||||
const sysStart = raw.indexOf(systemMarker) + systemMarker.length;
|
||||
const sysContent = raw.substring(sysStart).trim();
|
||||
if (hadSystem && sysContent) result.improvedSystem = sysContent;
|
||||
} else {
|
||||
// Only <<PROMPT>> marker
|
||||
const promptStart = raw.indexOf(promptMarker) + promptMarker.length;
|
||||
const promptContent = raw.substring(promptStart).trim();
|
||||
if (hadPrompt && promptContent) result.improvedPrompt = promptContent;
|
||||
}
|
||||
} else {
|
||||
// No markers — assign to whichever field was provided
|
||||
const trimmed = raw.trim();
|
||||
if (hadSystem && !hadPrompt) {
|
||||
if (trimmed) result.improvedSystem = trimmed;
|
||||
} else if (!hadSystem && hadPrompt) {
|
||||
if (trimmed) result.improvedPrompt = trimmed;
|
||||
} else if (hadSystem && hadPrompt) {
|
||||
// Fallback: assign to prompt
|
||||
if (trimmed) result.improvedPrompt = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
114
src/lib/playground/types.ts
Normal file
114
src/lib/playground/types.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// src/lib/playground/types.ts
|
||||
// Re-exports from codeExport + standalone pricing table
|
||||
|
||||
export type {
|
||||
PlaygroundEndpoint,
|
||||
ExportLanguage,
|
||||
ChatMessage,
|
||||
ToolDefinition,
|
||||
PlaygroundState,
|
||||
} from "./codeExport";
|
||||
|
||||
export { PlaygroundStateSchema, API_KEY_PLACEHOLDER, exportCode, exportAllLanguages, endpointToPath } from "./codeExport";
|
||||
|
||||
export type { ImprovePromptRequest, ImprovePromptResult } from "./promptImprover";
|
||||
|
||||
export {
|
||||
ImprovePromptRequestSchema,
|
||||
META_SYSTEM_PROMPT,
|
||||
buildImproveChatBody,
|
||||
parseImprovedContent,
|
||||
} from "./promptImprover";
|
||||
|
||||
/**
|
||||
* Pricing entry for a model.
|
||||
* Label "(estimated)" indicates these are approximate values for UI display (D13).
|
||||
*/
|
||||
export interface ProviderPricing {
|
||||
/** Cost per 1k input tokens in USD. */
|
||||
inUsdPer1k: number;
|
||||
/** Cost per 1k output tokens in USD. */
|
||||
outUsdPer1k: number;
|
||||
/** Display label — always includes "(estimated)". */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static inline pricing table for popular models.
|
||||
* Used in client-side cost estimation (D13).
|
||||
* DO NOT use for billing — always labeled "(estimated)".
|
||||
*
|
||||
* Prices are approximate as of 2025. For real billing see /api/usage.
|
||||
*/
|
||||
export const MODEL_PRICING_TABLE: Record<string, ProviderPricing> = {
|
||||
"gpt-4o": {
|
||||
inUsdPer1k: 0.0025,
|
||||
outUsdPer1k: 0.01,
|
||||
label: "(estimated)",
|
||||
},
|
||||
"gpt-4o-mini": {
|
||||
inUsdPer1k: 0.00015,
|
||||
outUsdPer1k: 0.0006,
|
||||
label: "(estimated)",
|
||||
},
|
||||
"claude-sonnet-4-6": {
|
||||
inUsdPer1k: 0.003,
|
||||
outUsdPer1k: 0.015,
|
||||
label: "(estimated)",
|
||||
},
|
||||
"claude-opus-4-7": {
|
||||
inUsdPer1k: 0.015,
|
||||
outUsdPer1k: 0.075,
|
||||
label: "(estimated)",
|
||||
},
|
||||
"claude-haiku-4-5": {
|
||||
inUsdPer1k: 0.0008,
|
||||
outUsdPer1k: 0.004,
|
||||
label: "(estimated)",
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
inUsdPer1k: 0.00125,
|
||||
outUsdPer1k: 0.01,
|
||||
label: "(estimated)",
|
||||
},
|
||||
"gemini-2.0-flash": {
|
||||
inUsdPer1k: 0.0001,
|
||||
outUsdPer1k: 0.0004,
|
||||
label: "(estimated)",
|
||||
},
|
||||
"deepseek-chat": {
|
||||
inUsdPer1k: 0.00014,
|
||||
outUsdPer1k: 0.00028,
|
||||
label: "(estimated)",
|
||||
},
|
||||
"llama-3.3-70b": {
|
||||
inUsdPer1k: 0.00059,
|
||||
outUsdPer1k: 0.00079,
|
||||
label: "(estimated)",
|
||||
},
|
||||
"mistral-large": {
|
||||
inUsdPer1k: 0.002,
|
||||
outUsdPer1k: 0.006,
|
||||
label: "(estimated)",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Look up pricing for a model by name.
|
||||
* Returns null if not found (use upstream usage data instead).
|
||||
*/
|
||||
export function getModelPricing(model: string): ProviderPricing | null {
|
||||
return MODEL_PRICING_TABLE[model] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up pricing for a model — alias with explicit `estimated: true` in return type.
|
||||
* Required by §3 contract: `getProviderPricing(model): { inUsdPer1k, outUsdPer1k, estimated: true } | null`.
|
||||
*/
|
||||
export function getProviderPricing(
|
||||
model: string,
|
||||
): { inUsdPer1k: number; outUsdPer1k: number; estimated: true } | null {
|
||||
const entry = MODEL_PRICING_TABLE[model];
|
||||
if (!entry) return null;
|
||||
return { inUsdPer1k: entry.inUsdPer1k, outUsdPer1k: entry.outUsdPer1k, estimated: true };
|
||||
}
|
||||
68
src/shared/schemas/playground.ts
Normal file
68
src/shared/schemas/playground.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// src/shared/schemas/playground.ts
|
||||
import { z } from "zod";
|
||||
|
||||
/** Row da tabela playground_presets. */
|
||||
export const PlaygroundPresetRowSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1).max(100),
|
||||
endpoint: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
system: z.string().max(50_000).nullable(),
|
||||
params_json: z.string(), // JSON serializado (parsed na rota)
|
||||
created_at: z.string().datetime(),
|
||||
});
|
||||
export type PlaygroundPresetRow = z.infer<typeof PlaygroundPresetRowSchema>;
|
||||
|
||||
/** Body de POST /api/playground/presets. */
|
||||
export const PlaygroundPresetCreateSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
endpoint: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
system: z.string().max(50_000).nullable().optional(),
|
||||
params: z.record(z.string(), z.any()).default({}),
|
||||
});
|
||||
|
||||
/** Body de PUT /api/playground/presets/[id]. */
|
||||
export const PlaygroundPresetUpdateSchema = PlaygroundPresetCreateSchema.partial();
|
||||
|
||||
export const PlaygroundPresetListItemSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
endpoint: z.string(),
|
||||
model: z.string(),
|
||||
system: z.string().nullable(),
|
||||
params: z.record(z.string(), z.any()),
|
||||
created_at: z.string().datetime(),
|
||||
});
|
||||
export type PlaygroundPresetListItem = z.infer<typeof PlaygroundPresetListItemSchema>;
|
||||
|
||||
/** Tool definition aceito pelo Build tab. Validação client-side. */
|
||||
export const ToolDefinitionSchema = z.object({
|
||||
type: z.literal("function"),
|
||||
function: z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
description: z.string().max(1024).optional(),
|
||||
parameters: z.record(z.string(), z.any()),
|
||||
}),
|
||||
});
|
||||
|
||||
/** JSON schema do response_format (Structured Output). Validação client-side. */
|
||||
export const StructuredOutputSchema = z.object({
|
||||
type: z.literal("json_schema"),
|
||||
json_schema: z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
schema: z.record(z.string(), z.any()),
|
||||
strict: z.boolean().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Stream metrics — uma medição por coluna/mensagem. */
|
||||
export const StreamMetricsSchema = z.object({
|
||||
ttftMs: z.number().nonnegative().nullable(), // null = não chegou primeiro chunk ainda
|
||||
totalMs: z.number().nonnegative().nullable(),
|
||||
tokensOut: z.number().int().nonnegative(),
|
||||
tokensIn: z.number().int().nonnegative(),
|
||||
tps: z.number().nonnegative().nullable(), // tokens/segundo (out/totalSec)
|
||||
costUsd: z.number().nonnegative().nullable(),
|
||||
});
|
||||
export type StreamMetrics = z.infer<typeof StreamMetricsSchema>;
|
||||
38
src/shared/schemas/searchTools.ts
Normal file
38
src/shared/schemas/searchTools.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// src/shared/schemas/searchTools.ts
|
||||
import { z } from "zod";
|
||||
|
||||
/** Item exposto pelo /api/search/providers (estendido em F4). */
|
||||
export const SearchProviderCatalogItemSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
/** "search" para os 12 search providers; "fetch" para firecrawl/jina/tavily-fetch. */
|
||||
kind: z.enum(["search", "fetch"]),
|
||||
costPerQuery: z.number().nonnegative(),
|
||||
freeMonthlyQuota: z.number().int().nonnegative(),
|
||||
searchTypes: z.array(z.string()).optional(), // só search
|
||||
fetchFormats: z.array(z.string()).optional(), // só fetch
|
||||
/** "configured" = creds presentes; "missing" = sem creds; "rate_limited" = todas as keys em cooldown. */
|
||||
status: z.enum(["configured", "missing", "rate_limited"]),
|
||||
/** Link para configurar provider. */
|
||||
configureHref: z.string().default("/dashboard/providers"),
|
||||
});
|
||||
export type SearchProviderCatalogItem = z.infer<typeof SearchProviderCatalogItemSchema>;
|
||||
|
||||
export const SearchProviderCatalogResponseSchema = z.object({
|
||||
providers: z.array(SearchProviderCatalogItemSchema),
|
||||
});
|
||||
|
||||
/** ScrapeResult mostrado na aba Scrape (já é a resposta de /v1/web/fetch, mas tipado). */
|
||||
export const ScrapeResultSchema = z.object({
|
||||
provider: z.string(),
|
||||
url: z.string(),
|
||||
content: z.string(),
|
||||
links: z.array(z.string()),
|
||||
metadata: z
|
||||
.object({
|
||||
title: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
screenshot_url: z.string().nullable(),
|
||||
});
|
||||
582
tests/unit/playground-code-export.test.ts
Normal file
582
tests/unit/playground-code-export.test.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { exportCode, exportAllLanguages, endpointToPath, API_KEY_PLACEHOLDER } = await import(
|
||||
"../../src/lib/playground/codeExport.ts"
|
||||
);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Assert security invariants on every generated snippet. */
|
||||
function assertSecurityInvariants(generated: string, label: string) {
|
||||
assert.ok(generated.includes(API_KEY_PLACEHOLDER), `${label}: must include $OMNIROUTE_API_KEY`);
|
||||
assert.ok(generated.length > 0, `${label}: must not be empty`);
|
||||
assert.doesNotMatch(
|
||||
generated,
|
||||
/sk-[A-Za-z0-9_\-]{16,}/,
|
||||
`${label}: must not contain real API keys`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
generated,
|
||||
/Bearer\s+[A-Za-z0-9_\-]{20,}\s/,
|
||||
`${label}: must not contain real Bearer tokens`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── endpointToPath ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("endpointToPath: maps all 10 endpoints correctly", () => {
|
||||
assert.equal(endpointToPath("chat.completions"), "/v1/chat/completions");
|
||||
assert.equal(endpointToPath("completions"), "/v1/completions");
|
||||
assert.equal(endpointToPath("embeddings"), "/v1/embeddings");
|
||||
assert.equal(endpointToPath("images"), "/v1/images/generations");
|
||||
assert.equal(endpointToPath("audio.transcriptions"), "/v1/audio/transcriptions");
|
||||
assert.equal(endpointToPath("audio.speech"), "/v1/audio/speech");
|
||||
assert.equal(endpointToPath("moderations"), "/v1/moderations");
|
||||
assert.equal(endpointToPath("rerank"), "/v1/rerank");
|
||||
assert.equal(endpointToPath("search"), "/v1/search");
|
||||
assert.equal(endpointToPath("web.fetch"), "/v1/web/fetch");
|
||||
});
|
||||
|
||||
// ── API_KEY_PLACEHOLDER ───────────────────────────────────────────────────────
|
||||
|
||||
test("API_KEY_PLACEHOLDER is $OMNIROUTE_API_KEY", () => {
|
||||
assert.equal(API_KEY_PLACEHOLDER, "$OMNIROUTE_API_KEY");
|
||||
});
|
||||
|
||||
// ── Table-driven tests for chat.completions ────────────────────────────────────
|
||||
|
||||
const baseState = {
|
||||
endpoint: "chat.completions" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "gpt-4o-mini",
|
||||
stream: false,
|
||||
};
|
||||
|
||||
test("chat.completions × curl: contains required elements", () => {
|
||||
const generated = exportCode(baseState, "curl");
|
||||
assertSecurityInvariants(generated, "chat.completions/curl");
|
||||
assert.ok(generated.includes("/v1/chat/completions"), "path present");
|
||||
assert.ok(generated.includes("Authorization: Bearer"), "auth header present");
|
||||
assert.ok(generated.includes("gpt-4o-mini"), "model present");
|
||||
});
|
||||
|
||||
test("chat.completions × python: contains required elements", () => {
|
||||
const generated = exportCode(baseState, "python");
|
||||
assertSecurityInvariants(generated, "chat.completions/python");
|
||||
assert.ok(generated.includes("import requests"), "imports requests");
|
||||
assert.ok(generated.includes('os.environ["OMNIROUTE_API_KEY"]'), "uses os.environ");
|
||||
assert.ok(generated.includes("gpt-4o-mini"), "model present");
|
||||
});
|
||||
|
||||
test("chat.completions × typescript: contains required elements", () => {
|
||||
const generated = exportCode(baseState, "typescript");
|
||||
assertSecurityInvariants(generated, "chat.completions/typescript");
|
||||
assert.ok(generated.includes("await fetch("), "uses fetch");
|
||||
assert.ok(generated.includes("process.env.OMNIROUTE_API_KEY"), "uses process.env");
|
||||
assert.ok(generated.includes("gpt-4o-mini"), "model present");
|
||||
});
|
||||
|
||||
// ── chat.completions with systemPrompt ────────────────────────────────────────
|
||||
|
||||
test("chat.completions: uses systemPrompt when messages is empty", () => {
|
||||
const state = { ...baseState, systemPrompt: "You are helpful.", messages: [] };
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assert.ok(generated.includes("You are helpful."), `${lang}: systemPrompt in output`);
|
||||
}
|
||||
});
|
||||
|
||||
test("chat.completions: uses messages when provided", () => {
|
||||
const state = {
|
||||
...baseState,
|
||||
messages: [
|
||||
{ role: "user" as const, content: "My custom message" },
|
||||
],
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assert.ok(generated.includes("My custom message"), `${lang}: message in output`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── completions ────────────────────────────────────────────────────────────────
|
||||
|
||||
test("completions × all languages: security + path", () => {
|
||||
const state = {
|
||||
endpoint: "completions" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "gpt-3.5-turbo-instruct",
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `completions/${lang}`);
|
||||
assert.ok(generated.includes("/v1/completions"), `${lang}: correct path`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── embeddings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("embeddings × all languages: security + path", () => {
|
||||
const state = {
|
||||
endpoint: "embeddings" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "text-embedding-3-small",
|
||||
prompt: "Hello world",
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `embeddings/${lang}`);
|
||||
assert.ok(generated.includes("/v1/embeddings"), `${lang}: correct path`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── images ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("images × all languages: security + path", () => {
|
||||
const state = {
|
||||
endpoint: "images" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "dall-e-3",
|
||||
prompt: "A beautiful sunset",
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `images/${lang}`);
|
||||
assert.ok(generated.includes("/v1/images/generations"), `${lang}: correct path`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── search ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("search × all languages: security + path + query", () => {
|
||||
const state = {
|
||||
endpoint: "search" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
query: "AI news today",
|
||||
searchProvider: "tavily",
|
||||
searchType: "web" as const,
|
||||
maxResults: 5,
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `search/${lang}`);
|
||||
assert.ok(generated.includes("/v1/search"), `${lang}: correct path`);
|
||||
assert.ok(generated.includes("AI news today"), `${lang}: query present`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── web.fetch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("web.fetch × all languages: security + path + url", () => {
|
||||
const state = {
|
||||
endpoint: "web.fetch" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
url: "https://example.com",
|
||||
fetchProvider: "firecrawl" as const,
|
||||
fetchFormat: "markdown" as const,
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `web.fetch/${lang}`);
|
||||
assert.ok(generated.includes("/v1/web/fetch"), `${lang}: correct path`);
|
||||
assert.ok(generated.includes("https://example.com"), `${lang}: url present`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── rerank ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("rerank × all languages: security + path + query", () => {
|
||||
const state = {
|
||||
endpoint: "rerank" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
query: "find relevant docs",
|
||||
rerankModel: "rerank-english-v3.0",
|
||||
documents: ["Doc 1", "Doc 2"],
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `rerank/${lang}`);
|
||||
assert.ok(generated.includes("/v1/rerank"), `${lang}: correct path`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── audio.transcriptions ──────────────────────────────────────────────────────
|
||||
|
||||
test("audio.transcriptions × all languages: security + path", () => {
|
||||
const state = {
|
||||
endpoint: "audio.transcriptions" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "whisper-1",
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `audio.transcriptions/${lang}`);
|
||||
assert.ok(generated.includes("/v1/audio/transcriptions"), `${lang}: correct path`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── audio.speech ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("audio.speech × all languages: security + path", () => {
|
||||
const state = {
|
||||
endpoint: "audio.speech" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "tts-1",
|
||||
prompt: "Hello world",
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `audio.speech/${lang}`);
|
||||
assert.ok(generated.includes("/v1/audio/speech"), `${lang}: correct path`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── moderations ───────────────────────────────────────────────────────────────
|
||||
|
||||
test("moderations × all languages: security + path", () => {
|
||||
const state = {
|
||||
endpoint: "moderations" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "text-moderation-latest",
|
||||
prompt: "Hello world",
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `moderations/${lang}`);
|
||||
assert.ok(generated.includes("/v1/moderations"), `${lang}: correct path`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── exportAllLanguages ─────────────────────────────────────────────────────────
|
||||
|
||||
test("exportAllLanguages returns all 3 snippets with valid content", () => {
|
||||
const state = { ...baseState, model: "gpt-4o" };
|
||||
const result = exportAllLanguages(state);
|
||||
|
||||
assert.ok(typeof result.curl === "string" && result.curl.length > 0, "curl non-empty");
|
||||
assert.ok(typeof result.python === "string" && result.python.length > 0, "python non-empty");
|
||||
assert.ok(
|
||||
typeof result.typescript === "string" && result.typescript.length > 0,
|
||||
"typescript non-empty",
|
||||
);
|
||||
|
||||
for (const [lang, snippet] of Object.entries(result)) {
|
||||
assertSecurityInvariants(snippet, `exportAll/${lang}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── JSON formatting (pretty-printed bodies) ───────────────────────────────────
|
||||
|
||||
test("curl: body is present (JSON.stringify used)", () => {
|
||||
const state = {
|
||||
endpoint: "embeddings" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "text-embedding-3-small",
|
||||
prompt: "Test input",
|
||||
};
|
||||
const generated = exportCode(state, "curl");
|
||||
// The body should contain the model name in JSON form
|
||||
assert.ok(generated.includes("text-embedding-3-small"), "model in JSON body");
|
||||
});
|
||||
|
||||
test("python: body uses json.loads for JSON parsing", () => {
|
||||
const state = {
|
||||
endpoint: "embeddings" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "text-embedding-3-small",
|
||||
prompt: "Test input",
|
||||
};
|
||||
const generated = exportCode(state, "python");
|
||||
assert.ok(generated.includes("json.loads"), "uses json.loads");
|
||||
});
|
||||
|
||||
test("typescript: body uses JSON object literal", () => {
|
||||
const state = {
|
||||
endpoint: "embeddings" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "text-embedding-3-small",
|
||||
prompt: "Test input",
|
||||
};
|
||||
const generated = exportCode(state, "typescript");
|
||||
assert.ok(generated.includes("const body ="), "uses const body =");
|
||||
});
|
||||
|
||||
// ── Default fallback branches ─────────────────────────────────────────────────
|
||||
// These tests cover the ?? defaults in buildBody to satisfy branch coverage
|
||||
|
||||
test("chat.completions: defaults model when not provided", () => {
|
||||
const state = {
|
||||
endpoint: "chat.completions" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
// no model
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `chat.completions/defaults/${lang}`);
|
||||
assert.ok(generated.includes("gpt-4o-mini"), `${lang}: default model fallback`);
|
||||
// default prompt "Hello!" should appear (no messages, no systemPrompt, no prompt)
|
||||
assert.ok(generated.includes("Hello!"), `${lang}: default prompt fallback`);
|
||||
// default stream=false
|
||||
assert.ok(generated.includes("false") || generated.includes("stream"), `${lang}: stream default`);
|
||||
}
|
||||
});
|
||||
|
||||
test("chat.completions: no messages, no systemPrompt — builds default user message", () => {
|
||||
const state = {
|
||||
endpoint: "chat.completions" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "gpt-4o",
|
||||
// no messages, no systemPrompt, no prompt
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assert.ok(generated.includes("Hello!"), `${lang}: default Hello! prompt`);
|
||||
}
|
||||
});
|
||||
|
||||
test("completions: defaults model when not provided", () => {
|
||||
const state = {
|
||||
endpoint: "completions" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
// no model, no prompt, no stream
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `completions/defaults/${lang}`);
|
||||
assert.ok(generated.includes("gpt-3.5-turbo-instruct"), `${lang}: default model`);
|
||||
assert.ok(generated.includes("Hello,"), `${lang}: default prompt`);
|
||||
}
|
||||
});
|
||||
|
||||
test("embeddings: defaults model and prompt when not provided", () => {
|
||||
const state = {
|
||||
endpoint: "embeddings" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
// no model, no prompt
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `embeddings/defaults/${lang}`);
|
||||
assert.ok(generated.includes("text-embedding-3-small"), `${lang}: default model`);
|
||||
assert.ok(generated.includes("Hello world"), `${lang}: default input`);
|
||||
}
|
||||
});
|
||||
|
||||
test("images: defaults model and prompt when not provided", () => {
|
||||
const state = {
|
||||
endpoint: "images" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
// no model, no prompt
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `images/defaults/${lang}`);
|
||||
assert.ok(generated.includes("dall-e-3"), `${lang}: default model`);
|
||||
}
|
||||
});
|
||||
|
||||
test("audio.transcriptions: defaults model when not provided", () => {
|
||||
const state = {
|
||||
endpoint: "audio.transcriptions" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
// no model
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `audio.transcriptions/defaults/${lang}`);
|
||||
assert.ok(generated.includes("whisper-1"), `${lang}: default model`);
|
||||
}
|
||||
});
|
||||
|
||||
test("audio.speech: defaults model and prompt when not provided", () => {
|
||||
const state = {
|
||||
endpoint: "audio.speech" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
// no model, no prompt
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `audio.speech/defaults/${lang}`);
|
||||
assert.ok(generated.includes("tts-1"), `${lang}: default model`);
|
||||
assert.ok(generated.includes("Hello, world!"), `${lang}: default prompt`);
|
||||
}
|
||||
});
|
||||
|
||||
test("moderations: defaults model and prompt when not provided", () => {
|
||||
const state = {
|
||||
endpoint: "moderations" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
// no model, no prompt
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `moderations/defaults/${lang}`);
|
||||
assert.ok(generated.includes("text-moderation-latest"), `${lang}: default model`);
|
||||
}
|
||||
});
|
||||
|
||||
test("rerank: defaults query and documents when not provided", () => {
|
||||
const state = {
|
||||
endpoint: "rerank" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
// no model, no query, no documents
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `rerank/defaults/${lang}`);
|
||||
assert.ok(generated.includes("rerank-english-v3.0"), `${lang}: default rerank model`);
|
||||
assert.ok(generated.includes("search query"), `${lang}: default query`);
|
||||
}
|
||||
});
|
||||
|
||||
test("search: omits optional fields when not provided", () => {
|
||||
const state = {
|
||||
endpoint: "search" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
query: "test query",
|
||||
// no model, no provider, no type, no maxResults
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `search/minimal/${lang}`);
|
||||
assert.ok(generated.includes("test query"), `${lang}: query present`);
|
||||
}
|
||||
});
|
||||
|
||||
test("web.fetch: minimal state (only url)", () => {
|
||||
const state = {
|
||||
endpoint: "web.fetch" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
url: "https://my-site.com",
|
||||
// no provider, no format, no depth
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `web.fetch/minimal/${lang}`);
|
||||
assert.ok(generated.includes("https://my-site.com"), `${lang}: url present`);
|
||||
}
|
||||
});
|
||||
|
||||
test("chat.completions: with tools in state", () => {
|
||||
const state = {
|
||||
endpoint: "chat.completions" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "gpt-4o",
|
||||
tools: [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get weather",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assert.ok(generated.includes("get_weather"), `${lang}: tools included`);
|
||||
}
|
||||
});
|
||||
|
||||
test("chat.completions: with params in state", () => {
|
||||
const state = {
|
||||
endpoint: "chat.completions" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "gpt-4o",
|
||||
params: { temperature: 0.7, max_tokens: 500 },
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assert.ok(generated.includes("temperature"), `${lang}: params included`);
|
||||
}
|
||||
});
|
||||
|
||||
test("completions: with params in state", () => {
|
||||
const state = {
|
||||
endpoint: "completions" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
model: "gpt-3.5-turbo-instruct",
|
||||
prompt: "Say hello",
|
||||
params: { temperature: 0.5, max_tokens: 100 },
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `completions/params/${lang}`);
|
||||
assert.ok(generated.includes("temperature"), `${lang}: params included`);
|
||||
}
|
||||
});
|
||||
|
||||
test("search: with model in state (covers model branch)", () => {
|
||||
const state = {
|
||||
endpoint: "search" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
query: "test",
|
||||
model: "some-search-model",
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `search/model/${lang}`);
|
||||
assert.ok(generated.includes("some-search-model"), `${lang}: model included`);
|
||||
}
|
||||
});
|
||||
|
||||
test("search: default url used when url not provided", () => {
|
||||
const state = {
|
||||
endpoint: "search" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
// no query — will use default
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `search/default-query/${lang}`);
|
||||
assert.ok(generated.includes("search query"), `${lang}: default query`);
|
||||
}
|
||||
});
|
||||
|
||||
test("web.fetch: with all optional fields set (depth, format, provider)", () => {
|
||||
const state = {
|
||||
endpoint: "web.fetch" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
url: "https://example.com",
|
||||
fetchProvider: "firecrawl" as const,
|
||||
fetchFormat: "html" as const,
|
||||
fetchDepth: 1 as const,
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `web.fetch/full/${lang}`);
|
||||
assert.ok(generated.includes("firecrawl"), `${lang}: provider present`);
|
||||
assert.ok(generated.includes("html"), `${lang}: format present`);
|
||||
assert.ok(generated.includes("1") || generated.includes("depth"), `${lang}: depth present`);
|
||||
}
|
||||
});
|
||||
|
||||
test("web.fetch: default url when url not provided", () => {
|
||||
const state = {
|
||||
endpoint: "web.fetch" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
// no url
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `web.fetch/default-url/${lang}`);
|
||||
assert.ok(generated.includes("example.com"), `${lang}: default url`);
|
||||
}
|
||||
});
|
||||
|
||||
test("web.fetch: fetchDepth 0 is included (not null)", () => {
|
||||
const state = {
|
||||
endpoint: "web.fetch" as const,
|
||||
baseUrl: "http://localhost:20128",
|
||||
url: "https://example.com",
|
||||
fetchDepth: 0 as const,
|
||||
};
|
||||
for (const lang of ["curl", "python", "typescript"] as const) {
|
||||
const generated = exportCode(state, lang);
|
||||
assertSecurityInvariants(generated, `web.fetch/depth0/${lang}`);
|
||||
// depth: 0 should be included (fetchDepth != null check)
|
||||
assert.ok(generated.includes("depth"), `${lang}: depth key present`);
|
||||
}
|
||||
});
|
||||
230
tests/unit/playground-prompt-improver.test.ts
Normal file
230
tests/unit/playground-prompt-improver.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { buildImproveChatBody, parseImprovedContent, META_SYSTEM_PROMPT, ImprovePromptRequestSchema } =
|
||||
await import("../../src/lib/playground/promptImprover.ts");
|
||||
|
||||
// ── META_SYSTEM_PROMPT ────────────────────────────────────────────────────────
|
||||
|
||||
test("META_SYSTEM_PROMPT is a non-empty string", () => {
|
||||
assert.ok(typeof META_SYSTEM_PROMPT === "string", "is string");
|
||||
assert.ok(META_SYSTEM_PROMPT.length > 0, "is non-empty");
|
||||
assert.ok(META_SYSTEM_PROMPT.includes("<<SYSTEM>>"), "contains <<SYSTEM>> marker");
|
||||
assert.ok(META_SYSTEM_PROMPT.includes("<<PROMPT>>"), "contains <<PROMPT>> marker");
|
||||
assert.ok(META_SYSTEM_PROMPT.includes("prompt engineer"), "mentions prompt engineer");
|
||||
});
|
||||
|
||||
// ── buildImproveChatBody ──────────────────────────────────────────────────────
|
||||
|
||||
// Scenario 1: only system, concise tone
|
||||
test("buildImproveChatBody: only system, concise tone", () => {
|
||||
const body = buildImproveChatBody({
|
||||
system: "You are a helpful assistant.",
|
||||
model: "gpt-4o-mini",
|
||||
tone: "concise",
|
||||
});
|
||||
|
||||
assert.equal(body.model, "gpt-4o-mini");
|
||||
assert.equal(body.temperature, 0.3);
|
||||
assert.equal(body.max_tokens, 2048);
|
||||
assert.equal(body.stream, false);
|
||||
assert.equal(body.messages.length, 2);
|
||||
assert.equal(body.messages[0].role, "system");
|
||||
assert.equal(body.messages[0].content, META_SYSTEM_PROMPT);
|
||||
assert.equal(body.messages[1].role, "user");
|
||||
assert.ok(body.messages[1].content.includes("Be concise and direct."), "concise prefix");
|
||||
assert.ok(body.messages[1].content.includes("<<SYSTEM>>"), "system marker in user msg");
|
||||
assert.ok(
|
||||
body.messages[1].content.includes("You are a helpful assistant."),
|
||||
"system content present",
|
||||
);
|
||||
// Must NOT include <<PROMPT>> when no prompt was given
|
||||
assert.ok(!body.messages[1].content.includes("<<PROMPT>>"), "no prompt marker when no prompt");
|
||||
});
|
||||
|
||||
// Scenario 2: only prompt, detailed tone
|
||||
test("buildImproveChatBody: only prompt, detailed tone", () => {
|
||||
const body = buildImproveChatBody({
|
||||
prompt: "Fix this code for me please.",
|
||||
model: "claude-sonnet-4-6",
|
||||
tone: "detailed",
|
||||
});
|
||||
|
||||
assert.equal(body.model, "claude-sonnet-4-6");
|
||||
assert.equal(body.temperature, 0.3);
|
||||
assert.equal(body.max_tokens, 2048);
|
||||
assert.equal(body.stream, false);
|
||||
assert.ok(
|
||||
body.messages[1].content.includes("Be detailed and explicit in the rewrite."),
|
||||
"detailed prefix",
|
||||
);
|
||||
assert.ok(body.messages[1].content.includes("<<PROMPT>>"), "prompt marker present");
|
||||
assert.ok(
|
||||
body.messages[1].content.includes("Fix this code for me please."),
|
||||
"prompt content present",
|
||||
);
|
||||
// Must NOT include <<SYSTEM>> when no system was given
|
||||
assert.ok(!body.messages[1].content.includes("<<SYSTEM>>"), "no system marker when no system");
|
||||
});
|
||||
|
||||
// Scenario 3: both system and prompt, concise tone
|
||||
test("buildImproveChatBody: both system and prompt, concise tone", () => {
|
||||
const body = buildImproveChatBody({
|
||||
system: "You are a coder.",
|
||||
prompt: "Write hello world.",
|
||||
model: "gpt-4o",
|
||||
tone: "concise",
|
||||
});
|
||||
|
||||
assert.ok(body.messages[1].content.includes("<<SYSTEM>>"), "system marker present");
|
||||
assert.ok(body.messages[1].content.includes("<<PROMPT>>"), "prompt marker present");
|
||||
assert.ok(body.messages[1].content.includes("You are a coder."), "system content");
|
||||
assert.ok(body.messages[1].content.includes("Write hello world."), "prompt content");
|
||||
assert.ok(body.messages[1].content.includes("Be concise and direct."), "concise prefix");
|
||||
});
|
||||
|
||||
// Scenario 4: both, detailed tone
|
||||
test("buildImproveChatBody: both system and prompt, detailed tone", () => {
|
||||
const body = buildImproveChatBody({
|
||||
system: "You are expert.",
|
||||
prompt: "Summarize this.",
|
||||
model: "gpt-4o",
|
||||
tone: "detailed",
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
body.messages[1].content.includes("Be detailed and explicit in the rewrite."),
|
||||
"detailed prefix",
|
||||
);
|
||||
});
|
||||
|
||||
// Scenario 5: only system, detailed tone
|
||||
test("buildImproveChatBody: only system, detailed tone", () => {
|
||||
const body = buildImproveChatBody({
|
||||
system: "You are a researcher.",
|
||||
model: "gpt-4o-mini",
|
||||
tone: "detailed",
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
body.messages[1].content.includes("Be detailed and explicit in the rewrite."),
|
||||
"detailed prefix",
|
||||
);
|
||||
assert.ok(body.messages[1].content.includes("<<SYSTEM>>"), "system marker");
|
||||
});
|
||||
|
||||
// Scenario 6: only prompt, concise tone
|
||||
test("buildImproveChatBody: only prompt, concise tone", () => {
|
||||
const body = buildImproveChatBody({
|
||||
prompt: "Explain quantum computing.",
|
||||
model: "gemini-2.0-flash",
|
||||
tone: "concise",
|
||||
});
|
||||
|
||||
assert.ok(body.messages[1].content.includes("Be concise and direct."), "concise prefix");
|
||||
assert.ok(body.messages[1].content.includes("<<PROMPT>>"), "prompt marker");
|
||||
});
|
||||
|
||||
// ── parseImprovedContent ──────────────────────────────────────────────────────
|
||||
|
||||
test("parseImprovedContent: only system marker present, hadSystem=true", () => {
|
||||
const raw = "<<SYSTEM>>\nYou are a professional assistant.";
|
||||
const result = parseImprovedContent(raw, true, false);
|
||||
assert.equal(result.improvedSystem, "You are a professional assistant.");
|
||||
assert.equal(result.improvedPrompt, undefined);
|
||||
});
|
||||
|
||||
test("parseImprovedContent: only prompt marker present, hadPrompt=true", () => {
|
||||
const raw = "<<PROMPT>>\nWrite clean code with comments.";
|
||||
const result = parseImprovedContent(raw, false, true);
|
||||
assert.equal(result.improvedPrompt, "Write clean code with comments.");
|
||||
assert.equal(result.improvedSystem, undefined);
|
||||
});
|
||||
|
||||
test("parseImprovedContent: both markers present, hadSystem+hadPrompt", () => {
|
||||
const raw = "<<SYSTEM>>\nYou are a professional assistant.\n\n<<PROMPT>>\nWrite clean code.";
|
||||
const result = parseImprovedContent(raw, true, true);
|
||||
assert.equal(result.improvedSystem, "You are a professional assistant.");
|
||||
assert.equal(result.improvedPrompt, "Write clean code.");
|
||||
});
|
||||
|
||||
test("parseImprovedContent: no markers, hadSystem only", () => {
|
||||
const raw = "You are a professional assistant.";
|
||||
const result = parseImprovedContent(raw, true, false);
|
||||
assert.equal(result.improvedSystem, "You are a professional assistant.");
|
||||
assert.equal(result.improvedPrompt, undefined);
|
||||
});
|
||||
|
||||
test("parseImprovedContent: no markers, hadPrompt only", () => {
|
||||
const raw = "Write clean code with good comments.";
|
||||
const result = parseImprovedContent(raw, false, true);
|
||||
assert.equal(result.improvedPrompt, "Write clean code with good comments.");
|
||||
assert.equal(result.improvedSystem, undefined);
|
||||
});
|
||||
|
||||
test("parseImprovedContent: no markers, both had — fallback to prompt", () => {
|
||||
const raw = "Some improved content.";
|
||||
const result = parseImprovedContent(raw, true, true);
|
||||
// Fallback: entire content goes to prompt
|
||||
assert.equal(result.improvedPrompt, "Some improved content.");
|
||||
});
|
||||
|
||||
test("parseImprovedContent: empty raw string returns empty object", () => {
|
||||
const result = parseImprovedContent("", true, true);
|
||||
assert.equal(result.improvedSystem, undefined);
|
||||
assert.equal(result.improvedPrompt, undefined);
|
||||
});
|
||||
|
||||
test("parseImprovedContent: trims whitespace", () => {
|
||||
const raw = "<<SYSTEM>>\n You are an assistant. \n\n<<PROMPT>>\n Fix this code. ";
|
||||
const result = parseImprovedContent(raw, true, true);
|
||||
assert.equal(result.improvedSystem, "You are an assistant.");
|
||||
assert.equal(result.improvedPrompt, "Fix this code.");
|
||||
});
|
||||
|
||||
test("parseImprovedContent: reversed order (<<PROMPT>> before <<SYSTEM>>)", () => {
|
||||
// LLM might respond with <<PROMPT>> first then <<SYSTEM>>
|
||||
const raw = "<<PROMPT>>\nWrite clean code.\n\n<<SYSTEM>>\nYou are a helpful coder.";
|
||||
const result = parseImprovedContent(raw, true, true);
|
||||
// Both markers present, <<PROMPT>> is at index < <<SYSTEM>> index
|
||||
// sysStart > promptStart in this case — covers else branch for sysContent
|
||||
// and the if(hasSystemMarker && systemIndex > promptStart) branch for promptContent
|
||||
assert.ok(result.improvedSystem !== undefined || result.improvedPrompt !== undefined,
|
||||
"should parse at least one field from reversed markers");
|
||||
});
|
||||
|
||||
// ── ImprovePromptRequestSchema validation ─────────────────────────────────────
|
||||
|
||||
test("ImprovePromptRequestSchema: valid with system only", () => {
|
||||
const parsed = ImprovePromptRequestSchema.safeParse({
|
||||
system: "You are helpful.",
|
||||
model: "gpt-4o",
|
||||
tone: "concise",
|
||||
});
|
||||
assert.ok(parsed.success, "valid request with system only");
|
||||
});
|
||||
|
||||
test("ImprovePromptRequestSchema: valid with prompt only", () => {
|
||||
const parsed = ImprovePromptRequestSchema.safeParse({
|
||||
prompt: "Tell me about AI.",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
assert.ok(parsed.success, "valid request with prompt only");
|
||||
});
|
||||
|
||||
test("ImprovePromptRequestSchema: invalid when both system and prompt are empty", () => {
|
||||
const parsed = ImprovePromptRequestSchema.safeParse({
|
||||
system: " ",
|
||||
prompt: " ",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
assert.ok(!parsed.success, "should fail when both are empty/whitespace");
|
||||
});
|
||||
|
||||
test("ImprovePromptRequestSchema: invalid when model is empty", () => {
|
||||
const parsed = ImprovePromptRequestSchema.safeParse({
|
||||
prompt: "Tell me something",
|
||||
model: "",
|
||||
});
|
||||
assert.ok(!parsed.success, "should fail with empty model");
|
||||
});
|
||||
316
tests/unit/playground-schemas.test.ts
Normal file
316
tests/unit/playground-schemas.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
const {
|
||||
PlaygroundPresetRowSchema,
|
||||
PlaygroundPresetCreateSchema,
|
||||
PlaygroundPresetUpdateSchema,
|
||||
PlaygroundPresetListItemSchema,
|
||||
ToolDefinitionSchema,
|
||||
StructuredOutputSchema,
|
||||
StreamMetricsSchema,
|
||||
} = await import("../../src/shared/schemas/playground.ts");
|
||||
|
||||
// ── PlaygroundPresetRowSchema ──────────────────────────────────────────────────
|
||||
|
||||
test("PlaygroundPresetRowSchema: valid row parses correctly", () => {
|
||||
const row = {
|
||||
id: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "My Preset",
|
||||
endpoint: "chat.completions",
|
||||
model: "gpt-4o-mini",
|
||||
system: "You are helpful.",
|
||||
params_json: '{"temperature":0.7}',
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
};
|
||||
const result = PlaygroundPresetRowSchema.safeParse(row);
|
||||
assert.ok(result.success, "valid row should parse");
|
||||
if (result.success) {
|
||||
assert.equal(result.data.id, row.id);
|
||||
assert.equal(result.data.name, row.name);
|
||||
assert.equal(result.data.system, "You are helpful.");
|
||||
}
|
||||
});
|
||||
|
||||
test("PlaygroundPresetRowSchema: null system is valid", () => {
|
||||
const row = {
|
||||
id: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Minimal Preset",
|
||||
endpoint: "embeddings",
|
||||
model: "text-embedding-3-small",
|
||||
system: null,
|
||||
params_json: "{}",
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
};
|
||||
const result = PlaygroundPresetRowSchema.safeParse(row);
|
||||
assert.ok(result.success, "null system should be valid");
|
||||
});
|
||||
|
||||
test("PlaygroundPresetRowSchema: invalid — missing required fields", () => {
|
||||
const result = PlaygroundPresetRowSchema.safeParse({ id: "not-a-uuid" });
|
||||
assert.ok(!result.success, "missing fields should fail");
|
||||
if (!result.success) {
|
||||
assert.ok(result.error instanceof ZodError);
|
||||
}
|
||||
});
|
||||
|
||||
test("PlaygroundPresetRowSchema: invalid — id is not UUID", () => {
|
||||
const row = {
|
||||
id: "not-a-uuid",
|
||||
name: "My Preset",
|
||||
endpoint: "chat.completions",
|
||||
model: "gpt-4o",
|
||||
system: null,
|
||||
params_json: "{}",
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
};
|
||||
const result = PlaygroundPresetRowSchema.safeParse(row);
|
||||
assert.ok(!result.success, "non-UUID id should fail");
|
||||
});
|
||||
|
||||
test("PlaygroundPresetRowSchema: invalid — name empty string", () => {
|
||||
const row = {
|
||||
id: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "",
|
||||
endpoint: "chat.completions",
|
||||
model: "gpt-4o",
|
||||
system: null,
|
||||
params_json: "{}",
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
};
|
||||
const result = PlaygroundPresetRowSchema.safeParse(row);
|
||||
assert.ok(!result.success, "empty name should fail");
|
||||
});
|
||||
|
||||
// ── PlaygroundPresetCreateSchema ──────────────────────────────────────────────
|
||||
|
||||
test("PlaygroundPresetCreateSchema: valid payload parses", () => {
|
||||
const payload = {
|
||||
name: "My new preset",
|
||||
endpoint: "chat.completions",
|
||||
model: "gpt-4o-mini",
|
||||
system: "Be helpful.",
|
||||
params: { temperature: 0.7, max_tokens: 1000 },
|
||||
};
|
||||
const result = PlaygroundPresetCreateSchema.safeParse(payload);
|
||||
assert.ok(result.success, "valid create payload should parse");
|
||||
if (result.success) {
|
||||
assert.deepEqual(result.data.params, { temperature: 0.7, max_tokens: 1000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("PlaygroundPresetCreateSchema: params defaults to {}", () => {
|
||||
const payload = {
|
||||
name: "Minimal preset",
|
||||
endpoint: "embeddings",
|
||||
model: "text-embedding-3-small",
|
||||
};
|
||||
const result = PlaygroundPresetCreateSchema.safeParse(payload);
|
||||
assert.ok(result.success, "minimal payload should parse");
|
||||
if (result.success) {
|
||||
assert.deepEqual(result.data.params, {});
|
||||
}
|
||||
});
|
||||
|
||||
test("PlaygroundPresetCreateSchema: invalid — name too long", () => {
|
||||
const result = PlaygroundPresetCreateSchema.safeParse({
|
||||
name: "a".repeat(101),
|
||||
endpoint: "chat.completions",
|
||||
model: "gpt-4o",
|
||||
});
|
||||
assert.ok(!result.success, "name >100 chars should fail");
|
||||
});
|
||||
|
||||
// ── PlaygroundPresetUpdateSchema ──────────────────────────────────────────────
|
||||
|
||||
test("PlaygroundPresetUpdateSchema: partial update is valid", () => {
|
||||
const result = PlaygroundPresetUpdateSchema.safeParse({ name: "Updated name" });
|
||||
assert.ok(result.success, "partial update with just name should parse");
|
||||
});
|
||||
|
||||
test("PlaygroundPresetUpdateSchema: empty object is valid (all fields optional)", () => {
|
||||
const result = PlaygroundPresetUpdateSchema.safeParse({});
|
||||
assert.ok(result.success, "empty patch should parse");
|
||||
});
|
||||
|
||||
// ── PlaygroundPresetListItemSchema ────────────────────────────────────────────
|
||||
|
||||
test("PlaygroundPresetListItemSchema: round-trip", () => {
|
||||
const item = {
|
||||
id: "123e4567-e89b-12d3-a456-426614174000",
|
||||
name: "Test",
|
||||
endpoint: "chat.completions",
|
||||
model: "gpt-4o",
|
||||
system: null,
|
||||
params: { temperature: 0.5 },
|
||||
created_at: "2025-01-01T00:00:00.000Z",
|
||||
};
|
||||
const result = PlaygroundPresetListItemSchema.safeParse(item);
|
||||
assert.ok(result.success, "list item round-trip");
|
||||
if (result.success) {
|
||||
assert.deepEqual(result.data.params, { temperature: 0.5 });
|
||||
}
|
||||
});
|
||||
|
||||
// ── ToolDefinitionSchema ──────────────────────────────────────────────────────
|
||||
|
||||
test("ToolDefinitionSchema: valid tool definition parses", () => {
|
||||
const tool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get current weather for a location.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: { type: "string", description: "City name" },
|
||||
},
|
||||
required: ["location"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = ToolDefinitionSchema.safeParse(tool);
|
||||
assert.ok(result.success, "valid tool definition should parse");
|
||||
});
|
||||
|
||||
test("ToolDefinitionSchema: invalid — type is not 'function'", () => {
|
||||
const tool = {
|
||||
type: "other",
|
||||
function: {
|
||||
name: "my_tool",
|
||||
parameters: {},
|
||||
},
|
||||
};
|
||||
const result = ToolDefinitionSchema.safeParse(tool);
|
||||
assert.ok(!result.success, "type not 'function' should fail");
|
||||
});
|
||||
|
||||
test("ToolDefinitionSchema: invalid — name empty", () => {
|
||||
const tool = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "",
|
||||
parameters: {},
|
||||
},
|
||||
};
|
||||
const result = ToolDefinitionSchema.safeParse(tool);
|
||||
assert.ok(!result.success, "empty name should fail");
|
||||
});
|
||||
|
||||
test("ToolDefinitionSchema: invalid — missing function key", () => {
|
||||
const tool = { type: "function" };
|
||||
const result = ToolDefinitionSchema.safeParse(tool);
|
||||
assert.ok(!result.success, "missing function key should fail");
|
||||
});
|
||||
|
||||
// ── StructuredOutputSchema ────────────────────────────────────────────────────
|
||||
|
||||
test("StructuredOutputSchema: valid structured output parses", () => {
|
||||
const so = {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "my_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
answer: { type: "string" },
|
||||
},
|
||||
},
|
||||
strict: true,
|
||||
},
|
||||
};
|
||||
const result = StructuredOutputSchema.safeParse(so);
|
||||
assert.ok(result.success, "valid structured output should parse");
|
||||
});
|
||||
|
||||
test("StructuredOutputSchema: without strict field (optional)", () => {
|
||||
const so = {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "simple",
|
||||
schema: { type: "object" },
|
||||
},
|
||||
};
|
||||
const result = StructuredOutputSchema.safeParse(so);
|
||||
assert.ok(result.success, "strict field is optional");
|
||||
});
|
||||
|
||||
test("StructuredOutputSchema: invalid — type not 'json_schema'", () => {
|
||||
const so = {
|
||||
type: "json_object",
|
||||
json_schema: { name: "test", schema: {} },
|
||||
};
|
||||
const result = StructuredOutputSchema.safeParse(so);
|
||||
assert.ok(!result.success, "wrong type should fail");
|
||||
});
|
||||
|
||||
test("StructuredOutputSchema: invalid — name empty", () => {
|
||||
const so = {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "",
|
||||
schema: {},
|
||||
},
|
||||
};
|
||||
const result = StructuredOutputSchema.safeParse(so);
|
||||
assert.ok(!result.success, "empty name should fail");
|
||||
});
|
||||
|
||||
// ── StreamMetricsSchema ───────────────────────────────────────────────────────
|
||||
|
||||
test("StreamMetricsSchema: valid metrics with all values", () => {
|
||||
const metrics = {
|
||||
ttftMs: 250,
|
||||
totalMs: 3500,
|
||||
tokensOut: 150,
|
||||
tokensIn: 50,
|
||||
tps: 42.8,
|
||||
costUsd: 0.00023,
|
||||
};
|
||||
const result = StreamMetricsSchema.safeParse(metrics);
|
||||
assert.ok(result.success, "valid metrics should parse");
|
||||
if (result.success) {
|
||||
assert.equal(result.data.ttftMs, 250);
|
||||
assert.equal(result.data.tps, 42.8);
|
||||
}
|
||||
});
|
||||
|
||||
test("StreamMetricsSchema: valid — nullable fields can be null", () => {
|
||||
const metrics = {
|
||||
ttftMs: null,
|
||||
totalMs: null,
|
||||
tokensOut: 0,
|
||||
tokensIn: 0,
|
||||
tps: null,
|
||||
costUsd: null,
|
||||
};
|
||||
const result = StreamMetricsSchema.safeParse(metrics);
|
||||
assert.ok(result.success, "null metrics should parse");
|
||||
});
|
||||
|
||||
test("StreamMetricsSchema: invalid — negative tokensOut", () => {
|
||||
const metrics = {
|
||||
ttftMs: 100,
|
||||
totalMs: 500,
|
||||
tokensOut: -1,
|
||||
tokensIn: 10,
|
||||
tps: 2.0,
|
||||
costUsd: 0.001,
|
||||
};
|
||||
const result = StreamMetricsSchema.safeParse(metrics);
|
||||
assert.ok(!result.success, "negative tokensOut should fail");
|
||||
});
|
||||
|
||||
test("StreamMetricsSchema: invalid — tokensOut is float (not int)", () => {
|
||||
const metrics = {
|
||||
ttftMs: 100,
|
||||
totalMs: 500,
|
||||
tokensOut: 1.5,
|
||||
tokensIn: 10,
|
||||
tps: 2.0,
|
||||
costUsd: 0.001,
|
||||
};
|
||||
const result = StreamMetricsSchema.safeParse(metrics);
|
||||
assert.ok(!result.success, "float tokensOut should fail int check");
|
||||
});
|
||||
246
tests/unit/search-tools-schemas.test.ts
Normal file
246
tests/unit/search-tools-schemas.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
const { SearchProviderCatalogItemSchema, SearchProviderCatalogResponseSchema, ScrapeResultSchema } =
|
||||
await import("../../src/shared/schemas/searchTools.ts");
|
||||
|
||||
// ── SearchProviderCatalogItemSchema ───────────────────────────────────────────
|
||||
|
||||
test("SearchProviderCatalogItemSchema: valid search provider parses", () => {
|
||||
const item = {
|
||||
id: "tavily",
|
||||
name: "Tavily",
|
||||
kind: "search",
|
||||
costPerQuery: 0.001,
|
||||
freeMonthlyQuota: 1000,
|
||||
searchTypes: ["web", "news"],
|
||||
status: "configured",
|
||||
configureHref: "/dashboard/providers",
|
||||
};
|
||||
const result = SearchProviderCatalogItemSchema.safeParse(item);
|
||||
assert.ok(result.success, "valid search item should parse");
|
||||
if (result.success) {
|
||||
assert.equal(result.data.kind, "search");
|
||||
assert.equal(result.data.id, "tavily");
|
||||
assert.equal(result.data.status, "configured");
|
||||
}
|
||||
});
|
||||
|
||||
test("SearchProviderCatalogItemSchema: valid fetch provider parses", () => {
|
||||
const item = {
|
||||
id: "firecrawl",
|
||||
name: "Firecrawl",
|
||||
kind: "fetch",
|
||||
costPerQuery: 0.002,
|
||||
freeMonthlyQuota: 500,
|
||||
fetchFormats: ["markdown", "html", "links"],
|
||||
status: "configured",
|
||||
configureHref: "/dashboard/providers",
|
||||
};
|
||||
const result = SearchProviderCatalogItemSchema.safeParse(item);
|
||||
assert.ok(result.success, "valid fetch item should parse");
|
||||
if (result.success) {
|
||||
assert.equal(result.data.kind, "fetch");
|
||||
assert.deepEqual(result.data.fetchFormats, ["markdown", "html", "links"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("SearchProviderCatalogItemSchema: status 'missing' is valid", () => {
|
||||
const item = {
|
||||
id: "bing",
|
||||
name: "Bing",
|
||||
kind: "search",
|
||||
costPerQuery: 0.005,
|
||||
freeMonthlyQuota: 0,
|
||||
status: "missing",
|
||||
};
|
||||
const result = SearchProviderCatalogItemSchema.safeParse(item);
|
||||
assert.ok(result.success, "status=missing should parse");
|
||||
if (result.success) {
|
||||
// configureHref has a default value
|
||||
assert.equal(result.data.configureHref, "/dashboard/providers");
|
||||
}
|
||||
});
|
||||
|
||||
test("SearchProviderCatalogItemSchema: status 'rate_limited' is valid", () => {
|
||||
const item = {
|
||||
id: "serper",
|
||||
name: "Serper",
|
||||
kind: "search",
|
||||
costPerQuery: 0.001,
|
||||
freeMonthlyQuota: 100,
|
||||
status: "rate_limited",
|
||||
};
|
||||
const result = SearchProviderCatalogItemSchema.safeParse(item);
|
||||
assert.ok(result.success, "status=rate_limited should parse");
|
||||
});
|
||||
|
||||
test("SearchProviderCatalogItemSchema: invalid — kind not in enum", () => {
|
||||
const item = {
|
||||
id: "custom",
|
||||
name: "Custom",
|
||||
kind: "unknown",
|
||||
costPerQuery: 0.001,
|
||||
freeMonthlyQuota: 0,
|
||||
status: "configured",
|
||||
};
|
||||
const result = SearchProviderCatalogItemSchema.safeParse(item);
|
||||
assert.ok(!result.success, "invalid kind should fail");
|
||||
if (!result.success) {
|
||||
assert.ok(result.error instanceof ZodError);
|
||||
}
|
||||
});
|
||||
|
||||
test("SearchProviderCatalogItemSchema: invalid — negative costPerQuery", () => {
|
||||
const item = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
kind: "search",
|
||||
costPerQuery: -0.001,
|
||||
freeMonthlyQuota: 0,
|
||||
status: "configured",
|
||||
};
|
||||
const result = SearchProviderCatalogItemSchema.safeParse(item);
|
||||
assert.ok(!result.success, "negative costPerQuery should fail");
|
||||
});
|
||||
|
||||
test("SearchProviderCatalogItemSchema: invalid — status not in enum", () => {
|
||||
const item = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
kind: "search",
|
||||
costPerQuery: 0,
|
||||
freeMonthlyQuota: 0,
|
||||
status: "active",
|
||||
};
|
||||
const result = SearchProviderCatalogItemSchema.safeParse(item);
|
||||
assert.ok(!result.success, "invalid status should fail");
|
||||
});
|
||||
|
||||
// ── SearchProviderCatalogResponseSchema ───────────────────────────────────────
|
||||
|
||||
test("SearchProviderCatalogResponseSchema: valid response with multiple providers", () => {
|
||||
const response = {
|
||||
providers: [
|
||||
{
|
||||
id: "tavily",
|
||||
name: "Tavily",
|
||||
kind: "search",
|
||||
costPerQuery: 0.001,
|
||||
freeMonthlyQuota: 1000,
|
||||
status: "configured",
|
||||
},
|
||||
{
|
||||
id: "firecrawl",
|
||||
name: "Firecrawl",
|
||||
kind: "fetch",
|
||||
costPerQuery: 0.002,
|
||||
freeMonthlyQuota: 500,
|
||||
status: "configured",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = SearchProviderCatalogResponseSchema.safeParse(response);
|
||||
assert.ok(result.success, "valid response should parse");
|
||||
if (result.success) {
|
||||
assert.equal(result.data.providers.length, 2);
|
||||
assert.equal(result.data.providers[0].kind, "search");
|
||||
assert.equal(result.data.providers[1].kind, "fetch");
|
||||
}
|
||||
});
|
||||
|
||||
test("SearchProviderCatalogResponseSchema: empty providers array is valid", () => {
|
||||
const result = SearchProviderCatalogResponseSchema.safeParse({ providers: [] });
|
||||
assert.ok(result.success, "empty providers array should parse");
|
||||
});
|
||||
|
||||
test("SearchProviderCatalogResponseSchema: invalid — providers not array", () => {
|
||||
const result = SearchProviderCatalogResponseSchema.safeParse({ providers: "not-array" });
|
||||
assert.ok(!result.success, "non-array providers should fail");
|
||||
});
|
||||
|
||||
// ── ScrapeResultSchema ────────────────────────────────────────────────────────
|
||||
|
||||
test("ScrapeResultSchema: valid scrape result parses", () => {
|
||||
const result_data = {
|
||||
provider: "firecrawl",
|
||||
url: "https://example.com",
|
||||
content: "# Hello\n\nThis is the content.",
|
||||
links: ["https://example.com/page1", "https://example.com/page2"],
|
||||
metadata: {
|
||||
title: "Example Domain",
|
||||
description: "An example page.",
|
||||
},
|
||||
screenshot_url: null,
|
||||
};
|
||||
const result = ScrapeResultSchema.safeParse(result_data);
|
||||
assert.ok(result.success, "valid scrape result should parse");
|
||||
if (result.success) {
|
||||
assert.equal(result.data.provider, "firecrawl");
|
||||
assert.equal(result.data.links.length, 2);
|
||||
assert.equal(result.data.metadata?.title, "Example Domain");
|
||||
}
|
||||
});
|
||||
|
||||
test("ScrapeResultSchema: null metadata is valid", () => {
|
||||
const result_data = {
|
||||
provider: "jina-reader",
|
||||
url: "https://example.com",
|
||||
content: "Content here.",
|
||||
links: [],
|
||||
metadata: null,
|
||||
screenshot_url: null,
|
||||
};
|
||||
const result = ScrapeResultSchema.safeParse(result_data);
|
||||
assert.ok(result.success, "null metadata should parse");
|
||||
});
|
||||
|
||||
test("ScrapeResultSchema: with screenshot_url as string", () => {
|
||||
const result_data = {
|
||||
provider: "firecrawl",
|
||||
url: "https://example.com",
|
||||
content: "Content.",
|
||||
links: [],
|
||||
metadata: { title: null, description: null },
|
||||
screenshot_url: "https://screenshots.example.com/abc123.png",
|
||||
};
|
||||
const result = ScrapeResultSchema.safeParse(result_data);
|
||||
assert.ok(result.success, "screenshot_url as string should parse");
|
||||
if (result.success) {
|
||||
assert.equal(result.data.screenshot_url, "https://screenshots.example.com/abc123.png");
|
||||
}
|
||||
});
|
||||
|
||||
test("ScrapeResultSchema: null metadata title/description is valid", () => {
|
||||
const result_data = {
|
||||
provider: "tavily-search",
|
||||
url: "https://example.com",
|
||||
content: "Content.",
|
||||
links: [],
|
||||
metadata: { title: null, description: null },
|
||||
screenshot_url: null,
|
||||
};
|
||||
const result = ScrapeResultSchema.safeParse(result_data);
|
||||
assert.ok(result.success, "null title/description in metadata should parse");
|
||||
});
|
||||
|
||||
test("ScrapeResultSchema: invalid — missing required fields", () => {
|
||||
const result = ScrapeResultSchema.safeParse({ provider: "firecrawl" });
|
||||
assert.ok(!result.success, "missing required fields should fail");
|
||||
if (!result.success) {
|
||||
assert.ok(result.error instanceof ZodError);
|
||||
}
|
||||
});
|
||||
|
||||
test("ScrapeResultSchema: invalid — links must be array of strings", () => {
|
||||
const result = ScrapeResultSchema.safeParse({
|
||||
provider: "firecrawl",
|
||||
url: "https://example.com",
|
||||
content: "Content.",
|
||||
links: [42, 43], // non-strings
|
||||
metadata: null,
|
||||
screenshot_url: null,
|
||||
});
|
||||
assert.ok(!result.success, "non-string links should fail");
|
||||
});
|
||||
153
tests/unit/ui/markdown-message.test.tsx
Normal file
153
tests/unit/ui/markdown-message.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment jsdom
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// ── Import component at module level (after mocks) ────────────────────────────
|
||||
|
||||
const { default: MarkdownMessage } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/playground/components/MarkdownMessage"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
|
||||
|
||||
function renderMarkdown(content: string, className?: string): HTMLDivElement {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => {
|
||||
root.render(<MarkdownMessage content={content} className={className} />);
|
||||
});
|
||||
containers.push({ root, el });
|
||||
return el;
|
||||
}
|
||||
|
||||
async function waitForCondition(fn: () => boolean, timeoutMs = 3000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error("waitForCondition timed out");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("MarkdownMessage", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, el } of containers.splice(0)) {
|
||||
act(() => root.unmount());
|
||||
el.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders a code block as <pre><code>", async () => {
|
||||
const el = renderMarkdown("```js\nconsole.log(1)\n```");
|
||||
// Wait for the component to render
|
||||
await waitForCondition(() => el.innerHTML.length > 0);
|
||||
|
||||
const pre = el.querySelector("pre");
|
||||
const code = el.querySelector("code");
|
||||
expect(pre || code).toBeTruthy();
|
||||
expect(el.innerHTML).toContain("console.log");
|
||||
});
|
||||
|
||||
it("renders a markdown table as <table>", async () => {
|
||||
const tableMarkdown = `
|
||||
| Name | Age |
|
||||
|------|-----|
|
||||
| Alice | 30 |
|
||||
| Bob | 25 |
|
||||
`;
|
||||
const el = renderMarkdown(tableMarkdown);
|
||||
await waitForCondition(() => el.innerHTML.length > 0);
|
||||
|
||||
const table = el.querySelector("table");
|
||||
expect(table).toBeTruthy();
|
||||
|
||||
const cells = el.querySelectorAll("td");
|
||||
expect(cells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders a markdown list as <ul><li>", async () => {
|
||||
const listMarkdown = `
|
||||
- Item one
|
||||
- Item two
|
||||
- Item three
|
||||
`;
|
||||
const el = renderMarkdown(listMarkdown);
|
||||
await waitForCondition(() => el.querySelector("ul") !== null);
|
||||
|
||||
const ul = el.querySelector("ul");
|
||||
expect(ul).toBeTruthy();
|
||||
|
||||
const listItems = el.querySelectorAll("li");
|
||||
expect(listItems.length).toBe(3);
|
||||
});
|
||||
|
||||
it("renders a markdown link as <a href>", async () => {
|
||||
const el = renderMarkdown("[Click here](https://example.com)");
|
||||
await waitForCondition(() => el.querySelector("a") !== null);
|
||||
|
||||
const anchor = el.querySelector("a");
|
||||
expect(anchor).toBeTruthy();
|
||||
expect(anchor?.getAttribute("href")).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("security: <script> tags appear as literal text, not executed", () => {
|
||||
const xssContent = "<script>alert(1)</script>";
|
||||
const el = renderMarkdown(xssContent);
|
||||
|
||||
// Should NOT contain a <script> element in the DOM
|
||||
const scriptEl = el.querySelector("script");
|
||||
expect(scriptEl).toBeNull();
|
||||
|
||||
// react-markdown by default does not render raw HTML
|
||||
// The content should not create a <script> tag
|
||||
const innerHTML = el.innerHTML;
|
||||
expect(innerHTML).not.toContain("<script>");
|
||||
});
|
||||
|
||||
it("renders plain text content", async () => {
|
||||
const el = renderMarkdown("Hello, world!");
|
||||
await waitForCondition(() => el.textContent !== null && el.textContent.includes("Hello"));
|
||||
|
||||
expect(el.textContent).toContain("Hello, world!");
|
||||
});
|
||||
|
||||
it("accepts optional className prop", () => {
|
||||
const el = renderMarkdown("Text", "my-custom-class");
|
||||
|
||||
// The wrapper div should have the class
|
||||
const wrapper = el.firstElementChild;
|
||||
expect(wrapper).toBeTruthy();
|
||||
expect(wrapper?.classList.contains("my-custom-class")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders strong and emphasis formatting", async () => {
|
||||
const el = renderMarkdown("**bold text** and _italic text_");
|
||||
await waitForCondition(
|
||||
() => el.querySelector("strong") !== null || el.querySelector("em") !== null,
|
||||
);
|
||||
|
||||
const strong = el.querySelector("strong");
|
||||
const em = el.querySelector("em");
|
||||
expect(strong).toBeTruthy();
|
||||
expect(em).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user