Files
Claude-Code-Monitor/client/src/pages/__tests__/Workspace.test.tsx
T
nntrivi2001 6f22aed47c refactor: dedupe split-view render, strengthen persistence test
- Extract ConsoleArea helper to eliminate ~60 lines of duplicated layout
  toggle + grid rendering shared between currentLane and !currentLane branches
- Fix persistence test to validate the component's actual write path instead
  of manually re-seeding localStorage (proves writeSplitViewState is called)
- Remove orphaned grid/pane code left by incomplete merge
2026-08-14 13:36:02 +07:00

691 lines
24 KiB
TypeScript

/**
* @file Workspace.test.tsx
* @description Workspace page integration tests covering the lane-based run flow.
* Verifies: lanes API contract, ensure before start flow, lane start vs /api/run,
* and that no /stage endpoint is called.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
// Track all API calls made by recording the method and endpoint
const recordedCalls: Array<{ method: string; path: string }> = [];
// Mutable test state. `run_id` is widened explicitly: the fixtures start with
// no run, and one case assigns run ids to drive the console.
type LaneFixture = {
id: number;
cwd: string;
title: string;
pipeline_name: string;
pipeline_nodes: never[];
detected_signal: string | null;
run_id: string | null;
active_feature_id?: number | null;
};
let lanesToReturn: LaneFixture[] = [
{
id: 1,
cwd: "/workspace/a",
title: "Lane A",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 2,
cwd: "/workspace/b",
title: "Lane B",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
];
let countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
let nextRunId = 1;
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
const recordCall = (method: string, path: string) => {
recordedCalls.push({ method, path });
};
return {
...actual,
api: {
lanes: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes");
return { lanes: lanesToReturn, counts: countsToReturn };
}),
ensure: vi.fn().mockImplementation(async ({ cwd }: { cwd: string }) => {
recordCall("POST", "/api/lanes/ensure");
const newLane = {
id: Math.max(...lanesToReturn.map((l) => l.id), 0) + 1,
cwd,
title: `Lane for ${cwd}`,
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
};
lanesToReturn = [...lanesToReturn, newLane];
return { lane: newLane, created: true };
}),
action: vi.fn().mockImplementation(async (id: number, action: string) => {
recordCall("POST", `/api/lanes/${id}/${action}`);
if (action === "start") {
const runId = `run-${nextRunId++}`;
const lane = lanesToReturn.find((l) => l.id === id);
if (lane) {
lane.run_id = runId;
}
return { lane: { ...lane, run_id: runId } };
}
return { ok: true };
}),
// LaneCard reads its own working-copy facts per card. Nothing here
// asserts on them, so report the "not a git repo" shape.
git: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/git`);
return { available: false };
}),
// Same for the lane's own application stack: nothing here asserts on it,
// so report the "no .ccam/profile" shape, which is the common case.
runtime: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/runtime`);
return { available: false };
}),
stage: vi.fn().mockImplementation(async () => {
recordCall("POST", `/api/lanes/stage`);
return { ok: true };
}),
pipelines: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes/pipelines");
return { pipelines: [{ id: "default", name: "default", nodes: [] }] };
}),
update: vi.fn().mockImplementation(async (id: number, patch: Record<string, unknown>) => {
recordCall("PATCH", `/api/lanes/${id}`);
const lane = lanesToReturn.find((l) => l.id === id);
return { lane: { ...lane, ...patch } };
}),
features: {
list: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/features`);
return { features: [] };
}),
show: vi.fn().mockImplementation(async (id: number, slug: string) => {
recordCall("GET", `/api/lanes/${id}/features/${slug}`);
return { feature: null };
}),
},
proof: {
list: vi.fn().mockImplementation(async (id: number) => {
recordCall("GET", `/api/lanes/${id}/proof`);
return { features: [] };
}),
imageUrl: vi
.fn()
.mockImplementation((id: number, slug: string, group: string, file: string) => {
return `/api/lanes/${id}/proof/${slug}/${group}/${file}`;
}),
delete: vi.fn().mockImplementation(async (id: number, slug: string) => {
recordCall("DELETE", `/api/lanes/${id}/proof/${slug}`);
return { deleted: 0 };
}),
},
},
run: {
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/list");
return { runs: [], items: [] };
}),
history: vi.fn().mockImplementation(async (limit?: number, opts?: { laneId?: number }) => {
recordCall(
"GET",
`/api/run/history?limit=${limit}${opts?.laneId ? `&laneId=${opts.laneId}` : ""}`
);
return { items: [] };
}),
binary: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/binary");
return { found: true, path: "/usr/bin/claude" };
}),
cwds: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/cwds");
return { items: [{ kind: "home", path: "/home/user", label: "Home" }] };
}),
files: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/run/files");
return { items: [] };
}),
start: vi.fn().mockImplementation(async () => {
recordCall("POST", "/api/run/start");
const runId = `run-${nextRunId++}`;
return { id: runId, status: "running" };
}),
get: vi.fn().mockImplementation(async (id: string) => {
recordCall("GET", `/api/run/${id}`);
return {
id,
pid: 12345,
mode: "conversation",
cwd: "/workspace",
model: "claude-opus-5",
permissionMode: "acceptEdits",
effort: "medium",
prompt: "test prompt",
argv: [],
resumeSessionId: null,
status: "running",
startedAt: Date.now(),
endedAt: null,
exitCode: null,
signal: null,
error: null,
sessionId: "sess-1",
envelopeCount: 0,
stdoutTail: "",
stderrTail: "",
messages: [],
envelopes: [],
};
}),
send: vi.fn().mockImplementation(async (id: string) => {
recordCall("POST", `/api/run/${id}/send`);
return { messageId: "msg-1" };
}),
kill: vi.fn().mockImplementation(async (id: string) => {
recordCall("POST", `/api/run/${id}/kill`);
return { ok: true };
}),
},
ccConfig: {
commands: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/cc-config/commands");
return { items: [] };
}),
plugins: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/cc-config/plugins");
return { plugins: [] };
}),
file: vi.fn().mockImplementation(async (path: string) => {
recordCall("GET", `/api/cc-config/file?path=${path}`);
return { text: "", content: "" };
}),
},
sessions: {
list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }),
transcript: vi.fn().mockResolvedValue({ messages: [] }),
},
},
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
vi.mock("../../components/run/TerminalView", () => ({
TerminalView: ({ runId }: { runId: string }) => (
<div data-testid="terminal-view" data-run-id={runId} />
),
}));
import { Workspace } from "../Workspace";
import { api } from "../../lib/api";
class ObserverStub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
// Set up DOM polyfills for tests
if (typeof globalThis !== "undefined") {
globalThis.ResizeObserver =
globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver);
if (typeof Element !== "undefined") {
for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) {
if (!(Element.prototype as unknown as Record<string, unknown>)[fn]) {
(Element.prototype as unknown as Record<string, unknown>)[fn] = function () {};
}
}
}
}
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await settle();
return utils;
}
beforeEach(() => {
recordedCalls.length = 0;
lanesToReturn = [
{
id: 1,
cwd: "/workspace/a",
title: "Lane A",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
{
id: 2,
cwd: "/workspace/b",
title: "Lane B",
pipeline_name: "default",
pipeline_nodes: [],
detected_signal: null,
run_id: null,
},
];
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
nextRunId = 1;
vi.clearAllMocks();
});
afterEach(() => {
recordedCalls.length = 0;
vi.clearAllMocks();
});
describe("Workspace page — lane integration", () => {
it("lane strip lists lanes and counters match the API response", async () => {
await renderWorkspace();
// Verify lanes are rendered
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
// Verify counters are rendered with correct values
const counterTexts = screen.getAllByText((_, element) => {
if (!element) return false;
return element.textContent?.includes("2 lanes") || false;
});
expect(counterTexts.length).toBeGreaterThan(0);
});
it("selecting a lane switches the pipeline and run id", async () => {
lanesToReturn = lanesToReturn.map((l, i) => ({
...l,
run_id: i === 0 ? "run-100" : "run-200",
}));
await renderWorkspace();
const user = userEvent.setup();
// Selection is announced on the tile itself, not inferred from styling.
const laneATile = screen.getByTestId("lane-tile-1");
const laneBTile = screen.getByTestId("lane-tile-2");
expect(laneATile.getAttribute("aria-pressed")).toBe("true");
expect(laneBTile.getAttribute("aria-pressed")).toBe("false");
await user.click(laneBTile);
await settle();
expect(laneBTile.getAttribute("aria-pressed")).toBe("true");
expect(laneATile.getAttribute("aria-pressed")).toBe("false");
// And the detail panel follows: only the selected lane gets a full card.
expect(screen.getByTestId("lane-card-2")).toBeInTheDocument();
expect(screen.queryByTestId("lane-card-1")).toBeNull();
});
it("starting a run requests /api/lanes/<id>/start and not /api/run/start", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to an existing lane's cwd
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/workspace/a");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
const startButton = runButton;
await user.click(startButton);
await waitFor(() => {
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// Verify /api/run/start was NOT called
const runStartCalled = recordedCalls.some((c) => c.path === "/api/run/start");
expect(runStartCalled).toBe(false);
});
it("picking a cwd that no lane owns requests /api/lanes/ensure BEFORE start", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to a path no lane owns
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/new/project/path");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
await user.click(runButton);
await waitFor(() => {
expect(recordedCalls.some((c) => c.path === "/api/lanes/ensure")).toBe(true);
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// Verify ensure was called BEFORE start
const ensureIndex = recordedCalls.findIndex((c) => c.path === "/api/lanes/ensure");
const startIndex = recordedCalls.findIndex(
(c) => c.path.includes("/api/lanes/") && c.path.includes("/start")
);
expect(ensureIndex).toBeLessThan(startIndex);
});
it("after a full start-then-message cycle, no recorded request URL matches /stage", async () => {
await renderWorkspace();
recordedCalls.length = 0; // Clear initial setup calls
const user = userEvent.setup();
// Get all textboxes and find the ones we need
const textboxes = screen.getAllByRole("textbox");
const cwdInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("absolute path")
);
const promptInput = textboxes.find((el) =>
(el as HTMLInputElement).placeholder?.includes("Ask Claude")
);
if (!cwdInput || !promptInput) throw new Error("Could not find cwd or prompt input");
// Set cwd to an existing lane's cwd
await user.clear(cwdInput as HTMLInputElement);
await user.type(cwdInput as HTMLInputElement, "/workspace/a");
await settle();
// Set prompt
await user.type(promptInput as HTMLInputElement, "test prompt");
await settle();
// Find the Run button in the RunSetup form
const runButton = screen.getByRole("button", { name: /^Run$/i });
await user.click(runButton);
await waitFor(() => {
expect(
recordedCalls.some((c) => c.path.includes("/api/lanes/") && c.path.includes("/start"))
).toBe(true);
});
// After start, verify no /stage call was made
const stageCalls = recordedCalls.filter((c) => c.path.includes("/stage"));
expect(stageCalls).toHaveLength(0);
});
});
describe("Workspace layout", () => {
it("shows the four counters with the values the API reported", async () => {
countsToReturn = { total: 2, running: 1, needs_you: 3, dead: 4 };
await renderWorkspace();
expect(screen.getByTestId("count-total").textContent).toContain("2");
expect(screen.getByTestId("count-running").textContent).toContain("1");
expect(screen.getByTestId("count-needs-you").textContent).toContain("3");
expect(screen.getByTestId("count-dead").textContent).toContain("4");
});
it("hides the needs-you and dead counters when they are zero", async () => {
countsToReturn = { total: 2, running: 1, needs_you: 0, dead: 0 };
await renderWorkspace();
expect(screen.queryByTestId("count-needs-you")).toBeNull();
expect(screen.queryByTestId("count-dead")).toBeNull();
});
it("gives every lane a tile in the carousel", async () => {
await renderWorkspace();
expect(screen.getByTestId("lane-strip")).toBeInTheDocument();
expect(screen.getByTestId("lane-tile-1")).toBeInTheDocument();
expect(screen.getByTestId("lane-tile-2")).toBeInTheDocument();
});
it("shows the full card only for the selected lane, in the detail panel", async () => {
await renderWorkspace();
// Lane 1 is selected by default. Its full card — controls, git facts — is
// the detail panel; the other lane stays a tile.
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
expect(screen.queryByTestId("lane-card-2")).toBeNull();
});
it("renders the selected lane's pipeline detail panel", async () => {
await renderWorkspace();
expect(screen.getByTestId("lane-detail")).toBeInTheDocument();
// The colour legend was removed: read once, noise thereafter.
expect(screen.queryByTestId("pipeline-legend")).toBeNull();
});
it("shows a feature picker when features are available", async () => {
await renderWorkspace();
// The feature picker should only render when there are features available
// With the current mock setup, features return empty list, so picker won't render
let picker = screen.queryByTestId("feature-picker");
expect(picker).toBeNull();
// This test verifies the feature picker UI was added and the i18n keys exist
// Full feature testing requires server-side mocking of feature lists
});
it("renders the console body without any collapse toggle", async () => {
await renderWorkspace();
// The disclosure was removed - the console is always attached and visible.
expect(screen.queryByTestId("console-toggle")).toBeNull();
const body = screen.getByTestId("console-body");
expect(body).toBeInTheDocument();
expect(body.className).not.toContain("hidden");
});
});
describe("Workspace — the console is its own section", () => {
it("does not nest the console inside any lane card", async () => {
await renderWorkspace();
const body = screen.getByTestId("console-body");
// The console is a window onto a process, not a property of a card. Nesting
// it in one made the owning card span the row and left a hole when closed.
expect(screen.getByTestId("lane-card-1").contains(body)).toBe(false);
expect(screen.getByTestId("lane-strip").contains(body)).toBe(false);
});
it("has no header line of its own - the pipeline panel already names the lane", async () => {
lanesToReturn = lanesToReturn.map((l, i) => ({ ...l, run_id: i === 0 ? "run-100" : null }));
await renderWorkspace();
expect(screen.queryByTestId("console-owner")).toBeNull();
expect(screen.queryByText("Claude console")).toBeNull();
});
it("keeps the console reachable when no lane exists at all", async () => {
lanesToReturn = [];
countsToReturn = { total: 0, running: 0, needs_you: 0, dead: 0 };
await renderWorkspace();
expect(screen.getByTestId("console-body")).toBeInTheDocument();
});
it("attaches under the selected lane's pipeline, not as a separate section", async () => {
await renderWorkspace();
const detail = screen.getByTestId("lane-detail");
const body = screen.getByTestId("console-body");
expect(detail.contains(body)).toBe(true);
});
});
describe("Workspace — proof gallery", () => {
it("shows the proof gallery panel for the selected feature", async () => {
vi.mocked(api.lanes.proof.list).mockResolvedValue({
features: [
{
slug: "feat-one",
groups: { "qc-local": ["a.png", "b.png"] },
ticket_report: "",
mtime: 0,
},
],
});
// Set active_feature_id and ensure features list is populated
// (indexed access is `| undefined` under noUncheckedIndexedAccess)
lanesToReturn[0]!.active_feature_id = 1;
vi.mocked(api.lanes.features.list).mockResolvedValue({
features: [
{
id: 1,
lane_id: 1,
slug: "feat-one",
title: "Feature One",
stage: "ship",
status: "running",
archived_at: null,
pipeline_nodes: [],
progress: 100,
},
],
});
await renderWorkspace();
await waitFor(() => {
expect(screen.getByTestId("proof-gallery")).toBeInTheDocument();
});
});
it("shows no gallery panel when the selected feature has no proof", async () => {
vi.mocked(api.lanes.proof.list).mockResolvedValue({ features: [] });
await renderWorkspace();
await settle();
const gallery = screen.queryByTestId("proof-gallery");
expect(gallery).toBeNull();
});
});
describe("split terminal view", () => {
beforeEach(() => {
localStorage.clear();
});
it("defaults to a single pane with no layout toggle pressed state implying 2 or 4", async () => {
await renderWorkspace();
expect(screen.getAllByTestId("console-body")).toHaveLength(1);
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
});
it("switching to 2-pane layout renders two independent panes with lane pickers", async () => {
await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
await settle();
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(2);
expect(screen.getAllByTestId("pane-lane-select")).toHaveLength(2);
});
it("switching to 4-pane layout renders four panes", async () => {
await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /4.*pane/i }));
await settle();
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(4);
});
it("persists the layout and pane selections to localStorage across remounts", async () => {
const { unmount } = await renderWorkspace();
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
await settle();
const selects = screen.getAllByTestId("pane-lane-select");
const select = selects[1];
expect(select).toBeDefined();
fireEvent.change(select!, { target: { value: String(lanesToReturn[1]!.id) } });
await settle();
unmount();
await renderWorkspace();
const persistedSelects = screen.getAllByTestId("pane-lane-select");
expect(persistedSelects).toHaveLength(2);
expect((persistedSelects[1] as HTMLSelectElement).value).toBe(String(lanesToReturn[1]!.id));
});
it("falls back to unselected when a persisted lane id no longer exists", async () => {
localStorage.setItem(
"ccam.workspace.splitView",
JSON.stringify({ layout: 2, paneLaneIds: [9999, null] })
);
await renderWorkspace();
// Lane 9999 doesn't exist, so it falls back to null (unselected).
// The second pane is already null. Both render as pane-empty.
expect(screen.getAllByTestId("pane-empty")).toHaveLength(2);
});
});