Merge branch 'feat/playground-search-foundation-F1' into chore/playground-search-audit-F10

This commit is contained in:
diegosouzapw
2026-05-28 11:26:54 -03:00
11 changed files with 2415 additions and 0 deletions

View File

@@ -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>
);
}

View 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"),
};
}

View 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
View 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 };
}

View 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>;

View 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(),
});

View 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`);
}
});

View 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");
});

View 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");
});

View 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");
});

View 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();
});
});