From a5cad5ab2ad242af9a6c3bfb0f6cb43b4a134a84 Mon Sep 17 00:00:00 2001
From: Diego Rodrigues de Sa e Souza
<8016841+diegosouzapw@users.noreply.github.com>
Date: Tue, 14 Jul 2026 16:24:19 -0300
Subject: [PATCH] =?UTF-8?q?fix(tests):=20vitest=20UI=20suite=20back=20to?=
=?UTF-8?q?=20green=20(69=20fails=20triaged=20=E2=80=94=20WS6.1)=20(#7127)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
test:vitest:ui was advisory/parked with 70 failing tests across 30 files (of
159 total). Triaged by grouping failures by root cause instead of fixing
one-by-one:
- 15 files (use-virtual-list, use-traffic-stream, use-system-proxy-exit-guard,
use-session-recorder, use-resizable-panels, traffic-inspector-page,
timing-i18n, stats-tab, session-recorder-bar, same-context-filter,
historic-session-banner, conversation-tab, conversation-tab-separators,
cli-tools-no-mitm-tab, agent-bridge-server-card-a11y) were authored against
node:test but live under tests/unit/ui/*.test.tsx, which vitest.config.ts
collects but test:unit's glob (only *.test.ts) never does — orphaned. Fixed
by switching their describe/it/beforeEach imports to "vitest".
- jsdom does not implement window.matchMedia, and several dashboard
components read it via useTheme() (directly, or transitively through
ProviderIcon). Added tests/_setup/vitestUiPolyfills.ts (wired into
vitest.config.ts) with a minimal MediaQueryList polyfill — fixed
providerCascadeNode, ProviderIcon-icon-url, CliAgentsPage, playground-studio,
comboLiveStudio, memories-tab, home-topology-hidden, ProxyRegistryManager-tdz.
- playground-build-tab.test.tsx (9 tests) and compressionHub*.test.tsx (2
tests) asserted against pre-redesign UI: BuildTab now sits behind a 3-step
BuildWizard (mode picker -> configure -> run), and CompressionHub is a
Phase-2 thin overview without the old master toggle/mode selector/pipeline
list. Rewrote the build-tab test to drive the wizard, and removed the two
compressionHub.test.tsx assertions already superseded by
compressionHub-active-selector.test.tsx. compressionHub-context-editing.test.tsx
asserted stale Portuguese copy against a component that deliberately uses
literal English strings (documented hydration workaround) — aligned to the
real text.
- search-tools-compare-tab.test.tsx: the D22 4-provider cap documented in
docs/frameworks/SEARCH_TOOLS_STUDIO.md was never implemented in CompareTab —
fixed the component (disable extra toggles + cap selectAll + warning
message) since the test was correct and the component was the bug. Also
fixed an assertion looking for a
that never existed (the results
panel is a div-based side-by-side layout).
- CliAgentsPage.test.tsx: the agent-tool catalog grew from 6 to 8 (omp, letta
added) since the test was written — updated the fixture and expected count.
- memories-tab.test.tsx: a call-order-dependent fetch mock
(mockResolvedValueOnce + fallback) broke once MemoriesTab started firing an
immediate health check that raced its 300ms-debounced list fetch — switched
to a URL-keyed mock like the rest of the file.
- home-topology-hidden-4596.test.tsx: useLiveDashboard now runs an async
handshake fetch before opening the WebSocket — stubbed fetch and awaited it.
- same-context-filter.test.tsx: the filter branch moved from
useTrafficStream.applyFilter into the extracted, reusable
matchesTrafficFilter() helper — updated the source-grep target.
- tests/unit/ui/provider-plan-config.test.tsx deleted: it tested
ProviderPlanConfigClient, which tests/unit/quota-plans-route-retired.test.ts
proves was deliberately retired (Plans screen removed).
Result: test:vitest:ui 158/158 files, 870/870 tests passing (was 30 failed /
159, 70 failed / 743). test:vitest (MCP/autoCombo) still green at 28/28,
253/253. Not promoted to blocking in this PR per the task — the owner
promotes after reviewing the green suite.
---
.../maintenance/vitest-ui-suite-green.md | 1 +
.../components/tabs/CompareTab.tsx | 17 ++-
tests/_setup/vitestUiPolyfills.ts | 23 +++
tests/unit/ui/CliAgentsPage.test.tsx | 12 +-
.../ui/agent-bridge-server-card-a11y.test.tsx | 2 +-
tests/unit/ui/cli-tools-no-mitm-tab.test.tsx | 2 +-
.../compressionHub-context-editing.test.tsx | 9 +-
tests/unit/ui/compressionHub.test.tsx | 45 +-----
.../ui/conversation-tab-separators.test.tsx | 2 +-
tests/unit/ui/conversation-tab.test.tsx | 2 +-
.../unit/ui/historic-session-banner.test.tsx | 2 +-
.../ui/home-topology-hidden-4596.test.tsx | 17 ++-
tests/unit/ui/memories-tab.test.tsx | 31 ++--
tests/unit/ui/playground-build-tab.test.tsx | 88 ++++++++++--
tests/unit/ui/playground-compare-tab.test.tsx | 13 +-
tests/unit/ui/playground-studio.test.tsx | 4 +
tests/unit/ui/provider-plan-config.test.tsx | 134 ------------------
tests/unit/ui/same-context-filter.test.tsx | 24 +++-
.../unit/ui/search-tools-compare-tab.test.tsx | 11 +-
.../unit/ui/search-tools-scrape-tab.test.tsx | 4 +-
tests/unit/ui/session-recorder-bar.test.tsx | 2 +-
tests/unit/ui/stats-tab.test.tsx | 2 +-
tests/unit/ui/timing-i18n.test.tsx | 2 +-
tests/unit/ui/traffic-inspector-page.test.tsx | 2 +-
tests/unit/ui/use-resizable-panels.test.tsx | 2 +-
tests/unit/ui/use-session-recorder.test.tsx | 2 +-
.../ui/use-system-proxy-exit-guard.test.tsx | 2 +-
tests/unit/ui/use-traffic-stream.test.tsx | 2 +-
tests/unit/ui/use-virtual-list.test.tsx | 2 +-
vitest.config.ts | 1 +
30 files changed, 229 insertions(+), 233 deletions(-)
create mode 100644 changelog.d/maintenance/vitest-ui-suite-green.md
create mode 100644 tests/_setup/vitestUiPolyfills.ts
delete mode 100644 tests/unit/ui/provider-plan-config.test.tsx
diff --git a/changelog.d/maintenance/vitest-ui-suite-green.md b/changelog.d/maintenance/vitest-ui-suite-green.md
new file mode 100644
index 0000000000..539058bd72
--- /dev/null
+++ b/changelog.d/maintenance/vitest-ui-suite-green.md
@@ -0,0 +1 @@
+- chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up)
diff --git a/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx b/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx
index a9165a7684..8b702028cd 100644
--- a/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx
+++ b/src/app/(dashboard)/dashboard/search-tools/components/tabs/CompareTab.tsx
@@ -5,6 +5,8 @@ import { useTranslations } from "next-intl";
import Link from "next/link";
import type { SearchProviderCatalogItem } from "@/shared/schemas/searchTools";
+const MAX_COMPARE_PROVIDERS = 4; // D22: cap at 4 providers running in parallel
+
export interface CompareResult {
provider: string;
latency: number;
@@ -69,12 +71,15 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
const toggleProvider = useCallback((id: string) => {
setSelectedProviderIds((prev) => {
if (prev.includes(id)) return prev.filter((p) => p !== id);
+ if (prev.length >= MAX_COMPARE_PROVIDERS) return prev;
return [...prev, id];
});
}, []);
const selectAll = useCallback(() => {
- setSelectedProviderIds(activeSearchProviders.map((p) => p.id));
+ setSelectedProviderIds(
+ activeSearchProviders.slice(0, MAX_COMPARE_PROVIDERS).map((p) => p.id)
+ );
}, [activeSearchProviders]);
const clearAll = useCallback(() => {
@@ -230,7 +235,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
- {/* Provider picker — no cap */}
+ {/* Provider picker — capped at MAX_COMPARE_PROVIDERS (D22) */}
@@ -253,9 +258,15 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
+ {selectedProviderIds.length >= MAX_COMPARE_PROVIDERS && (
+
+ Maximum of {MAX_COMPARE_PROVIDERS} providers can be compared at once.
+
+ )}
{activeSearchProviders.map((p) => {
const selected = selectedProviderIds.includes(p.id);
+ const atCap = !selected && selectedProviderIds.length >= MAX_COMPARE_PROVIDERS;
return (
toggleProvider(p.id)}
+ disabled={atCap}
data-testid={`provider-toggle-${p.id}`}
aria-pressed={selected}
>
diff --git a/tests/_setup/vitestUiPolyfills.ts b/tests/_setup/vitestUiPolyfills.ts
new file mode 100644
index 0000000000..96d873972b
--- /dev/null
+++ b/tests/_setup/vitestUiPolyfills.ts
@@ -0,0 +1,23 @@
+// jsdom (unlike real browsers) does not implement `window.matchMedia`. Several
+// dashboard components read the OS color-scheme preference via
+// `window.matchMedia("(prefers-color-scheme: dark)")` (see
+// `src/shared/hooks/useTheme.ts`), so any test that mounts a component using
+// that hook (directly or transitively, e.g. via `ProviderIcon`) crashes with
+// `TypeError: window.matchMedia is not a function` unless this polyfill runs
+// first. Keep this minimal — it only needs to satisfy the subset of the
+// MediaQueryList API this codebase actually calls.
+if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
+ window.matchMedia = (query: string): MediaQueryList => {
+ const mql = {
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ };
+ return mql as unknown as MediaQueryList;
+ };
+}
diff --git a/tests/unit/ui/CliAgentsPage.test.tsx b/tests/unit/ui/CliAgentsPage.test.tsx
index 82f4a5205f..257f5e4149 100644
--- a/tests/unit/ui/CliAgentsPage.test.tsx
+++ b/tests/unit/ui/CliAgentsPage.test.tsx
@@ -54,12 +54,18 @@ const { default: CliAgentsPageClient } = await import(
// ── Fixtures ──────────────────────────────────────────────────────────────────
-/** 6 agent tool ids from the catalog (§3.2 of plan-14) */
+/**
+ * Agent tool ids from the catalog (§3.2 of plan-14, category: "agent" in
+ * src/shared/constants/cliTools.ts). "omp" and "letta" were added to the
+ * catalog after plan-14 shipped, bringing the count from 6 to 8.
+ */
const AGENT_IDS = [
"openclaw",
"hermes-agent",
"goose",
"interpreter",
+ "omp",
+ "letta",
"warp",
"agent-deck",
] as const;
@@ -144,9 +150,9 @@ describe("CliAgentsPageClient", () => {
expect(container.textContent).toContain("pageTitle");
}, 15000);
- it("2. renders exactly 6 agent tool cards", async () => {
+ it("2. renders exactly 8 agent tool cards", async () => {
const container = await renderPage();
- expect(countAgentCards(container)).toBe(6);
+ expect(countAgentCards(container)).toBe(8);
}, 15000);
it("3. search filter — 'hermes' shows 1 card (hermes-agent)", async () => {
diff --git a/tests/unit/ui/agent-bridge-server-card-a11y.test.tsx b/tests/unit/ui/agent-bridge-server-card-a11y.test.tsx
index 9db239e614..b1892179c6 100644
--- a/tests/unit/ui/agent-bridge-server-card-a11y.test.tsx
+++ b/tests/unit/ui/agent-bridge-server-card-a11y.test.tsx
@@ -2,7 +2,7 @@
* a11y tests for AgentBridgeServerCard — each action button must have aria-label.
* Uses source-text inspection (no JSDOM render needed) for the structural assertion.
*/
-import { describe, it } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
diff --git a/tests/unit/ui/cli-tools-no-mitm-tab.test.tsx b/tests/unit/ui/cli-tools-no-mitm-tab.test.tsx
index bce2f28fcc..cba7c99ffe 100644
--- a/tests/unit/ui/cli-tools-no-mitm-tab.test.tsx
+++ b/tests/unit/ui/cli-tools-no-mitm-tab.test.tsx
@@ -4,7 +4,7 @@
*
* Uses source-text inspection — no JSDOM render needed.
*/
-import { describe, it } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
diff --git a/tests/unit/ui/compressionHub-context-editing.test.tsx b/tests/unit/ui/compressionHub-context-editing.test.tsx
index 88fee2e490..6abeb667b6 100644
--- a/tests/unit/ui/compressionHub-context-editing.test.tsx
+++ b/tests/unit/ui/compressionHub-context-editing.test.tsx
@@ -139,8 +139,11 @@ describe("CompressionHub — Context Editing", () => {
});
await flush();
+ // CompressionHub deliberately does NOT use useTranslations (see the
+ // hydration note at the top of CompressionHub.tsx) — its strings are
+ // literal English text, exactly like EngineConfigPage.
const text = container.textContent ?? "";
- expect(text).toContain("Compressão delegada ao provedor");
+ expect(text).toContain("Provider-delegated compression");
expect(text).toContain("Context Editing (Claude)");
});
@@ -156,8 +159,8 @@ describe("CompressionHub — Context Editing", () => {
await flush();
const text = container.textContent ?? "";
- expect(text).toContain("apenas para Claude");
- expect(text).toContain("não reescrevemos a mensagem");
+ expect(text).toContain("available for Claude (Anthropic) only");
+ expect(text).toContain("we do not rewrite the message");
});
it("PUTs contextEditing: { enabled: true } when the toggle is flipped on", async () => {
diff --git a/tests/unit/ui/compressionHub.test.tsx b/tests/unit/ui/compressionHub.test.tsx
index 166c0fef04..27d380d3f0 100644
--- a/tests/unit/ui/compressionHub.test.tsx
+++ b/tests/unit/ui/compressionHub.test.tsx
@@ -111,28 +111,13 @@ async function flush() {
// ── Tests ─────────────────────────────────────────────────────────────────
describe("CompressionHub", () => {
- it("renders the master switch, mode selector, and the layered pipeline", async () => {
- setupFetchMock({ enabled: true, mode: "stacked", pipeline: [{ engine: "rtk" }] });
- const { default: CompressionHub } =
- await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub");
-
- let container!: HTMLElement;
- await act(async () => {
- container = mountInContainer( );
- });
- await flush();
-
- const text = container.textContent ?? "";
- expect(text).toContain("Compression Hub");
- expect(text).toContain("Token Saver");
- expect(text).toContain("Stacked");
- // Active pipeline engine (from the default combo) renders
- expect(text).toContain("RTK");
- // Inactive engines from the catalog render too
- expect(text).toContain("Caveman");
- // Active-pipeline callout shows when enabled && stacked
- expect(text).toContain("Layer pipeline is active");
- });
+ // NOTE: the master Token Saver toggle, mode selector, and layered-pipeline
+ // preview this describe block used to assert on were removed by the Phase 2
+ // Hub redesign (see the "Phase 2" comment at the top of CompressionHub.tsx —
+ // the Hub is now a thin overview with just an active-profile selector + the
+ // Context Editing toggle). That redesign, including the explicit assertion
+ // that the master toggle/mode selector/reorder buttons no longer render, is
+ // covered by compressionHub-active-selector.test.tsx.
it("INVARIANT #1: no per-layer control issues a PUT/POST to /api/context/combos/default", { timeout: 20000 }, async () => {
const comboWrites: { method: string }[] = [];
@@ -186,22 +171,6 @@ describe("CompressionHub", () => {
expect(comboWrites).toHaveLength(0);
});
-
- it("shows the activation warning when Token Saver is off", async () => {
- setupFetchMock({ enabled: false, mode: "off", pipeline: [] });
- const { default: CompressionHub } =
- await import("../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub");
-
- let container!: HTMLElement;
- await act(async () => {
- container = mountInContainer( );
- });
- await flush();
-
- const text = container.textContent ?? "";
- expect(text).toContain("Enable Token Saver");
- expect(text).toContain("only run in Stacked mode");
- });
});
describe("CompressionCombosPageClient", () => {
diff --git a/tests/unit/ui/conversation-tab-separators.test.tsx b/tests/unit/ui/conversation-tab-separators.test.tsx
index 7faad9b6b1..5723955a56 100644
--- a/tests/unit/ui/conversation-tab-separators.test.tsx
+++ b/tests/unit/ui/conversation-tab-separators.test.tsx
@@ -3,7 +3,7 @@
* Validates the rendering logic: both sections appear when both request/response have turns;
* only CONTEXT HISTORY appears when response is empty.
*/
-import { describe, it } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
import { normalizeConversation } from "../../../src/mitm/inspector/conversationNormalizer.ts";
import type { InterceptedRequest } from "../../../src/mitm/inspector/types.ts";
diff --git a/tests/unit/ui/conversation-tab.test.tsx b/tests/unit/ui/conversation-tab.test.tsx
index 62d405258e..92f8ab213c 100644
--- a/tests/unit/ui/conversation-tab.test.tsx
+++ b/tests/unit/ui/conversation-tab.test.tsx
@@ -1,7 +1,7 @@
/**
* Tests for ConversationTab — normalizeConversation + chat bubble rendering logic
*/
-import { describe, it } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
import { normalizeConversation } from "../../../src/mitm/inspector/conversationNormalizer.ts";
import type { InterceptedRequest } from "../../../src/mitm/inspector/types.ts";
diff --git a/tests/unit/ui/historic-session-banner.test.tsx b/tests/unit/ui/historic-session-banner.test.tsx
index 910178514d..fb27927710 100644
--- a/tests/unit/ui/historic-session-banner.test.tsx
+++ b/tests/unit/ui/historic-session-banner.test.tsx
@@ -1,7 +1,7 @@
/**
* Tests for HistoricSessionBanner — render with sessionName/null + backToLive callback
*/
-import { describe, it } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
// Pure logic tests — no DOM needed (no next-intl in node:test runner)
diff --git a/tests/unit/ui/home-topology-hidden-4596.test.tsx b/tests/unit/ui/home-topology-hidden-4596.test.tsx
index cdb3a64662..2c3ba169b6 100644
--- a/tests/unit/ui/home-topology-hidden-4596.test.tsx
+++ b/tests/unit/ui/home-topology-hidden-4596.test.tsx
@@ -19,6 +19,14 @@ describe("home topology hidden networking (#4596)", () => {
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
websocketMock.mockClear();
+ // useLiveDashboard runs a runtime handshake (GET /api/v1/ws?handshake=1) to
+ // discover the public WS URL before it opens the socket, so it never
+ // connects to the hardcoded default and then flaps to the public URL. Stub
+ // it to fail fast instead of letting the real global fetch hit the network.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() => Promise.reject(new Error("network unavailable in test")))
+ );
vi.stubGlobal(
"WebSocket",
class WebSocketMock {
@@ -59,9 +67,14 @@ describe("home topology hidden networking (#4596)", () => {
expect(websocketMock).not.toHaveBeenCalled();
});
- it("opens a WebSocket when the topology section is enabled", () => {
- act(() => {
+ it("opens a WebSocket when the topology section is enabled", async () => {
+ await act(async () => {
root!.render( );
+ // Let the handshake fetch's rejection propagate through .catch()/.finally()
+ // so `wsUrlResolved` flips to true and the connect effect re-runs.
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
});
expect(websocketMock).toHaveBeenCalledTimes(1);
});
diff --git a/tests/unit/ui/memories-tab.test.tsx b/tests/unit/ui/memories-tab.test.tsx
index cb56eaf143..845e1e4d7e 100644
--- a/tests/unit/ui/memories-tab.test.tsx
+++ b/tests/unit/ui/memories-tab.test.tsx
@@ -292,18 +292,27 @@ describe("MemoriesTab", () => {
});
it("calls DELETE when delete confirmed", async () => {
- const mockFetch = vi.fn();
- mockFetch.mockResolvedValueOnce({
- ok: true,
- json: async () => ({
- data: MOCK_MEMORIES,
- total: 2,
- totalPages: 1,
- stats: { total: 2, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } },
- }),
+ // MemoriesTab fires two independent fetches on mount: an immediate health
+ // check (/api/memory/health) and a 300ms-debounced memories list fetch
+ // (/api/memory?...). A call-order-dependent mock (mockResolvedValueOnce +
+ // fallback) is fragile here because the health check resolves first and
+ // would consume the "once" response meant for the list. Key off the URL
+ // instead, like the rest of this file's fetch mocks do.
+ const mockFetch = vi.fn((url: string) => {
+ if (typeof url === "string" && url.startsWith("/api/memory?")) {
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({
+ data: MOCK_MEMORIES,
+ total: 2,
+ totalPages: 1,
+ stats: { total: 2, tokensUsed: 0, hitRate: 0, cacheStats: { hits: 0, misses: 0 } },
+ }),
+ });
+ }
+ return Promise.resolve({ ok: true, json: async () => ({}) });
});
- mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) });
- globalThis.fetch = mockFetch;
+ globalThis.fetch = mockFetch as unknown as typeof fetch;
const { default: MemoriesTab } = await import(
"../../../src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab"
);
diff --git a/tests/unit/ui/playground-build-tab.test.tsx b/tests/unit/ui/playground-build-tab.test.tsx
index c954679818..2699aa4c53 100644
--- a/tests/unit/ui/playground-build-tab.test.tsx
+++ b/tests/unit/ui/playground-build-tab.test.tsx
@@ -64,6 +64,52 @@ function renderBuildTab(config = BASE_CONFIG): HTMLDivElement {
return el;
}
+// ── BuildWizard navigation helpers ──────────────────────────────────────────
+//
+// BuildTab now renders behind a 3-step wizard (BuildWizard.tsx):
+// step 1 — pick a mode (Tools / JSON / Both)
+// step 2 — configure tools and/or the JSON schema, depending on the mode
+// step 3 — run + toolbar badges + prompt textarea
+//
+// next-intl is mocked as a key pass-through above, so every translated label
+// renders as its raw i18n key (e.g. "nextButton", "modeToolsTitle").
+
+type BuildMode = "tools" | "json" | "both";
+
+function clickNext(el: HTMLDivElement): void {
+ const nextBtn = Array.from(el.querySelectorAll("button")).find((b) =>
+ b.textContent?.includes("nextButton"),
+ ) as HTMLButtonElement;
+ act(() => {
+ nextBtn.click();
+ });
+}
+
+function selectMode(el: HTMLDivElement, mode: BuildMode): void {
+ // Step 1's default mode is already "tools" — only click a mode card when a
+ // different mode is required.
+ if (mode === "tools") return;
+ const key = mode === "json" ? "modeJsonTitle" : "modeBothTitle";
+ const card = Array.from(el.querySelectorAll("button")).find((b) =>
+ b.textContent?.includes(key),
+ ) as HTMLButtonElement;
+ act(() => {
+ card.click();
+ });
+}
+
+/** Drive the wizard from step 1 to step 2, selecting `mode` along the way. */
+function goToStep2(el: HTMLDivElement, mode: BuildMode = "tools"): void {
+ selectMode(el, mode);
+ clickNext(el);
+}
+
+/** Drive the wizard from step 1 all the way to step 3 (run screen). */
+function goToStep3(el: HTMLDivElement, mode: BuildMode = "tools"): void {
+ goToStep2(el, mode);
+ clickNext(el);
+}
+
afterEach(() => {
for (const { root, el } of containers) {
act(() => root.unmount());
@@ -76,28 +122,34 @@ afterEach(() => {
describe("BuildTab", () => {
it("renders Run button", () => {
const el = renderBuildTab();
- const runBtn = el.querySelector("[class*='bg-primary']");
- expect(runBtn?.textContent).toContain("runLabel");
+ goToStep3(el);
+ const runBtn = Array.from(el.querySelectorAll("button")).find((b) =>
+ b.textContent?.includes("runButton"),
+ );
+ expect(runBtn).not.toBeUndefined();
});
it("renders Function calling section", () => {
const el = renderBuildTab();
- expect(el.textContent).toContain("Function calling");
+ goToStep2(el, "tools");
+ expect(el.textContent).toContain("toolsLabel");
expect(el.textContent).toContain("Add tool");
});
it("renders Structured output section", () => {
const el = renderBuildTab();
- expect(el.textContent).toContain("Structured output");
+ goToStep2(el, "json");
+ expect(el.textContent).toContain("structuredOutputLabel");
expect(el.textContent).toContain("JSON mode");
});
it("adds a tool and shows it in function calling UI", async () => {
const el = renderBuildTab();
+ goToStep2(el, "tools");
- // Find add tool form inputs in the right panel
+ // Find add tool form inputs in the tools panel
const allInputs = el.querySelectorAll("input[type='text']") as NodeListOf;
- // The first or second input should be the function name
+ // The first input is the function name
const nameInput = allInputs[0];
act(() => setInputValue(nameInput, "search_web"));
@@ -115,16 +167,15 @@ describe("BuildTab", () => {
it("shows validation error for invalid JSON in tool params", async () => {
const el = renderBuildTab();
+ goToStep2(el, "tools");
const allInputs = el.querySelectorAll("input[type='text']") as NodeListOf;
act(() => setInputValue(allInputs[0], "bad_tool"));
// The parameters textarea is in the Add tool form section — it has default valid JSON.
- // We need to find the textarea labeled "JSON schema for parameters" in the add form.
const paramsTextareas = Array.from(el.querySelectorAll("textarea")).filter(
(t) => t.getAttribute("aria-label") === "JSON schema for parameters",
);
- // The last one is in the Add tool form (the first may be the message prompt textarea)
const paramsTextarea = paramsTextareas[paramsTextareas.length - 1] as HTMLTextAreaElement;
act(() => setInputValue(paramsTextarea, "NOT JSON {{{"));
@@ -140,6 +191,7 @@ describe("BuildTab", () => {
it("enables JSON mode toggle and shows schema editor", async () => {
const el = renderBuildTab();
+ goToStep2(el, "json");
const toggle = el.querySelector("[role='switch']") as HTMLButtonElement;
expect(toggle).not.toBeNull();
@@ -153,6 +205,7 @@ describe("BuildTab", () => {
it("shows tool badge in toolbar when tools are added", async () => {
const el = renderBuildTab();
+ goToStep2(el, "tools");
const allInputs = el.querySelectorAll("input[type='text']") as NodeListOf;
act(() => setInputValue(allInputs[0], "my_tool"));
@@ -163,7 +216,9 @@ describe("BuildTab", () => {
) as HTMLButtonElement;
await act(async () => { addToolBtn.click(); });
- // Badge "1 tool" should appear in toolbar
+ clickNext(el); // step 2 -> step 3
+
+ // Badge "1 tool" should appear in the step-3 toolbar
expect(el.textContent).toContain("1 tool");
});
@@ -183,6 +238,7 @@ describe("BuildTab", () => {
);
const el = renderBuildTab();
+ goToStep2(el, "tools");
// Add a tool
const allInputs = el.querySelectorAll("input[type='text']") as NodeListOf;
@@ -193,14 +249,16 @@ describe("BuildTab", () => {
) as HTMLButtonElement;
await act(async () => { addToolBtn.click(); });
- // Type a prompt
- const promptTextarea = el.querySelector("textarea[placeholder*='message']") as HTMLTextAreaElement;
+ clickNext(el); // step 2 -> step 3
+
+ // Type a prompt (the only textarea left on step 3 is the prompt input)
+ const promptTextarea = el.querySelector("textarea") as HTMLTextAreaElement;
act(() => setInputValue(promptTextarea, "Run this tool"));
- // Click Run (label is "runLabel" via mocked t())
+ // Click Run (label is "runButton" via mocked t())
const runBtns = el.querySelectorAll("button");
const runBtn = Array.from(runBtns).find(
- (b) => b.textContent?.includes("runLabel") && !b.textContent?.includes("clearAll"),
+ (b) => b.textContent?.includes("runButton"),
) as HTMLButtonElement;
await act(async () => { runBtn.click(); });
await act(async () => {
@@ -218,8 +276,12 @@ describe("BuildTab", () => {
it("shows JSON mode badge in toolbar when JSON mode is enabled", async () => {
const el = renderBuildTab();
+ goToStep2(el, "json");
const toggle = el.querySelector("[role='switch']") as HTMLButtonElement;
await act(async () => { toggle.click(); });
+
+ clickNext(el); // step 2 -> step 3
+
expect(el.textContent).toContain("JSON mode");
});
});
diff --git a/tests/unit/ui/playground-compare-tab.test.tsx b/tests/unit/ui/playground-compare-tab.test.tsx
index bbb12ebe53..82fc3ad0e8 100644
--- a/tests/unit/ui/playground-compare-tab.test.tsx
+++ b/tests/unit/ui/playground-compare-tab.test.tsx
@@ -92,9 +92,9 @@ function renderCompareTab(config = BASE_CONFIG): HTMLDivElement {
return el;
}
-function setInputValue(el: HTMLInputElement, value: string) {
+function setInputValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
const nativeSetter = Object.getOwnPropertyDescriptor(
- window.HTMLInputElement.prototype,
+ el instanceof HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype,
"value",
)?.set;
nativeSetter?.call(el, value);
@@ -204,6 +204,10 @@ describe("CompareTab", () => {
act(() => setInputValue(input, "model-2"));
await act(async () => { addBtn.click(); });
+ // Run all is disabled until a prompt is entered
+ const promptTextarea = el.querySelector("[aria-label='User prompt']") as HTMLTextAreaElement;
+ act(() => setInputValue(promptTextarea, "Compare this"));
+
const runBtn = el.querySelector("[aria-label='Run all columns']") as HTMLButtonElement;
await act(async () => { runBtn.click(); });
@@ -219,6 +223,11 @@ describe("CompareTab", () => {
);
const el = renderCompareTab();
+
+ // Run all is disabled until a prompt is entered
+ const promptTextarea = el.querySelector("[aria-label='User prompt']") as HTMLTextAreaElement;
+ act(() => setInputValue(promptTextarea, "Compare this"));
+
const runBtn = el.querySelector("[aria-label='Run all columns']") as HTMLButtonElement;
act(() => { runBtn.click(); });
diff --git a/tests/unit/ui/playground-studio.test.tsx b/tests/unit/ui/playground-studio.test.tsx
index 8a975219ba..91db18dbf8 100644
--- a/tests/unit/ui/playground-studio.test.tsx
+++ b/tests/unit/ui/playground-studio.test.tsx
@@ -68,6 +68,10 @@ vi.mock("@/shared/components/MonacoEditor", () => ({
vi.mock("@/shared/constants/providers", () => ({
ALIAS_TO_ID: {},
+ AI_PROVIDERS: {},
+ OPENAI_COMPATIBLE_PREFIX: "openai-compatible-",
+ ANTHROPIC_COMPATIBLE_PREFIX: "anthropic-compatible-",
+ CLAUDE_CODE_COMPATIBLE_PREFIX: "anthropic-compatible-cc-",
}));
vi.mock("@/shared/utils/maskEmail", () => ({
diff --git a/tests/unit/ui/provider-plan-config.test.tsx b/tests/unit/ui/provider-plan-config.test.tsx
deleted file mode 100644
index 0351a92a54..0000000000
--- a/tests/unit/ui/provider-plan-config.test.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-// @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";
-
-vi.mock("next-intl", () => ({
- useTranslations: () => (key: string) => key,
-}));
-
-vi.mock("@/shared/components", () => ({
- Button: ({
- children,
- onClick,
- disabled,
- }: {
- children: React.ReactNode;
- onClick?: () => void;
- disabled?: boolean;
- }) => (
-
- {children}
-
- ),
-}));
-
-vi.mock("@/shared/components/ProviderIcon", () => ({
- default: () => ,
-}));
-
-vi.mock("@/lib/quota/planRegistry", () => ({
- knownProviders: () => ["openai", "anthropic"],
- getKnownPlan: (prov: string) => {
- if (prov === "openai") {
- return { dimensions: [{ unit: "tokens", window: "daily", limit: 100000 }] };
- }
- return null;
- },
-}));
-
-const MOCK_CONNECTIONS = [
- { id: "conn_1", provider: "openai", name: "GPT Account" },
- { id: "conn_2", provider: "anthropic", email: "user@example.com" },
-];
-
-const mockFetch = vi.fn();
-vi.stubGlobal("fetch", mockFetch);
-
-const { default: ProviderPlanConfigClient } = await import(
- "../../../src/app/(dashboard)/dashboard/costs/quota-share/plans/ProviderPlanConfigClient"
-);
-
-let container: HTMLDivElement | null = null;
-let root: ReturnType | null = null;
-
-async function renderPage() {
- (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
- true;
- container = document.createElement("div");
- document.body.appendChild(container);
- await act(async () => {
- root = createRoot(container!);
- root.render( );
- });
- // Wait for initial fetch effect to resolve
- await act(async () => {
- await new Promise((r) => setTimeout(r, 30));
- });
-}
-
-describe("ProviderPlanConfigClient", { timeout: 15000 }, () => {
- beforeEach(() => {
- mockFetch.mockImplementation((url: string) => {
- if (String(url).includes("/api/providers/client")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({ connections: MOCK_CONNECTIONS }),
- } as unknown as Response);
- }
- if (String(url).includes("/api/quota/plans")) {
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve([]),
- } as unknown as Response);
- }
- return Promise.resolve({
- ok: true,
- json: () => Promise.resolve({}),
- } as unknown as Response);
- });
- });
-
- afterEach(() => {
- if (root && container) act(() => root!.unmount());
- container?.remove();
- container = null;
- root = null;
- vi.clearAllMocks();
- });
-
- it("renders the page title", async () => {
- await renderPage();
- expect(document.body.innerHTML).toContain("title");
- });
-
- it("renders catalog section with known providers", async () => {
- await renderPage();
- // catalogTitle key should appear
- expect(document.body.innerHTML).toContain("catalogTitle");
- expect(document.body.innerHTML).toContain("openai");
- });
-
- it("renders connection selector with options", async () => {
- await renderPage();
- const select = document.querySelector("select") as HTMLSelectElement;
- expect(select).not.toBeNull();
- expect(select.options.length).toBeGreaterThan(1);
- });
-
- it("shows right-panel placeholder when no connection selected", async () => {
- await renderPage();
- expect(document.body.innerHTML).toContain("unknownProviderNotice");
- });
-
- it("renders save button after selecting a connection", async () => {
- await renderPage();
- const select = document.querySelector("select") as HTMLSelectElement;
- await act(async () => {
- select.value = "conn_1";
- select.dispatchEvent(new Event("change", { bubbles: true }));
- });
- expect(document.body.innerHTML).toContain("saveOverrideButton");
- });
-});
diff --git a/tests/unit/ui/same-context-filter.test.tsx b/tests/unit/ui/same-context-filter.test.tsx
index ddc9aaef0b..40bcc16b57 100644
--- a/tests/unit/ui/same-context-filter.test.tsx
+++ b/tests/unit/ui/same-context-filter.test.tsx
@@ -6,7 +6,7 @@
* - RequestRow exports an onSameContext prop
* - useTrafficFilters.setSameContext is referenced from TrafficInspectorPageClient
*/
-import { describe, it } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
@@ -17,21 +17,35 @@ const ROOT = path.resolve(
__dirname,
"../../../src/app/(dashboard)/dashboard/tools/traffic-inspector"
);
+const SRC_ROOT = path.resolve(__dirname, "../../../src");
function read(rel: string): string {
return fs.readFileSync(path.join(ROOT, rel), "utf8");
}
+function readSrc(rel: string): string {
+ return fs.readFileSync(path.join(SRC_ROOT, rel), "utf8");
+}
+
describe("R5-4 same-context filter end-to-end", () => {
it("useTrafficStream.applyFilter has sameContextKey branch", () => {
- const src = read("hooks/useTrafficStream.ts");
+ // The comparison itself now lives in the extracted, independently-testable
+ // matchesTrafficFilter() helper (src/lib/inspector/matchesTrafficFilter.ts) —
+ // useTrafficStream.applyFilter just delegates to it.
+ const hookSrc = read("hooks/useTrafficStream.ts");
assert.ok(
- src.includes("sameContextKey") && src.includes("contextKey"),
- "applyFilter should branch on sameContextKey / contextKey"
+ hookSrc.includes("matchesTrafficFilter"),
+ "applyFilter should delegate to matchesTrafficFilter"
+ );
+
+ const matcherSrc = readSrc("lib/inspector/matchesTrafficFilter.ts");
+ assert.ok(
+ matcherSrc.includes("sameContextKey") && matcherSrc.includes("contextKey"),
+ "matchesTrafficFilter should branch on sameContextKey / contextKey"
);
// Must actually exclude requests where contextKey differs
assert.ok(
- src.includes("req.contextKey !== f.sameContextKey"),
+ matcherSrc.includes("req.contextKey !== f.sameContextKey"),
"should exclude when contextKey !== sameContextKey"
);
});
diff --git a/tests/unit/ui/search-tools-compare-tab.test.tsx b/tests/unit/ui/search-tools-compare-tab.test.tsx
index 3e014faf8a..3cb92d0ffc 100644
--- a/tests/unit/ui/search-tools-compare-tab.test.tsx
+++ b/tests/unit/ui/search-tools-compare-tab.test.tsx
@@ -270,12 +270,13 @@ describe("CompareTab", () => {
await new Promise((r) => setTimeout(r, 150));
});
- // Check that the table exists and contains overlap info
- const table = el.querySelector("table");
- if (table) {
+ // The results panel renders as a div-based side-by-side layout (not a ) —
+ // the overlap summary footer lives inside [data-testid='compare-results'].
+ const resultsPanel = el.querySelector("[data-testid='compare-results']");
+ if (resultsPanel) {
// URL overlap row should contain a fraction like "1/2"
- const tableText = table.textContent ?? "";
- expect(tableText).toMatch(/URL overlap|\d+\/\d+/);
+ const panelText = resultsPanel.textContent ?? "";
+ expect(panelText).toMatch(/in common|\d+\/\d+/);
} else {
// Loading state is still active — acceptable
expect(el.querySelector("[data-testid='compare-loading']")).toBeTruthy();
diff --git a/tests/unit/ui/search-tools-scrape-tab.test.tsx b/tests/unit/ui/search-tools-scrape-tab.test.tsx
index 67509d4f47..f63bac664b 100644
--- a/tests/unit/ui/search-tools-scrape-tab.test.tsx
+++ b/tests/unit/ui/search-tools-scrape-tab.test.tsx
@@ -112,7 +112,9 @@ describe("ScrapeTab", () => {
});
const errorEl = el.querySelector("[data-testid='url-error']");
expect(errorEl).toBeTruthy();
- expect(errorEl?.textContent).toContain("URL");
+ // next-intl is mocked as a key pass-through above (per repo convention), so the
+ // rendered text is the raw i18n key, not the translated "URL is required" copy.
+ expect(errorEl?.textContent).toContain("scrapeUrlRequired");
});
it("shows error for invalid URL", () => {
diff --git a/tests/unit/ui/session-recorder-bar.test.tsx b/tests/unit/ui/session-recorder-bar.test.tsx
index 2363617277..d4e88c48e3 100644
--- a/tests/unit/ui/session-recorder-bar.test.tsx
+++ b/tests/unit/ui/session-recorder-bar.test.tsx
@@ -1,7 +1,7 @@
/**
* Tests for SessionRecorderBar — start/stop flow + timer logic
*/
-import { describe, it } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
function formatElapsed(s: number): string {
diff --git a/tests/unit/ui/stats-tab.test.tsx b/tests/unit/ui/stats-tab.test.tsx
index 3cdd100ee8..03703966e0 100644
--- a/tests/unit/ui/stats-tab.test.tsx
+++ b/tests/unit/ui/stats-tab.test.tsx
@@ -2,7 +2,7 @@
* Asserts that StatsTab lazy-loads StatsCharts via next/dynamic (ssr: false)
* and does NOT statically import anything from "recharts".
*/
-import { describe, it } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
diff --git a/tests/unit/ui/timing-i18n.test.tsx b/tests/unit/ui/timing-i18n.test.tsx
index 5d2ee14a77..e52f840c82 100644
--- a/tests/unit/ui/timing-i18n.test.tsx
+++ b/tests/unit/ui/timing-i18n.test.tsx
@@ -5,7 +5,7 @@
* Round-3 F-I18N translated ConversationTab/StatsTab/StatsCharts but missed
* TimingTab (5 labels) and TimingWaterfall (2 labels). Round-4 closed the gap.
*/
-import { describe, it } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
diff --git a/tests/unit/ui/traffic-inspector-page.test.tsx b/tests/unit/ui/traffic-inspector-page.test.tsx
index f908eab22b..67f1cbd1c0 100644
--- a/tests/unit/ui/traffic-inspector-page.test.tsx
+++ b/tests/unit/ui/traffic-inspector-page.test.tsx
@@ -1,7 +1,7 @@
/**
* Smoke tests for Traffic Inspector page structure and constants
*/
-import { describe, it } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
describe("Traffic Inspector page smoke tests", () => {
diff --git a/tests/unit/ui/use-resizable-panels.test.tsx b/tests/unit/ui/use-resizable-panels.test.tsx
index 156d653738..5dee3cdcf1 100644
--- a/tests/unit/ui/use-resizable-panels.test.tsx
+++ b/tests/unit/ui/use-resizable-panels.test.tsx
@@ -1,7 +1,7 @@
/**
* Tests for useResizablePanels — drag changes width, collapse to 48px, localStorage persistence
*/
-import { describe, it } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
const MIN_WIDTH = 280;
diff --git a/tests/unit/ui/use-session-recorder.test.tsx b/tests/unit/ui/use-session-recorder.test.tsx
index f4889b244a..f39277a653 100644
--- a/tests/unit/ui/use-session-recorder.test.tsx
+++ b/tests/unit/ui/use-session-recorder.test.tsx
@@ -4,7 +4,7 @@
* Verifies that during recording, new traffic WS events trigger
* POST to /api/tools/traffic-inspector/sessions/{id}/requests.
*/
-import { describe, it, before, after } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
diff --git a/tests/unit/ui/use-system-proxy-exit-guard.test.tsx b/tests/unit/ui/use-system-proxy-exit-guard.test.tsx
index af1eaeeb1b..fa6d7597a5 100644
--- a/tests/unit/ui/use-system-proxy-exit-guard.test.tsx
+++ b/tests/unit/ui/use-system-proxy-exit-guard.test.tsx
@@ -6,7 +6,7 @@
* This matches how use-traffic-stream.test.tsx tests hook logic (pure logic,
* no React renderer needed).
*/
-import { describe, it, beforeEach } from "node:test";
+import { describe, it, beforeEach } from "vitest";
import assert from "node:assert/strict";
// ---------------------------------------------------------------------------
diff --git a/tests/unit/ui/use-traffic-stream.test.tsx b/tests/unit/ui/use-traffic-stream.test.tsx
index ce8804fa9b..39ca601e28 100644
--- a/tests/unit/ui/use-traffic-stream.test.tsx
+++ b/tests/unit/ui/use-traffic-stream.test.tsx
@@ -1,7 +1,7 @@
/**
* Tests for useTrafficStream — WebSocket snapshot/new/update/clear + reconnect backoff
*/
-import { describe, it, before, after, mock } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
diff --git a/tests/unit/ui/use-virtual-list.test.tsx b/tests/unit/ui/use-virtual-list.test.tsx
index 5ef5e689fb..bfb1bc9a53 100644
--- a/tests/unit/ui/use-virtual-list.test.tsx
+++ b/tests/unit/ui/use-virtual-list.test.tsx
@@ -1,7 +1,7 @@
/**
* Tests for useVirtualList — virtualizes 1000+ items without rendering all
*/
-import { describe, it } from "node:test";
+import { describe, it } from "vitest";
import assert from "node:assert/strict";
const ESTIMATED_ROW_HEIGHT = 48;
diff --git a/vitest.config.ts b/vitest.config.ts
index 4664608302..6f8d643da5 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -6,6 +6,7 @@ export default defineConfig({
test: {
environment: "jsdom",
globals: true,
+ setupFiles: ["./tests/_setup/vitestUiPolyfills.ts"],
pool: "threads",
maxWorkers: 20,
fileParallelism: true,