848 lines
31 KiB
TypeScript
848 lines
31 KiB
TypeScript
// @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");
|
||
});
|
||
|
||
it("sanitizes stack trace from error: 'at /path' patterns are stripped from UI (Hard Rule #12)", async () => {
|
||
vi.stubGlobal(
|
||
"fetch",
|
||
vi.fn().mockImplementation((url: string) => {
|
||
if ((url as string).includes("/api/translator/translate")) {
|
||
const errWithStack = new Error("foo\n at /home/user/dev/file.ts:42:10\n at /node_modules/bar.js:1:1");
|
||
return Promise.reject(errWithStack);
|
||
}
|
||
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 ?? "";
|
||
// Error should be displayed
|
||
expect(text).toContain("❌");
|
||
// Stack trace 'at /' patterns MUST NOT appear in the rendered UI (Hard Rule #12)
|
||
expect(text).not.toContain("at /");
|
||
});
|
||
|
||
// ── 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");
|
||
});
|
||
});
|