201eae68bb
`npm run build` runs `tsc -b` first and it has been failing: `api.ts` used `NamedLock` without importing it, and four lane test fixtures predate `Lane.active_feature_id` / the widened `LaneRuntime`, so spreading a `Partial<Lane>` over them no longer satisfied the required fields. Nothing shipped could be rebuilt while this was red, which is how a client change reaches a dashboard running in production mode. The fixture fixes are casts with a note, not type relaxations — the base literals still list every required field, so the assertion states what they already prove.
484 lines
19 KiB
TypeScript
484 lines
19 KiB
TypeScript
/**
|
|
* @file Regression test for the lane card's status badge. Every lane status
|
|
* the server can set must have a real translated word behind
|
|
* `t("status." + lane.status)`; before this test existed, no locale defined
|
|
* any `status.*` key, and i18next's default missing-key behavior (return the
|
|
* key itself) hid that from the `||` fallback, so the badge showed literal
|
|
* text like `status.active`.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import LaneCard from "../LaneCard";
|
|
import type { Lane, LaneRuntime } from "../../../lib/types";
|
|
import { api } from "../../../lib/api";
|
|
|
|
vi.mock("../../../lib/api", () => ({
|
|
api: {
|
|
lanes: {
|
|
git: vi.fn(),
|
|
runtime: vi.fn(),
|
|
up: vi.fn(),
|
|
down: vi.fn(),
|
|
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
|
integration: vi.fn(),
|
|
agentsInstall: vi.fn(),
|
|
mcpSync: vi.fn(),
|
|
syncBaseCheck: vi.fn(),
|
|
},
|
|
locks: {
|
|
list: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(api.lanes.git).mockReset();
|
|
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
|
// A lane with no profile is the default fixture: most lanes never run a
|
|
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
|
vi.mocked(api.lanes.runtime).mockReset();
|
|
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
|
vi.mocked(api.lanes.integration).mockReset();
|
|
vi.mocked(api.lanes.integration).mockResolvedValue({ enabled: false });
|
|
});
|
|
vi.mocked(api.locks.list).mockReset();
|
|
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
|
|
|
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
|
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
|
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
|
// base object below still lists all of them, so the cast asserts what the
|
|
// literal already proves.
|
|
return {
|
|
id: 1,
|
|
title: "demo",
|
|
cwd: "/work/demo",
|
|
branch: "lane/demo",
|
|
kind: "adopted",
|
|
source_repo: null,
|
|
pipeline: "default",
|
|
session_id: null,
|
|
run_id: null,
|
|
stage: "plan",
|
|
stage_since: null,
|
|
status: "idle",
|
|
gate_decision: null,
|
|
ci_status: null,
|
|
needs_action: null,
|
|
links: {},
|
|
stages: {},
|
|
notes: null,
|
|
pipeline_name: "Default",
|
|
pipeline_nodes: [],
|
|
progress: 0,
|
|
stage_seconds: null,
|
|
last_event_seconds: null,
|
|
liveness: "idle",
|
|
detected_stage: null,
|
|
detected_signal: null,
|
|
slot: null,
|
|
ports: {},
|
|
...overrides,
|
|
} as Lane;
|
|
}
|
|
|
|
describe("LaneCard status badge", () => {
|
|
for (const status of ["idle", "running", "provisioning", "failed"] as const) {
|
|
it(`renders a real word for status "${status}", not the raw key`, () => {
|
|
render(<LaneCard lane={makeLane({ status })} onAction={vi.fn()} />);
|
|
expect(screen.queryByText(`status.${status}`)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(status.toUpperCase())).not.toBeInTheDocument();
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("LaneCard child worktrees", () => {
|
|
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
|
|
const onSelectLane = vi.fn();
|
|
const worktree = makeLane({ id: 8, title: "Worktree A", status: "running" });
|
|
render(
|
|
<LaneCard
|
|
lane={makeLane({ id: 1 })}
|
|
onAction={vi.fn()}
|
|
childWorktrees={[worktree]}
|
|
onSelectLane={onSelectLane}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByTestId("lane-child-worktrees")).toBeInTheDocument();
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByRole("button", { name: /Worktree A/ }));
|
|
expect(onSelectLane).toHaveBeenCalledWith(8);
|
|
});
|
|
|
|
it("renders nothing when there are no child worktrees", () => {
|
|
render(<LaneCard lane={makeLane({ id: 1 })} onAction={vi.fn()} />);
|
|
expect(screen.queryByTestId("lane-child-worktrees")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
const pipelineNodes: Lane["pipeline_nodes"] = [
|
|
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
|
|
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
|
{ id: "implement", label: "implement", icon: "🛠", gate: false, state: "current" },
|
|
{ id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending" },
|
|
];
|
|
|
|
describe("LaneCard rebuilt layout", () => {
|
|
const full = (over: Partial<Lane> = {}) =>
|
|
makeLane({
|
|
id: 7,
|
|
title: "Rename Metric to Rule",
|
|
kind: "managed",
|
|
status: "running",
|
|
liveness: "active",
|
|
stage: "plan",
|
|
stage_seconds: 152,
|
|
progress: 48,
|
|
ci_status: "green",
|
|
pipeline_nodes: pipelineNodes,
|
|
...over,
|
|
});
|
|
|
|
it("labels the card with the lane id", () => {
|
|
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
|
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the progress percentage and the time on stage", () => {
|
|
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
|
expect(screen.getByText("48%")).toBeInTheDocument();
|
|
expect(screen.getByText("2m 32s")).toBeInTheDocument();
|
|
});
|
|
|
|
it("gives the progress bar a width matching the lane's progress", () => {
|
|
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
|
expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%");
|
|
});
|
|
|
|
it("surfaces a needs-you message", () => {
|
|
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
|
|
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("fires a plain action with its own name", async () => {
|
|
const onAction = vi.fn();
|
|
render(<LaneCard lane={full()} onAction={onAction} />);
|
|
await userEvent.setup().click(screen.getByTestId("lane-action-stop"));
|
|
expect(onAction).toHaveBeenCalledWith("stop");
|
|
});
|
|
|
|
it("shows both deletions as buttons, and keeps reset behind the menu", async () => {
|
|
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
|
// Deleting the lane and deleting its history are the two the user goes
|
|
// looking for, so they are visible without opening anything. Reset is not.
|
|
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
|
|
expect(screen.getByTestId("lane-action-purge")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
|
|
|
await userEvent.setup().click(screen.getByTestId("lane-more"));
|
|
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
|
});
|
|
|
|
it("routes both deletions through the modal rather than firing them", async () => {
|
|
const onAction = vi.fn();
|
|
render(<LaneCard lane={full()} onAction={onAction} />);
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByTestId("lane-action-remove"));
|
|
expect(onAction).not.toHaveBeenCalled();
|
|
await user.click(screen.getByTestId("lane-action-purge"));
|
|
expect(onAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes reset through the confirmation modal rather than firing it", async () => {
|
|
const onAction = vi.fn();
|
|
render(<LaneCard lane={full()} onAction={onAction} />);
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByTestId("lane-more"));
|
|
await user.click(screen.getByTestId("lane-action-reset"));
|
|
expect(onAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("offers reset only for a managed lane, and drops the menu entirely without it", async () => {
|
|
const user = userEvent.setup();
|
|
const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
|
await user.click(screen.getByTestId("lane-more"));
|
|
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
|
unmount();
|
|
|
|
// An adopted lane has nothing left in the menu, so there is no ⋯ to open.
|
|
render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
|
|
expect(screen.queryByTestId("lane-more")).toBeNull();
|
|
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
|
expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("LaneCard git block", () => {
|
|
const facts = {
|
|
available: true as const,
|
|
branch: "feat/rename-metric",
|
|
head: "9b3e74a",
|
|
subject: "free-text rule mode in the form",
|
|
dirty: 2,
|
|
untracked: 1,
|
|
};
|
|
|
|
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
|
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
|
render(<LaneCard lane={makeLane({ branch: "stale/recorded" })} onAction={vi.fn()} />);
|
|
|
|
const block = await screen.findByTestId("lane-git");
|
|
expect(block.textContent).toContain("feat/rename-metric");
|
|
expect(block.textContent).toContain("9b3e74a");
|
|
expect(block.textContent).toContain("free-text rule mode in the form");
|
|
expect(block.textContent).toContain("2");
|
|
// The live branch replaces the lane's recorded one rather than doubling it.
|
|
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
|
});
|
|
|
|
it("omits the uncommitted line when the tree is clean", async () => {
|
|
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
|
const block = await screen.findByTestId("lane-git");
|
|
expect(block.textContent).not.toContain("modified");
|
|
});
|
|
|
|
it("renders the card with no git block and no error when git is unavailable", async () => {
|
|
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
|
render(<LaneCard lane={makeLane({ title: "plain dir lane" })} onAction={vi.fn()} />);
|
|
|
|
expect(await screen.findByText("plain dir lane")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("lane-git")).toBeNull();
|
|
});
|
|
|
|
it("swallows a rejected request instead of surfacing an error", async () => {
|
|
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
|
render(<LaneCard lane={makeLane({ title: "offline lane" })} onAction={vi.fn()} />);
|
|
|
|
expect(await screen.findByText("offline lane")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("lane-git")).toBeNull();
|
|
expect(screen.queryByText(/network down/)).toBeNull();
|
|
});
|
|
|
|
it("stops polling once the card unmounts", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
|
const { unmount } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
|
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
|
unmount();
|
|
vi.advanceTimersByTime(120_000);
|
|
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("LaneCard — the lane's own application stack", () => {
|
|
const upRuntime = {
|
|
available: true as const,
|
|
provisioned: true as const,
|
|
slot: 3,
|
|
kind: "managed" as const,
|
|
hooks: ["boot", "health"],
|
|
profileDir: "/work/demo/.ccam/profile",
|
|
services: [{ name: "web", pid: 4242, alive: true }],
|
|
ports: { api: { port: 8003, expected: 8003, listening: true } },
|
|
steppedAside: false,
|
|
up: true,
|
|
healthy: true,
|
|
logs: ["boot.log"],
|
|
logDir: "/lanes/.state/lane3/logs",
|
|
lastError: null,
|
|
};
|
|
|
|
it("shows nothing at all for a lane whose repo declares no profile", async () => {
|
|
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
|
render(<LaneCard lane={makeLane({ title: "no profile" })} onAction={vi.fn()} />);
|
|
|
|
expect(await screen.findByText("no profile")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("lane-runtime-1")).toBeNull();
|
|
expect(screen.queryByTestId("lane-runtime-toggle")).toBeNull();
|
|
});
|
|
|
|
it("lists each declared port with its listening state", async () => {
|
|
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
|
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
|
|
|
expect(await screen.findByTestId("lane-runtime-1")).toBeInTheDocument();
|
|
expect(screen.getByText(":8003")).toBeInTheDocument();
|
|
expect(screen.getByTestId("lane-runtime-state")).toHaveTextContent(/healthy/i);
|
|
});
|
|
|
|
it("flags a port that stepped aside from its base, showing the expected number", async () => {
|
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
|
...upRuntime,
|
|
steppedAside: true,
|
|
ports: { api: { port: 8103, expected: 8003, listening: true } },
|
|
});
|
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
|
|
|
expect(await screen.findByText(":8103")).toBeInTheDocument();
|
|
expect(screen.getByText(/8003/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("stops the stack through /down, never through the run's onAction prop", async () => {
|
|
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
|
|
vi.mocked(api.lanes.down).mockResolvedValue({
|
|
ok: true,
|
|
killed: [4242],
|
|
runtime: { available: false },
|
|
});
|
|
const onAction = vi.fn();
|
|
render(<LaneCard lane={makeLane()} onAction={onAction} />);
|
|
|
|
await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));
|
|
|
|
await waitFor(() => expect(api.lanes.down).toHaveBeenCalledWith(1));
|
|
expect(api.lanes.up).not.toHaveBeenCalled();
|
|
expect(onAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("boots a provisioned-but-down lane and stays busy past the 202", async () => {
|
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
|
...upRuntime,
|
|
services: [{ name: "web", pid: 4242, alive: false }],
|
|
ports: { api: { port: 8003, expected: 8003, listening: false } },
|
|
up: false,
|
|
healthy: false,
|
|
});
|
|
vi.mocked(api.lanes.up).mockResolvedValue({ ok: true, laneId: 1 });
|
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
|
|
|
await userEvent.setup().click(await screen.findByTestId("lane-runtime-toggle"));
|
|
|
|
await waitFor(() => expect(api.lanes.up).toHaveBeenCalledWith(1));
|
|
// The request resolving is not the stack being up: the server answered 202
|
|
// and is still booting, so the button must not go idle yet.
|
|
await waitFor(() => expect(screen.getByTestId("lane-runtime-toggle")).toBeDisabled());
|
|
});
|
|
|
|
it("surfaces the last boot error when nothing is streaming", async () => {
|
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
|
...upRuntime,
|
|
up: false,
|
|
healthy: false,
|
|
lastError: { at: "2026-08-03T00:00:00Z", code: "EUNHEALTHY", message: "health check failed" },
|
|
});
|
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
|
|
|
expect(await screen.findByText(/EUNHEALTHY/)).toBeInTheDocument();
|
|
expect(screen.getByText(/health check failed/)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("LaneCard — named locks", () => {
|
|
it("shows a lock badge when this lane holds a named lock", async () => {
|
|
vi.mocked(api.locks.list).mockResolvedValue({
|
|
locks: [{ name: "build", holder: "lane1", since: 0, ageSec: 120 }],
|
|
});
|
|
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
|
|
|
|
expect(await screen.findByTestId("lane-locks-1")).toBeInTheDocument();
|
|
expect(screen.getByText(/lock/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows no lock badge when locks belong to a different lane", async () => {
|
|
vi.mocked(api.locks.list).mockResolvedValue({
|
|
locks: [{ name: "build", holder: "lane99", since: 0, ageSec: 120 }],
|
|
});
|
|
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
|
|
|
|
expect(screen.queryByTestId("lane-locks-1")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe("agents install / mcp sync / integration badges / sync check", () => {
|
|
beforeEach(() => {
|
|
// Only the fields this describe block's assertions read; `as LaneRuntime`
|
|
// keeps the double from having to restate a shape the component never
|
|
// touches here.
|
|
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
|
available: true as const,
|
|
provisioned: true as const,
|
|
up: false,
|
|
slot: 1,
|
|
profileDir: "/work/demo/.ccam/profile",
|
|
ports: {},
|
|
} as unknown as LaneRuntime);
|
|
vi.mocked(api.lanes.integration).mockImplementation((_id, name) =>
|
|
Promise.resolve({ enabled: name === "tracker" })
|
|
);
|
|
});
|
|
|
|
it("shows integration badges reflecting each toggle's state", async () => {
|
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
|
const tracker = await screen.findByTestId("lane-integration-tracker");
|
|
const devQc = await screen.findByTestId("lane-integration-dev_qc");
|
|
expect(tracker.className).toContain("status-success");
|
|
expect(devQc.className).not.toContain("status-success");
|
|
});
|
|
|
|
it("clicking Install agents calls the API and shows the result", async () => {
|
|
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
|
|
installed: ["qc-local.md", "senior-gate-reviewer.md"],
|
|
});
|
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
|
const button = await screen.findByTestId("lane-agents-install");
|
|
await userEvent.click(button);
|
|
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
|
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
|
|
});
|
|
|
|
it("clicking Sync MCP calls the API and shows the result", async () => {
|
|
vi.mocked(api.lanes.mcpSync).mockResolvedValue({
|
|
servers: ["playwright"],
|
|
profilesSeeded: [],
|
|
});
|
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
|
const button = await screen.findByTestId("lane-mcp-sync");
|
|
await userEvent.click(button);
|
|
await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1));
|
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("playwright");
|
|
});
|
|
|
|
it("clicking Check dev sync reports a clean result", async () => {
|
|
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
|
|
code: 0,
|
|
devDelta: ["a.txt", "b.txt"],
|
|
overlap: [],
|
|
});
|
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
|
const button = await screen.findByTestId("lane-sync-check");
|
|
await userEvent.click(button);
|
|
await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1));
|
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2");
|
|
});
|
|
|
|
it("clicking Check dev sync reports a migration collision", async () => {
|
|
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
|
|
code: 5,
|
|
collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
|
|
});
|
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
|
const button = await screen.findByTestId("lane-sync-check");
|
|
await userEvent.click(button);
|
|
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
|
|
});
|
|
|
|
it("hides all four additions when the lane has no profile", async () => {
|
|
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
|
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
|
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
|
|
expect(screen.queryByTestId("lane-agents-install")).toBeNull();
|
|
expect(screen.queryByTestId("lane-mcp-sync")).toBeNull();
|
|
expect(screen.queryByTestId("lane-sync-check")).toBeNull();
|
|
expect(screen.queryByTestId("lane-integration-tracker")).toBeNull();
|
|
});
|
|
});
|