Merge F8 (MonitorTab) into refactor/pages-v3-19

This commit is contained in:
diegosouzapw
2026-05-28 00:31:11 -03:00
2 changed files with 858 additions and 0 deletions

View File

@@ -0,0 +1,381 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect, useRef, useCallback } from "react";
import { Card, Badge, EmptyState } from "@/shared/components";
import { FORMAT_META } from "../exampleTemplates";
interface MonitorTabProps {
// F9 passes callback for empty state CTA.
onGoToTranslate?: () => void;
}
interface TranslationEvent {
id?: string;
timestamp?: string | number;
provider?: string;
model?: string;
sourceFormat?: string;
targetFormat?: string;
status?: string;
statusCode?: number | string;
latency?: number;
endpoint?: string;
isComboRouted?: boolean;
routeEndpoint?: string;
routeProvider?: string;
routeCombo?: string;
routeConnectionShortId?: string;
}
interface StatCardProps {
icon: string;
label: string;
value: string | number;
color: "blue" | "green" | "red" | "purple" | "amber" | "cyan";
}
const COLOR_MAP: Record<
StatCardProps["color"],
{ shell: string; icon: string }
> = {
blue: { shell: "bg-blue-500/10", icon: "text-blue-500" },
green: { shell: "bg-green-500/10", icon: "text-green-500" },
red: { shell: "bg-red-500/10", icon: "text-red-500" },
purple: { shell: "bg-purple-500/10", icon: "text-purple-500" },
amber: { shell: "bg-amber-500/10", icon: "text-amber-500" },
cyan: { shell: "bg-cyan-500/10", icon: "text-cyan-500" },
};
function StatCard({ icon, label, value, color }: StatCardProps) {
const resolved = COLOR_MAP[color] ?? COLOR_MAP.blue;
return (
<Card>
<div className="p-4 flex items-center gap-3">
<div className={`flex items-center justify-center w-10 h-10 rounded-lg ${resolved.shell}`}>
<span
className={`material-symbols-outlined text-[22px] ${resolved.icon}`}
aria-hidden="true"
>
{icon}
</span>
</div>
<div>
<p className="text-lg font-bold text-text-main">{value}</p>
<p className="text-[10px] text-text-muted uppercase tracking-wider">{label}</p>
</div>
</div>
</Card>
);
}
/**
* MonitorTab
*
* Refactor of LiveMonitorMode with 100% functional parity + additions:
* - monitorOriginHint header always visible (explains event origin)
* - empty state CTA with "Ir para Translate" button (onGoToTranslate)
* - preserves 3s polling, auto-refresh toggle, 6 stat cards, events table
* - cleanup useEffect: clearInterval on unmount
*/
export default function MonitorTab({ onGoToTranslate }: MonitorTabProps) {
const t = useTranslations("translator");
const tc = useTranslations("common");
const translateOrFallback = useCallback(
(key: string, fallback: string, values?: Record<string, unknown>) => {
try {
const translated = t(key, values);
return translated === key || translated === `translator.${key}` ? fallback : translated;
} catch {
return fallback;
}
},
[t],
);
const [events, setEvents] = useState<TranslationEvent[]>([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const notAvailable = t("notAvailableSymbol");
const formatLatency = (value: number) => t("millisecondsShort", { value });
const fetchHistory = useCallback(async () => {
try {
const res = await fetch("/api/translator/history?limit=50");
if (res.ok) {
const data = (await res.json()) as { events?: TranslationEvent[] };
setEvents(data.events ?? []);
}
} catch {
// ignore fetch errors in polling context — do not leak stack traces
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchHistory();
if (autoRefresh) {
intervalRef.current = setInterval(() => {
void fetchHistory();
}, 3000);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [autoRefresh, fetchHistory]);
// Computed stats
const successCount = events.filter((e) => e.status === "success").length;
const errorCount = events.filter((e) => e.status === "error").length;
const comboCount = events.filter((e) => e.isComboRouted).length;
const uniqueEndpoints = new Set(
events.map((e) => e.routeEndpoint ?? e.endpoint).filter(Boolean),
).size;
const avgLatency =
events.length > 0
? Math.round(events.reduce((sum, e) => sum + (e.latency ?? 0), 0) / events.length)
: 0;
return (
<div className="space-y-5 min-w-0">
{/* Origin hint — always visible (monitorOriginHint) */}
<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"
data-testid="monitor-origin-hint"
>
<span
className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0"
aria-hidden="true"
>
info
</span>
<p>
{translateOrFallback(
"monitorOriginHint",
"Eventos gerados pelo Translate ou pelo pipeline principal aparecem aqui em tempo real.",
)}
</p>
</div>
{/* Stat Cards — 6 cards: total, success, errors, avg latency, combo-routed, unique endpoints */}
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
<StatCard
icon="translate"
label={t("totalTranslations")}
value={events.length}
color="blue"
/>
<StatCard icon="check_circle" label={t("successful")} value={successCount} color="green" />
<StatCard icon="error" label={t("errors")} value={errorCount} color="red" />
<StatCard
icon="speed"
label={t("avgLatency")}
value={formatLatency(avgLatency)}
color="purple"
/>
<StatCard
icon="hub"
label={translateOrFallback("comboRouted", "Combo-routed")}
value={comboCount}
color="amber"
/>
<StatCard
icon="lan"
label={translateOrFallback("uniqueEndpoints", "Unique endpoints")}
value={uniqueEndpoints}
color="cyan"
/>
</div>
{/* Memory note */}
<div className="flex items-center gap-2 rounded-lg border border-amber-500/10 bg-amber-500/5 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
<span className="material-symbols-outlined text-[14px]">memory</span>
<p>
{t("liveMonitorMemoryNote")}{" "}
<span className="text-text-muted">{t("liveMonitorMemoryCapNote")}</span>
</p>
</div>
{/* Auto-refresh controls */}
<Card>
<div className="p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className={`material-symbols-outlined text-[18px] ${autoRefresh ? "text-green-500 animate-pulse" : "text-text-muted"}`}
aria-hidden="true"
>
{autoRefresh ? "radio_button_checked" : "radio_button_unchecked"}
</span>
<button
onClick={() => setAutoRefresh((prev) => !prev)}
className="text-sm text-text-main hover:text-primary transition-colors"
aria-label={
autoRefresh
? translateOrFallback("pauseAutoRefresh", "Pause auto-refresh")
: translateOrFallback("resumeAutoRefresh", "Resume auto-refresh")
}
data-testid="auto-refresh-toggle"
>
{autoRefresh
? translateOrFallback("liveAutoRefreshing", "Atualizando ao vivo")
: translateOrFallback("paused", "Pausado")}
</button>
</div>
<div className="flex items-center gap-2">
{/* Live/Paused badge */}
<Badge variant={autoRefresh ? "success" : "default"} size="sm" dot>
{autoRefresh
? translateOrFallback("live", "Live")
: translateOrFallback("paused", "Paused")}
</Badge>
<button
onClick={() => void fetchHistory()}
className="flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
aria-label={tc("refresh")}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
refresh
</span>
{tc("refresh")}
</button>
</div>
</div>
</Card>
{/* Events table */}
<Card>
<div className="p-4">
<h3 className="text-sm font-semibold text-text-main mb-3">{t("recentTranslations")}</h3>
{loading ? (
<div className="flex items-center justify-center py-12 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2" aria-hidden="true">
progress_activity
</span>
{tc("loading")}
</div>
) : events.length === 0 ? (
/* Empty state with CTA (new in MonitorTab) */
<div data-testid="monitor-empty-state">
<EmptyState
icon="📊"
title={translateOrFallback("noTranslations", "Nenhuma tradução ainda")}
description={translateOrFallback(
"monitorEmptyCta",
"Volte para a aba Translate e envie um request — ele aparecerá aqui.",
)}
actionLabel={translateOrFallback("monitorOpenTranslateButton", "Ir para Translate")}
onAction={onGoToTranslate ?? null}
/>
</div>
) : (
<div className="overflow-x-auto" data-testid="monitor-events-table">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border">
<th className="pb-2 pr-4">{t("time")}</th>
<th className="pb-2 pr-4">
{translateOrFallback("routeDetails", "Route")}
</th>
<th className="pb-2 pr-4">{t("source")}</th>
<th className="pb-2 pr-4">{t("target")}</th>
<th className="pb-2 pr-4">{t("model")}</th>
<th className="pb-2 pr-4">{t("status")}</th>
<th className="pb-2 text-right">{t("latency")}</th>
</tr>
</thead>
<tbody>
{events.map((event, i) => {
const srcMeta = FORMAT_META[event.sourceFormat as keyof typeof FORMAT_META] ?? {
label: event.sourceFormat ?? "?",
color: "gray",
};
const tgtMeta = FORMAT_META[event.targetFormat as keyof typeof FORMAT_META] ?? {
label: event.targetFormat ?? "?",
color: "gray",
};
return (
<tr
key={event.id ?? i}
className="border-b border-border/50 hover:bg-bg-subtle/50 transition-colors"
data-testid="monitor-event-row"
>
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
{event.timestamp
? new Date(event.timestamp).toLocaleTimeString()
: notAvailable}
</td>
<td className="py-2 pr-4 min-w-[220px]">
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="default" size="sm">
{event.routeProvider ?? event.provider ?? notAvailable}
</Badge>
{event.routeCombo ? (
<Badge variant="primary" size="sm">
{translateOrFallback("comboBadge", "Combo")}: {event.routeCombo}
</Badge>
) : null}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-text-muted">
<span>
{translateOrFallback("routeEndpointLabel", "Endpoint")}:{" "}
{event.routeEndpoint ?? event.endpoint ?? notAvailable}
</span>
{event.routeConnectionShortId ? (
<span>
{translateOrFallback("routeConnectionLabel", "Conn")}:{" "}
<span className="font-mono">{event.routeConnectionShortId}</span>
</span>
) : null}
</div>
</div>
</td>
<td className="py-2 pr-4">
<Badge variant="default" size="sm">
{srcMeta.label}
</Badge>
</td>
<td className="py-2 pr-4">
<Badge variant="primary" size="sm">
{tgtMeta.label}
</Badge>
</td>
<td className="py-2 pr-4 text-xs font-mono text-text-muted break-all">
{event.model ?? notAvailable}
</td>
<td className="py-2 pr-4">
{event.status === "success" ? (
<Badge variant="success" size="sm" dot>
{t("ok")}
</Badge>
) : (
<Badge variant="error" size="sm" dot>
{event.statusCode ?? t("errorShort")}
</Badge>
)}
</td>
<td className="py-2 text-right text-xs text-text-muted">
{event.latency ? formatLatency(event.latency) : notAvailable}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,477 @@
// @vitest-environment jsdom
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 — returns fallback key so we can assert on translateOrFallback ──
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// ── Shared component stubs ────────────────────────────────────────────────────
vi.mock("@/shared/components", () => ({
Card: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="card" className={className}>
{children}
</div>
),
Badge: ({
children,
variant,
dot,
size,
}: {
children: React.ReactNode;
variant?: string;
dot?: boolean;
size?: string;
}) => (
<span data-testid="badge" data-variant={variant} data-dot={dot} data-size={size}>
{children}
</span>
),
EmptyState: ({
title,
description,
actionLabel,
onAction,
icon,
}: {
title?: string;
description?: string;
actionLabel?: string;
onAction?: (() => void) | null;
icon?: string;
}) => (
<div data-testid="empty-state">
{icon && <span data-testid="empty-icon">{icon}</span>}
{title && <p data-testid="empty-title">{title}</p>}
{description && <p data-testid="empty-description">{description}</p>}
{actionLabel && onAction && (
<button data-testid="empty-action" onClick={onAction}>
{actionLabel}
</button>
)}
</div>
),
}));
// ── FORMAT_META stub ──────────────────────────────────────────────────────────
vi.mock(
"@/app/(dashboard)/dashboard/translator/exampleTemplates",
() => ({
FORMAT_META: {
openai: { label: "OpenAI", color: "green" },
claude: { label: "Claude", color: "orange" },
gemini: { label: "Gemini", color: "blue" },
},
}),
);
// ── fetch mock helpers ────────────────────────────────────────────────────────
function mockFetchEmpty() {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, events: [] }),
}),
);
}
function mockFetchWithEvents(
events: Array<{
id?: string;
timestamp?: string;
provider?: string;
model?: string;
sourceFormat?: string;
targetFormat?: string;
status?: string;
latency?: number;
}>,
) {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, events }),
}),
);
}
// ── DOM lifecycle helpers ─────────────────────────────────────────────────────
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => container.remove());
return container;
}
/**
* Helper: mount the component and flush the initial async fetch
* WITHOUT triggering the recurring setInterval loop.
* Uses vi.advanceTimersByTimeAsync(0) to drain microtask queue
* after the initial fetch resolves, then stops — does NOT advance
* by 3000ms so the interval does not fire.
*/
async function mountAndFlushInitialFetch(
component: React.ReactElement,
container: HTMLElement,
): Promise<ReturnType<typeof createRoot>> {
const root = createRoot(container);
await act(async () => {
root.render(component);
});
// Drain pending microtasks (the initial fetchHistory Promise) without
// advancing time so setInterval doesn't trigger.
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
return root;
}
describe("MonitorTab", () => {
beforeEach(() => {
vi.useFakeTimers();
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
while (cleanupCallbacks.length > 0) {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
});
// ── 1. Smoke render ──────────────────────────────────────────────────────────
it("exports a default function component", async () => {
const mod = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
expect(typeof mod.default).toBe("function");
});
it("renders the origin hint header (monitorOriginHint) always visible", async () => {
mockFetchEmpty();
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
await mountAndFlushInitialFetch(<MonitorTab />, container);
const hint = container.querySelector("[data-testid='monitor-origin-hint']");
expect(hint).toBeTruthy();
// The hint should contain the info icon
const icons = hint?.querySelectorAll(".material-symbols-outlined");
const iconTexts = Array.from(icons ?? []).map((el) => el.textContent?.trim());
expect(iconTexts).toContain("info");
});
it("renders 6 StatCards with correct icons", async () => {
mockFetchEmpty();
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
await mountAndFlushInitialFetch(<MonitorTab />, container);
// Stat card icons: translate, check_circle, error, speed, hub, lan
const icons = container.querySelectorAll(".material-symbols-outlined");
const iconTexts = Array.from(icons).map((el) => el.textContent?.trim());
expect(iconTexts).toContain("translate");
expect(iconTexts).toContain("check_circle");
expect(iconTexts).toContain("error");
expect(iconTexts).toContain("speed");
expect(iconTexts).toContain("hub");
expect(iconTexts).toContain("lan");
});
// ── 2. Empty state ───────────────────────────────────────────────────────────
it("shows empty state when events array is empty", async () => {
mockFetchEmpty();
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
await mountAndFlushInitialFetch(<MonitorTab />, container);
const emptyState = container.querySelector("[data-testid='empty-state']");
expect(emptyState).toBeTruthy();
// Table should NOT be rendered
expect(container.querySelector("[data-testid='monitor-events-table']")).toBeNull();
});
it("empty state shows CTA description text from monitorEmptyCta", async () => {
mockFetchEmpty();
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
await mountAndFlushInitialFetch(<MonitorTab />, container);
// When t() mock returns key, translateOrFallback detects key === translation and uses hardcoded fallback
const emptyDescription = container.querySelector("[data-testid='empty-description']");
expect(emptyDescription?.textContent).toContain("Volte para a aba Translate");
});
it("empty state 'Ir para Translate' button calls onGoToTranslate callback", async () => {
mockFetchEmpty();
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
const onGoToTranslate = vi.fn();
await mountAndFlushInitialFetch(<MonitorTab onGoToTranslate={onGoToTranslate} />, container);
const actionBtn = container.querySelector("[data-testid='empty-action']") as HTMLButtonElement | null;
expect(actionBtn).toBeTruthy();
// Label comes from monitorOpenTranslateButton fallback
expect(actionBtn?.textContent).toContain("Ir para Translate");
await act(async () => {
actionBtn?.click();
});
expect(onGoToTranslate).toHaveBeenCalledOnce();
});
it("empty state action button is not rendered when onGoToTranslate is not provided", async () => {
mockFetchEmpty();
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
await mountAndFlushInitialFetch(<MonitorTab />, container);
// EmptyState stub only renders button when onAction is truthy
const actionBtn = container.querySelector("[data-testid='empty-action']");
expect(actionBtn).toBeNull();
});
// ── 3. Events table ──────────────────────────────────────────────────────────
it("renders events table with rows when events are present", async () => {
const sampleEvents = [
{
id: "evt-1",
timestamp: new Date("2026-05-27T10:00:00Z").toISOString(),
provider: "openai",
model: "gpt-4",
sourceFormat: "claude",
targetFormat: "openai",
status: "success",
latency: 320,
},
{
id: "evt-2",
timestamp: new Date("2026-05-27T10:01:00Z").toISOString(),
provider: "gemini",
model: "gemini-pro",
sourceFormat: "openai",
targetFormat: "gemini",
status: "error",
latency: 150,
},
];
mockFetchWithEvents(sampleEvents);
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
await mountAndFlushInitialFetch(<MonitorTab />, container);
// Table must be present
const table = container.querySelector("[data-testid='monitor-events-table']");
expect(table).toBeTruthy();
// EmptyState must NOT be rendered
expect(container.querySelector("[data-testid='empty-state']")).toBeNull();
// 2 event rows
const rows = container.querySelectorAll("[data-testid='monitor-event-row']");
expect(rows).toHaveLength(2);
});
it("table renders source and target format labels via FORMAT_META", async () => {
const sampleEvents = [
{
id: "evt-1",
sourceFormat: "claude",
targetFormat: "openai",
status: "success",
},
];
mockFetchWithEvents(sampleEvents);
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
await mountAndFlushInitialFetch(<MonitorTab />, container);
const text = container.textContent ?? "";
expect(text).toContain("Claude"); // FORMAT_META["claude"].label
expect(text).toContain("OpenAI"); // FORMAT_META["openai"].label
});
it("table columns include: time, route, source, target, model, status, latency headers", async () => {
const sampleEvents = [{ id: "x", status: "success" }];
mockFetchWithEvents(sampleEvents);
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
await mountAndFlushInitialFetch(<MonitorTab />, container);
// Column headers use t() keys — mock returns the key itself
const tableText = container.querySelector("thead")?.textContent ?? "";
expect(tableText).toContain("time");
expect(tableText).toContain("source");
expect(tableText).toContain("target");
expect(tableText).toContain("model");
expect(tableText).toContain("status");
expect(tableText).toContain("latency");
});
// ── 4. Auto-refresh toggle ───────────────────────────────────────────────────
it("toggle auto-refresh button is present with aria-label and shows live state text", async () => {
mockFetchEmpty();
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
await mountAndFlushInitialFetch(<MonitorTab />, container);
const toggleBtn = container.querySelector(
"[data-testid='auto-refresh-toggle']",
) as HTMLButtonElement | null;
expect(toggleBtn).toBeTruthy();
// Initial state: auto-refresh is ON — translateOrFallback detects key === translation → uses fallback
expect(toggleBtn?.textContent?.trim()).toContain("Atualizando ao vivo");
// aria-label should be set
expect(toggleBtn?.getAttribute("aria-label")).toBeTruthy();
});
it("clicking toggle changes button text from live to paused", async () => {
mockFetchEmpty();
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
await mountAndFlushInitialFetch(<MonitorTab />, container);
const toggleBtn = container.querySelector(
"[data-testid='auto-refresh-toggle']",
) as HTMLButtonElement | null;
expect(toggleBtn).toBeTruthy();
// Click to pause
await act(async () => {
toggleBtn?.click();
});
// After pause: button text should switch to the "paused" fallback
expect(toggleBtn?.textContent?.trim()).toContain("Pausado");
});
it("auto-refresh polling fires fetch again after 3 seconds", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, events: [] }),
});
vi.stubGlobal("fetch", fetchMock);
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
await mountAndFlushInitialFetch(<MonitorTab />, container);
const callsAfterMount = fetchMock.mock.calls.length;
expect(callsAfterMount).toBeGreaterThanOrEqual(1);
// Advance 3 seconds → one more interval tick
await act(async () => {
await vi.advanceTimersByTimeAsync(3000);
});
expect(fetchMock.mock.calls.length).toBeGreaterThan(callsAfterMount);
});
it("pausing auto-refresh stops additional polling after toggle", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, events: [] }),
});
vi.stubGlobal("fetch", fetchMock);
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
await mountAndFlushInitialFetch(<MonitorTab />, container);
// Pause auto-refresh
const toggleBtn = container.querySelector(
"[data-testid='auto-refresh-toggle']",
) as HTMLButtonElement | null;
await act(async () => {
toggleBtn?.click();
});
// After toggling, the component re-renders with autoRefresh=false.
// The new useEffect runs with autoRefresh=false → no new interval.
// But the old interval was cleared on cleanup.
// Drain any pending microtasks from the state update.
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
const callsAfterPause = fetchMock.mock.calls.length;
// Advance 9 seconds — should NOT trigger more interval fetches
await act(async () => {
await vi.advanceTimersByTimeAsync(9000);
});
// Allow any pending promises to settle
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(fetchMock.mock.calls.length).toBe(callsAfterPause);
});
// ── 5. Error sanitization ────────────────────────────────────────────────────
it("fetch error does not leak stack traces into the DOM", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(
new Error("Network Error\n at fetch (/some/internal/path.ts:42:10)"),
),
);
const { default: MonitorTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/MonitorTab"
);
const container = makeContainer();
// Don't use mountAndFlushInitialFetch here — we want to let the rejection settle
const root = createRoot(container);
await act(async () => {
root.render(<MonitorTab />);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
const domText = container.textContent ?? "";
expect(domText).not.toMatch(/at\s+\//);
expect(domText).not.toMatch(/Network Error/);
});
});