fix(workbench): test-runner teardown hardening + frontend CI lane (Wave R R0a)
Full 'pnpm test' previously hung indefinitely (killed at CI walls, misdiagnosed as teardown slowness). Two root causes found and fixed: 1. RightInspector.test.tsx called setSubject() during render with a fresh object every pass -> infinite synchronous render loop (100% CPU spin, blocks the event loop so no timeout can fire). Moved into useEffect. 2. Test QueryClients used retry:false but default gcTime (5 min) -> a live GC timer per cached query kept workers from exiting. New shared createTestQueryClient() with gcTime:0 adopted across all 7 test files. Hardening so any future leak fails loudly instead of hanging: - vite.config.ts: testTimeout/hookTimeout 10s, teardownTimeout 5s - useManagedTimeout hook owns previously-bare setTimeouts in DigestBadge, MetadataTable, RatificationCommandPanel (4 sites), CommandPalette focus First-ever frontend CI lane (.github/workflows/workbench-ui.yml): path-filtered to workbench-ui/** AND the workflow file itself, pnpm install --frozen-lockfile + build + vitest, timeout-minutes 15. Honesty fix: KeyboardHelp no longer advertises j/k, /, Enter (unbuilt until R0d restores them registry-backed). Route conformance test (ADR-0162 §6, executable): empty/error/loading contracts asserted for Chat/Proposals/Evals/Replay; fixed the two gaps it caught (ReplayRoute bare-div error branch -> ErrorState contract; ArtifactList empty state gained a next action). Result: 27 files / 181 tests pass, suite EXITS in 4.9s wall-clock (was: indefinite hang).
This commit is contained in:
parent
52d5d815ec
commit
513f80d5fb
21 changed files with 411 additions and 41 deletions
58
.github/workflows/workbench-ui.yml
vendored
Normal file
58
.github/workflows/workbench-ui.yml
vendored
Normal file
|
|
@ -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
|
||||
|
|
@ -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 (
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })}>
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 <RightInspector />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter>
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
|
|
@ -24,7 +23,7 @@ function LocationProbe() {
|
|||
function renderRoute(initialEntry = "/proposals") {
|
||||
return render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })}
|
||||
client={createTestQueryClient()}
|
||||
>
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<Routes>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center" data-testid="artifact-list-empty">
|
||||
<p className="text-sm text-[var(--color-text-muted)]">No artifacts available.</p>
|
||||
<div className="p-2" data-testid="artifact-list-empty">
|
||||
<EmptyState
|
||||
statement="No artifacts available."
|
||||
nextAction={{ kind: "cli", command: "core eval cognition" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,11 +81,18 @@ export function ReplayRoute() {
|
|||
{/* Left Pane: Artifact list */}
|
||||
<div className="border-r border-[var(--color-border-subtle)] overflow-y-auto pr-2">
|
||||
{isLoadingArtifacts ? (
|
||||
<LoadingState label="Comparing artifacts..." />
|
||||
<LoadingState label="Loading artifacts..." />
|
||||
) : artifactsQuery.isError ? (
|
||||
<div className="p-2 text-xs text-[var(--color-state-danger-text)]">
|
||||
Failed to load artifacts.
|
||||
</div>
|
||||
<ErrorState
|
||||
whatFailed={
|
||||
artifactsError instanceof WorkbenchApiError
|
||||
? artifactsError.message
|
||||
: "Failed to load artifacts."
|
||||
}
|
||||
mutationStatus="No corpus mutation occurred."
|
||||
reproducer="curl http://127.0.0.1:8765/artifacts"
|
||||
retrySafety="Retry: safe"
|
||||
/>
|
||||
) : (
|
||||
<ArtifactList
|
||||
artifacts={artifactsQuery.data || []}
|
||||
|
|
|
|||
|
|
@ -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, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes, useSearchParams } from "react-router-dom";
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
|
|
@ -94,12 +95,7 @@ const mockReplayComparison: ReplayComparison = {
|
|||
};
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, initialEntries = ["/"]) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const queryClient = createTestQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={initialEntries}>{ui}</MemoryRouter>
|
||||
|
|
|
|||
171
workbench-ui/src/app/routeConformance.test.tsx
Normal file
171
workbench-ui/src/app/routeConformance.test.tsx
Normal file
|
|
@ -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<never>(() => {});
|
||||
}
|
||||
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(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MemoryRouter>{element}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// 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: <ProposalsRoute />,
|
||||
loadingLabel: "Loading proposal queue...",
|
||||
emptyStatement: "No proposals match this queue view.",
|
||||
emptyCommand: "core teaching proposals --state pending",
|
||||
},
|
||||
{
|
||||
name: "Evals",
|
||||
element: <EvalsRoute />,
|
||||
loadingLabel: "Loading eval lanes...",
|
||||
emptyStatement: "No eval lanes discovered.",
|
||||
emptyCommand: "core eval --list",
|
||||
},
|
||||
{
|
||||
name: "Replay",
|
||||
element: <ReplayRoute />,
|
||||
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(<ChatRoute />);
|
||||
expectEmptyContract("Ask CORE a question.", "core chat");
|
||||
});
|
||||
|
||||
it("loading: shows a specific label after submit, never 'Thinking...'", async () => {
|
||||
installFetch("pending");
|
||||
renderRoute(<ChatRoute />);
|
||||
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(<ChatRoute />);
|
||||
await submitPrompt();
|
||||
await expectErrorContract();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<button
|
||||
|
|
@ -33,7 +35,7 @@ function CopyButton({ text }: { text: string }) {
|
|||
onClick={() => {
|
||||
void copyText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
scheduleReset(() => setCopied(false), 1500);
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -148,7 +148,8 @@ function CommandPaletteContent({
|
|||
if (open) {
|
||||
setQuery("");
|
||||
setFocusedIndex(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
const focusTimer = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(focusTimer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
|
|
|
|||
70
workbench-ui/src/design/hooks/useManagedTimeout.test.tsx
Normal file
70
workbench-ui/src/design/hooks/useManagedTimeout.test.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { useManagedTimeout } from "./useManagedTimeout";
|
||||
|
||||
function Flasher() {
|
||||
const [on, setOn] = useState(false);
|
||||
const schedule = useManagedTimeout();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOn(true);
|
||||
schedule(() => setOn(false), 1000);
|
||||
}}
|
||||
>
|
||||
{on ? "on" : "off"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useManagedTimeout", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fires the scheduled callback after the delay", () => {
|
||||
render(<Flasher />);
|
||||
|
||||
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(<Flasher />);
|
||||
|
||||
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(<Flasher />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
unmount();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
23
workbench-ui/src/design/hooks/useManagedTimeout.ts
Normal file
23
workbench-ui/src/design/hooks/useManagedTimeout.ts
Normal file
|
|
@ -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<ReturnType<typeof setTimeout>>();
|
||||
|
||||
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);
|
||||
}, []);
|
||||
}
|
||||
18
workbench-ui/src/test/createTestQueryClient.ts
Normal file
18
workbench-ui/src/test/createTestQueryClient.ts
Normal file
|
|
@ -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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue