- Prettier 3.6.2: .prettierrc.json + .prettierignore + format/format:check scripts; formatted the web/ tree. - ESLint 9 flat config (@eslint/js + typescript-eslint + react-hooks + react-refresh, eslint-config-prettier last so Prettier owns formatting); lint script; src/components/ui/** opts out of react-refresh (shadcn primitives co-export their cva variants). 0 problems. - Playwright 1.61.1: playwright.config.ts (build+preview webServer) + an e2e Board-renders smoke against a mocked /api; test:e2e script. Reusable workflow installs chromium itself. - ci-web.yaml: required-scripts -> format:check,lint,build,test,test:e2e; run-format-check/lint/e2e -> true; e2e-browser chromium. All five scripts verified green locally.
296 lines
7.3 KiB
TypeScript
296 lines
7.3 KiB
TypeScript
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { ReactNode } from "react";
|
|
import { Board } from "./Board";
|
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
import type { StateResponse, Topology } from "../api";
|
|
|
|
/** Wrap in TooltipProvider — the real app mounts this at the root. */
|
|
function renderWithProviders(ui: ReactNode) {
|
|
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
|
}
|
|
|
|
const TOPO: Topology = {
|
|
trees: [
|
|
{ id: "core", label: "Core" },
|
|
{ id: "sdlc", label: "SDLC" },
|
|
],
|
|
nodes: [
|
|
{
|
|
id: "intake",
|
|
label: "Intake",
|
|
agent: "coordinator",
|
|
tree: "core",
|
|
kind: "phase",
|
|
gated: false,
|
|
},
|
|
{
|
|
id: "clarify",
|
|
label: "Clarify",
|
|
agent: "coordinator",
|
|
tree: "core",
|
|
kind: "gate",
|
|
gated: true,
|
|
},
|
|
{
|
|
id: "plan",
|
|
label: "Plan",
|
|
agent: "Claude",
|
|
tree: "sdlc",
|
|
kind: "phase",
|
|
gated: false,
|
|
},
|
|
],
|
|
edges: [],
|
|
};
|
|
|
|
function makeState(): StateResponse {
|
|
return {
|
|
ok: true,
|
|
error: null,
|
|
generated_at: "now",
|
|
db_path: "x",
|
|
stages: [
|
|
{
|
|
key: "intake",
|
|
label: "Intake",
|
|
agent: "coordinator",
|
|
phases: ["intake"],
|
|
gated: false,
|
|
role_node: false,
|
|
state: "idle",
|
|
count: 1,
|
|
tasks: [
|
|
{
|
|
thread_id: "aaaa1111",
|
|
short_id: "aaaa1111",
|
|
task: "remediate CVE",
|
|
current_phase: "intake",
|
|
status: "active",
|
|
waiting: false,
|
|
waiting_since: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "clarify",
|
|
label: "Clarify",
|
|
agent: "coordinator",
|
|
phases: ["clarify"],
|
|
gated: true,
|
|
role_node: false,
|
|
state: "awaiting_human",
|
|
count: 1,
|
|
tasks: [
|
|
{
|
|
thread_id: "bbbb2222",
|
|
short_id: "bbbb2222",
|
|
task: "fix login bug",
|
|
current_phase: "clarify",
|
|
status: "waiting_human",
|
|
waiting: true,
|
|
waiting_since: "2024-01-01T00:00:00Z",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
tasks: [
|
|
{
|
|
thread_id: "aaaa1111",
|
|
short_id: "aaaa1111",
|
|
task: "remediate CVE",
|
|
current_phase: "intake",
|
|
status: "active",
|
|
waiting: false,
|
|
waiting_since: null,
|
|
},
|
|
{
|
|
thread_id: "bbbb2222",
|
|
short_id: "bbbb2222",
|
|
task: "fix login bug",
|
|
current_phase: "clarify",
|
|
status: "waiting_human",
|
|
waiting: true,
|
|
waiting_since: "2024-01-01T00:00:00Z",
|
|
},
|
|
],
|
|
waiting: [],
|
|
question_counts: {},
|
|
summary: { active: 1, parked: 0, waiting: 1, open_questions: 0, total: 2 },
|
|
budget: { available: false },
|
|
nodes: {},
|
|
};
|
|
}
|
|
|
|
describe("Board", () => {
|
|
it("renders a column per stage with correct labels", () => {
|
|
renderWithProviders(
|
|
<Board
|
|
state={makeState()}
|
|
topology={TOPO}
|
|
selectedId={null}
|
|
nodeFilter={null}
|
|
onSelect={() => {}}
|
|
onClearNodeFilter={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByLabelText("Stage: Intake")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Stage: Clarify")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows task count badge in each column header", () => {
|
|
renderWithProviders(
|
|
<Board
|
|
state={makeState()}
|
|
topology={TOPO}
|
|
selectedId={null}
|
|
nodeFilter={null}
|
|
onSelect={() => {}}
|
|
onClearNodeFilter={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const intakeCol = screen.getByLabelText("Stage: Intake");
|
|
// Both columns have 1 task each
|
|
expect(within(intakeCol).getByText("1")).toBeInTheDocument();
|
|
|
|
const clarifyCol = screen.getByLabelText("Stage: Clarify");
|
|
expect(within(clarifyCol).getByText("1")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders task descriptions inside columns", () => {
|
|
renderWithProviders(
|
|
<Board
|
|
state={makeState()}
|
|
topology={TOPO}
|
|
selectedId={null}
|
|
nodeFilter={null}
|
|
onSelect={() => {}}
|
|
onClearNodeFilter={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("remediate CVE")).toBeInTheDocument();
|
|
expect(screen.getByText("fix login bug")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows a waiting affordance (clock icon) for a waiting task", () => {
|
|
renderWithProviders(
|
|
<Board
|
|
state={makeState()}
|
|
topology={TOPO}
|
|
selectedId={null}
|
|
nodeFilter={null}
|
|
onSelect={() => {}}
|
|
onClearNodeFilter={() => {}}
|
|
/>,
|
|
);
|
|
|
|
// The Clock icon has aria-label="awaiting human"
|
|
expect(screen.getByLabelText("awaiting human")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows lock icon on a gated stage column", () => {
|
|
renderWithProviders(
|
|
<Board
|
|
state={makeState()}
|
|
topology={TOPO}
|
|
selectedId={null}
|
|
nodeFilter={null}
|
|
onSelect={() => {}}
|
|
onClearNodeFilter={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByLabelText("human gate")).toBeInTheDocument();
|
|
});
|
|
|
|
it("clicking a card fires onSelect with the thread_id", () => {
|
|
const onSelect = vi.fn();
|
|
renderWithProviders(
|
|
<Board
|
|
state={makeState()}
|
|
topology={TOPO}
|
|
selectedId={null}
|
|
nodeFilter={null}
|
|
onSelect={onSelect}
|
|
onClearNodeFilter={() => {}}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("remediate CVE"));
|
|
expect(onSelect).toHaveBeenCalledWith("aaaa1111");
|
|
});
|
|
|
|
it("selected card gets aria-selected=true", () => {
|
|
renderWithProviders(
|
|
<Board
|
|
state={makeState()}
|
|
topology={TOPO}
|
|
selectedId="aaaa1111"
|
|
nodeFilter={null}
|
|
onSelect={() => {}}
|
|
onClearNodeFilter={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const cards = screen.getAllByRole("option", { selected: true });
|
|
expect(cards).toHaveLength(1);
|
|
});
|
|
|
|
it("shows node filter chip when nodeFilter is set", () => {
|
|
const onClearNodeFilter = vi.fn();
|
|
renderWithProviders(
|
|
<Board
|
|
state={makeState()}
|
|
topology={TOPO}
|
|
selectedId={null}
|
|
nodeFilter="intake"
|
|
onSelect={() => {}}
|
|
onClearNodeFilter={onClearNodeFilter}
|
|
/>,
|
|
);
|
|
|
|
const chip = screen.getByLabelText("Clear node filter: intake");
|
|
expect(chip).toBeInTheDocument();
|
|
fireEvent.click(chip);
|
|
expect(onClearNodeFilter).toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls back to deriving stages from tasks when stages is undefined", () => {
|
|
const stateNoStages: StateResponse = {
|
|
...makeState(),
|
|
stages: undefined,
|
|
};
|
|
renderWithProviders(
|
|
<Board
|
|
state={stateNoStages}
|
|
topology={TOPO}
|
|
selectedId={null}
|
|
nodeFilter={null}
|
|
onSelect={() => {}}
|
|
onClearNodeFilter={() => {}}
|
|
/>,
|
|
);
|
|
|
|
// Should still show the tasks grouped by phase
|
|
expect(screen.getByText("remediate CVE")).toBeInTheDocument();
|
|
expect(screen.getByText("fix login bug")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders null state gracefully with loading message", () => {
|
|
renderWithProviders(
|
|
<Board
|
|
state={null}
|
|
topology={null}
|
|
selectedId={null}
|
|
nodeFilter={null}
|
|
onSelect={() => {}}
|
|
onClearNodeFilter={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
|
});
|
|
});
|