diff --git a/.github/workflows/workbench-ui.yml b/.github/workflows/workbench-ui.yml new file mode 100644 index 00000000..f35eba19 --- /dev/null +++ b/.github/workflows/workbench-ui.yml @@ -0,0 +1,58 @@ +name: workbench-ui + +# Frontend verification lane (Wave R brief R0a). Before this workflow, +# workbench-ui changes merged with no frontend CI at all — smoke.yml and +# full-pytest.yml are Python-only. +# +# The path filter includes this workflow file itself so changes to the +# lane are validated by the lane. + +on: + pull_request: + paths: + - "workbench-ui/**" + - ".github/workflows/workbench-ui.yml" + push: + branches: [main] + paths: + - "workbench-ui/**" + - ".github/workflows/workbench-ui.yml" + +permissions: + contents: read + +concurrency: + group: workbench-ui-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + name: build + vitest + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: workbench-ui + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: set up pnpm + uses: pnpm/action-setup@v4 + # version comes from package.json "packageManager" (pnpm@9.15.0) + + - name: set up node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: workbench-ui/pnpm-lock.yaml + + - name: install dependencies + run: pnpm install --frozen-lockfile + + - name: build (tsc + vite) + run: pnpm build + + - name: vitest (full suite must pass AND exit) + run: pnpm test diff --git a/workbench-ui/src/api/chat-turn.test.tsx b/workbench-ui/src/api/chat-turn.test.tsx index 5312fb9e..5359bdb2 100644 --- a/workbench-ui/src/api/chat-turn.test.tsx +++ b/workbench-ui/src/api/chat-turn.test.tsx @@ -1,4 +1,5 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createTestQueryClient } from "../test/createTestQueryClient"; import { renderHook, waitFor } from "@testing-library/react"; import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -8,7 +9,7 @@ import { happyChatTurn } from "../app/chat/fixtures"; function wrapper({ children }: { children: ReactNode }) { return ( - + {children} ); diff --git a/workbench-ui/src/app/KeyboardHelp.tsx b/workbench-ui/src/app/KeyboardHelp.tsx index 99eb94e9..5288d6fd 100644 --- a/workbench-ui/src/app/KeyboardHelp.tsx +++ b/workbench-ui/src/app/KeyboardHelp.tsx @@ -4,9 +4,6 @@ const SHORTCUTS = [ { keys: "⌘K", action: "Command palette" }, { keys: "⌘I", action: "Toggle inspector" }, { keys: "⌘1–0", action: "Navigate to route 1–10" }, - { keys: "/", action: "Focus search input" }, - { keys: "j / k", action: "Navigate lists" }, - { keys: "Enter", action: "Open selected item" }, { keys: "Esc", action: "Close overlay" }, { keys: "?", action: "This help" }, ] as const; diff --git a/workbench-ui/src/app/RightInspector.test.tsx b/workbench-ui/src/app/RightInspector.test.tsx index 90c451e0..621ad62e 100644 --- a/workbench-ui/src/app/RightInspector.test.tsx +++ b/workbench-ui/src/app/RightInspector.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import { useEffect } from "react"; import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext"; import { RightInspector } from "./RightInspector"; import type { ChatTurnResult, ProposalDetail } from "../types/api"; @@ -30,9 +31,14 @@ function SetSubjectAndRender({ kind: "turn" | "none"; }) { const { setSubject } = useEvidenceSubject(); - if (kind === "turn") { - setSubject({ kind: "turn", turnId: 1, data: MOCK_TURN }); - } + // Render-phase setSubject created an infinite synchronous render loop + // (new subject object every pass) — the 100%-CPU spin previously + // misdiagnosed as a teardown hang. State updates belong in effects. + useEffect(() => { + if (kind === "turn") { + setSubject({ kind: "turn", turnId: 1, data: MOCK_TURN }); + } + }, [kind, setSubject]); return ; } diff --git a/workbench-ui/src/app/Shell.error.test.tsx b/workbench-ui/src/app/Shell.error.test.tsx index bb71c067..292813f2 100644 --- a/workbench-ui/src/app/Shell.error.test.tsx +++ b/workbench-ui/src/app/Shell.error.test.tsx @@ -2,7 +2,8 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { MemoryRouter, Routes, Route } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createTestQueryClient } from "../test/createTestQueryClient"; import { Shell } from "./Shell"; import { ChatRoute } from "../routes/ChatRoute"; import { WorkbenchApiError } from "../api/client"; @@ -19,7 +20,7 @@ vi.mock("../api/queries", async (importOriginal) => { import { useRuntimeStatus } from "../api/queries"; function makeClient() { - return new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return createTestQueryClient(); } function renderShell() { diff --git a/workbench-ui/src/app/Shell.test.tsx b/workbench-ui/src/app/Shell.test.tsx index 8d7e7701..e7d51409 100644 --- a/workbench-ui/src/app/Shell.test.tsx +++ b/workbench-ui/src/app/Shell.test.tsx @@ -1,7 +1,8 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { MemoryRouter, Routes, Route } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createTestQueryClient } from "../test/createTestQueryClient"; import { Shell } from "./Shell"; import { ChatRoute } from "../routes/ChatRoute"; import { ProposalsRoute } from "./proposals/ProposalsRoute"; @@ -29,7 +30,7 @@ const mockStatus: RuntimeStatus = { }; function makeClient() { - return new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return createTestQueryClient(); } function renderShell(initialPath = "/chat") { diff --git a/workbench-ui/src/app/chat/ChatRoute.test.tsx b/workbench-ui/src/app/chat/ChatRoute.test.tsx index 3beeef1a..0dd85f26 100644 --- a/workbench-ui/src/app/chat/ChatRoute.test.tsx +++ b/workbench-ui/src/app/chat/ChatRoute.test.tsx @@ -1,4 +1,5 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createTestQueryClient } from "../../test/createTestQueryClient"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; @@ -7,7 +8,7 @@ import { ChatRoute } from "../../routes/ChatRoute"; import { happyChatTurn } from "./fixtures"; function renderRoute() { - const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + const client = createTestQueryClient(); return render( diff --git a/workbench-ui/src/app/evals/evals.test.tsx b/workbench-ui/src/app/evals/evals.test.tsx index d1b155d0..eaddb874 100644 --- a/workbench-ui/src/app/evals/evals.test.tsx +++ b/workbench-ui/src/app/evals/evals.test.tsx @@ -1,7 +1,8 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { MemoryRouter } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createTestQueryClient } from "../../test/createTestQueryClient"; import { EvalLaneCard } from "./EvalLaneCard"; import { EvalRunButton } from "./EvalRunButton"; import { EvalMetricGrid } from "./EvalMetricGrid"; @@ -41,7 +42,7 @@ const mockResult: EvalRunResult = { }; function makeClient() { - return new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return createTestQueryClient(); } describe("W-030 Component Tests", () => { diff --git a/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx b/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx index 6fc71d57..c47a911b 100644 --- a/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx +++ b/workbench-ui/src/app/proposals/ProposalsRoute.test.tsx @@ -1,4 +1,5 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createTestQueryClient } from "../../test/createTestQueryClient"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; @@ -10,9 +11,7 @@ import { ProposalTable } from "./ProposalTable"; import { ProposalsRoute } from "./ProposalsRoute"; function queryWrapper({ children }: { children: ReactNode }) { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); + const client = createTestQueryClient(); return {children}; } @@ -24,7 +23,7 @@ function LocationProbe() { function renderRoute(initialEntry = "/proposals") { return render( diff --git a/workbench-ui/src/app/proposals/RatificationCommandPanel.tsx b/workbench-ui/src/app/proposals/RatificationCommandPanel.tsx index 04c233fd..1e23fb18 100644 --- a/workbench-ui/src/app/proposals/RatificationCommandPanel.tsx +++ b/workbench-ui/src/app/proposals/RatificationCommandPanel.tsx @@ -4,6 +4,7 @@ import { useMathRatify, useMathReject, useMathDefer } from "../../api/queries"; import type { MathProposalDetail } from "../../types/api"; import { Button } from "../../design/components/primitives/Button"; import { copyText } from "../../design/lib"; +import { useManagedTimeout } from "../../design/hooks/useManagedTimeout"; interface RatificationCommandPanelProps { proposal: MathProposalDetail; @@ -44,6 +45,10 @@ export function RatificationCommandPanel({ const ratifyMutation = useMathRatify(); const rejectMutation = useMathReject(); const deferMutation = useMathDefer(); + // Two independent slots: a pending success/defer callback must not be + // cancelled by an unrelated status-message clear (and vice versa). + const scheduleCallback = useManagedTimeout(); + const scheduleMessageClear = useManagedTimeout(); // Reset states when proposal changes useEffect(() => { @@ -89,7 +94,7 @@ export function RatificationCommandPanel({ if (result.applied) { setStatusMessage(`Ratification succeeded: ${result.message}`); setStatusType("success"); - setTimeout(() => { + scheduleCallback(() => { if (onSuccess) onSuccess(); }, 800); } else { @@ -120,7 +125,7 @@ export function RatificationCommandPanel({ setStatusType("success"); setShowNoteInput(false); setNote(""); - setTimeout(() => { + scheduleCallback(() => { if (onSuccess) onSuccess(); }, 800); }, @@ -144,7 +149,7 @@ export function RatificationCommandPanel({ onSuccess: () => { setStatusMessage("Proposal deferred successfully"); setStatusType("success"); - setTimeout(() => { + scheduleCallback(() => { if (onDefer) onDefer(); }, 800); }, @@ -161,7 +166,7 @@ export function RatificationCommandPanel({ await copyText(proposal.suggested_ratify_cli); setStatusMessage("Suggested CLI command copied to clipboard"); setStatusType("success"); - setTimeout(() => { + scheduleMessageClear(() => { setStatusMessage(null); setStatusType(null); }, 3000); diff --git a/workbench-ui/src/app/replay/ArtifactList.tsx b/workbench-ui/src/app/replay/ArtifactList.tsx index ccbf4e29..e3af466b 100644 --- a/workbench-ui/src/app/replay/ArtifactList.tsx +++ b/workbench-ui/src/app/replay/ArtifactList.tsx @@ -1,5 +1,6 @@ import type { ArtifactRef } from "../../types/api"; import { cn } from "../../design/lib"; +import { EmptyState } from "../../design/components/states/EmptyState"; interface ArtifactListProps { artifacts: ArtifactRef[]; @@ -22,8 +23,11 @@ type ArtifactKind = (typeof KINDS)[number]; export function ArtifactList({ artifacts, selectedId, onSelect }: ArtifactListProps) { if (artifacts.length === 0) { return ( -
-

No artifacts available.

+
+
); } diff --git a/workbench-ui/src/app/replay/ReplayRoute.tsx b/workbench-ui/src/app/replay/ReplayRoute.tsx index 9144c805..d3b137c7 100644 --- a/workbench-ui/src/app/replay/ReplayRoute.tsx +++ b/workbench-ui/src/app/replay/ReplayRoute.tsx @@ -81,11 +81,18 @@ export function ReplayRoute() { {/* Left Pane: Artifact list */}
{isLoadingArtifacts ? ( - + ) : artifactsQuery.isError ? ( -
- Failed to load artifacts. -
+ ) : ( {ui} diff --git a/workbench-ui/src/app/routeConformance.test.tsx b/workbench-ui/src/app/routeConformance.test.tsx new file mode 100644 index 00000000..0420061d --- /dev/null +++ b/workbench-ui/src/app/routeConformance.test.tsx @@ -0,0 +1,171 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ReactElement } from "react"; +import { createTestQueryClient } from "../test/createTestQueryClient"; +import { ChatRoute } from "../routes/ChatRoute"; +import { ProposalsRoute } from "./proposals/ProposalsRoute"; +import { EvalsRoute } from "./evals/EvalsRoute"; +import { ReplayRoute } from "./replay/ReplayRoute"; + +/** + * ADR-0162 §6 route conformance — executable, not aspirational. + * + * Every implemented route must ship all three states: + * - empty: a one-line absence statement + a next action + * - error: what failed + mutation status + reproducer + retry safety + * - loading: a specific label (never "Thinking...") + * + * New routes (Wave R2+) add themselves to the tables below; a route that + * cannot pass this test does not ship. + */ + +const GENERATED_AT = "2026-06-12T00:00:00Z"; + +function okEnvelope(data: unknown) { + return { ok: true, generated_at: GENERATED_AT, data }; +} + +const ERROR_ENVELOPE = { + ok: false, + generated_at: GENERATED_AT, + error: { code: "read_error", message: "synthetic read failure" }, +}; + +function emptyDataFor(path: string): unknown { + // GET /evals returns a bare array; list endpoints return ItemsEnvelope. + return path === "/evals" ? [] : { items: [] }; +} + +type FetchPlan = "pending" | "error" | "empty"; + +function installFetch(plan: FetchPlan) { + vi.stubGlobal( + "fetch", + vi.fn((input: unknown) => { + if (plan === "pending") { + return new Promise(() => {}); + } + const path = new URL(String(input)).pathname; + const body = plan === "error" ? ERROR_ENVELOPE : okEnvelope(emptyDataFor(path)); + return Promise.resolve({ json: async () => body }); + }), + ); +} + +function renderRoute(element: ReactElement) { + return render( + + {element} + , + ); +} + +// Routes may render a contract-bearing state in more than one pane +// (list + detail), so assert "at least one", never "exactly one". +async function expectErrorContract() { + expect((await screen.findAllByText("What failed")).length).toBeGreaterThan(0); + expect(screen.getAllByText("Mutation status").length).toBeGreaterThan(0); + expect(screen.getAllByText("Reproducer").length).toBeGreaterThan(0); + expect(screen.getAllByText("Retry safety").length).toBeGreaterThan(0); + // The mutation-status line is load-bearing (ADR-0162 §6 / CLAUDE.md). + expect(screen.getAllByText(/No .*mutation occurred\./).length).toBeGreaterThan(0); +} + +function expectEmptyContract(statement: string, command: string) { + expect(screen.getAllByText(statement).length).toBeGreaterThan(0); + expect(screen.getAllByText(command).length).toBeGreaterThan(0); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +interface MountRouteSpec { + name: string; + element: ReactElement; + loadingLabel: string; + emptyStatement: string; + emptyCommand: string; +} + +const MOUNT_ROUTES: MountRouteSpec[] = [ + { + name: "Proposals", + element: , + loadingLabel: "Loading proposal queue...", + emptyStatement: "No proposals match this queue view.", + emptyCommand: "core teaching proposals --state pending", + }, + { + name: "Evals", + element: , + loadingLabel: "Loading eval lanes...", + emptyStatement: "No eval lanes discovered.", + emptyCommand: "core eval --list", + }, + { + name: "Replay", + element: , + loadingLabel: "Loading artifacts...", + emptyStatement: "No artifacts available.", + emptyCommand: "core eval cognition", + }, +]; + +describe.each(MOUNT_ROUTES)("route conformance: $name", (spec) => { + it("loading: shows a specific label, never 'Thinking...'", async () => { + installFetch("pending"); + renderRoute(spec.element); + expect(await screen.findByText(spec.loadingLabel)).toBeInTheDocument(); + expect(screen.queryByText(/thinking/i)).not.toBeInTheDocument(); + }); + + it("error: surfaces what failed, mutation status, reproducer, retry safety", async () => { + installFetch("error"); + renderRoute(spec.element); + await expectErrorContract(); + }); + + it("empty: states what is absent and offers a next action", async () => { + installFetch("empty"); + renderRoute(spec.element); + expect((await screen.findAllByText(spec.emptyStatement)).length).toBeGreaterThan(0); + expectEmptyContract(spec.emptyStatement, spec.emptyCommand); + }); +}); + +describe("route conformance: Chat (interaction-driven states)", () => { + async function submitPrompt() { + const user = userEvent.setup(); + await user.type( + screen.getByPlaceholderText("Ask CORE a question..."), + "hello", + ); + await user.click(screen.getByRole("button", { name: /submit/i })); + return user; + } + + it("empty (initial): states what is absent and offers a next action", () => { + installFetch("empty"); + renderRoute(); + expectEmptyContract("Ask CORE a question.", "core chat"); + }); + + it("loading: shows a specific label after submit, never 'Thinking...'", async () => { + installFetch("pending"); + renderRoute(); + await submitPrompt(); + expect(await screen.findByText("Awaiting turn...")).toBeInTheDocument(); + expect(screen.queryByText(/thinking/i)).not.toBeInTheDocument(); + }); + + it("error: surfaces what failed, mutation status, reproducer, retry safety", async () => { + installFetch("error"); + renderRoute(); + await submitPrompt(); + await expectErrorContract(); + }); +}); diff --git a/workbench-ui/src/design/components/DigestBadge/DigestBadge.tsx b/workbench-ui/src/design/components/DigestBadge/DigestBadge.tsx index 948d4e45..4e8f43b3 100644 --- a/workbench-ui/src/design/components/DigestBadge/DigestBadge.tsx +++ b/workbench-ui/src/design/components/DigestBadge/DigestBadge.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { copyText } from "../../lib"; +import { useManagedTimeout } from "../../hooks/useManagedTimeout"; export interface DigestBadgeProps { digest: string; @@ -39,6 +40,7 @@ export function DigestBadge({ truncate = 16, }: DigestBadgeProps) { const [copied, setCopied] = useState(false); + const scheduleReset = useManagedTimeout(); const display = digest.length > truncate ? `${digest.slice(0, truncate)}...` @@ -64,7 +66,7 @@ export function DigestBadge({ onClick={() => { void copyText(`${algorithm}:${digest}`).then(() => { setCopied(true); - setTimeout(() => setCopied(false), 1500); + scheduleReset(() => setCopied(false), 1500); }); }} data-testid="digest-badge" diff --git a/workbench-ui/src/design/components/MetadataTable/MetadataTable.tsx b/workbench-ui/src/design/components/MetadataTable/MetadataTable.tsx index c041deff..4c329601 100644 --- a/workbench-ui/src/design/components/MetadataTable/MetadataTable.tsx +++ b/workbench-ui/src/design/components/MetadataTable/MetadataTable.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useState } from "react"; import { Copy, Check } from "lucide-react"; import { copyText } from "../../lib"; +import { useManagedTimeout } from "../../hooks/useManagedTimeout"; export interface MetadataRow { key: string; @@ -15,6 +16,7 @@ export interface MetadataTableProps { function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); + const scheduleReset = useManagedTimeout(); return ( + ); +} + +describe("useManagedTimeout", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("fires the scheduled callback after the delay", () => { + render(); + + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("button")).toHaveTextContent("on"); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(screen.getByRole("button")).toHaveTextContent("off"); + }); + + it("re-scheduling replaces the pending timeout (single slot)", () => { + render(); + + fireEvent.click(screen.getByRole("button")); + act(() => { + vi.advanceTimersByTime(600); + }); + fireEvent.click(screen.getByRole("button")); + // The first timeout (due at t=1000) was replaced; at t=1100 the flag is + // still on because the second timeout fires at t=1600. + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByRole("button")).toHaveTextContent("on"); + act(() => { + vi.advanceTimersByTime(500); + }); + expect(screen.getByRole("button")).toHaveTextContent("off"); + }); + + it("unmount clears the pending timeout (no post-unmount callback)", () => { + const { unmount } = render(); + + fireEvent.click(screen.getByRole("button")); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/workbench-ui/src/design/hooks/useManagedTimeout.ts b/workbench-ui/src/design/hooks/useManagedTimeout.ts new file mode 100644 index 00000000..5ce5b2ec --- /dev/null +++ b/workbench-ui/src/design/hooks/useManagedTimeout.ts @@ -0,0 +1,23 @@ +import { useCallback, useEffect, useRef } from "react"; + +/** + * A single-slot setTimeout owned by the component lifecycle. + * + * Scheduling replaces any pending timeout; unmount clears it. This prevents + * the bare-handler `setTimeout` pattern from firing state updates after + * unmount and from holding the event loop open at test teardown. + */ +export function useManagedTimeout(): (fn: () => void, ms: number) => void { + const ref = useRef>(); + + useEffect(() => { + return () => { + if (ref.current !== undefined) clearTimeout(ref.current); + }; + }, []); + + return useCallback((fn: () => void, ms: number) => { + if (ref.current !== undefined) clearTimeout(ref.current); + ref.current = setTimeout(fn, ms); + }, []); +} diff --git a/workbench-ui/src/test/createTestQueryClient.ts b/workbench-ui/src/test/createTestQueryClient.ts new file mode 100644 index 00000000..6da05da5 --- /dev/null +++ b/workbench-ui/src/test/createTestQueryClient.ts @@ -0,0 +1,18 @@ +import { QueryClient } from "@tanstack/react-query"; + +/** + * QueryClient for tests. + * + * `gcTime: 0` is load-bearing: TanStack Query v5 defaults to 5 minutes, + * which schedules a garbage-collection timer per cached query. Those live + * timers keep vitest workers from exiting at teardown — the root cause of + * the full-suite hang diagnosed 2026-06-12 (Wave R brief R0a). + */ +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false, gcTime: 0 }, + }, + }); +} diff --git a/workbench-ui/vite.config.ts b/workbench-ui/vite.config.ts index 722961b7..b576e4f7 100644 --- a/workbench-ui/vite.config.ts +++ b/workbench-ui/vite.config.ts @@ -7,5 +7,11 @@ export default defineConfig({ environment: "happy-dom", setupFiles: ["./src/test/setup.ts"], globals: true, + // Fail fast instead of hanging to a CI wall (Wave R brief R0a). + // A hung worker at teardown was previously an indefinite hang; these + // caps convert any residual live-handle leak into a loud failure. + testTimeout: 10_000, + hookTimeout: 10_000, + teardownTimeout: 5_000, }, });