feat(translator): add CompressionPreviewAccordion (F7)

- Extracted Compression Preview from PlaygroundMode lines 506-584 into standalone accordion
- Wrapped in Collapsible with lazy-render (D7): content only mounts after first open
- Accepts inputContent via prop (lifted from TranslateTab in F9) or shows empty-state hint
- POST /api/compression/preview unchanged; error path sanitized (no stack-trace leak)
- 33 Vitest tests covering smoke, lazy-render guard, mode select, fetch dispatch, result grid, error path
This commit is contained in:
diegosouzapw
2026-05-27 20:46:55 -03:00
parent bb200cd0ba
commit de7c0c6bba
2 changed files with 866 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
"use client";
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button, Select } from "@/shared/components";
import { cn } from "@/shared/utils/cn";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CompressionPreviewResult {
originalTokens: number;
compressedTokens: number;
tokensSaved: number;
savingsPct: number;
techniquesUsed: string[];
durationMs: number;
}
export interface CompressionPreviewAccordionProps {
/** Force the accordion open on mount (used by deep-link). */
forceOpen?: boolean;
/** Called whenever the open state changes (used for URL sync). */
onOpenChange?: (open: boolean) => void;
/**
* Content to compress. If provided (from TranslateTab state), the accordion
* uses it directly. If absent or empty, shows an empty-state hint.
*/
inputContent?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Sanitize an error message: strip Node stack-trace lines (e.g. "at /home/…"). */
function sanitizeError(e: unknown): string {
const raw = e instanceof Error ? e.message : String(e);
// Remove stack-trace lines that start with "at " followed by a path
return raw.replace(/\s+at\s+[^\n]+/g, "").trim();
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const COMPRESSION_MODES = [
{ value: "off", label: "Off" },
{ value: "lite", label: "Lite" },
{ value: "standard", label: "Standard" },
{ value: "aggressive", label: "Aggressive" },
{ value: "ultra", label: "Ultra" },
] as const;
// ---------------------------------------------------------------------------
// Inner content (always mounted when hasOpened is true)
// ---------------------------------------------------------------------------
function CompressionPreviewContent({ inputContent = "" }: { inputContent?: string }) {
const t = useTranslations("translator");
const [compressionMode, setCompressionMode] = useState<string>("standard");
const [compressionResult, setCompressionResult] = useState<CompressionPreviewResult | null>(
null,
);
const [compressionLoading, setCompressionLoading] = useState(false);
const [compressionError, setCompressionError] = useState<string | null>(null);
const hasInput = inputContent.trim().length > 0;
const handleCompressionPreview = useCallback(async () => {
if (!hasInput) return;
let messages: Array<{ role: string; content: string }>;
try {
const parsed: Record<string, unknown> = JSON.parse(inputContent);
messages = Array.isArray(parsed.messages)
? (parsed.messages as Array<{ role: string; content: string }>)
: [{ role: "user", content: inputContent }];
} catch {
messages = [{ role: "user", content: inputContent }];
}
setCompressionLoading(true);
setCompressionError(null);
try {
const res = await fetch("/api/compression/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages, mode: compressionMode }),
});
const data: CompressionPreviewResult & { error?: string } = await res.json();
if (!res.ok) throw new Error(data.error ?? "Preview failed");
setCompressionResult(data);
} catch (e: unknown) {
setCompressionError(sanitizeError(e));
} finally {
setCompressionLoading(false);
}
}, [hasInput, inputContent, compressionMode]);
return (
<div className="space-y-4">
{/* Empty state */}
{!hasInput && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-black/5 dark:bg-white/5 text-sm text-text-muted">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
info
</span>
<span>
{t("compressionEmptyHint") ||
"Preencha o campo de entrada na aba Translate (Simple Controls ou Raw JSON) para habilitar o preview."}
</span>
</div>
)}
{/* Controls */}
<div className="flex items-center gap-3 flex-wrap">
<Select
value={compressionMode}
onChange={(e) => setCompressionMode(e.target.value)}
options={COMPRESSION_MODES}
className="text-sm"
aria-label={t("compressionModeLabel") || "Modo de compressão"}
/>
<Button
icon="play_arrow"
onClick={handleCompressionPreview}
loading={compressionLoading}
disabled={compressionLoading || !hasInput}
className="text-sm"
>
{compressionLoading
? t("compressionPreviewing") || "Previewing…"
: t("compressionPreviewButton") || "Preview Compression"}
</Button>
</div>
{/* Error */}
{compressionError && (
<div className="text-sm text-red-500" role="alert">
{compressionError}
</div>
)}
{/* Result grid — 4 cards */}
{compressionResult && (
<div className="space-y-3">
<div
className="grid grid-cols-2 md:grid-cols-4 gap-3"
data-testid="compression-result-grid"
>
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
<div className="text-xs text-text-muted">Original</div>
<div className="text-lg font-bold">{compressionResult.originalTokens}</div>
<div className="text-xs text-text-muted">tokens</div>
</div>
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
<div className="text-xs text-text-muted">Compressed</div>
<div className="text-lg font-bold">{compressionResult.compressedTokens}</div>
<div className="text-xs text-text-muted">tokens</div>
</div>
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
<div className="text-xs text-text-muted">Saved</div>
<div className="text-lg font-bold text-green-500">
{compressionResult.tokensSaved}
</div>
<div className="text-xs text-text-muted">{compressionResult.savingsPct}%</div>
</div>
<div className="card p-3 text-center bg-black/5 dark:bg-white/5 rounded-lg border border-border">
<div className="text-xs text-text-muted">Duration</div>
<div className="text-lg font-bold">{compressionResult.durationMs}</div>
<div className="text-xs text-text-muted">ms</div>
</div>
</div>
{compressionResult.techniquesUsed.length > 0 && (
<div className="text-xs text-text-muted">
<span className="font-semibold">{t("techniques") || "Técnicas:"}</span>{" "}
{compressionResult.techniquesUsed.join(", ")}
</div>
)}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Accordion wrapper — owns open state to support D7 lazy-render + onOpenChange
// ---------------------------------------------------------------------------
/**
* CompressionPreviewAccordion — F7
*
* Extracted from PlaygroundMode.tsx lines 506-584 (Compression Preview Panel).
* Uses a self-contained collapsible header (matches Collapsible visual style)
* with an explicit `open` state so we can implement:
* - D7 lazy-render guard (mount content only after first open)
* - `onOpenChange` callback for deep-link URL sync
* - `forceOpen` prop for deep-link initial state
*
* Note: We manage open state here rather than delegating to Collapsible because
* Collapsible is purely uncontrolled (no onOpenChange prop). D7 requires knowing
* when the accordion opens to set hasOpened, which requires controlled state.
*/
export default function CompressionPreviewAccordion({
forceOpen = false,
onOpenChange,
inputContent,
}: CompressionPreviewAccordionProps) {
const t = useTranslations("translator");
// Lazy-render guard (D7): track whether the accordion has ever been opened.
const [hasOpened, setHasOpened] = useState(forceOpen);
const [open, setOpen] = useState(forceOpen);
const handleToggle = useCallback(() => {
const next = !open;
if (next && !hasOpened) {
setHasOpened(true);
}
setOpen(next);
onOpenChange?.(next);
}, [open, hasOpened, onOpenChange]);
// i18n with inline EN fallbacks (D19 pattern).
const title = t("advancedCompressionTitle") || "Compression Preview";
const subtitle =
t("advancedCompressionSubtitle") || "Estime economia de tokens em diferentes modos.";
return (
<div
className="rounded-lg border border-black/5 dark:border-white/5 bg-surface w-full"
data-testid="compression-accordion"
>
{/* Header row — matches Collapsible visual style */}
<div
className={cn(
"flex items-center gap-3 p-4 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors",
open && "border-b border-black/5 dark:border-white/5",
)}
>
<button
type="button"
onClick={handleToggle}
aria-expanded={open}
aria-controls="compression-preview-content"
className="flex items-center gap-3 flex-1 min-w-0 text-left -m-1 p-1 rounded"
>
<span
className="material-symbols-outlined text-text-muted text-[20px] shrink-0"
aria-hidden="true"
>
{open ? "expand_more" : "chevron_right"}
</span>
<span
className="material-symbols-outlined text-text-muted text-[18px] shrink-0"
aria-hidden="true"
>
compress
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-main truncate">{title}</div>
<div className="text-xs text-text-muted truncate">{subtitle}</div>
</div>
</button>
</div>
{/* Content — D7 lazy-render */}
{open && (
<div id="compression-preview-content" className="p-4">
{/* hasOpened is set to true before we set open=true, so this is always true when open */}
{hasOpened && <CompressionPreviewContent inputContent={inputContent} />}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,585 @@
// @vitest-environment jsdom
/**
* Tests for CompressionPreviewAccordion (F7).
*
* Coverage targets:
* - smoke render (component mounts without errors)
* - lazy-render guard (D7): children only mount after accordion opens
* - mode select changes (off / lite / standard / aggressive / ultra)
* - Preview button dispatches POST /api/compression/preview with { messages, mode }
* - result grid (4 cards) renders on success
* - error path is sanitized (no stack-trace leak — "at /" not in DOM)
*/
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// Stub shared components
vi.mock("@/shared/components", () => ({
Button: ({
children,
onClick,
disabled,
loading: _loading,
icon: _icon,
className: _className,
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
icon?: string;
className?: string;
}) => (
<button data-testid="button" onClick={onClick} disabled={disabled}>
{children}
</button>
),
Select: ({
value,
onChange,
options,
className: _className,
"aria-label": ariaLabel,
}: {
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
options: ReadonlyArray<{ value: string; label: string }>;
className?: string;
"aria-label"?: string;
}) => (
<select data-testid="select" value={value} aria-label={ariaLabel} onChange={onChange}>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
),
}));
// Stub cn utility
vi.mock("@/shared/utils/cn", () => ({
cn: (...classes: (string | undefined | false)[]) => classes.filter(Boolean).join(" "),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
async function renderComponent(
props: {
forceOpen?: boolean;
inputContent?: string;
onOpenChange?: (open: boolean) => void;
} = {},
) {
const { default: CompressionPreviewAccordion } = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<CompressionPreviewAccordion {...props} />);
});
return { container, root };
}
/** Click the accordion toggle button to open/close it. */
async function clickToggle(container: HTMLElement) {
const btn = container.querySelector(
"button[aria-expanded]",
) as HTMLButtonElement | null;
expect(btn).toBeTruthy();
await act(async () => {
btn?.click();
});
}
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
vi.clearAllMocks();
});
afterEach(() => {
while (cleanupCallbacks.length > 0) cleanupCallbacks.pop()?.();
document.body.innerHTML = "";
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("CompressionPreviewAccordion — export", () => {
it("exports a default function component", async () => {
const mod = await import(
"@/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion"
);
expect(typeof mod.default).toBe("function");
});
});
describe("CompressionPreviewAccordion — smoke render", () => {
it("renders without crashing (closed by default)", async () => {
const { container } = await renderComponent();
expect(container.querySelector("[data-testid='compression-accordion']")).toBeTruthy();
});
it("renders the toggle button with compress icon and i18n title", async () => {
const { container } = await renderComponent();
// compress icon in header
const icons = container.querySelectorAll(".material-symbols-outlined");
const iconTexts = Array.from(icons).map((el) => el.textContent?.trim());
expect(iconTexts).toContain("compress");
// title text (mock returns the key)
const text = container.textContent ?? "";
expect(text).toContain("advancedCompressionTitle");
});
it("renders the subtitle", async () => {
const { container } = await renderComponent();
const text = container.textContent ?? "";
expect(text).toContain("advancedCompressionSubtitle");
});
it("toggle button starts closed (aria-expanded=false)", async () => {
const { container } = await renderComponent();
const btn = container.querySelector("button[aria-expanded]");
expect(btn?.getAttribute("aria-expanded")).toBe("false");
});
it("toggle button starts open when forceOpen=true (aria-expanded=true)", async () => {
const { container } = await renderComponent({ forceOpen: true });
const btn = container.querySelector("button[aria-expanded]");
expect(btn?.getAttribute("aria-expanded")).toBe("true");
});
});
describe("CompressionPreviewAccordion — lazy-render guard (D7)", () => {
it("does NOT mount content when closed (forceOpen=false)", async () => {
const { container } = await renderComponent({ forceOpen: false });
// Content region should not exist
expect(container.querySelector("#compression-preview-content")).toBeNull();
// Mode select should not be in DOM
expect(container.querySelector("[data-testid='select']")).toBeNull();
});
it("mounts content after opening accordion", async () => {
const { container } = await renderComponent({ forceOpen: false });
// Initially closed
expect(container.querySelector("[data-testid='select']")).toBeNull();
// Open
await clickToggle(container);
// Content should now be mounted
expect(container.querySelector("#compression-preview-content")).toBeTruthy();
expect(container.querySelector("[data-testid='select']")).toBeTruthy();
});
it("mounts content immediately when forceOpen=true", async () => {
const { container } = await renderComponent({ forceOpen: true });
expect(container.querySelector("#compression-preview-content")).toBeTruthy();
expect(container.querySelector("[data-testid='select']")).toBeTruthy();
});
it("toggle opens accordion (aria-expanded flips to true)", async () => {
const { container } = await renderComponent();
const btn = container.querySelector("button[aria-expanded]");
expect(btn?.getAttribute("aria-expanded")).toBe("false");
await clickToggle(container);
expect(btn?.getAttribute("aria-expanded")).toBe("true");
});
it("toggle closes accordion again (aria-expanded flips back to false)", async () => {
const { container } = await renderComponent({ forceOpen: true });
const btn = container.querySelector("button[aria-expanded]");
expect(btn?.getAttribute("aria-expanded")).toBe("true");
await clickToggle(container);
expect(btn?.getAttribute("aria-expanded")).toBe("false");
});
it("calls onOpenChange with true when opening", async () => {
const onOpenChange = vi.fn();
const { container } = await renderComponent({ forceOpen: false, onOpenChange });
await clickToggle(container);
expect(onOpenChange).toHaveBeenCalledWith(true);
});
it("calls onOpenChange with false when closing", async () => {
const onOpenChange = vi.fn();
const { container } = await renderComponent({ forceOpen: true, onOpenChange });
await clickToggle(container);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
});
describe("CompressionPreviewAccordion — empty state", () => {
it("shows empty-state hint when inputContent is empty string", async () => {
const { container } = await renderComponent({ forceOpen: true, inputContent: "" });
const text = container.textContent ?? "";
expect(text).toContain("compressionEmptyHint");
});
it("shows empty-state hint when inputContent is absent", async () => {
const { container } = await renderComponent({ forceOpen: true });
const text = container.textContent ?? "";
expect(text).toContain("compressionEmptyHint");
});
it("Preview button is disabled when inputContent is empty", async () => {
const { container } = await renderComponent({ forceOpen: true, inputContent: "" });
const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null;
expect(btn?.disabled).toBe(true);
});
it("shows preview button enabled when inputContent is non-empty", async () => {
const { container } = await renderComponent({
forceOpen: true,
inputContent: JSON.stringify({ messages: [{ role: "user", content: "Hello" }] }),
});
const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null;
expect(btn).toBeTruthy();
expect(btn?.disabled).toBe(false);
});
});
describe("CompressionPreviewAccordion — mode select", () => {
const MODES = ["off", "lite", "standard", "aggressive", "ultra"] as const;
it("renders all 5 mode options", async () => {
const { container } = await renderComponent({ forceOpen: true, inputContent: "hello" });
const select = container.querySelector("[data-testid='select']") as HTMLSelectElement | null;
expect(select).toBeTruthy();
const options = Array.from(select?.options ?? []).map((o) => o.value);
for (const mode of MODES) {
expect(options).toContain(mode);
}
});
it("default mode is 'standard'", async () => {
const { container } = await renderComponent({ forceOpen: true, inputContent: "hello" });
const select = container.querySelector("[data-testid='select']") as HTMLSelectElement | null;
expect(select?.value).toBe("standard");
});
for (const mode of MODES) {
it(`changing mode to '${mode}' updates select value`, async () => {
const { container } = await renderComponent({ forceOpen: true, inputContent: "hello" });
const select = container.querySelector("[data-testid='select']") as HTMLSelectElement | null;
expect(select).toBeTruthy();
await act(async () => {
// Set the value and fire change event
select!.value = mode;
select!.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(select?.value).toBe(mode);
});
}
});
describe("CompressionPreviewAccordion — Preview fetch", () => {
it("calls POST /api/compression/preview with { messages, mode } on button click", async () => {
const mockResult = {
originalTokens: 100,
compressedTokens: 80,
tokensSaved: 20,
savingsPct: 20,
techniquesUsed: ["dedup", "trim"],
durationMs: 42,
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockResult,
});
vi.stubGlobal("fetch", fetchMock);
const inputContent = JSON.stringify({
messages: [{ role: "user", content: "Hello world" }],
});
const { container } = await renderComponent({ forceOpen: true, inputContent });
const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null;
expect(btn).toBeTruthy();
await act(async () => {
btn?.click();
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"/api/compression/preview",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({ "Content-Type": "application/json" }),
}),
);
// Verify body has correct shape
const callArgs = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(callArgs[1].body as string) as {
messages: Array<{ role: string; content: string }>;
mode: string;
};
expect(body).toMatchObject({
messages: [{ role: "user", content: "Hello world" }],
mode: "standard",
});
vi.unstubAllGlobals();
});
it("wraps plain-text inputContent as { role: 'user', content } when not valid JSON", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
originalTokens: 10,
compressedTokens: 8,
tokensSaved: 2,
savingsPct: 20,
techniquesUsed: [],
durationMs: 5,
}),
});
vi.stubGlobal("fetch", fetchMock);
const { container } = await renderComponent({ forceOpen: true, inputContent: "plain text" });
const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null;
await act(async () => {
btn?.click();
});
const callArgs = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(callArgs[1].body as string) as {
messages: Array<{ role: string; content: string }>;
};
expect(body.messages).toEqual([{ role: "user", content: "plain text" }]);
vi.unstubAllGlobals();
});
it("sends selected mode in the request body", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
originalTokens: 50,
compressedTokens: 40,
tokensSaved: 10,
savingsPct: 20,
techniquesUsed: [],
durationMs: 20,
}),
});
vi.stubGlobal("fetch", fetchMock);
const { container } = await renderComponent({ forceOpen: true, inputContent: "some text" });
// Change mode to "aggressive"
const select = container.querySelector("[data-testid='select']") as HTMLSelectElement | null;
await act(async () => {
select!.value = "aggressive";
select!.dispatchEvent(new Event("change", { bubbles: true }));
});
// Click preview
const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null;
await act(async () => {
btn?.click();
});
const callArgs = fetchMock.mock.calls[0] as [string, RequestInit];
const body = JSON.parse(callArgs[1].body as string) as { mode: string };
expect(body.mode).toBe("aggressive");
vi.unstubAllGlobals();
});
});
describe("CompressionPreviewAccordion — result grid (4 cards)", () => {
it("renders 4 metric cards after successful preview", async () => {
const mockResult = {
originalTokens: 200,
compressedTokens: 150,
tokensSaved: 50,
savingsPct: 25,
techniquesUsed: ["dedup"],
durationMs: 88,
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockResult,
});
vi.stubGlobal("fetch", fetchMock);
const { container } = await renderComponent({
forceOpen: true,
inputContent: "some input",
});
const btn = container.querySelector("[data-testid='button']") as HTMLButtonElement | null;
await act(async () => {
btn?.click();
});
const grid = container.querySelector("[data-testid='compression-result-grid']");
expect(grid).toBeTruthy();
const cards = grid?.querySelectorAll(".card");
expect(cards?.length).toBe(4);
const text = container.textContent ?? "";
expect(text).toContain("200"); // originalTokens
expect(text).toContain("150"); // compressedTokens
expect(text).toContain("50"); // tokensSaved
expect(text).toContain("88"); // durationMs
vi.unstubAllGlobals();
});
it("renders techniquesUsed list when non-empty", async () => {
const mockResult = {
originalTokens: 100,
compressedTokens: 90,
tokensSaved: 10,
savingsPct: 10,
techniquesUsed: ["dedup", "trim", "compact"],
durationMs: 33,
};
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockResult,
});
vi.stubGlobal("fetch", fetchMock);
const { container } = await renderComponent({
forceOpen: true,
inputContent: "some input",
});
await act(async () => {
(container.querySelector("[data-testid='button']") as HTMLButtonElement)?.click();
});
const text = container.textContent ?? "";
expect(text).toContain("dedup");
expect(text).toContain("trim");
expect(text).toContain("compact");
vi.unstubAllGlobals();
});
it("does NOT render result grid before a successful preview", async () => {
const { container } = await renderComponent({ forceOpen: true, inputContent: "hello" });
expect(container.querySelector("[data-testid='compression-result-grid']")).toBeNull();
});
});
describe("CompressionPreviewAccordion — error path (Hard Rule #12)", () => {
it("shows sanitized error message on fetch failure", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ error: "Internal Server Error" }),
});
vi.stubGlobal("fetch", fetchMock);
const { container } = await renderComponent({
forceOpen: true,
inputContent: "some input",
});
await act(async () => {
(container.querySelector("[data-testid='button']") as HTMLButtonElement)?.click();
});
const errorEl = container.querySelector("[role='alert']");
expect(errorEl).toBeTruthy();
expect(errorEl?.textContent).toContain("Internal Server Error");
vi.unstubAllGlobals();
});
it("error message does NOT contain stack-trace lines (at /path/...)", async () => {
const stackError = new Error(
"Something went wrong\n at /home/user/app/src/file.ts:42:13\n at Object.<anonymous> /home/user/app/tests/test.ts:10:5",
);
const fetchMock = vi.fn().mockRejectedValue(stackError);
vi.stubGlobal("fetch", fetchMock);
const { container } = await renderComponent({
forceOpen: true,
inputContent: "some input",
});
await act(async () => {
(container.querySelector("[data-testid='button']") as HTMLButtonElement)?.click();
});
const errorEl = container.querySelector("[role='alert']");
expect(errorEl).toBeTruthy();
const errorText = errorEl?.textContent ?? "";
// Must NOT contain "at /" (stack-trace pattern)
expect(errorText).not.toMatch(/\sat\s\//);
// Should still contain the core message
expect(errorText).toContain("Something went wrong");
vi.unstubAllGlobals();
});
it("shows 'Preview failed' when fetch returns non-ok without error field", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}), // no error field
});
vi.stubGlobal("fetch", fetchMock);
const { container } = await renderComponent({
forceOpen: true,
inputContent: "some input",
});
await act(async () => {
(container.querySelector("[data-testid='button']") as HTMLButtonElement)?.click();
});
const errorEl = container.querySelector("[role='alert']");
expect(errorEl).toBeTruthy();
expect(errorEl?.textContent).toContain("Preview failed");
vi.unstubAllGlobals();
});
});