/** * @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, fireEvent } from "@testing-library/react"; import { MemoryRouter, useSearchParams } 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>(); const recordCall = (method: string, path: string) => { recordedCalls.push({ method, path }); }; return { ...actual, api: { aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` }, 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 .agenthub/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) => { 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 }; }), }, }, // The lane card's router picker. Only the built-in profile, which is the // shape that renders no picker — nothing here asserts on it. aiRouters: { list: vi.fn().mockImplementation(async () => { recordCall("GET", "/api/ai-routers"); return { items: [] }; }), }, 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 }) => (
), })); 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)[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)); }); } /** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */ function Picker() { const [params, setParams] = useSearchParams(); return ( <> {params.get("lane")} {[1, 2].map((id) => (