/** * @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ĩ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, act, screen, waitFor } 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; }; 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>(); 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 }; }), }, 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: () => {}, }, })); import { Workspace } from "../Workspace"; 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)[fn]) { (Element.prototype as unknown as Record)[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( ); 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//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("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); }); });