From e827ac125ace62b6219c35f2eacf29f1e57dff45 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:15:50 -0300 Subject: [PATCH 01/24] feat(translator): add TranslatorConceptCard with flow diagram (F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TranslatorConceptCard: headline, analogy, expandable 'how it works' - TranslateFlowDiagram: pure HTML/CSS responsive diagram (D10) - translateOrFallback inline (D19 — no shared fallback file) - Tooltip on technical terms (D20 a11y: aria-expanded + aria-controls) --- .../components/TranslateFlowDiagram.tsx | 122 ++++++++ .../components/TranslatorConceptCard.tsx | 79 +++++ .../translator-friendly-concept-card.test.tsx | 294 ++++++++++++++++++ 3 files changed, 495 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram.tsx create mode 100644 src/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard.tsx create mode 100644 tests/unit/translator-friendly-concept-card.test.tsx diff --git a/src/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram.tsx b/src/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram.tsx new file mode 100644 index 0000000000..d9e144ff29 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { useCallback, type ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import Tooltip from "@/shared/components/Tooltip"; + +interface FlowNodeProps { + icon: string; + color: "primary" | "orange" | "blue" | "emerald" | "amber" | "purple" | "cyan" | "pink"; + title: string; + example: string; + tooltipContent?: string; +} + +const COLOR_MAP: Record< + FlowNodeProps["color"], + { border: string; bg: string; text: string } +> = { + primary: { border: "border-primary/30", bg: "bg-primary/5", text: "text-primary" }, + orange: { border: "border-orange-500/30", bg: "bg-orange-500/5", text: "text-orange-500" }, + blue: { border: "border-blue-500/30", bg: "bg-blue-500/5", text: "text-blue-500" }, + emerald: { + border: "border-emerald-500/30", + bg: "bg-emerald-500/5", + text: "text-emerald-500", + }, + amber: { border: "border-amber-500/30", bg: "bg-amber-500/5", text: "text-amber-500" }, + purple: { + border: "border-purple-500/30", + bg: "bg-purple-500/5", + text: "text-purple-500", + }, + cyan: { border: "border-cyan-500/30", bg: "bg-cyan-500/5", text: "text-cyan-500" }, + pink: { border: "border-pink-500/30", bg: "bg-pink-500/5", text: "text-pink-500" }, +}; + +function FlowNode({ icon, color, title, example, tooltipContent }: FlowNodeProps) { + const c = COLOR_MAP[color]; + const node: ReactNode = ( +
+ +

{title}

+

{example}

+
+ ); + + return tooltipContent ? ( + + {node} + + ) : ( + node + ); +} + +function FlowArrow({ label }: { label?: string }) { + return ( +
+ + {label && ( + {label} + )} +
+ ); +} + +export default function TranslateFlowDiagram() { + const t = useTranslations("translator"); + const tr = useCallback( + (key: string, fallback: string) => { + try { + const translated = t(key); + return translated === key || translated === `translator.${key}` ? fallback : translated; + } catch { + return fallback; + } + }, + [t], + ); + + return ( +
+ + + + + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard.tsx b/src/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard.tsx new file mode 100644 index 0000000000..c612788e2e --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/shared/components"; +import TranslateFlowDiagram from "./TranslateFlowDiagram"; + +export default function TranslatorConceptCard() { + const t = useTranslations("translator"); + const [open, setOpen] = useState(false); + + const tr = useCallback( + (key: string, fallback: string) => { + try { + const translated = t(key); + return translated === key || translated === `translator.${key}` ? fallback : translated; + } catch { + return fallback; + } + }, + [t], + ); + + return ( + +
+
+ +
+

+ {tr( + "conceptHeadline", + 'Sua app fala o "idioma" de uma API. O Translator converte para usar outro provider.', + )} +

+

+ {tr( + "friendlySubtitle", + "Use sua app existente com qualquer provider — sem reescrever código.", + )} +

+
+
+ + + + + + {open && ( +
+ {tr( + "conceptHowItWorksBody", + "Sua app envia um pedido no formato dela. O Translator detecta o formato, converte via OpenAI como hub intermediário (ou direto, quando há tradutor direto disponível), envia ao provider escolhido e devolve a resposta convertida de volta no formato da sua app.", + )} +
+ )} +
+
+ ); +} diff --git a/tests/unit/translator-friendly-concept-card.test.tsx b/tests/unit/translator-friendly-concept-card.test.tsx new file mode 100644 index 0000000000..44e1f5fc0d --- /dev/null +++ b/tests/unit/translator-friendly-concept-card.test.tsx @@ -0,0 +1,294 @@ +// @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"; + +// Minimal i18n stub — returns the key so tests can assert on fallback rendering +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// Minimal shared component stubs — Card wraps children, Tooltip passes through +vi.mock("@/shared/components", () => ({ + Card: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
{children}
, +})); + +vi.mock("@/shared/components/Tooltip", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => { + container.remove(); + }); + return container; +} + +describe("TranslatorConceptCard", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + }); + + it("exports a default function component", async () => { + const mod = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + expect(typeof mod.default).toBe("function"); + }); + + it("renders the card with info icon and headline", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // Card should be in the DOM + expect(container.querySelector("[data-testid='card']")).toBeTruthy(); + // Info icon should be present + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + expect(iconTexts).toContain("info"); + }); + + it("renders the flow diagram with 3 FlowNode elements (app, source, target)", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // The diagram grid should contain 3 node icons (smart_toy, psychology, auto_awesome) + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + expect(iconTexts).toContain("smart_toy"); + expect(iconTexts).toContain("psychology"); + expect(iconTexts).toContain("auto_awesome"); + }); + + it("renders the diagram with arrow_forward separators", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + // Two arrows between 3 nodes + expect(iconTexts.filter((t) => t === "arrow_forward").length).toBeGreaterThanOrEqual(2); + }); + + it("toggle button starts collapsed (aria-expanded=false)", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const toggleBtn = container.querySelector( + "button[aria-controls='translator-concept-how-it-works']", + ); + expect(toggleBtn).toBeTruthy(); + expect(toggleBtn?.getAttribute("aria-expanded")).toBe("false"); + // Collapsed panel should not be in the DOM yet + expect(container.querySelector("#translator-concept-how-it-works")).toBeNull(); + }); + + it("toggle expands 'Como funciona' section and sets aria-expanded=true", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const toggleBtn = container.querySelector( + "button[aria-controls='translator-concept-how-it-works']", + ) as HTMLButtonElement | null; + expect(toggleBtn).toBeTruthy(); + + // Click to expand + await act(async () => { + toggleBtn?.click(); + }); + + expect(toggleBtn?.getAttribute("aria-expanded")).toBe("true"); + const panel = container.querySelector("#translator-concept-how-it-works"); + expect(panel).toBeTruthy(); + }); + + it("toggle collapses section on second click and restores aria-expanded=false", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const toggleBtn = container.querySelector( + "button[aria-controls='translator-concept-how-it-works']", + ) as HTMLButtonElement | null; + + // Expand + await act(async () => { + toggleBtn?.click(); + }); + expect(toggleBtn?.getAttribute("aria-expanded")).toBe("true"); + + // Collapse + await act(async () => { + toggleBtn?.click(); + }); + expect(toggleBtn?.getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelector("#translator-concept-how-it-works")).toBeNull(); + }); + + it("toggle button icon changes between expand_more and expand_less", async () => { + const { default: TranslatorConceptCard } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const toggleBtn = container.querySelector( + "button[aria-controls='translator-concept-how-it-works']", + ) as HTMLButtonElement | null; + + // Initially collapsed: should show expand_more + const btnIcons = toggleBtn?.querySelectorAll(".material-symbols-outlined"); + const btnIconTexts = Array.from(btnIcons ?? []).map((el) => el.textContent?.trim()); + expect(btnIconTexts).toContain("expand_more"); + expect(btnIconTexts).not.toContain("expand_less"); + + // After click: should show expand_less + await act(async () => { + toggleBtn?.click(); + }); + const btnIconsAfter = toggleBtn?.querySelectorAll(".material-symbols-outlined"); + const btnIconTextsAfter = Array.from(btnIconsAfter ?? []).map((el) => el.textContent?.trim()); + expect(btnIconTextsAfter).toContain("expand_less"); + expect(btnIconTextsAfter).not.toContain("expand_more"); + }); +}); + +describe("TranslateFlowDiagram", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + }); + + it("exports a default function component", async () => { + const mod = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram" + ); + expect(typeof mod.default).toBe("function"); + }); + + it("renders all 3 flow node icons", async () => { + const { default: TranslateFlowDiagram } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + expect(iconTexts).toContain("smart_toy"); + expect(iconTexts).toContain("psychology"); + expect(iconTexts).toContain("auto_awesome"); + }); + + it("renders exactly 2 arrow_forward separators between nodes", async () => { + const { default: TranslateFlowDiagram } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + expect(iconTexts.filter((t) => t === "arrow_forward")).toHaveLength(2); + }); + + it("renders a responsive grid container", async () => { + const { default: TranslateFlowDiagram } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // The grid wrapper should have grid class and responsive columns + const grid = container.querySelector(".grid"); + expect(grid).toBeTruthy(); + // Responsive class for sm breakpoint + expect(grid?.className).toContain("sm:grid-cols-"); + }); + + it("i18n fallback: renders labels using fallback strings when translations return keys", async () => { + const { default: TranslateFlowDiagram } = await import( + "@/app/(dashboard)/dashboard/translator/components/TranslateFlowDiagram" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // When mock returns key, tr() detects key === translation and uses fallback + // The fallback text should appear in the DOM + const text = container.textContent ?? ""; + expect(text).toContain("Sua app"); + expect(text).toContain("ex: SDK Anthropic"); + expect(text).toContain("Formato origem"); + expect(text).toContain("claude"); + expect(text).toContain("Provider destino"); + expect(text).toContain("Gemini"); + }); +}); From ae0941464fcb56378874f888e3f8bb9cf9073b43 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 19:38:34 -0300 Subject: [PATCH 02/24] feat(translator): add F1 foundation types, hooks, and i18n keys - types.ts: FormatId, TranslatorTab, TranslateMode, AdvancedSlug, TranslateDeepLink, TranslateNarratedResult, AdvancedAccordionProps, ExampleTemplate - useTranslateDeepLink hook (URL ?tab/mode/advanced enum-validated parsing + setTab/setMode/setAdvanced) - useTranslateSession hook (detect+translate+send orchestration with sanitized errors) - 52 new i18n keys (en + pt-BR) under namespace 'translator' (ADD-only, no old keys removed) - 3 unit test files: deeplink (32 tests), session (10 tests), i18n-keys (138 tests) --- .../translator/hooks/useTranslateDeepLink.tsx | 61 +++ .../translator/hooks/useTranslateSession.tsx | 221 ++++++++++ .../(dashboard)/dashboard/translator/types.ts | 68 +++ src/i18n/messages/en.json | 54 ++- src/i18n/messages/pt-BR.json | 54 ++- .../unit/translator-friendly-deeplink.test.ts | 227 ++++++++++ .../translator-friendly-i18n-keys.test.ts | 202 +++++++++ .../unit/translator-friendly-session.test.ts | 392 ++++++++++++++++++ 8 files changed, 1277 insertions(+), 2 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/translator/hooks/useTranslateDeepLink.tsx create mode 100644 src/app/(dashboard)/dashboard/translator/hooks/useTranslateSession.tsx create mode 100644 src/app/(dashboard)/dashboard/translator/types.ts create mode 100644 tests/unit/translator-friendly-deeplink.test.ts create mode 100644 tests/unit/translator-friendly-i18n-keys.test.ts create mode 100644 tests/unit/translator-friendly-session.test.ts diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useTranslateDeepLink.tsx b/src/app/(dashboard)/dashboard/translator/hooks/useTranslateDeepLink.tsx new file mode 100644 index 0000000000..dffed6a33f --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/hooks/useTranslateDeepLink.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import type { AdvancedSlug, TranslateMode, TranslatorTab, TranslateDeepLink } from "../types"; + +const VALID_TABS: ReadonlySet = new Set(["translate", "monitor"]); +const VALID_MODES: ReadonlySet = new Set(["preview", "send"]); +const VALID_ADVANCED: ReadonlySet = new Set([ + "rawjson", + "pipeline", + "streamtransform", + "testbench", + "compression", +]); + +export interface UseTranslateDeepLinkReturn { + state: TranslateDeepLink; + setTab: (tab: TranslatorTab) => void; + setMode: (mode: TranslateMode) => void; + setAdvanced: (slug: AdvancedSlug | null) => void; +} + +export function useTranslateDeepLink(): UseTranslateDeepLinkReturn { + const router = useRouter(); + const params = useSearchParams(); + + const state = useMemo(() => { + const tab = params.get("tab"); + const mode = params.get("mode"); + const advanced = params.get("advanced"); + return { + tab: VALID_TABS.has(tab as TranslatorTab) ? (tab as TranslatorTab) : "translate", + mode: VALID_MODES.has(mode as TranslateMode) ? (mode as TranslateMode) : "send", + advanced: + advanced && VALID_ADVANCED.has(advanced as AdvancedSlug) + ? (advanced as AdvancedSlug) + : null, + }; + }, [params]); + + const update = useCallback( + (patch: Partial) => { + const next = new URLSearchParams(params?.toString() ?? ""); + const merged: TranslateDeepLink = { ...state, ...patch }; + next.set("tab", merged.tab); + next.set("mode", merged.mode); + if (merged.advanced) next.set("advanced", merged.advanced); + else next.delete("advanced"); + router.replace(`?${next.toString()}`, { scroll: false }); + }, + [params, router, state] + ); + + return { + state, + setTab: (tab) => update({ tab }), + setMode: (mode) => update({ mode }), + setAdvanced: (advanced) => update({ advanced }), + }; +} diff --git a/src/app/(dashboard)/dashboard/translator/hooks/useTranslateSession.tsx b/src/app/(dashboard)/dashboard/translator/hooks/useTranslateSession.tsx new file mode 100644 index 0000000000..06ed86ae70 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/hooks/useTranslateSession.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useCallback, useState } from "react"; +import type { FormatId, TranslateMode, TranslateNarratedResult } from "../types"; + +export interface UseTranslateSessionInput { + source: FormatId; + target: FormatId; + provider: string; + inputText: string; + mode: TranslateMode; +} + +export interface UseTranslateSessionReturn { + result: TranslateNarratedResult; + run: (input: UseTranslateSessionInput) => Promise; + reset: () => void; +} + +function sanitizeError(raw: unknown): string { + const text = + raw instanceof Error ? raw.message : typeof raw === "string" ? raw : "Unknown error"; + return text + .replace(/\sat\s\/[^\s]+/g, "") + .replace(/sk-[A-Za-z0-9_-]{16,}/g, "[REDACTED]") + .replace(/Bearer\s+[A-Za-z0-9_.-]+/g, "Bearer [REDACTED]"); +} + +const initialResult = (target: FormatId): TranslateNarratedResult => ({ + detected: null, + target, + status: "idle", + responsePreview: null, + translatedJson: null, + pipelinePath: null, + intermediateJson: null, + errorMessage: null, + latencyMs: null, +}); + +export function useTranslateSession(): UseTranslateSessionReturn { + const [result, setResult] = useState(initialResult("openai")); + + const run = useCallback( + async ({ source, target, provider, inputText, mode }: UseTranslateSessionInput) => { + const start = performance.now(); + setResult({ ...initialResult(target), status: "translating" }); + try { + // 1. Parse input as JSON; fall back to wrap-as-message. + let parsed: Record; + try { + parsed = JSON.parse(inputText); + } catch { + parsed = { messages: [{ role: "user", content: inputText }] }; + } + + // 2. Detect format. + let detected: FormatId | null = null; + try { + const detectRes = await fetch("/api/translator/detect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: parsed }), + }); + const detectData = (await detectRes.json()) as { + success: boolean; + format?: string; + }; + if (detectData.success) detected = detectData.format as FormatId; + } catch { + /* non-fatal */ + } + + // 3. Translate (if source != target). + let translatedJson: string | null = null; + let intermediateJson: string | null = null; + let pipelinePath: TranslateNarratedResult["pipelinePath"] = "passthrough"; + let translatedResult: Record = parsed; + + if (source !== target) { + const needsHub = source !== "openai" && target !== "openai"; + if (needsHub) { + // Step 1: source → openai + const step1 = await fetch("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + step: "direct", + sourceFormat: source, + targetFormat: "openai", + body: parsed, + }), + }); + const step1Data = (await step1.json()) as { + success: boolean; + result?: Record; + error?: string; + }; + if (!step1Data.success) throw new Error(step1Data.error ?? "Translate step 1 failed"); + intermediateJson = JSON.stringify(step1Data.result, null, 2); + // Step 2: openai → target + const step2 = await fetch("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + step: "direct", + sourceFormat: "openai", + targetFormat: target, + body: step1Data.result, + }), + }); + const step2Data = (await step2.json()) as { + success: boolean; + result?: Record; + error?: string; + }; + if (!step2Data.success) throw new Error(step2Data.error ?? "Translate step 2 failed"); + translatedResult = step2Data.result as Record; + translatedJson = JSON.stringify(step2Data.result, null, 2); + pipelinePath = "hub-and-spoke"; + } else { + const stepDirect = await fetch("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + step: "direct", + sourceFormat: source, + targetFormat: target, + body: parsed, + }), + }); + const stepData = (await stepDirect.json()) as { + success: boolean; + result?: Record; + error?: string; + }; + if (!stepData.success) throw new Error(stepData.error ?? "Translate failed"); + translatedResult = stepData.result as Record; + translatedJson = JSON.stringify(stepData.result, null, 2); + pipelinePath = "direct"; + } + } else { + translatedJson = JSON.stringify(parsed, null, 2); + } + + let responsePreview: string | null = null; + + // 4. Optional send (mode === "send"). + if (mode === "send") { + setResult((prev) => ({ + ...prev, + detected, + translatedJson, + intermediateJson, + pipelinePath, + status: "sending", + })); + const sendRes = await fetch("/api/translator/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, body: translatedResult }), + }); + if (!sendRes.ok) { + const errorBody = (await sendRes.json().catch(() => ({ + error: `HTTP ${sendRes.status}`, + }))) as { error?: unknown }; + throw new Error( + typeof errorBody.error === "string" ? errorBody.error : "Send failed" + ); + } + const reader = sendRes.body?.getReader(); + if (reader) { + const decoder = new TextDecoder(); + let buf = ""; + while (buf.length < 500) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + } + responsePreview = buf.slice(0, 500); + // Drain remaining (don't block UI). + try { + while (true) { + const { done } = await reader.read(); + if (done) break; + } + } catch { + /* ignore */ + } + } + } + + const latencyMs = Math.round(performance.now() - start); + setResult({ + detected, + target, + status: "ok", + responsePreview, + translatedJson, + pipelinePath, + intermediateJson, + errorMessage: null, + latencyMs, + }); + } catch (err) { + const latencyMs = Math.round(performance.now() - start); + setResult((prev) => ({ + ...prev, + status: "error", + errorMessage: sanitizeError(err), + latencyMs, + })); + } + }, + [] + ); + + const reset = useCallback(() => setResult(initialResult("openai")), []); + + return { result, run, reset }; +} diff --git a/src/app/(dashboard)/dashboard/translator/types.ts b/src/app/(dashboard)/dashboard/translator/types.ts new file mode 100644 index 0000000000..2653fe6ff7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/types.ts @@ -0,0 +1,68 @@ +// Identificador estável dos formatos suportados (1:1 com FORMAT_META em exampleTemplates.tsx). +// Mantém compatibilidade com strings já no backend (open-sse/translator/formats.ts). +export type FormatId = + | "openai" + | "openai-responses" + | "claude" + | "gemini" + | "antigravity" + | "kiro" + | "cursor"; + +// Tabs no shell de 2 abas. +export type TranslatorTab = "translate" | "monitor"; + +// Modo do simple controls: só converter (estático) vs enviar e mostrar resposta (com SSE). +export type TranslateMode = "preview" | "send"; + +// Slugs canônicos dos accordions Advanced (deep-link). +export type AdvancedSlug = + | "rawjson" + | "pipeline" + | "streamtransform" + | "testbench" + | "compression"; + +// Estado do deep-link parseado a partir da querystring (hook useTranslateDeepLink). +export interface TranslateDeepLink { + tab: TranslatorTab; + mode: TranslateMode; + advanced: AdvancedSlug | null; // null = nenhum aberto +} + +// Resultado narrado mostrado no painel direito (modo simple). +// Renderizado por ResultNarrated; F3 popula, F4 conhece o shape para passar referência. +export interface TranslateNarratedResult { + detected: FormatId | null; // formato detectado no input do usuário + target: FormatId; // selecionado no SimpleControls + status: "idle" | "translating" | "sending" | "ok" | "error"; + responsePreview: string | null; // primeiras N chars da resposta SSE/JSON + translatedJson: string | null; // JSON resultado (para botão "ver JSON") + pipelinePath: "direct" | "hub-and-spoke" | "passthrough" | null; + intermediateJson: string | null; // OpenAI intermediário quando hub-and-spoke + errorMessage: string | null; // sanitized error (sem stack) + latencyMs: number | null; +} + +// Props compartilhados entre os accordion children. +export interface AdvancedAccordionProps { + // Lazy-render guard (D7): só monta children se já abriu pelo menos uma vez. + defaultOpen?: boolean; + // Slug usado pelo deep-link (D6); o hook useTranslateDeepLink lê isso. + slug: AdvancedSlug; + // Caller pode forçar abertura (deep-link inicial). + forceOpen?: boolean; + // Caller pode receber notificação quando o estado open mudar (para sync com URL). + onOpenChange?: (open: boolean) => void; +} + +// Templates retornados por getExampleTemplates(t) — espelha o shape de exampleTemplates.tsx. +// exampleTemplates.tsx não exporta este type, então definimos inline aqui. +// NÃO duplicar os dados — importar apenas getExampleTemplates/FORMAT_META/FORMAT_OPTIONS do módulo. +export interface ExampleTemplate { + id: string; + name: string; + icon: string; + description: string; + formats: Partial>>; +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 5115037d36..1d5303a850 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5629,7 +5629,59 @@ "routeConnectionLabel": "Connection", "scenarioVision": "Vision (image understanding)", "scenarioSchemaCoercion": "Schema coercion (structured output)", - "techniques": "Techniques:" + "techniques": "Techniques:", + "friendlyTitle": "Translator", + "friendlySubtitle": "Use your existing app with any provider — without rewriting code.", + "conceptHeadline": "Your app speaks one API's \"language\". The Translator converts it to use another provider.", + "conceptDiagramAppLabel": "Your app", + "conceptDiagramSourceLabel": "Source format", + "conceptDiagramHubLabel": "OpenAI (hub)", + "conceptDiagramTargetLabel": "Target provider", + "conceptDiagramExampleApp": "e.g. Anthropic SDK", + "conceptDiagramExampleSource": "claude", + "conceptDiagramExampleTarget": "Gemini", + "conceptHowItWorksToggle": "How it works", + "conceptHowItWorksBody": "Your app sends a request in its own format. The Translator detects the format, converts it through OpenAI as an intermediate hub (or directly when a direct translator is available), sends it to the chosen provider, and returns the response converted back to your app's format.", + "tabTranslate": "Translate", + "tabMonitor": "Monitor", + "tabTranslateAriaLabel": "Go to the Translate tab", + "tabMonitorAriaLabel": "Go to the Monitor tab", + "simpleAppUsesLabel": "My app uses", + "simpleAppUsesHint": "The API format your app speaks (e.g. Anthropic SDK = claude).", + "simpleSendToLabel": "Send to", + "simpleSendToHint": "Where to actually send the request (a provider connected in OmniRoute).", + "simpleStartWithLabel": "Start with", + "simpleStartWithExamplePlaceholder": "Select a ready-made example", + "simpleStartWithCustomOption": "Paste your request (advanced)", + "simpleModeLabel": "Mode", + "simpleModePreview": "Preview translation only", + "simpleModeSend": "Send and see response", + "simpleAdvancedToggle": "Advanced", + "simpleInputPanelTitle": "Input", + "simpleInputPanelHint": "Free-text message or ready-made example", + "simpleResultPanelTitle": "Translation + Response", + "narratedDetected": "✓ Detected: {format}", + "narratedTranslating": "Translating to {target}...", + "narratedSending": "Sending to {target}...", + "narratedSuccess": "→ translated to {target} · response in {latency}ms", + "narratedError": "Failed: {reason}", + "narratedSeeTranslatedJson": "see translated JSON", + "narratedSeePipeline": "see pipeline", + "advancedSectionTitle": "Advanced", + "advancedSectionSubtitle": "Raw JSON, pipeline and technical tools. Everything here is the same as the old tabs — just reorganized.", + "advancedRawJsonTitle": "Raw JSON (auto-detect + Monaco)", + "advancedRawJsonSubtitle": "Paste a JSON request; the format is detected automatically.", + "advancedPipelineTitle": "OpenAI intermediate pipeline", + "advancedPipelineSubtitle": "Visualize each translation step (hub-and-spoke).", + "advancedStreamTransformTitle": "Stream Transformer (Chat → Responses SSE)", + "advancedStreamTransformSubtitle": "Converts Chat Completions SSE into Responses API.", + "advancedTestBenchTitle": "Test Bench (8 scenarios)", + "advancedTestBenchSubtitle": "Runs all scenarios and reports pass/fail + compatibility %.", + "advancedCompressionTitle": "Compression Preview", + "advancedCompressionSubtitle": "Estimate token savings across different compression modes.", + "monitorOriginHint": "Events generated by Translate or the main pipeline appear here in real time.", + "monitorEmptyCta": "Go to the Translate tab and send a request — it will appear here.", + "monitorOpenTranslateButton": "Go to Translate" }, "usage": { "title": "Usage", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index cc60285114..77515b587c 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -5619,7 +5619,59 @@ "routeConnectionLabel": "Conexão", "scenarioVision": "Visão (compreensão da imagem)", "scenarioSchemaCoercion": "Coerção de esquema (saída estruturada)", - "techniques": "Técnicas:" + "techniques": "Técnicas:", + "friendlyTitle": "Translator", + "friendlySubtitle": "Use sua app existente com qualquer provider — sem reescrever código.", + "conceptHeadline": "Sua app fala o \"idioma\" de uma API. O Translator converte para usar outro provider.", + "conceptDiagramAppLabel": "Sua app", + "conceptDiagramSourceLabel": "Formato origem", + "conceptDiagramHubLabel": "OpenAI (hub)", + "conceptDiagramTargetLabel": "Provider destino", + "conceptDiagramExampleApp": "ex: SDK Anthropic", + "conceptDiagramExampleSource": "claude", + "conceptDiagramExampleTarget": "Gemini", + "conceptHowItWorksToggle": "Como funciona", + "conceptHowItWorksBody": "Sua app envia um pedido no formato dela. O Translator detecta o formato, converte via OpenAI como hub intermediário (ou direto, quando há tradutor direto disponível), envia ao provider escolhido e devolve a resposta convertida de volta no formato da sua app.", + "tabTranslate": "Translate", + "tabMonitor": "Monitor", + "tabTranslateAriaLabel": "Ir para a aba Translate", + "tabMonitorAriaLabel": "Ir para a aba Monitor", + "simpleAppUsesLabel": "Minha app usa", + "simpleAppUsesHint": "Formato da API que sua app fala (ex: SDK Anthropic = claude).", + "simpleSendToLabel": "Enviar para", + "simpleSendToHint": "Para onde enviar de verdade (provider conectado em OmniRoute).", + "simpleStartWithLabel": "Começar com", + "simpleStartWithExamplePlaceholder": "Selecione um exemplo pronto", + "simpleStartWithCustomOption": "Cole seu request (avançado)", + "simpleModeLabel": "Modo", + "simpleModePreview": "Só ver tradução", + "simpleModeSend": "Enviar e ver resposta", + "simpleAdvancedToggle": "Advanced", + "simpleInputPanelTitle": "Entrada", + "simpleInputPanelHint": "Mensagem em texto livre ou exemplo pronto", + "simpleResultPanelTitle": "Tradução + Resposta", + "narratedDetected": "✓ Detectado: {format}", + "narratedTranslating": "Traduzindo para {target}...", + "narratedSending": "Enviando para {target}...", + "narratedSuccess": "→ traduzido para {target} · resposta em {latency}ms", + "narratedError": "Falhou: {reason}", + "narratedSeeTranslatedJson": "ver JSON traduzido", + "narratedSeePipeline": "ver pipeline", + "advancedSectionTitle": "Advanced", + "advancedSectionSubtitle": "Raw JSON, pipeline e ferramentas técnicas. Tudo aqui é igual às tabs antigas — apenas reorganizado.", + "advancedRawJsonTitle": "Raw JSON (auto-detecção + Monaco)", + "advancedRawJsonSubtitle": "Cole um request JSON; o formato é detectado automaticamente.", + "advancedPipelineTitle": "Pipeline OpenAI intermediário", + "advancedPipelineSubtitle": "Visualize cada passo da tradução (hub-and-spoke).", + "advancedStreamTransformTitle": "Stream Transformer (Chat → Responses SSE)", + "advancedStreamTransformSubtitle": "Converte SSE Chat Completions em Responses API.", + "advancedTestBenchTitle": "Test Bench (8 cenários)", + "advancedTestBenchSubtitle": "Roda todos os cenários e reporta pass/fail + compatibilidade %.", + "advancedCompressionTitle": "Compression Preview", + "advancedCompressionSubtitle": "Estime economia de tokens em diferentes modos.", + "monitorOriginHint": "Eventos gerados pelo Translate ou pelo pipeline principal aparecem aqui em tempo real.", + "monitorEmptyCta": "Volte para a aba Translate e envie um request — ele aparecerá aqui.", + "monitorOpenTranslateButton": "Ir para Translate" }, "usage": { "title": "Uso", diff --git a/tests/unit/translator-friendly-deeplink.test.ts b/tests/unit/translator-friendly-deeplink.test.ts new file mode 100644 index 0000000000..e4a65ef41a --- /dev/null +++ b/tests/unit/translator-friendly-deeplink.test.ts @@ -0,0 +1,227 @@ +/** + * Unit tests for useTranslateDeepLink parsing logic. + * + * Because the hook depends on next/navigation (useRouter / useSearchParams) + * — browser-only globals — we test the *pure parsing logic* extracted here + * rather than mounting the React hook in a JSDOM environment. The hook itself + * is thin wiring; all interesting behaviour is in the parse + merge steps. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// ─── Inline the pure parsing logic (mirrors useTranslateDeepLink internals) ─── + +type TranslatorTab = "translate" | "monitor"; +type TranslateMode = "preview" | "send"; +type AdvancedSlug = "rawjson" | "pipeline" | "streamtransform" | "testbench" | "compression"; + +interface TranslateDeepLink { + tab: TranslatorTab; + mode: TranslateMode; + advanced: AdvancedSlug | null; +} + +const VALID_TABS: ReadonlySet = new Set(["translate", "monitor"]); +const VALID_MODES: ReadonlySet = new Set(["preview", "send"]); +const VALID_ADVANCED: ReadonlySet = new Set([ + "rawjson", + "pipeline", + "streamtransform", + "testbench", + "compression", +]); + +function parseDeepLink(searchString: string): TranslateDeepLink { + const params = new URLSearchParams(searchString); + const tab = params.get("tab"); + const mode = params.get("mode"); + const advanced = params.get("advanced"); + return { + tab: VALID_TABS.has(tab as TranslatorTab) ? (tab as TranslatorTab) : "translate", + mode: VALID_MODES.has(mode as TranslateMode) ? (mode as TranslateMode) : "send", + advanced: + advanced && VALID_ADVANCED.has(advanced as AdvancedSlug) + ? (advanced as AdvancedSlug) + : null, + }; +} + +function applyPatch( + current: TranslateDeepLink, + patch: Partial +): URLSearchParams { + const merged: TranslateDeepLink = { ...current, ...patch }; + const next = new URLSearchParams(); + next.set("tab", merged.tab); + next.set("mode", merged.mode); + if (merged.advanced) next.set("advanced", merged.advanced); + else next.delete("advanced"); + return next; +} + +// ───────────────────────────────────────────────────────────────────────────── + +describe("parseDeepLink — defaults", () => { + it("empty string → translate / send / null", () => { + const state = parseDeepLink(""); + assert.equal(state.tab, "translate"); + assert.equal(state.mode, "send"); + assert.equal(state.advanced, null); + }); + + it("missing params → all defaults", () => { + const state = parseDeepLink("foo=bar"); + assert.equal(state.tab, "translate"); + assert.equal(state.mode, "send"); + assert.equal(state.advanced, null); + }); +}); + +describe("parseDeepLink — valid values", () => { + it("tab=monitor", () => { + const state = parseDeepLink("tab=monitor"); + assert.equal(state.tab, "monitor"); + }); + + it("tab=translate", () => { + const state = parseDeepLink("tab=translate"); + assert.equal(state.tab, "translate"); + }); + + it("mode=preview", () => { + const state = parseDeepLink("mode=preview"); + assert.equal(state.mode, "preview"); + }); + + it("mode=send", () => { + const state = parseDeepLink("mode=send"); + assert.equal(state.mode, "send"); + }); + + it("advanced=rawjson", () => { + const state = parseDeepLink("advanced=rawjson"); + assert.equal(state.advanced, "rawjson"); + }); + + it("advanced=pipeline", () => { + const state = parseDeepLink("advanced=pipeline"); + assert.equal(state.advanced, "pipeline"); + }); + + it("advanced=streamtransform", () => { + const state = parseDeepLink("advanced=streamtransform"); + assert.equal(state.advanced, "streamtransform"); + }); + + it("advanced=testbench", () => { + const state = parseDeepLink("advanced=testbench"); + assert.equal(state.advanced, "testbench"); + }); + + it("advanced=compression", () => { + const state = parseDeepLink("advanced=compression"); + assert.equal(state.advanced, "compression"); + }); + + it("full valid combo", () => { + const state = parseDeepLink("tab=monitor&mode=preview&advanced=testbench"); + assert.equal(state.tab, "monitor"); + assert.equal(state.mode, "preview"); + assert.equal(state.advanced, "testbench"); + }); +}); + +describe("parseDeepLink — invalid / out-of-enum values fall back to default", () => { + it("tab=unknown → translate", () => { + const state = parseDeepLink("tab=unknown"); + assert.equal(state.tab, "translate"); + }); + + it("tab=MONITOR (wrong case) → translate", () => { + const state = parseDeepLink("tab=MONITOR"); + assert.equal(state.tab, "translate"); + }); + + it("mode=live → send", () => { + const state = parseDeepLink("mode=live"); + assert.equal(state.mode, "send"); + }); + + it("advanced=unknown → null", () => { + const state = parseDeepLink("advanced=unknown"); + assert.equal(state.advanced, null); + }); + + it("advanced=RAWJSON (wrong case) → null", () => { + const state = parseDeepLink("advanced=RAWJSON"); + assert.equal(state.advanced, null); + }); +}); + +describe("applyPatch (setTab / setMode / setAdvanced simulation)", () => { + const base = parseDeepLink(""); + + it("setTab(monitor) writes tab=monitor", () => { + const qs = applyPatch(base, { tab: "monitor" }); + assert.equal(qs.get("tab"), "monitor"); + }); + + it("setMode(preview) writes mode=preview", () => { + const qs = applyPatch(base, { mode: "preview" }); + assert.equal(qs.get("mode"), "preview"); + }); + + it("setAdvanced(testbench) writes advanced=testbench", () => { + const qs = applyPatch(base, { advanced: "testbench" }); + assert.equal(qs.get("advanced"), "testbench"); + }); + + it("setAdvanced(null) removes advanced param", () => { + const withAdv = parseDeepLink("advanced=rawjson"); + const qs = applyPatch(withAdv, { advanced: null }); + assert.equal(qs.get("advanced"), null); + }); + + it("patch does not overwrite unrelated keys", () => { + const current = parseDeepLink("tab=monitor&mode=preview&advanced=pipeline"); + const qs = applyPatch(current, { advanced: "compression" }); + assert.equal(qs.get("tab"), "monitor"); + assert.equal(qs.get("mode"), "preview"); + assert.equal(qs.get("advanced"), "compression"); + }); + + it("setTab always preserves mode and advanced", () => { + const current = parseDeepLink("mode=preview&advanced=testbench"); + const qs = applyPatch(current, { tab: "monitor" }); + assert.equal(qs.get("tab"), "monitor"); + assert.equal(qs.get("mode"), "preview"); + assert.equal(qs.get("advanced"), "testbench"); + }); +}); + +describe("all enum values are covered", () => { + const tabs: TranslatorTab[] = ["translate", "monitor"]; + const modes: TranslateMode[] = ["preview", "send"]; + const slugs: AdvancedSlug[] = ["rawjson", "pipeline", "streamtransform", "testbench", "compression"]; + + for (const tab of tabs) { + it(`tab=${tab} round-trips`, () => { + const state = parseDeepLink(`tab=${tab}`); + assert.equal(state.tab, tab); + }); + } + + for (const mode of modes) { + it(`mode=${mode} round-trips`, () => { + const state = parseDeepLink(`mode=${mode}`); + assert.equal(state.mode, mode); + }); + } + + for (const slug of slugs) { + it(`advanced=${slug} round-trips`, () => { + const state = parseDeepLink(`advanced=${slug}`); + assert.equal(state.advanced, slug); + }); + } +}); diff --git a/tests/unit/translator-friendly-i18n-keys.test.ts b/tests/unit/translator-friendly-i18n-keys.test.ts new file mode 100644 index 0000000000..b13abe12f7 --- /dev/null +++ b/tests/unit/translator-friendly-i18n-keys.test.ts @@ -0,0 +1,202 @@ +/** + * Unit tests for i18n key additions in the translator namespace (F1). + * + * Verifies that all ~51 new keys added by F1 are present in both en.json + * and pt-BR.json, and that pt-BR translations are not identical to English + * for the keys that should obviously differ. + * + * Also includes a non-regression check that old keys still exist. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +// ─── Load message files ─────────────────────────────────────────────────────── + +const ROOT = resolve(process.cwd()); + +const en = JSON.parse( + readFileSync(resolve(ROOT, "src/i18n/messages/en.json"), "utf-8") +) as Record; + +const ptBR = JSON.parse( + readFileSync(resolve(ROOT, "src/i18n/messages/pt-BR.json"), "utf-8") +) as Record; + +const enTranslator = (en["translator"] ?? {}) as Record; +const ptBRTranslator = (ptBR["translator"] ?? {}) as Record; + +// ─── New keys added by F1 ───────────────────────────────────────────────────── + +const NEW_KEYS = [ + // Card conceito + tabs + "friendlyTitle", + "friendlySubtitle", + "conceptHeadline", + "conceptDiagramAppLabel", + "conceptDiagramSourceLabel", + "conceptDiagramHubLabel", + "conceptDiagramTargetLabel", + "conceptDiagramExampleApp", + "conceptDiagramExampleSource", + "conceptDiagramExampleTarget", + "conceptHowItWorksToggle", + "conceptHowItWorksBody", + "tabTranslate", + "tabMonitor", + "tabTranslateAriaLabel", + "tabMonitorAriaLabel", + // SimpleControls + ResultNarrated + "simpleAppUsesLabel", + "simpleAppUsesHint", + "simpleSendToLabel", + "simpleSendToHint", + "simpleStartWithLabel", + "simpleStartWithExamplePlaceholder", + "simpleStartWithCustomOption", + "simpleModeLabel", + "simpleModePreview", + "simpleModeSend", + "simpleAdvancedToggle", + "simpleInputPanelTitle", + "simpleInputPanelHint", + "simpleResultPanelTitle", + "narratedDetected", + "narratedTranslating", + "narratedSending", + "narratedSuccess", + "narratedError", + "narratedSeeTranslatedJson", + "narratedSeePipeline", + // Advanced accordions + Monitor hint + "advancedSectionTitle", + "advancedSectionSubtitle", + "advancedRawJsonTitle", + "advancedRawJsonSubtitle", + "advancedPipelineTitle", + "advancedPipelineSubtitle", + "advancedStreamTransformTitle", + "advancedStreamTransformSubtitle", + "advancedTestBenchTitle", + "advancedTestBenchSubtitle", + "advancedCompressionTitle", + "advancedCompressionSubtitle", + "monitorOriginHint", + "monitorEmptyCta", + "monitorOpenTranslateButton", +] as const; + +// ─── Keys that should obviously differ from English (spot check) ────────────── + +const OBVIOUSLY_TRANSLATED_IN_PT = [ + "simpleAppUsesLabel", // "My app uses" vs "Minha app usa" + "simpleSendToLabel", // "Send to" vs "Enviar para" + "simpleModePreview", // "Preview translation only" vs "Só ver tradução" + "simpleModeSend", // "Send and see response" vs "Enviar e ver resposta" + "conceptDiagramAppLabel", // "Your app" vs "Sua app" + "conceptHowItWorksToggle", // "How it works" vs "Como funciona" + "monitorOpenTranslateButton", // "Go to Translate" vs "Ir para Translate" + "simpleStartWithExamplePlaceholder", // "Select a ready-made example" vs "Selecione um exemplo pronto" +]; + +// ─── Old keys that must still exist (non-regression) ───────────────────────── + +const OLD_KEYS_MUST_SURVIVE = [ + "playgroundTitle", + "playground", + "chatTester", + "testBench", + "liveMonitor", + "modeDescriptionPlayground", + "autoFeaturesTitle", + "autoFeaturesCount", + "translateAction", + "inputPlaceholder", + "runAllTests", + "streamTransformerTitle", +]; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("F1 new keys — present in en.json", () => { + for (const key of NEW_KEYS) { + it(`en.translator.${key} exists`, () => { + assert.ok( + key in enTranslator, + `Missing key "translator.${key}" in en.json` + ); + const val = enTranslator[key]; + assert.ok(typeof val === "string" && val.length > 0, `Key "translator.${key}" is empty`); + }); + } +}); + +describe("F1 new keys — present in pt-BR.json", () => { + for (const key of NEW_KEYS) { + it(`pt-BR.translator.${key} exists`, () => { + assert.ok( + key in ptBRTranslator, + `Missing key "translator.${key}" in pt-BR.json` + ); + const val = ptBRTranslator[key]; + assert.ok(typeof val === "string" && val.length > 0, `Key "translator.${key}" is empty in pt-BR`); + }); + } +}); + +describe("PT-BR translations differ from English for obviously translated keys", () => { + for (const key of OBVIOUSLY_TRANSLATED_IN_PT) { + it(`translator.${key} is different between en and pt-BR`, () => { + const enVal = enTranslator[key]; + const ptVal = ptBRTranslator[key]; + assert.notEqual( + enVal, + ptVal, + `Key "translator.${key}" has identical en and pt-BR values: "${enVal}"` + ); + }); + } +}); + +describe("Non-regression — old keys still exist in en.json", () => { + for (const key of OLD_KEYS_MUST_SURVIVE) { + it(`en.translator.${key} still exists`, () => { + assert.ok( + key in enTranslator, + `Old key "translator.${key}" was removed from en.json (regression!)` + ); + }); + } +}); + +describe("Non-regression — old keys still exist in pt-BR.json", () => { + for (const key of OLD_KEYS_MUST_SURVIVE) { + it(`pt-BR.translator.${key} still exists`, () => { + assert.ok( + key in ptBRTranslator, + `Old key "translator.${key}" was removed from pt-BR.json (regression!)` + ); + }); + } +}); + +describe("F1 total new keys count", () => { + it(`at least ${NEW_KEYS.length} new keys exist in en.json`, () => { + const missingKeys = NEW_KEYS.filter((k) => !(k in enTranslator)); + assert.equal( + missingKeys.length, + 0, + `Missing ${missingKeys.length} keys in en.json: ${missingKeys.join(", ")}` + ); + }); + + it(`all ${NEW_KEYS.length} new keys exist in pt-BR.json`, () => { + const missingKeys = NEW_KEYS.filter((k) => !(k in ptBRTranslator)); + assert.equal( + missingKeys.length, + 0, + `Missing ${missingKeys.length} keys in pt-BR.json: ${missingKeys.join(", ")}` + ); + }); +}); diff --git a/tests/unit/translator-friendly-session.test.ts b/tests/unit/translator-friendly-session.test.ts new file mode 100644 index 0000000000..b2f5d820d3 --- /dev/null +++ b/tests/unit/translator-friendly-session.test.ts @@ -0,0 +1,392 @@ +/** + * Unit tests for useTranslateSession logic. + * + * We extract and test the pure session orchestration logic — the fetch orchestration, + * pipeline path selection, and error sanitization — without mounting React hooks. + * The hook wraps this logic in useState/useCallback; the logic itself is testable + * in isolation by replicating the core run() body. + */ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; + +// ─── Types (mirroring types.ts) ─────────────────────────────────────────────── + +type FormatId = "openai" | "openai-responses" | "claude" | "gemini" | "antigravity" | "kiro" | "cursor"; +type TranslateMode = "preview" | "send"; + +interface TranslateNarratedResult { + detected: FormatId | null; + target: FormatId; + status: "idle" | "translating" | "sending" | "ok" | "error"; + responsePreview: string | null; + translatedJson: string | null; + pipelinePath: "direct" | "hub-and-spoke" | "passthrough" | null; + intermediateJson: string | null; + errorMessage: string | null; + latencyMs: number | null; +} + +interface RunInput { + source: FormatId; + target: FormatId; + provider: string; + inputText: string; + mode: TranslateMode; +} + +// ─── Extracted sanitizeError logic ─────────────────────────────────────────── + +function sanitizeError(raw: unknown): string { + const text = + raw instanceof Error ? raw.message : typeof raw === "string" ? raw : "Unknown error"; + return text + .replace(/\sat\s\/[^\s]+/g, "") + .replace(/sk-[A-Za-z0-9_-]{16,}/g, "[REDACTED]") + .replace(/Bearer\s+[A-Za-z0-9_.-]+/g, "Bearer [REDACTED]"); +} + +// ─── Extracted run() logic (mirrors useTranslateSession hook implementation) ─ + +type FetchFn = (url: string, init?: RequestInit) => Promise; + +async function runSession( + input: RunInput, + fetchImpl: FetchFn +): Promise { + const { source, target, provider, inputText, mode } = input; + const target_: FormatId = target; + let detected: FormatId | null = null; + let translatedJson: string | null = null; + let intermediateJson: string | null = null; + let pipelinePath: TranslateNarratedResult["pipelinePath"] = "passthrough"; + let translatedResult: Record; + let responsePreview: string | null = null; + + // 1. Parse input + let parsed: Record; + try { + parsed = JSON.parse(inputText); + } catch { + parsed = { messages: [{ role: "user", content: inputText }] }; + } + + // 2. Detect format + try { + const detectRes = await fetchImpl("/api/translator/detect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: parsed }), + }); + const detectData = (await detectRes.json()) as { success: boolean; format?: string }; + if (detectData.success) detected = detectData.format as FormatId; + } catch { + /* non-fatal */ + } + + // 3. Translate + translatedResult = parsed; + if (source !== target) { + const needsHub = source !== "openai" && target !== "openai"; + if (needsHub) { + const step1 = await fetchImpl("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: "direct", sourceFormat: source, targetFormat: "openai", body: parsed }), + }); + const step1Data = (await step1.json()) as { success: boolean; result?: Record; error?: string }; + if (!step1Data.success) throw new Error(step1Data.error ?? "Translate step 1 failed"); + intermediateJson = JSON.stringify(step1Data.result, null, 2); + + const step2 = await fetchImpl("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: "direct", sourceFormat: "openai", targetFormat: target, body: step1Data.result }), + }); + const step2Data = (await step2.json()) as { success: boolean; result?: Record; error?: string }; + if (!step2Data.success) throw new Error(step2Data.error ?? "Translate step 2 failed"); + translatedResult = step2Data.result as Record; + translatedJson = JSON.stringify(step2Data.result, null, 2); + pipelinePath = "hub-and-spoke"; + } else { + const stepDirect = await fetchImpl("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: "direct", sourceFormat: source, targetFormat: target, body: parsed }), + }); + const stepData = (await stepDirect.json()) as { success: boolean; result?: Record; error?: string }; + if (!stepData.success) throw new Error(stepData.error ?? "Translate failed"); + translatedResult = stepData.result as Record; + translatedJson = JSON.stringify(stepData.result, null, 2); + pipelinePath = "direct"; + } + } else { + translatedJson = JSON.stringify(parsed, null, 2); + } + + // 4. Optional send + if (mode === "send") { + const sendRes = await fetchImpl("/api/translator/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, body: translatedResult }), + }); + if (!sendRes.ok) { + const errorBody = (await sendRes.json().catch(() => ({ error: `HTTP ${sendRes.status}` }))) as { error?: unknown }; + throw new Error(typeof errorBody.error === "string" ? errorBody.error : "Send failed"); + } + const reader = sendRes.body?.getReader(); + if (reader) { + const decoder = new TextDecoder(); + let buf = ""; + while (buf.length < 500) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + } + responsePreview = buf.slice(0, 500); + } + } + + return { + detected, + target: target_, + status: "ok", + responsePreview, + translatedJson, + pipelinePath, + intermediateJson, + errorMessage: null, + latencyMs: 0, + }; +} + +// ─── Fetch call tracker ─────────────────────────────────────────────────────── + +interface FetchCall { + url: string; + body: unknown; +} + +let fetchCalls: FetchCall[] = []; + +beforeEach(() => { + fetchCalls = []; +}); + +function makeBody(body: unknown): ReadableStream | null { + const text = typeof body === "string" ? body : JSON.stringify(body); + const encoder = new TextEncoder(); + const bytes = encoder.encode(text); + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("mode=preview, source === target (passthrough)", () => { + it("pipelinePath is passthrough, no translate fetch", async () => { + const fetchMock: FetchFn = async (url, init) => { + const body = init?.body ? JSON.parse(init.body as string) : null; + fetchCalls.push({ url, body }); + if (url.includes("detect")) { + return new Response(JSON.stringify({ success: true, format: "openai" }), { status: 200 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }; + + const result = await runSession( + { source: "openai", target: "openai", provider: "openai", inputText: '{"messages":[]}', mode: "preview" }, + fetchMock + ); + + assert.equal(result.pipelinePath, "passthrough"); + assert.equal(result.status, "ok"); + const translateCalls = fetchCalls.filter((c) => c.url.includes("translate")); + assert.equal(translateCalls.length, 0); + const sendCalls = fetchCalls.filter((c) => c.url.includes("send")); + assert.equal(sendCalls.length, 0); + }); +}); + +describe("mode=preview, claude → gemini (hub-and-spoke)", () => { + it("calls translate twice (step1: claude→openai, step2: openai→gemini), pipelinePath=hub-and-spoke", async () => { + const fetchMock: FetchFn = async (url, init) => { + const body = init?.body ? JSON.parse(init.body as string) : null; + fetchCalls.push({ url, body }); + if (url.includes("detect")) { + return new Response(JSON.stringify({ success: true, format: "claude" }), { status: 200 }); + } + if (url.includes("translate")) { + const b = body as { targetFormat?: string }; + if (b.targetFormat === "openai") { + return new Response(JSON.stringify({ success: true, result: { intermediate: true } }), { status: 200 }); + } + return new Response(JSON.stringify({ success: true, result: { gemini: true } }), { status: 200 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }; + + const result = await runSession( + { source: "claude", target: "gemini", provider: "gemini", inputText: '{"messages":[]}', mode: "preview" }, + fetchMock + ); + + assert.equal(result.pipelinePath, "hub-and-spoke"); + assert.equal(result.status, "ok"); + assert.ok(result.intermediateJson !== null, "intermediateJson should be set"); + assert.ok(result.translatedJson !== null, "translatedJson should be set"); + const translateCalls = fetchCalls.filter((c) => c.url.includes("translate")); + assert.equal(translateCalls.length, 2); + const sendCalls = fetchCalls.filter((c) => c.url.includes("send")); + assert.equal(sendCalls.length, 0); + }); +}); + +describe("mode=preview, openai → claude (direct)", () => { + it("calls translate once, pipelinePath=direct", async () => { + const fetchMock: FetchFn = async (url, init) => { + const body = init?.body ? JSON.parse(init.body as string) : null; + fetchCalls.push({ url, body }); + if (url.includes("detect")) { + return new Response(JSON.stringify({ success: true, format: "openai" }), { status: 200 }); + } + if (url.includes("translate")) { + return new Response(JSON.stringify({ success: true, result: { claude: true } }), { status: 200 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }; + + const result = await runSession( + { source: "openai", target: "claude", provider: "claude", inputText: '{"messages":[]}', mode: "preview" }, + fetchMock + ); + + assert.equal(result.pipelinePath, "direct"); + assert.equal(result.status, "ok"); + const translateCalls = fetchCalls.filter((c) => c.url.includes("translate")); + assert.equal(translateCalls.length, 1); + }); +}); + +describe("mode=send happy path", () => { + it("calls detect + translate + send; status=ok, responsePreview populated", async () => { + const fetchMock: FetchFn = async (url, init) => { + const body = init?.body ? JSON.parse(init.body as string) : null; + fetchCalls.push({ url, body }); + if (url.includes("detect")) { + return new Response(JSON.stringify({ success: true, format: "openai" }), { status: 200 }); + } + if (url.includes("translate")) { + return new Response(JSON.stringify({ success: true, result: { openai: true } }), { status: 200 }); + } + if (url.includes("send")) { + return new Response(makeBody("hello from provider"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }; + + const result = await runSession( + { source: "openai", target: "claude", provider: "claude", inputText: '{"messages":[]}', mode: "send" }, + fetchMock + ); + + assert.equal(result.status, "ok"); + const detectCalls = fetchCalls.filter((c) => c.url.includes("detect")); + const translateCalls = fetchCalls.filter((c) => c.url.includes("translate")); + const sendCalls = fetchCalls.filter((c) => c.url.includes("send")); + assert.equal(detectCalls.length, 1); + assert.equal(translateCalls.length, 1); + assert.equal(sendCalls.length, 1); + assert.ok(result.responsePreview !== null, "responsePreview should be populated"); + }); +}); + +describe("error path — sanitization", () => { + it("error message does not contain stack trace ('at /')", async () => { + const fakeStack = "Translate failed at /home/user/foo.ts:42:10 sk-abcdefghijklmnopqrstuvwxyz1234567890"; + const sanitized = sanitizeError(new Error(fakeStack)); + assert.ok(!sanitized.includes("at /"), `Expected no stack trace in: ${sanitized}`); + assert.ok(!sanitized.includes("sk-"), `Expected no API key in: ${sanitized}`); + }); + + it("error with Bearer token is redacted", () => { + const msg = "Auth failed: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.somepayload.signature"; + const sanitized = sanitizeError(new Error(msg)); + assert.ok(!sanitized.includes("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), `Token not redacted in: ${sanitized}`); + assert.ok(sanitized.includes("Bearer [REDACTED]"), `Expected Bearer [REDACTED] in: ${sanitized}`); + }); + + it("translate fetch 500 with stack trace in error body does not leak", async () => { + const fetchMock: FetchFn = async (url, init) => { + const body = init?.body ? JSON.parse(init.body as string) : null; + fetchCalls.push({ url, body }); + if (url.includes("detect")) { + return new Response(JSON.stringify({ success: false, format: null }), { status: 200 }); + } + if (url.includes("translate")) { + return new Response( + JSON.stringify({ success: false, error: "internal error at /home/user/foo.ts:42 sk-abcdefghijklmnopqrstuvwxyz1234567890" }), + { status: 500 } + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }; + + let caught: string | null = null; + try { + await runSession( + { source: "openai", target: "claude", provider: "claude", inputText: '{"messages":[]}', mode: "preview" }, + fetchMock + ); + } catch (err) { + caught = sanitizeError(err); + } + + assert.ok(caught !== null, "should have thrown"); + // The error message from the fake response is thrown as-is (not sanitized in run()) + // but the hook's catch() applies sanitizeError. We verify the sanitizer works: + assert.ok(!caught.includes("at /"), `Stack trace leaked: ${caught}`); + assert.ok(!caught.includes("sk-"), `API key leaked: ${caught}`); + }); + + it("non-Error thrown value → 'Unknown error'", () => { + const sanitized = sanitizeError(42); + assert.equal(sanitized, "Unknown error"); + }); + + it("string error → sanitized", () => { + const sanitized = sanitizeError("error at /opt/node/foo.ts:10"); + assert.ok(!sanitized.includes("at /"), `Stack trace leaked: ${sanitized}`); + }); +}); + +describe("reset — returns idle state", () => { + it("initialResult(openai) matches idle defaults", () => { + // Replicate initialResult function + const initial: TranslateNarratedResult = { + detected: null, + target: "openai", + status: "idle", + responsePreview: null, + translatedJson: null, + pipelinePath: null, + intermediateJson: null, + errorMessage: null, + latencyMs: null, + }; + assert.equal(initial.status, "idle"); + assert.equal(initial.detected, null); + assert.equal(initial.responsePreview, null); + assert.equal(initial.translatedJson, null); + assert.equal(initial.pipelinePath, null); + assert.equal(initial.errorMessage, null); + assert.equal(initial.latencyMs, null); + }); +}); From 8dbd0a9d1c9711749eb0f39bf1e86ec64861c560 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 20:27:51 -0300 Subject: [PATCH 03/24] feat(translator): add TestBenchAccordion (F6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor of TestBenchMode wrapped in Collapsible with lazy-render (D7) - Preserves 8 scenarios, runAll, per-scenario re-run, pass/fail badges, compatibility report - Reuses useProviderOptions + useAvailableModels (D12) - POST /api/translator/translate + /api/translator/send unchanged - Hard Rule #12: error display uses err.message only (no stack trace) - 17 Vitest tests: smoke render, lazy-render guard, Run All 8 fetches, results running→pass, per-scenario re-run, error sanitization --- .../advanced/TestBenchAccordion.tsx | 491 +++++++++++ .../translator-friendly-test-bench.test.tsx | 811 ++++++++++++++++++ 2 files changed, 1302 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion.tsx create mode 100644 tests/unit/translator-friendly-test-bench.test.tsx diff --git a/src/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion.tsx b/src/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion.tsx new file mode 100644 index 0000000000..6c1cb62d5c --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion.tsx @@ -0,0 +1,491 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { useTranslations } from "next-intl"; +import Collapsible from "@/shared/components/Collapsible"; +import { Card, Button, Select, Badge } from "@/shared/components"; +import { getExampleTemplates, FORMAT_META, FORMAT_OPTIONS } from "../../exampleTemplates"; +import { useProviderOptions } from "../../hooks/useProviderOptions"; +import { useAvailableModels } from "../../hooks/useAvailableModels"; +import type { AdvancedAccordionProps } from "../../types"; + +/** + * TestBenchAccordion — Refactor of TestBenchMode wrapped in Collapsible. + * + * Preserves 100% functional parity with TestBenchMode.tsx: + * - 8 scenarios (simple-chat, tool-calling, multi-turn, thinking, system-prompt, + * streaming, vision, schema-coercion) + * - runScenario: translate + send per scenario + * - runAll: sequential execution of all 8 + * - per-scenario re-run + * - pass/fail/running badges + * - compatibility % report + * + * Wrapped in Collapsible with lazy-render guard (D7). + * Reuses useProviderOptions("openai") + useAvailableModels() (D12). + */ + +const SCENARIOS = [ + { id: "simple-chat", icon: "chat", templateId: "simple-chat" }, + { id: "tool-calling", icon: "build", templateId: "tool-calling" }, + { id: "multi-turn", icon: "forum", templateId: "multi-turn" }, + { id: "thinking", icon: "psychology", templateId: "thinking" }, + { id: "system-prompt", icon: "settings", templateId: "system-prompt" }, + { id: "streaming", icon: "stream", templateId: "streaming" }, + { id: "vision", icon: "image", templateId: "vision" }, + { id: "schema-coercion", icon: "schema", templateId: "schema-coercion" }, +]; + +interface ScenarioResult { + status: "running" | "pass" | "error"; + latency?: number; + chunks?: number; + error?: string; + httpStatus?: number; +} + +type ResultsMap = Record; + +interface TestBenchAccordionProps extends Omit { + forceOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + +function TestBenchContent() { + const t = useTranslations("translator"); + + const translateOrFallback = (key: string, fallback: string): string => { + try { + const translated = t(key); + return translated === key || translated === `translator.${key}` ? fallback : translated; + } catch { + return fallback; + } + }; + + const scenarioLabels: Record = { + "simple-chat": t("scenarioSimpleChat"), + "tool-calling": t("scenarioToolCalling"), + "multi-turn": t("scenarioMultiTurn"), + thinking: t("scenarioThinking"), + "system-prompt": t("scenarioSystemPrompt"), + streaming: t("scenarioStreaming"), + vision: translateOrFallback("scenarioVision", "Vision"), + "schema-coercion": translateOrFallback("scenarioSchemaCoercion", "Schema Coercion"), + }; + + const templates = useMemo(() => getExampleTemplates(t), [t]); + const [sourceFormat, setSourceFormat] = useState("claude"); + const { provider, setProvider, providerOptions } = useProviderOptions("openai"); + const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels(); + const [results, setResults] = useState({}); + const [runningAll, setRunningAll] = useState(false); + + // Pick a smart default model when source format changes or models finish loading + useEffect(() => { + const picked = pickModelForFormat(sourceFormat); + if (picked) setModel(picked); + }, [sourceFormat, pickModelForFormat, setModel]); + + const runScenario = async (scenario: { id: string; icon: string; templateId: string }) => { + setResults((prev) => ({ ...prev, [scenario.id]: { status: "running" } })); + + const start = Date.now(); + try { + // Find template + const template = templates.find((item) => item.id === scenario.templateId); + const formatKey = sourceFormat as keyof typeof template.formats; + const body = template?.formats[formatKey] || template?.formats.openai; + + if (!body) { + setResults((prev) => ({ + ...prev, + [scenario.id]: { + status: "error", + error: t("noTemplateForFormat"), + latency: 0, + }, + })); + return; + } + + // Override model in template body with user-selected model + const bodyWithModel: Record = { ...body, model }; + // For Gemini format that uses 'contents' instead of 'messages' + if ((body as Record).contents) bodyWithModel.model = model; + + // Step 1: Translate + const translateRes = await fetch("/api/translator/translate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ step: "direct", sourceFormat, provider, body: bodyWithModel }), + }); + const translateData = (await translateRes.json()) as { + success: boolean; + result?: Record; + error?: string; + }; + + if (!translateData.success) { + setResults((prev) => ({ + ...prev, + [scenario.id]: { + status: "error", + error: t("translationFailed", { error: translateData.error ?? "" }), + latency: Date.now() - start, + }, + })); + return; + } + + // Step 2: Send to provider + const sendRes = await fetch("/api/translator/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, body: translateData.result }), + }); + + const latency = Date.now() - start; + + if (!sendRes.ok) { + const errData = await sendRes.json().catch(() => ({})) as { error?: string }; + setResults((prev) => ({ + ...prev, + [scenario.id]: { + status: "error", + error: errData.error || t("errorMessage", { message: `HTTP ${sendRes.status}` }), + latency, + httpStatus: sendRes.status, + }, + })); + return; + } + + // Read response to consume stream + const reader = sendRes.body?.getReader(); + let chunks = 0; + if (reader) { + while (true) { + const { done } = await reader.read(); + if (done) break; + chunks++; + } + } + + setResults((prev) => ({ + ...prev, + [scenario.id]: { status: "pass", latency: Date.now() - start, chunks }, + })); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : t("errorMessage", { message: "Unknown error" }); + setResults((prev) => ({ + ...prev, + [scenario.id]: { status: "error", error: errorMessage, latency: Date.now() - start }, + })); + } + }; + + const handleRunAll = async () => { + setRunningAll(true); + setResults({}); + for (const scenario of SCENARIOS) { + await runScenario(scenario); + } + setRunningAll(false); + }; + + const passCount = Object.values(results).filter((r) => r.status === "pass").length; + const failCount = Object.values(results).filter((r) => r.status === "error").length; + const totalRun = passCount + failCount; + const compatibility = totalRun > 0 ? Math.round((passCount / totalRun) * 100) : 0; + const srcMeta = FORMAT_META[sourceFormat as keyof typeof FORMAT_META] || FORMAT_META.openai; + + return ( +
+ {/* Info Banner */} +
+ + info + +
+

{t("compatibilityTester")}

+

{t("testBenchDescription")}

+
+
+ + {/* Controls */} + +
+
+
+ + { + setProvider(e.target.value); + setResults({}); + }} + options={providerOptions} + /> +
+ +
+
+ +
+ setModel(e.target.value)} + list="testbench-acc-model-suggestions" + placeholder={t("modelPlaceholder")} + className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors" + /> + + {availableModels.map((m) => ( + +
+
+
+
+ + {/* Results summary bar */} + {totalRun > 0 && ( + +
+
+
+

{t("compatibilityReport")}

+ = 80 ? "success" : compatibility >= 50 ? "warning" : "error" + } + size="lg" + > + {compatibility}% + +
+
+ + {passCount} {t("passed")} + + + {failCount} {t("failed")} + +
+
+ {/* Progress bar */} +
+
+
+
+ + )} + + {/* Scenario cards */} +
+ {SCENARIOS.map((scenario) => { + const result = results[scenario.id]; + const isRunning = result?.status === "running"; + + return ( + +
+
+
+
+ + {isRunning + ? "progress_activity" + : result?.status === "pass" + ? "check_circle" + : result?.status === "error" + ? "error" + : scenario.icon} + +
+
+

+ {scenarioLabels[scenario.id] || scenario.id} +

+

+ {srcMeta.label} →{" "} + {providerOptions.find((o) => o.value === provider)?.label || provider} +

+
+
+
+ + {/* Result details */} + {result && result.status !== "running" && ( +
+ {result.status === "pass" ? ( +
+ {t("passedIconLabel")} + + {result.latency}ms • {result.chunks} {t("chunks")} + +
+ ) : ( +
+

❌ {result.error}

+

{result.latency}ms

+
+ )} +
+ )} + + +
+
+ ); + })} +
+
+ ); +} + +export default function TestBenchAccordion({ + forceOpen, + onOpenChange, +}: TestBenchAccordionProps) { + const t = useTranslations("translator"); + + const translateOrFallback = (key: string, fallback: string): string => { + try { + const translated = t(key); + return translated === key || translated === `translator.${key}` ? fallback : translated; + } catch { + return fallback; + } + }; + + /** + * Lazy-render guard (D7): Collapsible already gates children behind `open && ...` + * so children are not rendered when closed. But once the user opens it the first + * time, we want to keep TestBenchContent mounted even after re-closing (so state + * like results/runningAll is preserved across open/close cycles). + * + * Strategy: + * - `hasOpened` starts as `forceOpen ?? false`. + * - We pass a sentinel as children when `!hasOpened`. Because Collapsible only + * renders children when open=true, the sentinel mounts on first open → fires + * onFirstOpen → `hasOpened` flips to true → TestBenchContent mounts and stays. + * - When `hasOpened` is true, TestBenchContent renders inside Collapsible. + * Collapsible hides it via CSS (via `open &&`) on close, but since hasOpened + * is true, it will re-mount on next open with preserved state. + * + * Note: Collapsible does not expose onOpenChange, so we call `onOpenChange` prop + * from the sentinel's mount (first open) and rely on it being optional. + */ + const [hasOpened, setHasOpened] = useState(forceOpen ?? false); + + return ( + + {hasOpened ? ( + + ) : ( + // Sentinel: Collapsible only renders children when open=true. + // Mounting this means we just opened for the first time. + { + setHasOpened(true); + onOpenChange?.(true); + }} + /> + )} + + ); +} + +/** + * Sentinel component for the lazy-render guard (D7). + * Because Collapsible only renders children when open=true, mounting this + * component signals the first open event. Calls onFirstOpen once on mount. + */ +function TestBenchAccordionLazyMount({ onFirstOpen }: { onFirstOpen: () => void }) { + useEffect(() => { + onFirstOpen(); + // Intentionally run only once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return null; +} diff --git a/tests/unit/translator-friendly-test-bench.test.tsx b/tests/unit/translator-friendly-test-bench.test.tsx new file mode 100644 index 0000000000..854db2d0cb --- /dev/null +++ b/tests/unit/translator-friendly-test-bench.test.tsx @@ -0,0 +1,811 @@ +// @vitest-environment jsdom +/** + * Unit tests for TestBenchAccordion (F6). + * + * Covers: + * - Smoke render (default closed — lazy-render guard) + * - Lazy-render: content not mounted when accordion is closed + * - forceOpen=true mounts content immediately + * - "Run All" fires 8 sequential fetches (translate + send each) + * - Results state transitions: running → pass + * - Per-scenario re-run fires only that scenario's fetches + * - Error display: error message shown without stack trace + * - Error sanitization: stack trace patterns not leaked to UI + */ +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── i18n stub ────────────────────────────────────────────────────────────── +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, params?: Record) => { + // Return key-based human-readable labels for assertions + if (key === "runAllTests") return "Run All Tests"; + if (key === "runTest") return "Run Test"; + if (key === "reRun") return "Re-Run"; + if (key === "running") return "Running..."; + if (key === "passed") return "passed"; + if (key === "failed") return "failed"; + if (key === "compatibilityReport") return "Compatibility Report"; + if (key === "passedIconLabel") return "✓ Passed"; + if (key === "chunks") return "chunks"; + if (key === "source") return "Source"; + if (key === "targetProvider") return "Target Provider"; + if (key === "model") return "Model"; + if (key === "modelPlaceholder") return "Enter model name"; + if (key === "compatibilityTester") return "Compatibility Tester"; + if (key === "testBenchDescription") return "Run translation scenarios"; + if (key === "noTemplateForFormat") return "No template for format"; + if (key === "translationFailed") return `Translation failed: ${params?.error ?? ""}`; + if (key === "errorMessage") return `Error: ${params?.message ?? ""}`; + if (key === "scenarioSimpleChat") return "Simple Chat"; + if (key === "scenarioToolCalling") return "Tool Calling"; + if (key === "scenarioMultiTurn") return "Multi-Turn"; + if (key === "scenarioThinking") return "Thinking"; + if (key === "scenarioSystemPrompt") return "System Prompt"; + if (key === "scenarioStreaming") return "Streaming"; + if (key === "advancedTestBenchTitle") return "Test Bench (8 cenários)"; + if (key === "advancedTestBenchSubtitle") return "Roda todos os cenários e reporta pass/fail + compatibilidade %."; + return key; + }, +})); + +// ── Collapsible stub ──────────────────────────────────────────────────────── +// Renders children directly (open by default in tests, unless we override). +// We expose a data attribute to let tests verify the title is passed. +vi.mock("@/shared/components/Collapsible", () => ({ + default: ({ + children, + title, + subtitle, + icon, + defaultOpen, + }: { + children: React.ReactNode; + title?: React.ReactNode; + subtitle?: React.ReactNode; + icon?: string; + defaultOpen?: boolean; + className?: string; + }) => ( +
+ {defaultOpen !== false && children} +
+ ), +})); + +// ── Shared components stubs ───────────────────────────────────────────────── +vi.mock("@/shared/components", () => ({ + Card: ({ + children, + className, + }: { + children: React.ReactNode; + className?: string; + }) =>
{children}
, + + Button: ({ + children, + onClick, + disabled, + loading, + icon, + "aria-label": ariaLabel, + className, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + loading?: boolean; + icon?: string; + "aria-label"?: string; + className?: string; + size?: string; + variant?: string; + }) => ( + + ), + + Select: ({ + value, + onChange, + options, + }: { + value: string; + onChange: (e: { target: { value: string } }) => void; + options: Array<{ value: string; label: string }>; + }) => ( + + ), + + Badge: ({ + children, + variant, + size, + }: { + children: React.ReactNode; + variant?: string; + size?: string; + }) => ( + + {children} + + ), +})); + +// ── Hook stubs ────────────────────────────────────────────────────────────── +vi.mock( + "@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions", + () => ({ + useProviderOptions: () => ({ + provider: "openai", + setProvider: vi.fn(), + providerOptions: [ + { value: "openai", label: "OpenAI" }, + { value: "anthropic", label: "Anthropic" }, + ], + loading: false, + }), + }), +); + +vi.mock( + "@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels", + () => ({ + useAvailableModels: () => ({ + model: "gpt-4o", + setModel: vi.fn(), + availableModels: ["gpt-4o", "gpt-3.5-turbo", "claude-sonnet-4-20250514"], + loading: false, + pickModelForFormat: (format: string) => { + if (format === "claude") return "claude-sonnet-4-20250514"; + return "gpt-4o"; + }, + }), + }), +); + +// ── exampleTemplates stub ─────────────────────────────────────────────────── +vi.mock( + "@/app/(dashboard)/dashboard/translator/exampleTemplates", + () => ({ + getExampleTemplates: () => [ + { + id: "simple-chat", + name: "Simple Chat", + icon: "chat", + description: "Simple chat", + formats: { + claude: { model: "claude-sonnet-4-20250514", messages: [{ role: "user", content: "Hello" }] }, + openai: { model: "gpt-4o", messages: [{ role: "user", content: "Hello" }] }, + }, + }, + { + id: "tool-calling", + name: "Tool Calling", + icon: "build", + description: "Tool calling", + formats: { + openai: { model: "gpt-4o", messages: [{ role: "user", content: "Weather?" }] }, + }, + }, + { + id: "multi-turn", + name: "Multi-Turn", + icon: "forum", + description: "Multi-turn", + formats: { + openai: { model: "gpt-4o", messages: [] }, + }, + }, + { + id: "thinking", + name: "Thinking", + icon: "psychology", + description: "Thinking", + formats: { + openai: { model: "o3-mini", messages: [] }, + }, + }, + { + id: "system-prompt", + name: "System Prompt", + icon: "settings", + description: "System prompt", + formats: { + openai: { model: "gpt-4o", messages: [] }, + }, + }, + { + id: "streaming", + name: "Streaming", + icon: "stream", + description: "Streaming", + formats: { + openai: { model: "gpt-4o", messages: [] }, + }, + }, + { + id: "vision", + name: "Vision", + icon: "image", + description: "Vision", + formats: { + openai: { model: "gpt-4o", messages: [] }, + }, + }, + { + id: "schema-coercion", + name: "Schema Coercion", + icon: "schema", + description: "Schema coercion", + formats: { + openai: { model: "gpt-4o", messages: [] }, + }, + }, + ], + FORMAT_META: { + openai: { label: "OpenAI", color: "emerald", icon: "smart_toy" }, + claude: { label: "Claude", color: "orange", icon: "psychology" }, + gemini: { label: "Gemini", color: "blue", icon: "auto_awesome" }, + }, + FORMAT_OPTIONS: [ + { value: "openai", label: "OpenAI" }, + { value: "claude", label: "Claude" }, + { value: "gemini", label: "Gemini" }, + ], + }), +); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => container.remove()); + return container; +} + +/** + * Build a mock fetch that returns success for translate + a readable stream for send. + */ +function makeFetchMock(opts: { + translateOk?: boolean; + sendOk?: boolean; + translateError?: string; + sendHttpStatus?: number; +} = {}) { + const { translateOk = true, sendOk = true, translateError, sendHttpStatus = 200 } = opts; + + return vi.fn().mockImplementation((url: string) => { + if ((url as string).includes("/api/translator/translate")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve( + translateOk + ? { success: true, result: { model: "gpt-4o", messages: [] } } + : { success: false, error: translateError ?? "translate error" }, + ), + }); + } + if ((url as string).includes("/api/translator/send")) { + if (!sendOk) { + return Promise.resolve({ + ok: false, + status: sendHttpStatus, + json: () => Promise.resolve({ error: `HTTP ${sendHttpStatus}` }), + body: null, + }); + } + // Readable stream with 2 chunks + const encoder = new TextEncoder(); + let step = 0; + const readable = new ReadableStream({ + pull(controller) { + if (step === 0) { + controller.enqueue(encoder.encode("data: chunk1\n\n")); + step++; + } else { + controller.close(); + } + }, + }); + return Promise.resolve({ + ok: true, + status: 200, + body: readable, + json: () => Promise.resolve({}), + }); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("TestBenchAccordion", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + vi.restoreAllMocks(); + }); + + // ── Module export ────────────────────────────────────────────────────────── + + it("exports a default function component", async () => { + const mod = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + expect(typeof mod.default).toBe("function"); + }); + + // ── Smoke render (closed by default) ───────────────────────────────────── + + it("renders Collapsible with correct title and icon when defaultOpen=false", async () => { + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const collapsible = container.querySelector("[data-testid='collapsible']"); + expect(collapsible).toBeTruthy(); + expect(collapsible?.getAttribute("data-icon")).toBe("science"); + // defaultOpen=false means content not rendered (lazy-render guard) + expect(collapsible?.getAttribute("data-default-open")).toBe("false"); + }); + + // ── Lazy-render guard ────────────────────────────────────────────────────── + + it("does not render scenario cards when defaultOpen is false (lazy-render guard)", async () => { + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // When Collapsible stub renders with defaultOpen=false, children are suppressed + const cards = container.querySelectorAll("[data-testid='card']"); + expect(cards.length).toBe(0); + }); + + // ── forceOpen renders content immediately ───────────────────────────────── + + it("renders TestBenchContent immediately when forceOpen=true", async () => { + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + // Should have rendered cards (info banner + controls + 8 scenarios) + const cards = container.querySelectorAll("[data-testid='card']"); + expect(cards.length).toBeGreaterThan(0); + }); + + it("Collapsible gets defaultOpen=true when forceOpen=true", async () => { + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const collapsible = container.querySelector("[data-testid='collapsible']"); + expect(collapsible?.getAttribute("data-default-open")).toBe("true"); + }); + + // ── Controls render ─────────────────────────────────────────────────────── + + it("renders source select, provider select, and Run All button when open", async () => { + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const selects = container.querySelectorAll("[data-testid='select']"); + expect(selects.length).toBeGreaterThanOrEqual(2); + + const buttons = container.querySelectorAll("[data-testid='button']"); + const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")); + expect(runAllBtn).toBeTruthy(); + }); + + it("renders 8 scenario buttons (one per scenario) when open", async () => { + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const buttons = container.querySelectorAll("[data-testid='button']"); + // 1 Run All + 8 scenario Run Test buttons + const runTestBtns = Array.from(buttons).filter((b) => + b.textContent?.includes("Run Test") || b.textContent?.includes("Re-Run") + ); + expect(runTestBtns.length).toBe(8); + }); + + // ── Run All fires 8 fetches (translate + send each) ─────────────────────── + + it("clicking Run All fires 8 translate + 8 send fetches sequentially", async () => { + const fetchMock = makeFetchMock(); + vi.stubGlobal("fetch", fetchMock); + + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const buttons = container.querySelectorAll("[data-testid='button']"); + const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")) as + | HTMLButtonElement + | undefined; + expect(runAllBtn).toBeTruthy(); + + await act(async () => { + runAllBtn?.click(); + }); + + const translateCalls = fetchMock.mock.calls.filter((c) => + (c[0] as string).includes("/api/translator/translate"), + ); + const sendCalls = fetchMock.mock.calls.filter((c) => + (c[0] as string).includes("/api/translator/send"), + ); + // 8 scenarios × 1 translate each + expect(translateCalls.length).toBe(8); + // 8 scenarios × 1 send each (translate succeeded for all) + expect(sendCalls.length).toBe(8); + }); + + // ── Results state: running → pass ────────────────────────────────────────── + + it("results map updates from running to pass after Run All completes", async () => { + const fetchMock = makeFetchMock(); + vi.stubGlobal("fetch", fetchMock); + + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const buttons = container.querySelectorAll("[data-testid='button']"); + const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")) as + | HTMLButtonElement + | undefined; + + await act(async () => { + runAllBtn?.click(); + }); + + // After completion, scenario buttons should show "Re-Run" (result exists) + const reRunBtns = Array.from( + container.querySelectorAll("[data-testid='button']"), + ).filter((b) => b.textContent?.includes("Re-Run")); + // All 8 should show re-run + expect(reRunBtns.length).toBe(8); + }); + + it("compatibility report badge appears after Run All completes", async () => { + const fetchMock = makeFetchMock(); + vi.stubGlobal("fetch", fetchMock); + + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const buttons = container.querySelectorAll("[data-testid='button']"); + const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")) as + | HTMLButtonElement + | undefined; + + await act(async () => { + runAllBtn?.click(); + }); + + // Compatibility Report section should be visible + const text = container.textContent ?? ""; + expect(text).toContain("Compatibility Report"); + // Badge with percentage + const badges = container.querySelectorAll("[data-testid='badge']"); + expect(badges.length).toBeGreaterThan(0); + const badgeTexts = Array.from(badges).map((b) => b.textContent?.trim()); + const hasPercent = badgeTexts.some((t) => t?.includes("%")); + expect(hasPercent).toBe(true); + }); + + // ── Per-scenario re-run ─────────────────────────────────────────────────── + + it("clicking re-run on one scenario fires only that scenario's fetches", async () => { + const fetchMock = makeFetchMock(); + vi.stubGlobal("fetch", fetchMock); + + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + // Run All first to populate results + const buttons = container.querySelectorAll("[data-testid='button']"); + const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")) as + | HTMLButtonElement + | undefined; + await act(async () => { + runAllBtn?.click(); + }); + + const callCountAfterAll = fetchMock.mock.calls.length; + // Each scenario: 1 translate + 1 send = 2 calls; 8 scenarios = 16 total + expect(callCountAfterAll).toBe(16); + + // Now click Re-Run on first scenario + const reRunBtns = Array.from( + container.querySelectorAll("[data-testid='button']"), + ).filter((b) => b.textContent?.includes("Re-Run")) as HTMLButtonElement[]; + expect(reRunBtns.length).toBeGreaterThan(0); + + await act(async () => { + reRunBtns[0]?.click(); + }); + + // Should have added exactly 2 more calls (1 translate + 1 send) + const callCountAfterRerun = fetchMock.mock.calls.length; + expect(callCountAfterRerun).toBe(callCountAfterAll + 2); + }); + + // ── Error display sanitized ─────────────────────────────────────────────── + + it("displays error without stack trace when translate fails", async () => { + const fetchMock = makeFetchMock({ translateOk: false, translateError: "Invalid format" }); + vi.stubGlobal("fetch", fetchMock); + + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + // Run first scenario only + const buttons = container.querySelectorAll("[data-testid='button']"); + const firstRunBtn = Array.from(buttons).find((b) => + b.textContent?.includes("Run Test"), + ) as HTMLButtonElement | undefined; + await act(async () => { + firstRunBtn?.click(); + }); + + const text = container.textContent ?? ""; + // Error should be visible + expect(text).toContain("❌"); + // Stack trace must NOT be exposed (Hard Rule #12) + expect(text).not.toMatch(/\sat\s\//); + expect(text).not.toMatch(/Error: .+\.tsx?:\d+/); + }); + + it("displays error without stack trace when send fails with non-ok HTTP", async () => { + const fetchMock = makeFetchMock({ translateOk: true, sendOk: false, sendHttpStatus: 503 }); + vi.stubGlobal("fetch", fetchMock); + + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const buttons = container.querySelectorAll("[data-testid='button']"); + const firstRunBtn = Array.from(buttons).find((b) => + b.textContent?.includes("Run Test"), + ) as HTMLButtonElement | undefined; + await act(async () => { + firstRunBtn?.click(); + }); + + const text = container.textContent ?? ""; + expect(text).toContain("❌"); + // No stack trace + expect(text).not.toMatch(/\sat\s\//); + }); + + it("displays error without stack trace when fetch throws (network error)", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + if ((url as string).includes("/api/translator/translate")) { + return Promise.reject(new Error("Network error")); + } + return Promise.reject(new Error("Unexpected")); + }), + ); + + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const buttons = container.querySelectorAll("[data-testid='button']"); + const firstRunBtn = Array.from(buttons).find((b) => + b.textContent?.includes("Run Test"), + ) as HTMLButtonElement | undefined; + await act(async () => { + firstRunBtn?.click(); + }); + + const text = container.textContent ?? ""; + expect(text).toContain("❌"); + // No stack trace (err.message used, not err.stack) + expect(text).not.toMatch(/\sat\s\//); + // Should contain the sanitized error message + expect(text).toContain("Network error"); + }); + + // ── onOpenChange callback ───────────────────────────────────────────────── + + it("calls onOpenChange(true) when accordion opens for the first time", async () => { + const onOpenChange = vi.fn(); + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + // forceOpen=true triggers content mount → sentinel fires onFirstOpen → onOpenChange(true) + await act(async () => { + root.render(); + }); + // hasOpened starts as true when forceOpen=true, so sentinel doesn't render. + // onOpenChange is not called in this path. + // Test the default-closed path where sentinel fires: + const container2 = makeContainer(); + const root2 = createRoot(container2); + // Reset: render closed, then open via sentinel + await act(async () => { + root2.render(); + }); + // Default is closed, so no sentinel fires yet + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + // ── Translate POST body shape ────────────────────────────────────────────── + + it("sends correct body to /api/translator/translate with step=direct", async () => { + const fetchMock = makeFetchMock(); + vi.stubGlobal("fetch", fetchMock); + + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const buttons = container.querySelectorAll("[data-testid='button']"); + const firstRunBtn = Array.from(buttons).find((b) => + b.textContent?.includes("Run Test"), + ) as HTMLButtonElement | undefined; + await act(async () => { + firstRunBtn?.click(); + }); + + const translateCall = fetchMock.mock.calls.find((c) => + (c[0] as string).includes("/api/translator/translate"), + ); + expect(translateCall).toBeTruthy(); + const bodyStr = (translateCall?.[1] as RequestInit)?.body as string; + const body = JSON.parse(bodyStr); + expect(body.step).toBe("direct"); + expect(typeof body.sourceFormat).toBe("string"); + expect(typeof body.provider).toBe("string"); + expect(typeof body.body).toBe("object"); + }); + + // ── translate:send POST body shape ──────────────────────────────────────── + + it("sends translated result to /api/translator/send", async () => { + const fetchMock = makeFetchMock(); + vi.stubGlobal("fetch", fetchMock); + + const { default: TestBenchAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const buttons = container.querySelectorAll("[data-testid='button']"); + const firstRunBtn = Array.from(buttons).find((b) => + b.textContent?.includes("Run Test"), + ) as HTMLButtonElement | undefined; + await act(async () => { + firstRunBtn?.click(); + }); + + const sendCall = fetchMock.mock.calls.find((c) => + (c[0] as string).includes("/api/translator/send"), + ); + expect(sendCall).toBeTruthy(); + const bodyStr = (sendCall?.[1] as RequestInit)?.body as string; + const body = JSON.parse(bodyStr); + expect(typeof body.provider).toBe("string"); + expect(typeof body.body).toBe("object"); + }); +}); From d7372dea3f88fb3ecaa9f7897ad73eb2f9857011 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 20:40:31 -0300 Subject: [PATCH 04/24] feat(translator): add MonitorTab (F8) - Refactor of LiveMonitorMode with paridade - ADD: monitorOriginHint header explaining event origin - ADD: empty state with CTA 'Ir para Translate' (onGoToTranslate) - Preserves 3s polling /api/translator/history, auto-refresh toggle, stats, table - cleanup useEffect preserved --- .../translator/components/MonitorTab.tsx | 381 ++++++++++++++ .../translator-friendly-monitor-tab.test.tsx | 477 ++++++++++++++++++ 2 files changed, 858 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx create mode 100644 tests/unit/translator-friendly-monitor-tab.test.tsx diff --git a/src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx b/src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx new file mode 100644 index 0000000000..0d1fd9e490 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx @@ -0,0 +1,381 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { useState, useEffect, useRef, useCallback } from "react"; +import { Card, Badge, EmptyState } from "@/shared/components"; +import { FORMAT_META } from "../exampleTemplates"; + +interface MonitorTabProps { + // F9 passes callback for empty state CTA. + onGoToTranslate?: () => void; +} + +interface TranslationEvent { + id?: string; + timestamp?: string | number; + provider?: string; + model?: string; + sourceFormat?: string; + targetFormat?: string; + status?: string; + statusCode?: number | string; + latency?: number; + endpoint?: string; + isComboRouted?: boolean; + routeEndpoint?: string; + routeProvider?: string; + routeCombo?: string; + routeConnectionShortId?: string; +} + +interface StatCardProps { + icon: string; + label: string; + value: string | number; + color: "blue" | "green" | "red" | "purple" | "amber" | "cyan"; +} + +const COLOR_MAP: Record< + StatCardProps["color"], + { shell: string; icon: string } +> = { + blue: { shell: "bg-blue-500/10", icon: "text-blue-500" }, + green: { shell: "bg-green-500/10", icon: "text-green-500" }, + red: { shell: "bg-red-500/10", icon: "text-red-500" }, + purple: { shell: "bg-purple-500/10", icon: "text-purple-500" }, + amber: { shell: "bg-amber-500/10", icon: "text-amber-500" }, + cyan: { shell: "bg-cyan-500/10", icon: "text-cyan-500" }, +}; + +function StatCard({ icon, label, value, color }: StatCardProps) { + const resolved = COLOR_MAP[color] ?? COLOR_MAP.blue; + + return ( + +
+
+ +
+
+

{value}

+

{label}

+
+
+
+ ); +} + +/** + * MonitorTab + * + * Refactor of LiveMonitorMode with 100% functional parity + additions: + * - monitorOriginHint header always visible (explains event origin) + * - empty state CTA with "Ir para Translate" button (onGoToTranslate) + * - preserves 3s polling, auto-refresh toggle, 6 stat cards, events table + * - cleanup useEffect: clearInterval on unmount + */ +export default function MonitorTab({ onGoToTranslate }: MonitorTabProps) { + const t = useTranslations("translator"); + const tc = useTranslations("common"); + + const translateOrFallback = useCallback( + (key: string, fallback: string, values?: Record) => { + try { + const translated = t(key, values); + return translated === key || translated === `translator.${key}` ? fallback : translated; + } catch { + return fallback; + } + }, + [t], + ); + + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [autoRefresh, setAutoRefresh] = useState(true); + const intervalRef = useRef | null>(null); + + const notAvailable = t("notAvailableSymbol"); + const formatLatency = (value: number) => t("millisecondsShort", { value }); + + const fetchHistory = useCallback(async () => { + try { + const res = await fetch("/api/translator/history?limit=50"); + if (res.ok) { + const data = (await res.json()) as { events?: TranslationEvent[] }; + setEvents(data.events ?? []); + } + } catch { + // ignore fetch errors in polling context — do not leak stack traces + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchHistory(); + if (autoRefresh) { + intervalRef.current = setInterval(() => { + void fetchHistory(); + }, 3000); + } + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [autoRefresh, fetchHistory]); + + // Computed stats + const successCount = events.filter((e) => e.status === "success").length; + const errorCount = events.filter((e) => e.status === "error").length; + const comboCount = events.filter((e) => e.isComboRouted).length; + const uniqueEndpoints = new Set( + events.map((e) => e.routeEndpoint ?? e.endpoint).filter(Boolean), + ).size; + const avgLatency = + events.length > 0 + ? Math.round(events.reduce((sum, e) => sum + (e.latency ?? 0), 0) / events.length) + : 0; + + return ( +
+ {/* Origin hint — always visible (monitorOriginHint) */} +
+ +

+ {translateOrFallback( + "monitorOriginHint", + "Eventos gerados pelo Translate ou pelo pipeline principal aparecem aqui em tempo real.", + )} +

+
+ + {/* Stat Cards — 6 cards: total, success, errors, avg latency, combo-routed, unique endpoints */} +
+ + + + + + +
+ + {/* Memory note */} +
+ memory +

+ {t("liveMonitorMemoryNote")}{" "} + {t("liveMonitorMemoryCapNote")} +

+
+ + {/* Auto-refresh controls */} + +
+
+ + +
+
+ {/* Live/Paused badge */} + + {autoRefresh + ? translateOrFallback("live", "Live") + : translateOrFallback("paused", "Paused")} + + +
+
+
+ + {/* Events table */} + +
+

{t("recentTranslations")}

+ + {loading ? ( +
+ + {tc("loading")} +
+ ) : events.length === 0 ? ( + /* Empty state with CTA (new in MonitorTab) */ +
+ +
+ ) : ( +
+ + + + + + + + + + + + + + {events.map((event, i) => { + const srcMeta = FORMAT_META[event.sourceFormat as keyof typeof FORMAT_META] ?? { + label: event.sourceFormat ?? "?", + color: "gray", + }; + const tgtMeta = FORMAT_META[event.targetFormat as keyof typeof FORMAT_META] ?? { + label: event.targetFormat ?? "?", + color: "gray", + }; + + return ( + + + + + + + + + + ); + })} + +
{t("time")} + {translateOrFallback("routeDetails", "Route")} + {t("source")}{t("target")}{t("model")}{t("status")}{t("latency")}
+ {event.timestamp + ? new Date(event.timestamp).toLocaleTimeString() + : notAvailable} + +
+
+ + {event.routeProvider ?? event.provider ?? notAvailable} + + {event.routeCombo ? ( + + {translateOrFallback("comboBadge", "Combo")}: {event.routeCombo} + + ) : null} +
+
+ + {translateOrFallback("routeEndpointLabel", "Endpoint")}:{" "} + {event.routeEndpoint ?? event.endpoint ?? notAvailable} + + {event.routeConnectionShortId ? ( + + {translateOrFallback("routeConnectionLabel", "Conn")}:{" "} + {event.routeConnectionShortId} + + ) : null} +
+
+
+ + {srcMeta.label} + + + + {tgtMeta.label} + + + {event.model ?? notAvailable} + + {event.status === "success" ? ( + + {t("ok")} + + ) : ( + + {event.statusCode ?? t("errorShort")} + + )} + + {event.latency ? formatLatency(event.latency) : notAvailable} +
+
+ )} +
+
+
+ ); +} diff --git a/tests/unit/translator-friendly-monitor-tab.test.tsx b/tests/unit/translator-friendly-monitor-tab.test.tsx new file mode 100644 index 0000000000..1eb1d30285 --- /dev/null +++ b/tests/unit/translator-friendly-monitor-tab.test.tsx @@ -0,0 +1,477 @@ +// @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"; + +// ── i18n stub — returns fallback key so we can assert on translateOrFallback ── +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// ── Shared component stubs ──────────────────────────────────────────────────── +vi.mock("@/shared/components", () => ({ + Card: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
+ {children} +
+ ), + Badge: ({ + children, + variant, + dot, + size, + }: { + children: React.ReactNode; + variant?: string; + dot?: boolean; + size?: string; + }) => ( + + {children} + + ), + EmptyState: ({ + title, + description, + actionLabel, + onAction, + icon, + }: { + title?: string; + description?: string; + actionLabel?: string; + onAction?: (() => void) | null; + icon?: string; + }) => ( +
+ {icon && {icon}} + {title &&

{title}

} + {description &&

{description}

} + {actionLabel && onAction && ( + + )} +
+ ), +})); + +// ── FORMAT_META stub ────────────────────────────────────────────────────────── +vi.mock( + "@/app/(dashboard)/dashboard/translator/exampleTemplates", + () => ({ + FORMAT_META: { + openai: { label: "OpenAI", color: "green" }, + claude: { label: "Claude", color: "orange" }, + gemini: { label: "Gemini", color: "blue" }, + }, + }), +); + +// ── fetch mock helpers ──────────────────────────────────────────────────────── +function mockFetchEmpty() { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, events: [] }), + }), + ); +} + +function mockFetchWithEvents( + events: Array<{ + id?: string; + timestamp?: string; + provider?: string; + model?: string; + sourceFormat?: string; + targetFormat?: string; + status?: string; + latency?: number; + }>, +) { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, events }), + }), + ); +} + +// ── DOM lifecycle helpers ───────────────────────────────────────────────────── +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => container.remove()); + return container; +} + +/** + * Helper: mount the component and flush the initial async fetch + * WITHOUT triggering the recurring setInterval loop. + * Uses vi.advanceTimersByTimeAsync(0) to drain microtask queue + * after the initial fetch resolves, then stops — does NOT advance + * by 3000ms so the interval does not fire. + */ +async function mountAndFlushInitialFetch( + component: React.ReactElement, + container: HTMLElement, +): Promise> { + const root = createRoot(container); + await act(async () => { + root.render(component); + }); + // Drain pending microtasks (the initial fetchHistory Promise) without + // advancing time so setInterval doesn't trigger. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + return root; +} + +describe("MonitorTab", () => { + beforeEach(() => { + vi.useFakeTimers(); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + }); + + // ── 1. Smoke render ────────────────────────────────────────────────────────── + it("exports a default function component", async () => { + const mod = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + expect(typeof mod.default).toBe("function"); + }); + + it("renders the origin hint header (monitorOriginHint) always visible", async () => { + mockFetchEmpty(); + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + await mountAndFlushInitialFetch(, container); + + const hint = container.querySelector("[data-testid='monitor-origin-hint']"); + expect(hint).toBeTruthy(); + // The hint should contain the info icon + const icons = hint?.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons ?? []).map((el) => el.textContent?.trim()); + expect(iconTexts).toContain("info"); + }); + + it("renders 6 StatCards with correct icons", async () => { + mockFetchEmpty(); + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + await mountAndFlushInitialFetch(, container); + + // Stat card icons: translate, check_circle, error, speed, hub, lan + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + expect(iconTexts).toContain("translate"); + expect(iconTexts).toContain("check_circle"); + expect(iconTexts).toContain("error"); + expect(iconTexts).toContain("speed"); + expect(iconTexts).toContain("hub"); + expect(iconTexts).toContain("lan"); + }); + + // ── 2. Empty state ─────────────────────────────────────────────────────────── + it("shows empty state when events array is empty", async () => { + mockFetchEmpty(); + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + await mountAndFlushInitialFetch(, container); + + const emptyState = container.querySelector("[data-testid='empty-state']"); + expect(emptyState).toBeTruthy(); + // Table should NOT be rendered + expect(container.querySelector("[data-testid='monitor-events-table']")).toBeNull(); + }); + + it("empty state shows CTA description text from monitorEmptyCta", async () => { + mockFetchEmpty(); + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + await mountAndFlushInitialFetch(, container); + + // When t() mock returns key, translateOrFallback detects key === translation and uses hardcoded fallback + const emptyDescription = container.querySelector("[data-testid='empty-description']"); + expect(emptyDescription?.textContent).toContain("Volte para a aba Translate"); + }); + + it("empty state 'Ir para Translate' button calls onGoToTranslate callback", async () => { + mockFetchEmpty(); + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + const onGoToTranslate = vi.fn(); + await mountAndFlushInitialFetch(, container); + + const actionBtn = container.querySelector("[data-testid='empty-action']") as HTMLButtonElement | null; + expect(actionBtn).toBeTruthy(); + // Label comes from monitorOpenTranslateButton fallback + expect(actionBtn?.textContent).toContain("Ir para Translate"); + + await act(async () => { + actionBtn?.click(); + }); + expect(onGoToTranslate).toHaveBeenCalledOnce(); + }); + + it("empty state action button is not rendered when onGoToTranslate is not provided", async () => { + mockFetchEmpty(); + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + await mountAndFlushInitialFetch(, container); + + // EmptyState stub only renders button when onAction is truthy + const actionBtn = container.querySelector("[data-testid='empty-action']"); + expect(actionBtn).toBeNull(); + }); + + // ── 3. Events table ────────────────────────────────────────────────────────── + it("renders events table with rows when events are present", async () => { + const sampleEvents = [ + { + id: "evt-1", + timestamp: new Date("2026-05-27T10:00:00Z").toISOString(), + provider: "openai", + model: "gpt-4", + sourceFormat: "claude", + targetFormat: "openai", + status: "success", + latency: 320, + }, + { + id: "evt-2", + timestamp: new Date("2026-05-27T10:01:00Z").toISOString(), + provider: "gemini", + model: "gemini-pro", + sourceFormat: "openai", + targetFormat: "gemini", + status: "error", + latency: 150, + }, + ]; + mockFetchWithEvents(sampleEvents); + + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + await mountAndFlushInitialFetch(, container); + + // Table must be present + const table = container.querySelector("[data-testid='monitor-events-table']"); + expect(table).toBeTruthy(); + + // EmptyState must NOT be rendered + expect(container.querySelector("[data-testid='empty-state']")).toBeNull(); + + // 2 event rows + const rows = container.querySelectorAll("[data-testid='monitor-event-row']"); + expect(rows).toHaveLength(2); + }); + + it("table renders source and target format labels via FORMAT_META", async () => { + const sampleEvents = [ + { + id: "evt-1", + sourceFormat: "claude", + targetFormat: "openai", + status: "success", + }, + ]; + mockFetchWithEvents(sampleEvents); + + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + await mountAndFlushInitialFetch(, container); + + const text = container.textContent ?? ""; + expect(text).toContain("Claude"); // FORMAT_META["claude"].label + expect(text).toContain("OpenAI"); // FORMAT_META["openai"].label + }); + + it("table columns include: time, route, source, target, model, status, latency headers", async () => { + const sampleEvents = [{ id: "x", status: "success" }]; + mockFetchWithEvents(sampleEvents); + + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + await mountAndFlushInitialFetch(, container); + + // Column headers use t() keys — mock returns the key itself + const tableText = container.querySelector("thead")?.textContent ?? ""; + expect(tableText).toContain("time"); + expect(tableText).toContain("source"); + expect(tableText).toContain("target"); + expect(tableText).toContain("model"); + expect(tableText).toContain("status"); + expect(tableText).toContain("latency"); + }); + + // ── 4. Auto-refresh toggle ─────────────────────────────────────────────────── + it("toggle auto-refresh button is present with aria-label and shows live state text", async () => { + mockFetchEmpty(); + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + await mountAndFlushInitialFetch(, container); + + const toggleBtn = container.querySelector( + "[data-testid='auto-refresh-toggle']", + ) as HTMLButtonElement | null; + expect(toggleBtn).toBeTruthy(); + + // Initial state: auto-refresh is ON — translateOrFallback detects key === translation → uses fallback + expect(toggleBtn?.textContent?.trim()).toContain("Atualizando ao vivo"); + // aria-label should be set + expect(toggleBtn?.getAttribute("aria-label")).toBeTruthy(); + }); + + it("clicking toggle changes button text from live to paused", async () => { + mockFetchEmpty(); + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + await mountAndFlushInitialFetch(, container); + + const toggleBtn = container.querySelector( + "[data-testid='auto-refresh-toggle']", + ) as HTMLButtonElement | null; + expect(toggleBtn).toBeTruthy(); + + // Click to pause + await act(async () => { + toggleBtn?.click(); + }); + + // After pause: button text should switch to the "paused" fallback + expect(toggleBtn?.textContent?.trim()).toContain("Pausado"); + }); + + it("auto-refresh polling fires fetch again after 3 seconds", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, events: [] }), + }); + vi.stubGlobal("fetch", fetchMock); + + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + await mountAndFlushInitialFetch(, container); + + const callsAfterMount = fetchMock.mock.calls.length; + expect(callsAfterMount).toBeGreaterThanOrEqual(1); + + // Advance 3 seconds → one more interval tick + await act(async () => { + await vi.advanceTimersByTimeAsync(3000); + }); + expect(fetchMock.mock.calls.length).toBeGreaterThan(callsAfterMount); + }); + + it("pausing auto-refresh stops additional polling after toggle", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, events: [] }), + }); + vi.stubGlobal("fetch", fetchMock); + + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + await mountAndFlushInitialFetch(, container); + + // Pause auto-refresh + const toggleBtn = container.querySelector( + "[data-testid='auto-refresh-toggle']", + ) as HTMLButtonElement | null; + await act(async () => { + toggleBtn?.click(); + }); + // After toggling, the component re-renders with autoRefresh=false. + // The new useEffect runs with autoRefresh=false → no new interval. + // But the old interval was cleared on cleanup. + // Drain any pending microtasks from the state update. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + const callsAfterPause = fetchMock.mock.calls.length; + + // Advance 9 seconds — should NOT trigger more interval fetches + await act(async () => { + await vi.advanceTimersByTimeAsync(9000); + }); + + // Allow any pending promises to settle + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(fetchMock.mock.calls.length).toBe(callsAfterPause); + }); + + // ── 5. Error sanitization ──────────────────────────────────────────────────── + it("fetch error does not leak stack traces into the DOM", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue( + new Error("Network Error\n at fetch (/some/internal/path.ts:42:10)"), + ), + ); + + const { default: MonitorTab } = await import( + "@/app/(dashboard)/dashboard/translator/components/MonitorTab" + ); + const container = makeContainer(); + // Don't use mountAndFlushInitialFetch here — we want to let the rejection settle + const root = createRoot(container); + await act(async () => { + root.render(); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + const domText = container.textContent ?? ""; + expect(domText).not.toMatch(/at\s+\//); + expect(domText).not.toMatch(/Network Error/); + }); +}); From de7c0c6bba678c814681097547153112c9d2b181 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 20:46:55 -0300 Subject: [PATCH 05/24] feat(translator): add CompressionPreviewAccordion (F7) - Extracted Compression Preview from PlaygroundMode lines 506-584 into standalone accordion - Wrapped in Collapsible with lazy-render (D7): content only mounts after first open - Accepts inputContent via prop (lifted from TranslateTab in F9) or shows empty-state hint - POST /api/compression/preview unchanged; error path sanitized (no stack-trace leak) - 33 Vitest tests covering smoke, lazy-render guard, mode select, fetch dispatch, result grid, error path --- .../advanced/CompressionPreviewAccordion.tsx | 281 +++++++++ .../translator-friendly-compression.test.tsx | 585 ++++++++++++++++++ 2 files changed, 866 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx create mode 100644 tests/unit/translator-friendly-compression.test.tsx diff --git a/src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx b/src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx new file mode 100644 index 0000000000..5ae4d3afa7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useTranslations } from "next-intl"; +import { Button, Select } from "@/shared/components"; +import { cn } from "@/shared/utils/cn"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface CompressionPreviewResult { + originalTokens: number; + compressedTokens: number; + tokensSaved: number; + savingsPct: number; + techniquesUsed: string[]; + durationMs: number; +} + +export interface CompressionPreviewAccordionProps { + /** Force the accordion open on mount (used by deep-link). */ + forceOpen?: boolean; + /** Called whenever the open state changes (used for URL sync). */ + onOpenChange?: (open: boolean) => void; + /** + * Content to compress. If provided (from TranslateTab state), the accordion + * uses it directly. If absent or empty, shows an empty-state hint. + */ + inputContent?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Sanitize an error message: strip Node stack-trace lines (e.g. "at /home/…"). */ +function sanitizeError(e: unknown): string { + const raw = e instanceof Error ? e.message : String(e); + // Remove stack-trace lines that start with "at " followed by a path + return raw.replace(/\s+at\s+[^\n]+/g, "").trim(); +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const COMPRESSION_MODES = [ + { value: "off", label: "Off" }, + { value: "lite", label: "Lite" }, + { value: "standard", label: "Standard" }, + { value: "aggressive", label: "Aggressive" }, + { value: "ultra", label: "Ultra" }, +] as const; + +// --------------------------------------------------------------------------- +// Inner content (always mounted when hasOpened is true) +// --------------------------------------------------------------------------- + +function CompressionPreviewContent({ inputContent = "" }: { inputContent?: string }) { + const t = useTranslations("translator"); + + const [compressionMode, setCompressionMode] = useState("standard"); + const [compressionResult, setCompressionResult] = useState( + null, + ); + const [compressionLoading, setCompressionLoading] = useState(false); + const [compressionError, setCompressionError] = useState(null); + + const hasInput = inputContent.trim().length > 0; + + const handleCompressionPreview = useCallback(async () => { + if (!hasInput) return; + + let messages: Array<{ role: string; content: string }>; + try { + const parsed: Record = JSON.parse(inputContent); + messages = Array.isArray(parsed.messages) + ? (parsed.messages as Array<{ role: string; content: string }>) + : [{ role: "user", content: inputContent }]; + } catch { + messages = [{ role: "user", content: inputContent }]; + } + + setCompressionLoading(true); + setCompressionError(null); + + try { + const res = await fetch("/api/compression/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages, mode: compressionMode }), + }); + const data: CompressionPreviewResult & { error?: string } = await res.json(); + if (!res.ok) throw new Error(data.error ?? "Preview failed"); + setCompressionResult(data); + } catch (e: unknown) { + setCompressionError(sanitizeError(e)); + } finally { + setCompressionLoading(false); + } + }, [hasInput, inputContent, compressionMode]); + + return ( +
+ {/* Empty state */} + {!hasInput && ( +
+ + + {t("compressionEmptyHint") || + "Preencha o campo de entrada na aba Translate (Simple Controls ou Raw JSON) para habilitar o preview."} + +
+ )} + + {/* Controls */} +
+ + {options.map((o) => ( + + ))} + + ), +})); + +// Stub cn utility +vi.mock("@/shared/utils/cn", () => ({ + cn: (...classes: (string | undefined | false)[]) => classes.filter(Boolean).join(" "), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const cleanupCallbacks: Array<() => void> = []; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => container.remove()); + return container; +} + +async function renderComponent( + props: { + forceOpen?: boolean; + inputContent?: string; + onOpenChange?: (open: boolean) => void; + } = {}, +) { + const { default: CompressionPreviewAccordion } = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion" + ); + const container = makeContainer(); + const root = createRoot(container); + await act(async () => { + root.render(); + }); + return { container, root }; +} + +/** Click the accordion toggle button to open/close it. */ +async function clickToggle(container: HTMLElement) { + const btn = container.querySelector( + "button[aria-expanded]", + ) as HTMLButtonElement | null; + expect(btn).toBeTruthy(); + await act(async () => { + btn?.click(); + }); +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.clearAllMocks(); +}); + +afterEach(() => { + while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.(); + document.body.innerHTML = ""; +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("CompressionPreviewAccordion — export", () => { + it("exports a default function component", async () => { + const mod = await import( + "@/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion" + ); + expect(typeof mod.default).toBe("function"); + }); +}); + +describe("CompressionPreviewAccordion — smoke render", () => { + it("renders without crashing (closed by default)", async () => { + const { container } = await renderComponent(); + expect(container.querySelector("[data-testid='compression-accordion']")).toBeTruthy(); + }); + + it("renders the toggle button with compress icon and i18n title", async () => { + const { container } = await renderComponent(); + // compress icon in header + const icons = container.querySelectorAll(".material-symbols-outlined"); + const iconTexts = Array.from(icons).map((el) => el.textContent?.trim()); + expect(iconTexts).toContain("compress"); + + // title text (mock returns the key) + const text = container.textContent ?? ""; + expect(text).toContain("advancedCompressionTitle"); + }); + + it("renders the subtitle", async () => { + const { container } = await renderComponent(); + const text = container.textContent ?? ""; + expect(text).toContain("advancedCompressionSubtitle"); + }); + + it("toggle button starts closed (aria-expanded=false)", async () => { + const { container } = await renderComponent(); + const btn = container.querySelector("button[aria-expanded]"); + expect(btn?.getAttribute("aria-expanded")).toBe("false"); + }); + + it("toggle button starts open when forceOpen=true (aria-expanded=true)", async () => { + const { container } = await renderComponent({ forceOpen: true }); + const btn = container.querySelector("button[aria-expanded]"); + expect(btn?.getAttribute("aria-expanded")).toBe("true"); + }); +}); + +describe("CompressionPreviewAccordion — lazy-render guard (D7)", () => { + it("does NOT mount content when closed (forceOpen=false)", async () => { + const { container } = await renderComponent({ forceOpen: false }); + // Content region should not exist + expect(container.querySelector("#compression-preview-content")).toBeNull(); + // Mode select should not be in DOM + expect(container.querySelector("[data-testid='select']")).toBeNull(); + }); + + it("mounts content after opening accordion", async () => { + const { container } = await renderComponent({ forceOpen: false }); + + // Initially closed + expect(container.querySelector("[data-testid='select']")).toBeNull(); + + // Open + await clickToggle(container); + + // Content should now be mounted + expect(container.querySelector("#compression-preview-content")).toBeTruthy(); + expect(container.querySelector("[data-testid='select']")).toBeTruthy(); + }); + + it("mounts content immediately when forceOpen=true", async () => { + const { container } = await renderComponent({ forceOpen: true }); + expect(container.querySelector("#compression-preview-content")).toBeTruthy(); + expect(container.querySelector("[data-testid='select']")).toBeTruthy(); + }); + + it("toggle opens accordion (aria-expanded flips to true)", async () => { + const { container } = await renderComponent(); + const btn = container.querySelector("button[aria-expanded]"); + expect(btn?.getAttribute("aria-expanded")).toBe("false"); + + await clickToggle(container); + expect(btn?.getAttribute("aria-expanded")).toBe("true"); + }); + + it("toggle closes accordion again (aria-expanded flips back to false)", async () => { + const { container } = await renderComponent({ forceOpen: true }); + const btn = container.querySelector("button[aria-expanded]"); + expect(btn?.getAttribute("aria-expanded")).toBe("true"); + + await clickToggle(container); + expect(btn?.getAttribute("aria-expanded")).toBe("false"); + }); + + it("calls onOpenChange with true when opening", async () => { + const onOpenChange = vi.fn(); + const { container } = await renderComponent({ forceOpen: false, onOpenChange }); + await clickToggle(container); + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + + it("calls onOpenChange with false when closing", async () => { + const onOpenChange = vi.fn(); + const { container } = await renderComponent({ forceOpen: true, onOpenChange }); + await clickToggle(container); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); + +describe("CompressionPreviewAccordion — empty state", () => { + it("shows empty-state hint when inputContent is empty string", async () => { + const { container } = await renderComponent({ forceOpen: true, inputContent: "" }); + const text = container.textContent ?? ""; + expect(text).toContain("compressionEmptyHint"); + }); + + it("shows empty-state hint when inputContent is absent", async () => { + const { container } = await renderComponent({ forceOpen: true }); + const text = container.textContent ?? ""; + expect(text).toContain("compressionEmptyHint"); + }); + + it("Preview button is disabled when inputContent is empty", async () => { + const { container } = await renderComponent({ forceOpen: true, inputContent: "" }); + const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null; + expect(btn?.disabled).toBe(true); + }); + + it("shows preview button enabled when inputContent is non-empty", async () => { + const { container } = await renderComponent({ + forceOpen: true, + inputContent: JSON.stringify({ messages: [{ role: "user", content: "Hello" }] }), + }); + const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null; + expect(btn).toBeTruthy(); + expect(btn?.disabled).toBe(false); + }); +}); + +describe("CompressionPreviewAccordion — mode select", () => { + const MODES = ["off", "lite", "standard", "aggressive", "ultra"] as const; + + it("renders all 5 mode options", async () => { + const { container } = await renderComponent({ forceOpen: true, inputContent: "hello" }); + const select = container.querySelector("[data-testid='select']") as HTMLSelectElement | null; + expect(select).toBeTruthy(); + const options = Array.from(select?.options ?? []).map((o) => o.value); + for (const mode of MODES) { + expect(options).toContain(mode); + } + }); + + it("default mode is 'standard'", async () => { + const { container } = await renderComponent({ forceOpen: true, inputContent: "hello" }); + const select = container.querySelector("[data-testid='select']") as HTMLSelectElement | null; + expect(select?.value).toBe("standard"); + }); + + for (const mode of MODES) { + it(`changing mode to '${mode}' updates select value`, async () => { + const { container } = await renderComponent({ forceOpen: true, inputContent: "hello" }); + const select = container.querySelector("[data-testid='select']") as HTMLSelectElement | null; + expect(select).toBeTruthy(); + + await act(async () => { + // Set the value and fire change event + select!.value = mode; + select!.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(select?.value).toBe(mode); + }); + } +}); + +describe("CompressionPreviewAccordion — Preview fetch", () => { + it("calls POST /api/compression/preview with { messages, mode } on button click", async () => { + const mockResult = { + originalTokens: 100, + compressedTokens: 80, + tokensSaved: 20, + savingsPct: 20, + techniquesUsed: ["dedup", "trim"], + durationMs: 42, + }; + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockResult, + }); + vi.stubGlobal("fetch", fetchMock); + + const inputContent = JSON.stringify({ + messages: [{ role: "user", content: "Hello world" }], + }); + + const { container } = await renderComponent({ forceOpen: true, inputContent }); + + const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null; + expect(btn).toBeTruthy(); + + await act(async () => { + btn?.click(); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "/api/compression/preview", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "Content-Type": "application/json" }), + }), + ); + + // Verify body has correct shape + const callArgs = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(callArgs[1].body as string) as { + messages: Array<{ role: string; content: string }>; + mode: string; + }; + expect(body).toMatchObject({ + messages: [{ role: "user", content: "Hello world" }], + mode: "standard", + }); + + vi.unstubAllGlobals(); + }); + + it("wraps plain-text inputContent as { role: 'user', content } when not valid JSON", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + originalTokens: 10, + compressedTokens: 8, + tokensSaved: 2, + savingsPct: 20, + techniquesUsed: [], + durationMs: 5, + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const { container } = await renderComponent({ forceOpen: true, inputContent: "plain text" }); + + const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null; + await act(async () => { + btn?.click(); + }); + + const callArgs = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(callArgs[1].body as string) as { + messages: Array<{ role: string; content: string }>; + }; + expect(body.messages).toEqual([{ role: "user", content: "plain text" }]); + + vi.unstubAllGlobals(); + }); + + it("sends selected mode in the request body", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + originalTokens: 50, + compressedTokens: 40, + tokensSaved: 10, + savingsPct: 20, + techniquesUsed: [], + durationMs: 20, + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const { container } = await renderComponent({ forceOpen: true, inputContent: "some text" }); + + // Change mode to "aggressive" + const select = container.querySelector("[data-testid='select']") as HTMLSelectElement | null; + await act(async () => { + select!.value = "aggressive"; + select!.dispatchEvent(new Event("change", { bubbles: true })); + }); + + // Click preview + const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null; + await act(async () => { + btn?.click(); + }); + + const callArgs = fetchMock.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(callArgs[1].body as string) as { mode: string }; + expect(body.mode).toBe("aggressive"); + + vi.unstubAllGlobals(); + }); +}); + +describe("CompressionPreviewAccordion — result grid (4 cards)", () => { + it("renders 4 metric cards after successful preview", async () => { + const mockResult = { + originalTokens: 200, + compressedTokens: 150, + tokensSaved: 50, + savingsPct: 25, + techniquesUsed: ["dedup"], + durationMs: 88, + }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockResult, + }); + vi.stubGlobal("fetch", fetchMock); + + const { container } = await renderComponent({ + forceOpen: true, + inputContent: "some input", + }); + + const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null; + await act(async () => { + btn?.click(); + }); + + const grid = container.querySelector("[data-testid='compression-result-grid']"); + expect(grid).toBeTruthy(); + + const cards = grid?.querySelectorAll(".card"); + expect(cards?.length).toBe(4); + + const text = container.textContent ?? ""; + expect(text).toContain("200"); // originalTokens + expect(text).toContain("150"); // compressedTokens + expect(text).toContain("50"); // tokensSaved + expect(text).toContain("88"); // durationMs + + vi.unstubAllGlobals(); + }); + + it("renders techniquesUsed list when non-empty", async () => { + const mockResult = { + originalTokens: 100, + compressedTokens: 90, + tokensSaved: 10, + savingsPct: 10, + techniquesUsed: ["dedup", "trim", "compact"], + durationMs: 33, + }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockResult, + }); + vi.stubGlobal("fetch", fetchMock); + + const { container } = await renderComponent({ + forceOpen: true, + inputContent: "some input", + }); + + await act(async () => { + (container.querySelector("[data-testid='button']") as HTMLButtonElement)?.click(); + }); + + const text = container.textContent ?? ""; + expect(text).toContain("dedup"); + expect(text).toContain("trim"); + expect(text).toContain("compact"); + + vi.unstubAllGlobals(); + }); + + it("does NOT render result grid before a successful preview", async () => { + const { container } = await renderComponent({ forceOpen: true, inputContent: "hello" }); + expect(container.querySelector("[data-testid='compression-result-grid']")).toBeNull(); + }); +}); + +describe("CompressionPreviewAccordion — error path (Hard Rule #12)", () => { + it("shows sanitized error message on fetch failure", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ error: "Internal Server Error" }), + }); + vi.stubGlobal("fetch", fetchMock); + + const { container } = await renderComponent({ + forceOpen: true, + inputContent: "some input", + }); + + await act(async () => { + (container.querySelector("[data-testid='button']") as HTMLButtonElement)?.click(); + }); + + const errorEl = container.querySelector("[role='alert']"); + expect(errorEl).toBeTruthy(); + expect(errorEl?.textContent).toContain("Internal Server Error"); + + vi.unstubAllGlobals(); + }); + + it("error message does NOT contain stack-trace lines (at /path/...)", async () => { + const stackError = new Error( + "Something went wrong\n at /home/user/app/src/file.ts:42:13\n at Object. /home/user/app/tests/test.ts:10:5", + ); + const fetchMock = vi.fn().mockRejectedValue(stackError); + vi.stubGlobal("fetch", fetchMock); + + const { container } = await renderComponent({ + forceOpen: true, + inputContent: "some input", + }); + + await act(async () => { + (container.querySelector("[data-testid='button']") as HTMLButtonElement)?.click(); + }); + + const errorEl = container.querySelector("[role='alert']"); + expect(errorEl).toBeTruthy(); + + const errorText = errorEl?.textContent ?? ""; + // Must NOT contain "at /" (stack-trace pattern) + expect(errorText).not.toMatch(/\sat\s\//); + // Should still contain the core message + expect(errorText).toContain("Something went wrong"); + + vi.unstubAllGlobals(); + }); + + it("shows 'Preview failed' when fetch returns non-ok without error field", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({}), // no error field + }); + vi.stubGlobal("fetch", fetchMock); + + const { container } = await renderComponent({ + forceOpen: true, + inputContent: "some input", + }); + + await act(async () => { + (container.querySelector("[data-testid='button']") as HTMLButtonElement)?.click(); + }); + + const errorEl = container.querySelector("[role='alert']"); + expect(errorEl).toBeTruthy(); + expect(errorEl?.textContent).toContain("Preview failed"); + + vi.unstubAllGlobals(); + }); +}); From fb519b20029e44cce17424348c1ae8654f202851 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 20:50:51 -0300 Subject: [PATCH 06/24] feat(translator): add StreamTransformerAccordion (F5) - Refactor of StreamTransformerMode wrapped in Collapsible with lazy-render - Preserves rawSse input, transform button, MiniStat output, copy - POST /api/translator/transform-stream reused unchanged - D7 lazy-render --- .../advanced/StreamTransformerAccordion.tsx | 458 ++++++++++++++++++ ...lator-friendly-stream-transformer.test.tsx | 387 +++++++++++++++ 2 files changed, 845 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx create mode 100644 tests/unit/translator-friendly-stream-transformer.test.tsx diff --git a/src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx b/src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx new file mode 100644 index 0000000000..984776c3b5 --- /dev/null +++ b/src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx @@ -0,0 +1,458 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; + +import { Button, Card } from "@/shared/components"; +import { copyToClipboard } from "@/shared/utils/clipboard"; +import { cn } from "@/shared/utils/cn"; + +// ─── Sample payloads ────────────────────────────────────────────────────────── + +const SAMPLE_TEXT = `data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]} + +data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":" from OmniRoute"},"finish_reason":null}]} + +data: {"id":"chatcmpl_demo","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":4,"total_tokens":16}} + +data: [DONE] +`; + +const SAMPLE_TOOL = `data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_123","type":"function","function":{"name":"lookup_weather","arguments":"{\\"city\\":\\"Tok"}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"yo\\"}"}}]},"finish_reason":null}]} + +data: {"id":"chatcmpl_tool","object":"chat.completion.chunk","created":1745366400,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":23,"completion_tokens":9,"total_tokens":32}} + +data: [DONE] +`; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function getFramePreview(data: unknown): string { + if (typeof data === "string") return data; + if (!data || typeof data !== "object") return ""; + + const record = data as Record; + const delta = record.delta; + if (typeof delta === "string") return delta; + + const item = record.item; + if (item && typeof item === "object") { + const itemRecord = item as Record; + const type = itemRecord.type; + const text = itemRecord.text; + const name = itemRecord.name; + if (typeof text === "string" && text) return text; + if (typeof name === "string" && name) return `${type || "item"}: ${name}`; + if (typeof type === "string" && type) return type; + } + + const text = record.text; + if (typeof text === "string" && text) return text; + + return JSON.stringify(data).slice(0, 140); +} + +function parseSseFrames(rawSse: string): Array<{ event: string; preview: string }> { + return rawSse + .split("\n\n") + .map((frame) => frame.trim()) + .filter(Boolean) + .map((frame) => { + const eventLine = frame + .split("\n") + .find((line) => line.startsWith("event:")) + ?.replace(/^event:\s*/, "") + .trim(); + const dataLine = frame + .split("\n") + .find((line) => line.startsWith("data:")) + ?.replace(/^data:\s*/, ""); + + if (dataLine === "[DONE]") { + return { event: "done", preview: "[DONE]" }; + } + + let parsedData: unknown = dataLine || ""; + try { + parsedData = dataLine ? JSON.parse(dataLine) : ""; + } catch { + parsedData = dataLine || ""; + } + + return { + event: eventLine || "message", + preview: getFramePreview(parsedData), + }; + }); +} + +// ─── MiniStat ──────────────────────────────────────────────────────────────── + +function MiniStat({ label, value }: { label: string; value: number }) { + return ( + +
+

{value}

+

{label}

+
+
+ ); +} + +// ─── Props ─────────────────────────────────────────────────────────────────── + +export interface StreamTransformerAccordionProps { + forceOpen?: boolean; + onOpenChange?: (open: boolean) => void; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +/** + * Refactor of StreamTransformerMode wrapped in a Collapsible-style header with + * lazy-render guard (D7): content only mounts after the section is first opened. + * + * Visual structure matches @/shared/components/Collapsible (variant="default") so + * F9 can swap to the shared component without layout changes once Collapsible + * gains an onOpenChange callback. + */ +export default function StreamTransformerAccordion({ + forceOpen, + onOpenChange, +}: StreamTransformerAccordionProps) { + const t = useTranslations("translator"); + + const translateOrFallback = useCallback( + (key: string, fallback: string, values?: Record) => { + try { + const translated = t(key, values); + return translated === key || translated === `translator.${key}` ? fallback : translated; + } catch { + return fallback; + } + }, + [t] + ); + + // ── Open state (controlled by forceOpen; local toggle otherwise) ────────── + const [open, setOpen] = useState(Boolean(forceOpen)); + // D7 lazy-render guard: once mounted, keep content in DOM. + const [hasOpened, setHasOpened] = useState(Boolean(forceOpen)); + + const handleToggle = useCallback(() => { + const next = !open; + setOpen(next); + if (next) setHasOpened(true); + onOpenChange?.(next); + }, [open, onOpenChange]); + + // ── Transform state (only matters once content is mounted) ──────────────── + const [rawSse, setRawSse] = useState(SAMPLE_TEXT); + const [transformedSse, setTransformedSse] = useState(""); + const [loading, setLoading] = useState(false); + const [copiedField, setCopiedField] = useState(null); + const [error, setError] = useState(null); + + const transformedFrames = useMemo(() => parseSseFrames(transformedSse), [transformedSse]); + const eventCount = transformedFrames.length; + const uniqueEventCount = new Set(transformedFrames.map((frame) => frame.event)).size; + + const handleCopy = useCallback(async (value: string, field: string) => { + await copyToClipboard(value); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + }, []); + + const runTransform = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const res = await fetch("/api/translator/transform-stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rawSse }), + }); + const data = (await res.json()) as { + success?: boolean; + transformed?: string; + error?: string; + }; + + if (!res.ok || !data.success) { + // Hard Rule #12: display only the sanitized error string from buildErrorBody — no stack. + const displayError = data.error + ? String(data.error) + : translateOrFallback("requestFailed", "Request failed"); + throw new Error(displayError); + } + + setTransformedSse(data.transformed || ""); + } catch (err) { + const raw = err instanceof Error ? err.message : "Failed to transform stream"; + // Defence-in-depth: strip any accidental stack-trace suffix. + setError(raw.replace(/\s+at\s+\/.*/g, "")); + } finally { + setLoading(false); + } + }, [rawSse, translateOrFallback]); + + // ── Titles (computed once per render for readability) ───────────────────── + const title = translateOrFallback( + "advancedStreamTransformTitle", + "Stream Transformer (Chat → Responses SSE)" + ); + const subtitle = translateOrFallback( + "advancedStreamTransformSubtitle", + "Converte SSE Chat Completions em Responses API." + ); + + // ── Render ──────────────────────────────────────────────────────────────── + return ( +
+ {/* ── Collapsible header — mirrors Collapsible.tsx visual style ──── */} +
+ +
+ + {/* ── Content: lazy-render guard (D7) ────────────────────────────── */} + {(open || hasOpened) && ( +
+
+ {/* Info banner */} +
+ +
+

+ {translateOrFallback("streamTransformerTitle", "Responses Stream Transformer")} +

+

+ {translateOrFallback( + "streamTransformerDescription", + "Paste a chat completions SSE stream, run it through OmniRoute's Responses transformer, and inspect the emitted response.* events before wiring a client." + )} +

+
+
+ + +
+ {/* Action buttons */} +
+ + + +
+ + {/* Error display — Hard Rule #12: never show raw err.stack */} + {error && ( +
+ {error} +
+ )} + + {/* Input / Output panels */} +
+ {/* Raw SSE input */} +
+
+

+ {translateOrFallback("rawChatSseInput", "Raw chat completions SSE")} +

+ +
+