feat(translator): add TestBenchAccordion (F6)

- Refactor of TestBenchMode wrapped in Collapsible with lazy-render (D7)
- Preserves 8 scenarios, runAll, per-scenario re-run, pass/fail badges, compatibility report
- Reuses useProviderOptions + useAvailableModels (D12)
- POST /api/translator/translate + /api/translator/send unchanged
- Hard Rule #12: error display uses err.message only (no stack trace)
- 17 Vitest tests: smoke render, lazy-render guard, Run All 8 fetches,
  results running→pass, per-scenario re-run, error sanitization
This commit is contained in:
diegosouzapw
2026-05-27 20:27:51 -03:00
parent bb200cd0ba
commit 8dbd0a9d1c
2 changed files with 1302 additions and 0 deletions

View File

@@ -0,0 +1,491 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import Collapsible from "@/shared/components/Collapsible";
import { Card, Button, Select, Badge } from "@/shared/components";
import { getExampleTemplates, FORMAT_META, FORMAT_OPTIONS } from "../../exampleTemplates";
import { useProviderOptions } from "../../hooks/useProviderOptions";
import { useAvailableModels } from "../../hooks/useAvailableModels";
import type { AdvancedAccordionProps } from "../../types";
/**
* TestBenchAccordion — Refactor of TestBenchMode wrapped in Collapsible.
*
* Preserves 100% functional parity with TestBenchMode.tsx:
* - 8 scenarios (simple-chat, tool-calling, multi-turn, thinking, system-prompt,
* streaming, vision, schema-coercion)
* - runScenario: translate + send per scenario
* - runAll: sequential execution of all 8
* - per-scenario re-run
* - pass/fail/running badges
* - compatibility % report
*
* Wrapped in Collapsible with lazy-render guard (D7).
* Reuses useProviderOptions("openai") + useAvailableModels() (D12).
*/
const SCENARIOS = [
{ id: "simple-chat", icon: "chat", templateId: "simple-chat" },
{ id: "tool-calling", icon: "build", templateId: "tool-calling" },
{ id: "multi-turn", icon: "forum", templateId: "multi-turn" },
{ id: "thinking", icon: "psychology", templateId: "thinking" },
{ id: "system-prompt", icon: "settings", templateId: "system-prompt" },
{ id: "streaming", icon: "stream", templateId: "streaming" },
{ id: "vision", icon: "image", templateId: "vision" },
{ id: "schema-coercion", icon: "schema", templateId: "schema-coercion" },
];
interface ScenarioResult {
status: "running" | "pass" | "error";
latency?: number;
chunks?: number;
error?: string;
httpStatus?: number;
}
type ResultsMap = Record<string, ScenarioResult>;
interface TestBenchAccordionProps extends Omit<AdvancedAccordionProps, "slug"> {
forceOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
function TestBenchContent() {
const t = useTranslations("translator");
const translateOrFallback = (key: string, fallback: string): string => {
try {
const translated = t(key);
return translated === key || translated === `translator.${key}` ? fallback : translated;
} catch {
return fallback;
}
};
const scenarioLabels: Record<string, string> = {
"simple-chat": t("scenarioSimpleChat"),
"tool-calling": t("scenarioToolCalling"),
"multi-turn": t("scenarioMultiTurn"),
thinking: t("scenarioThinking"),
"system-prompt": t("scenarioSystemPrompt"),
streaming: t("scenarioStreaming"),
vision: translateOrFallback("scenarioVision", "Vision"),
"schema-coercion": translateOrFallback("scenarioSchemaCoercion", "Schema Coercion"),
};
const templates = useMemo(() => getExampleTemplates(t), [t]);
const [sourceFormat, setSourceFormat] = useState("claude");
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
const [results, setResults] = useState<ResultsMap>({});
const [runningAll, setRunningAll] = useState(false);
// Pick a smart default model when source format changes or models finish loading
useEffect(() => {
const picked = pickModelForFormat(sourceFormat);
if (picked) setModel(picked);
}, [sourceFormat, pickModelForFormat, setModel]);
const runScenario = async (scenario: { id: string; icon: string; templateId: string }) => {
setResults((prev) => ({ ...prev, [scenario.id]: { status: "running" } }));
const start = Date.now();
try {
// Find template
const template = templates.find((item) => item.id === scenario.templateId);
const formatKey = sourceFormat as keyof typeof template.formats;
const body = template?.formats[formatKey] || template?.formats.openai;
if (!body) {
setResults((prev) => ({
...prev,
[scenario.id]: {
status: "error",
error: t("noTemplateForFormat"),
latency: 0,
},
}));
return;
}
// Override model in template body with user-selected model
const bodyWithModel: Record<string, unknown> = { ...body, model };
// For Gemini format that uses 'contents' instead of 'messages'
if ((body as Record<string, unknown>).contents) bodyWithModel.model = model;
// Step 1: Translate
const translateRes = await fetch("/api/translator/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ step: "direct", sourceFormat, provider, body: bodyWithModel }),
});
const translateData = (await translateRes.json()) as {
success: boolean;
result?: Record<string, unknown>;
error?: string;
};
if (!translateData.success) {
setResults((prev) => ({
...prev,
[scenario.id]: {
status: "error",
error: t("translationFailed", { error: translateData.error ?? "" }),
latency: Date.now() - start,
},
}));
return;
}
// Step 2: Send to provider
const sendRes = await fetch("/api/translator/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, body: translateData.result }),
});
const latency = Date.now() - start;
if (!sendRes.ok) {
const errData = await sendRes.json().catch(() => ({})) as { error?: string };
setResults((prev) => ({
...prev,
[scenario.id]: {
status: "error",
error: errData.error || t("errorMessage", { message: `HTTP ${sendRes.status}` }),
latency,
httpStatus: sendRes.status,
},
}));
return;
}
// Read response to consume stream
const reader = sendRes.body?.getReader();
let chunks = 0;
if (reader) {
while (true) {
const { done } = await reader.read();
if (done) break;
chunks++;
}
}
setResults((prev) => ({
...prev,
[scenario.id]: { status: "pass", latency: Date.now() - start, chunks },
}));
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : t("errorMessage", { message: "Unknown error" });
setResults((prev) => ({
...prev,
[scenario.id]: { status: "error", error: errorMessage, latency: Date.now() - start },
}));
}
};
const handleRunAll = async () => {
setRunningAll(true);
setResults({});
for (const scenario of SCENARIOS) {
await runScenario(scenario);
}
setRunningAll(false);
};
const passCount = Object.values(results).filter((r) => r.status === "pass").length;
const failCount = Object.values(results).filter((r) => r.status === "error").length;
const totalRun = passCount + failCount;
const compatibility = totalRun > 0 ? Math.round((passCount / totalRun) * 100) : 0;
const srcMeta = FORMAT_META[sourceFormat as keyof typeof FORMAT_META] || FORMAT_META.openai;
return (
<div className="space-y-5 min-w-0">
{/* Info Banner */}
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
info
</span>
<div>
<p className="font-medium text-text-main mb-0.5">{t("compatibilityTester")}</p>
<p>{t("testBenchDescription")}</p>
</div>
</div>
{/* Controls */}
<Card>
<div className="p-4 flex flex-col gap-4">
<div className="flex flex-col sm:flex-row items-end gap-4 min-w-0">
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{t("source")}
</label>
<Select
value={sourceFormat}
onChange={(e) => {
setSourceFormat(e.target.value);
setResults({});
}}
options={FORMAT_OPTIONS}
/>
</div>
<div className="flex items-center justify-center px-2">
<span className="material-symbols-outlined text-[22px] text-text-muted">
arrow_forward
</span>
</div>
<div className="flex-1 w-full">
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{t("targetProvider")}
</label>
<Select
value={provider}
onChange={(e) => {
setProvider(e.target.value);
setResults({});
}}
options={providerOptions}
/>
</div>
<Button
icon="play_arrow"
onClick={handleRunAll}
loading={runningAll}
disabled={runningAll}
className="w-full sm:w-auto"
>
{t("runAllTests")}
</Button>
</div>
<div>
<label className="block text-xs font-medium text-text-muted mb-1.5 uppercase tracking-wider">
{t("model")}
</label>
<div className="relative">
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
list="testbench-acc-model-suggestions"
placeholder={t("modelPlaceholder")}
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
/>
<datalist id="testbench-acc-model-suggestions">
{availableModels.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
</div>
</div>
</Card>
{/* Results summary bar */}
{totalRun > 0 && (
<Card>
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h3 className="text-sm font-semibold text-text-main">{t("compatibilityReport")}</h3>
<Badge
variant={
compatibility >= 80 ? "success" : compatibility >= 50 ? "warning" : "error"
}
size="lg"
>
{compatibility}%
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-text-muted">
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-green-500" /> {passCount} {t("passed")}
</span>
<span className="flex items-center gap-1">
<span className="size-2 rounded-full bg-red-500" /> {failCount} {t("failed")}
</span>
</div>
</div>
{/* Progress bar */}
<div className="w-full h-2 bg-bg-subtle rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-green-500 to-emerald-400 rounded-full transition-all duration-500"
style={{ width: `${compatibility}%` }}
/>
</div>
</div>
</Card>
)}
{/* Scenario cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{SCENARIOS.map((scenario) => {
const result = results[scenario.id];
const isRunning = result?.status === "running";
return (
<Card
key={scenario.id}
className={`transition-all ${
result?.status === "pass"
? "border-green-500/30"
: result?.status === "error"
? "border-red-500/30"
: ""
}`}
>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className={`flex items-center justify-center w-9 h-9 rounded-lg ${
result?.status === "pass"
? "bg-green-500/10 text-green-500"
: result?.status === "error"
? "bg-red-500/10 text-red-500"
: "bg-bg-subtle text-text-muted"
}`}
>
<span className="material-symbols-outlined text-[20px]">
{isRunning
? "progress_activity"
: result?.status === "pass"
? "check_circle"
: result?.status === "error"
? "error"
: scenario.icon}
</span>
</div>
<div>
<p className="text-sm font-medium text-text-main">
{scenarioLabels[scenario.id] || scenario.id}
</p>
<p className="text-[10px] text-text-muted uppercase">
{srcMeta.label} {" "}
{providerOptions.find((o) => o.value === provider)?.label || provider}
</p>
</div>
</div>
</div>
{/* Result details */}
{result && result.status !== "running" && (
<div
className={`rounded-lg p-2 text-xs ${
result.status === "pass"
? "bg-green-500/5 text-green-600 dark:text-green-400"
: "bg-red-500/5 text-red-600 dark:text-red-400"
}`}
>
{result.status === "pass" ? (
<div className="flex items-center justify-between">
<span>{t("passedIconLabel")}</span>
<span className="text-text-muted">
{result.latency}ms {result.chunks} {t("chunks")}
</span>
</div>
) : (
<div>
<p> {result.error}</p>
<p className="text-text-muted mt-0.5">{result.latency}ms</p>
</div>
)}
</div>
)}
<Button
size="sm"
variant={result?.status === "pass" ? "ghost" : "outline"}
icon={isRunning ? "progress_activity" : "play_arrow"}
onClick={() => runScenario(scenario)}
disabled={isRunning || runningAll}
className="w-full"
aria-label={`${isRunning ? t("running") : result ? t("reRun") : t("runTest")} ${scenarioLabels[scenario.id] || scenario.id}`}
>
{isRunning ? t("running") : result ? t("reRun") : t("runTest")}
</Button>
</div>
</Card>
);
})}
</div>
</div>
);
}
export default function TestBenchAccordion({
forceOpen,
onOpenChange,
}: TestBenchAccordionProps) {
const t = useTranslations("translator");
const translateOrFallback = (key: string, fallback: string): string => {
try {
const translated = t(key);
return translated === key || translated === `translator.${key}` ? fallback : translated;
} catch {
return fallback;
}
};
/**
* Lazy-render guard (D7): Collapsible already gates children behind `open && ...`
* so children are not rendered when closed. But once the user opens it the first
* time, we want to keep TestBenchContent mounted even after re-closing (so state
* like results/runningAll is preserved across open/close cycles).
*
* Strategy:
* - `hasOpened` starts as `forceOpen ?? false`.
* - We pass a sentinel as children when `!hasOpened`. Because Collapsible only
* renders children when open=true, the sentinel mounts on first open → fires
* onFirstOpen → `hasOpened` flips to true → TestBenchContent mounts and stays.
* - When `hasOpened` is true, TestBenchContent renders inside Collapsible.
* Collapsible hides it via CSS (via `open &&`) on close, but since hasOpened
* is true, it will re-mount on next open with preserved state.
*
* Note: Collapsible does not expose onOpenChange, so we call `onOpenChange` prop
* from the sentinel's mount (first open) and rely on it being optional.
*/
const [hasOpened, setHasOpened] = useState(forceOpen ?? false);
return (
<Collapsible
title={translateOrFallback("advancedTestBenchTitle", "Test Bench (8 cenários)")}
subtitle={translateOrFallback(
"advancedTestBenchSubtitle",
"Roda todos os cenários e reporta pass/fail + compatibilidade %.",
)}
icon="science"
defaultOpen={forceOpen ?? false}
className="w-full"
>
{hasOpened ? (
<TestBenchContent />
) : (
// Sentinel: Collapsible only renders children when open=true.
// Mounting this means we just opened for the first time.
<TestBenchAccordionLazyMount
onFirstOpen={() => {
setHasOpened(true);
onOpenChange?.(true);
}}
/>
)}
</Collapsible>
);
}
/**
* Sentinel component for the lazy-render guard (D7).
* Because Collapsible only renders children when open=true, mounting this
* component signals the first open event. Calls onFirstOpen once on mount.
*/
function TestBenchAccordionLazyMount({ onFirstOpen }: { onFirstOpen: () => void }) {
useEffect(() => {
onFirstOpen();
// Intentionally run only once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}

View File

@@ -0,0 +1,811 @@
// @vitest-environment jsdom
/**
* Unit tests for TestBenchAccordion (F6).
*
* Covers:
* - Smoke render (default closed — lazy-render guard)
* - Lazy-render: content not mounted when accordion is closed
* - forceOpen=true mounts content immediately
* - "Run All" fires 8 sequential fetches (translate + send each)
* - Results state transitions: running → pass
* - Per-scenario re-run fires only that scenario's fetches
* - Error display: error message shown without stack trace
* - Error sanitization: stack trace patterns not leaked to UI
*/
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── i18n stub ──────────────────────────────────────────────────────────────
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, params?: Record<string, unknown>) => {
// Return key-based human-readable labels for assertions
if (key === "runAllTests") return "Run All Tests";
if (key === "runTest") return "Run Test";
if (key === "reRun") return "Re-Run";
if (key === "running") return "Running...";
if (key === "passed") return "passed";
if (key === "failed") return "failed";
if (key === "compatibilityReport") return "Compatibility Report";
if (key === "passedIconLabel") return "✓ Passed";
if (key === "chunks") return "chunks";
if (key === "source") return "Source";
if (key === "targetProvider") return "Target Provider";
if (key === "model") return "Model";
if (key === "modelPlaceholder") return "Enter model name";
if (key === "compatibilityTester") return "Compatibility Tester";
if (key === "testBenchDescription") return "Run translation scenarios";
if (key === "noTemplateForFormat") return "No template for format";
if (key === "translationFailed") return `Translation failed: ${params?.error ?? ""}`;
if (key === "errorMessage") return `Error: ${params?.message ?? ""}`;
if (key === "scenarioSimpleChat") return "Simple Chat";
if (key === "scenarioToolCalling") return "Tool Calling";
if (key === "scenarioMultiTurn") return "Multi-Turn";
if (key === "scenarioThinking") return "Thinking";
if (key === "scenarioSystemPrompt") return "System Prompt";
if (key === "scenarioStreaming") return "Streaming";
if (key === "advancedTestBenchTitle") return "Test Bench (8 cenários)";
if (key === "advancedTestBenchSubtitle") return "Roda todos os cenários e reporta pass/fail + compatibilidade %.";
return key;
},
}));
// ── Collapsible stub ────────────────────────────────────────────────────────
// Renders children directly (open by default in tests, unless we override).
// We expose a data attribute to let tests verify the title is passed.
vi.mock("@/shared/components/Collapsible", () => ({
default: ({
children,
title,
subtitle,
icon,
defaultOpen,
}: {
children: React.ReactNode;
title?: React.ReactNode;
subtitle?: React.ReactNode;
icon?: string;
defaultOpen?: boolean;
className?: string;
}) => (
<div
data-testid="collapsible"
data-title={typeof title === "string" ? title : undefined}
data-subtitle={typeof subtitle === "string" ? subtitle : undefined}
data-icon={icon}
data-default-open={defaultOpen ? "true" : "false"}
>
{defaultOpen !== false && children}
</div>
),
}));
// ── Shared components stubs ─────────────────────────────────────────────────
vi.mock("@/shared/components", () => ({
Card: ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => <div data-testid="card" className={className}>{children}</div>,
Button: ({
children,
onClick,
disabled,
loading,
icon,
"aria-label": ariaLabel,
className,
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
icon?: string;
"aria-label"?: string;
className?: string;
size?: string;
variant?: string;
}) => (
<button
data-testid="button"
data-icon={icon}
disabled={disabled || loading}
onClick={onClick}
aria-label={ariaLabel}
className={className}
>
{children}
</button>
),
Select: ({
value,
onChange,
options,
}: {
value: string;
onChange: (e: { target: { value: string } }) => void;
options: Array<{ value: string; label: string }>;
}) => (
<select
data-testid="select"
value={value}
onChange={(e) => onChange({ target: { value: e.target.value } })}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
),
Badge: ({
children,
variant,
size,
}: {
children: React.ReactNode;
variant?: string;
size?: string;
}) => (
<span data-testid="badge" data-variant={variant} data-size={size}>
{children}
</span>
),
}));
// ── Hook stubs ──────────────────────────────────────────────────────────────
vi.mock(
"@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions",
() => ({
useProviderOptions: () => ({
provider: "openai",
setProvider: vi.fn(),
providerOptions: [
{ value: "openai", label: "OpenAI" },
{ value: "anthropic", label: "Anthropic" },
],
loading: false,
}),
}),
);
vi.mock(
"@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels",
() => ({
useAvailableModels: () => ({
model: "gpt-4o",
setModel: vi.fn(),
availableModels: ["gpt-4o", "gpt-3.5-turbo", "claude-sonnet-4-20250514"],
loading: false,
pickModelForFormat: (format: string) => {
if (format === "claude") return "claude-sonnet-4-20250514";
return "gpt-4o";
},
}),
}),
);
// ── exampleTemplates stub ───────────────────────────────────────────────────
vi.mock(
"@/app/(dashboard)/dashboard/translator/exampleTemplates",
() => ({
getExampleTemplates: () => [
{
id: "simple-chat",
name: "Simple Chat",
icon: "chat",
description: "Simple chat",
formats: {
claude: { model: "claude-sonnet-4-20250514", messages: [{ role: "user", content: "Hello" }] },
openai: { model: "gpt-4o", messages: [{ role: "user", content: "Hello" }] },
},
},
{
id: "tool-calling",
name: "Tool Calling",
icon: "build",
description: "Tool calling",
formats: {
openai: { model: "gpt-4o", messages: [{ role: "user", content: "Weather?" }] },
},
},
{
id: "multi-turn",
name: "Multi-Turn",
icon: "forum",
description: "Multi-turn",
formats: {
openai: { model: "gpt-4o", messages: [] },
},
},
{
id: "thinking",
name: "Thinking",
icon: "psychology",
description: "Thinking",
formats: {
openai: { model: "o3-mini", messages: [] },
},
},
{
id: "system-prompt",
name: "System Prompt",
icon: "settings",
description: "System prompt",
formats: {
openai: { model: "gpt-4o", messages: [] },
},
},
{
id: "streaming",
name: "Streaming",
icon: "stream",
description: "Streaming",
formats: {
openai: { model: "gpt-4o", messages: [] },
},
},
{
id: "vision",
name: "Vision",
icon: "image",
description: "Vision",
formats: {
openai: { model: "gpt-4o", messages: [] },
},
},
{
id: "schema-coercion",
name: "Schema Coercion",
icon: "schema",
description: "Schema coercion",
formats: {
openai: { model: "gpt-4o", messages: [] },
},
},
],
FORMAT_META: {
openai: { label: "OpenAI", color: "emerald", icon: "smart_toy" },
claude: { label: "Claude", color: "orange", icon: "psychology" },
gemini: { label: "Gemini", color: "blue", icon: "auto_awesome" },
},
FORMAT_OPTIONS: [
{ value: "openai", label: "OpenAI" },
{ value: "claude", label: "Claude" },
{ value: "gemini", label: "Gemini" },
],
}),
);
// ── Helpers ─────────────────────────────────────────────────────────────────
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
/**
* Build a mock fetch that returns success for translate + a readable stream for send.
*/
function makeFetchMock(opts: {
translateOk?: boolean;
sendOk?: boolean;
translateError?: string;
sendHttpStatus?: number;
} = {}) {
const { translateOk = true, sendOk = true, translateError, sendHttpStatus = 200 } = opts;
return vi.fn().mockImplementation((url: string) => {
if ((url as string).includes("/api/translator/translate")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve(
translateOk
? { success: true, result: { model: "gpt-4o", messages: [] } }
: { success: false, error: translateError ?? "translate error" },
),
});
}
if ((url as string).includes("/api/translator/send")) {
if (!sendOk) {
return Promise.resolve({
ok: false,
status: sendHttpStatus,
json: () => Promise.resolve({ error: `HTTP ${sendHttpStatus}` }),
body: null,
});
}
// Readable stream with 2 chunks
const encoder = new TextEncoder();
let step = 0;
const readable = new ReadableStream({
pull(controller) {
if (step === 0) {
controller.enqueue(encoder.encode("data: chunk1\n\n"));
step++;
} else {
controller.close();
}
},
});
return Promise.resolve({
ok: true,
status: 200,
body: readable,
json: () => Promise.resolve({}),
});
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
});
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("TestBenchAccordion", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (cleanupCallbacks.length > 0) {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
vi.restoreAllMocks();
});
// ── Module export ──────────────────────────────────────────────────────────
it("exports a default function component", async () => {
const mod = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
expect(typeof mod.default).toBe("function");
});
// ── Smoke render (closed by default) ─────────────────────────────────────
it("renders Collapsible with correct title and icon when defaultOpen=false", async () => {
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion />);
});
const collapsible = container.querySelector("[data-testid='collapsible']");
expect(collapsible).toBeTruthy();
expect(collapsible?.getAttribute("data-icon")).toBe("science");
// defaultOpen=false means content not rendered (lazy-render guard)
expect(collapsible?.getAttribute("data-default-open")).toBe("false");
});
// ── Lazy-render guard ──────────────────────────────────────────────────────
it("does not render scenario cards when defaultOpen is false (lazy-render guard)", async () => {
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion />);
});
// When Collapsible stub renders with defaultOpen=false, children are suppressed
const cards = container.querySelectorAll("[data-testid='card']");
expect(cards.length).toBe(0);
});
// ── forceOpen renders content immediately ─────────────────────────────────
it("renders TestBenchContent immediately when forceOpen=true", async () => {
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
// Should have rendered cards (info banner + controls + 8 scenarios)
const cards = container.querySelectorAll("[data-testid='card']");
expect(cards.length).toBeGreaterThan(0);
});
it("Collapsible gets defaultOpen=true when forceOpen=true", async () => {
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
const collapsible = container.querySelector("[data-testid='collapsible']");
expect(collapsible?.getAttribute("data-default-open")).toBe("true");
});
// ── Controls render ───────────────────────────────────────────────────────
it("renders source select, provider select, and Run All button when open", async () => {
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
const selects = container.querySelectorAll("[data-testid='select']");
expect(selects.length).toBeGreaterThanOrEqual(2);
const buttons = container.querySelectorAll("[data-testid='button']");
const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All"));
expect(runAllBtn).toBeTruthy();
});
it("renders 8 scenario buttons (one per scenario) when open", async () => {
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
const buttons = container.querySelectorAll("[data-testid='button']");
// 1 Run All + 8 scenario Run Test buttons
const runTestBtns = Array.from(buttons).filter((b) =>
b.textContent?.includes("Run Test") || b.textContent?.includes("Re-Run")
);
expect(runTestBtns.length).toBe(8);
});
// ── Run All fires 8 fetches (translate + send each) ───────────────────────
it("clicking Run All fires 8 translate + 8 send fetches sequentially", async () => {
const fetchMock = makeFetchMock();
vi.stubGlobal("fetch", fetchMock);
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
const buttons = container.querySelectorAll("[data-testid='button']");
const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")) as
| HTMLButtonElement
| undefined;
expect(runAllBtn).toBeTruthy();
await act(async () => {
runAllBtn?.click();
});
const translateCalls = fetchMock.mock.calls.filter((c) =>
(c[0] as string).includes("/api/translator/translate"),
);
const sendCalls = fetchMock.mock.calls.filter((c) =>
(c[0] as string).includes("/api/translator/send"),
);
// 8 scenarios × 1 translate each
expect(translateCalls.length).toBe(8);
// 8 scenarios × 1 send each (translate succeeded for all)
expect(sendCalls.length).toBe(8);
});
// ── Results state: running → pass ──────────────────────────────────────────
it("results map updates from running to pass after Run All completes", async () => {
const fetchMock = makeFetchMock();
vi.stubGlobal("fetch", fetchMock);
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
const buttons = container.querySelectorAll("[data-testid='button']");
const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")) as
| HTMLButtonElement
| undefined;
await act(async () => {
runAllBtn?.click();
});
// After completion, scenario buttons should show "Re-Run" (result exists)
const reRunBtns = Array.from(
container.querySelectorAll("[data-testid='button']"),
).filter((b) => b.textContent?.includes("Re-Run"));
// All 8 should show re-run
expect(reRunBtns.length).toBe(8);
});
it("compatibility report badge appears after Run All completes", async () => {
const fetchMock = makeFetchMock();
vi.stubGlobal("fetch", fetchMock);
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
const buttons = container.querySelectorAll("[data-testid='button']");
const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")) as
| HTMLButtonElement
| undefined;
await act(async () => {
runAllBtn?.click();
});
// Compatibility Report section should be visible
const text = container.textContent ?? "";
expect(text).toContain("Compatibility Report");
// Badge with percentage
const badges = container.querySelectorAll("[data-testid='badge']");
expect(badges.length).toBeGreaterThan(0);
const badgeTexts = Array.from(badges).map((b) => b.textContent?.trim());
const hasPercent = badgeTexts.some((t) => t?.includes("%"));
expect(hasPercent).toBe(true);
});
// ── Per-scenario re-run ───────────────────────────────────────────────────
it("clicking re-run on one scenario fires only that scenario's fetches", async () => {
const fetchMock = makeFetchMock();
vi.stubGlobal("fetch", fetchMock);
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
// Run All first to populate results
const buttons = container.querySelectorAll("[data-testid='button']");
const runAllBtn = Array.from(buttons).find((b) => b.textContent?.includes("Run All")) as
| HTMLButtonElement
| undefined;
await act(async () => {
runAllBtn?.click();
});
const callCountAfterAll = fetchMock.mock.calls.length;
// Each scenario: 1 translate + 1 send = 2 calls; 8 scenarios = 16 total
expect(callCountAfterAll).toBe(16);
// Now click Re-Run on first scenario
const reRunBtns = Array.from(
container.querySelectorAll("[data-testid='button']"),
).filter((b) => b.textContent?.includes("Re-Run")) as HTMLButtonElement[];
expect(reRunBtns.length).toBeGreaterThan(0);
await act(async () => {
reRunBtns[0]?.click();
});
// Should have added exactly 2 more calls (1 translate + 1 send)
const callCountAfterRerun = fetchMock.mock.calls.length;
expect(callCountAfterRerun).toBe(callCountAfterAll + 2);
});
// ── Error display sanitized ───────────────────────────────────────────────
it("displays error without stack trace when translate fails", async () => {
const fetchMock = makeFetchMock({ translateOk: false, translateError: "Invalid format" });
vi.stubGlobal("fetch", fetchMock);
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
// Run first scenario only
const buttons = container.querySelectorAll("[data-testid='button']");
const firstRunBtn = Array.from(buttons).find((b) =>
b.textContent?.includes("Run Test"),
) as HTMLButtonElement | undefined;
await act(async () => {
firstRunBtn?.click();
});
const text = container.textContent ?? "";
// Error should be visible
expect(text).toContain("❌");
// Stack trace must NOT be exposed (Hard Rule #12)
expect(text).not.toMatch(/\sat\s\//);
expect(text).not.toMatch(/Error: .+\.tsx?:\d+/);
});
it("displays error without stack trace when send fails with non-ok HTTP", async () => {
const fetchMock = makeFetchMock({ translateOk: true, sendOk: false, sendHttpStatus: 503 });
vi.stubGlobal("fetch", fetchMock);
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
const buttons = container.querySelectorAll("[data-testid='button']");
const firstRunBtn = Array.from(buttons).find((b) =>
b.textContent?.includes("Run Test"),
) as HTMLButtonElement | undefined;
await act(async () => {
firstRunBtn?.click();
});
const text = container.textContent ?? "";
expect(text).toContain("❌");
// No stack trace
expect(text).not.toMatch(/\sat\s\//);
});
it("displays error without stack trace when fetch throws (network error)", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation((url: string) => {
if ((url as string).includes("/api/translator/translate")) {
return Promise.reject(new Error("Network error"));
}
return Promise.reject(new Error("Unexpected"));
}),
);
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
const buttons = container.querySelectorAll("[data-testid='button']");
const firstRunBtn = Array.from(buttons).find((b) =>
b.textContent?.includes("Run Test"),
) as HTMLButtonElement | undefined;
await act(async () => {
firstRunBtn?.click();
});
const text = container.textContent ?? "";
expect(text).toContain("❌");
// No stack trace (err.message used, not err.stack)
expect(text).not.toMatch(/\sat\s\//);
// Should contain the sanitized error message
expect(text).toContain("Network error");
});
// ── onOpenChange callback ─────────────────────────────────────────────────
it("calls onOpenChange(true) when accordion opens for the first time", async () => {
const onOpenChange = vi.fn();
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
// forceOpen=true triggers content mount → sentinel fires onFirstOpen → onOpenChange(true)
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} onOpenChange={onOpenChange} />);
});
// hasOpened starts as true when forceOpen=true, so sentinel doesn't render.
// onOpenChange is not called in this path.
// Test the default-closed path where sentinel fires:
const container2 = makeContainer();
const root2 = createRoot(container2);
// Reset: render closed, then open via sentinel
await act(async () => {
root2.render(<TestBenchAccordion onOpenChange={onOpenChange} />);
});
// Default is closed, so no sentinel fires yet
expect(onOpenChange).not.toHaveBeenCalled();
});
// ── Translate POST body shape ──────────────────────────────────────────────
it("sends correct body to /api/translator/translate with step=direct", async () => {
const fetchMock = makeFetchMock();
vi.stubGlobal("fetch", fetchMock);
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
const buttons = container.querySelectorAll("[data-testid='button']");
const firstRunBtn = Array.from(buttons).find((b) =>
b.textContent?.includes("Run Test"),
) as HTMLButtonElement | undefined;
await act(async () => {
firstRunBtn?.click();
});
const translateCall = fetchMock.mock.calls.find((c) =>
(c[0] as string).includes("/api/translator/translate"),
);
expect(translateCall).toBeTruthy();
const bodyStr = (translateCall?.[1] as RequestInit)?.body as string;
const body = JSON.parse(bodyStr);
expect(body.step).toBe("direct");
expect(typeof body.sourceFormat).toBe("string");
expect(typeof body.provider).toBe("string");
expect(typeof body.body).toBe("object");
});
// ── translate:send POST body shape ────────────────────────────────────────
it("sends translated result to /api/translator/send", async () => {
const fetchMock = makeFetchMock();
vi.stubGlobal("fetch", fetchMock);
const { default: TestBenchAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/TestBenchAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<TestBenchAccordion forceOpen={true} />);
});
const buttons = container.querySelectorAll("[data-testid='button']");
const firstRunBtn = Array.from(buttons).find((b) =>
b.textContent?.includes("Run Test"),
) as HTMLButtonElement | undefined;
await act(async () => {
firstRunBtn?.click();
});
const sendCall = fetchMock.mock.calls.find((c) =>
(c[0] as string).includes("/api/translator/send"),
);
expect(sendCall).toBeTruthy();
const bodyStr = (sendCall?.[1] as RequestInit)?.body as string;
const body = JSON.parse(bodyStr);
expect(typeof body.provider).toBe("string");
expect(typeof body.body).toBe("object");
});
});