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