mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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)
This commit is contained in:
@@ -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<TranslatorTab> = new Set(["translate", "monitor"]);
|
||||
const VALID_MODES: ReadonlySet<TranslateMode> = new Set(["preview", "send"]);
|
||||
const VALID_ADVANCED: ReadonlySet<AdvancedSlug> = 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<TranslateDeepLink>(() => {
|
||||
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<TranslateDeepLink>) => {
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<TranslateNarratedResult>(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<string, unknown>;
|
||||
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<string, unknown> = 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
error?: string;
|
||||
};
|
||||
if (!step2Data.success) throw new Error(step2Data.error ?? "Translate step 2 failed");
|
||||
translatedResult = step2Data.result as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
error?: string;
|
||||
};
|
||||
if (!stepData.success) throw new Error(stepData.error ?? "Translate failed");
|
||||
translatedResult = stepData.result as Record<string, unknown>;
|
||||
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 };
|
||||
}
|
||||
68
src/app/(dashboard)/dashboard/translator/types.ts
Normal file
68
src/app/(dashboard)/dashboard/translator/types.ts
Normal file
@@ -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<Record<FormatId, Record<string, unknown>>>;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
227
tests/unit/translator-friendly-deeplink.test.ts
Normal file
227
tests/unit/translator-friendly-deeplink.test.ts
Normal file
@@ -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<TranslatorTab> = new Set(["translate", "monitor"]);
|
||||
const VALID_MODES: ReadonlySet<TranslateMode> = new Set(["preview", "send"]);
|
||||
const VALID_ADVANCED: ReadonlySet<AdvancedSlug> = 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<TranslateDeepLink>
|
||||
): 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
202
tests/unit/translator-friendly-i18n-keys.test.ts
Normal file
202
tests/unit/translator-friendly-i18n-keys.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
const ptBR = JSON.parse(
|
||||
readFileSync(resolve(ROOT, "src/i18n/messages/pt-BR.json"), "utf-8")
|
||||
) as Record<string, unknown>;
|
||||
|
||||
const enTranslator = (en["translator"] ?? {}) as Record<string, unknown>;
|
||||
const ptBRTranslator = (ptBR["translator"] ?? {}) as Record<string, unknown>;
|
||||
|
||||
// ─── 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(", ")}`
|
||||
);
|
||||
});
|
||||
});
|
||||
392
tests/unit/translator-friendly-session.test.ts
Normal file
392
tests/unit/translator-friendly-session.test.ts
Normal file
@@ -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<Response>;
|
||||
|
||||
async function runSession(
|
||||
input: RunInput,
|
||||
fetchImpl: FetchFn
|
||||
): Promise<TranslateNarratedResult> {
|
||||
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<string, unknown>;
|
||||
let responsePreview: string | null = null;
|
||||
|
||||
// 1. Parse input
|
||||
let parsed: Record<string, unknown>;
|
||||
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<string, unknown>; 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<string, unknown>; error?: string };
|
||||
if (!step2Data.success) throw new Error(step2Data.error ?? "Translate step 2 failed");
|
||||
translatedResult = step2Data.result as Record<string, unknown>;
|
||||
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<string, unknown>; error?: string };
|
||||
if (!stepData.success) throw new Error(stepData.error ?? "Translate failed");
|
||||
translatedResult = stepData.result as Record<string, unknown>;
|
||||
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<Uint8Array> | 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user