/** * @file Workspace.test.tsx * @description Workspace page integration tests covering the lane-based run flow. * Verifies: lanes API contract, the home vs session view, the console tabs, and * that a pane has no run form of its own (a new session starts from the home). * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach, afterEach, onTestFinished } from "vitest"; import { render, act, cleanup, createEvent, fireEvent, screen, waitFor, within, } from "@testing-library/react"; import { MemoryRouter, Route, Routes, 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; tool?: string; }; 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; // The workspace's home (its own suite) fetches sources and memory; these // tests are about the session view it hides, so it is a stub here. vi.mock("../../components/lanes/WorkspaceHome", () => ({ WorkspaceHome: () =>
, })); // Hoisted with the mock: a workspace holds ten sessions (MAX_PANE_SLOT 9). const SLOTS = vi.hoisted(() => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); vi.mock("../../components/lanes/startSession", () => ({ paneRunId: (id: number, slot: number) => slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`, laneOfRun: (runId: string) => { const m = /^agenthub-lane-(\d+)(?:-[1-9])?$/.exec(runId); return m ? Number(m[1]) : null; }, freeSlot: (laneId: number, runs: Array<{ laneId: number; slot?: number; status: string }>) => { const busy = new Set( runs.filter((r) => r.laneId === laneId && r.status === "running").map((r) => r.slot ?? 0) ); return SLOTS.find((s) => !busy.has(s)) ?? null; }, // The server picks the slot (`slot: "auto"`); stand in for it with the first // one the mocked run list leaves free. startInFreeTab: async (laneId: number, body: Record) => { const { api } = await import("../../lib/api"); const { items } = await api.run.list(); const busy = new Set( (items as Array<{ laneId: number; slot?: number; status: string }>) .filter((r) => r.laneId === laneId && r.status === "running") .map((r) => r.slot ?? 0) ); const slot = SLOTS.find((x) => !busy.has(x)) ?? 0; await api.lanes.action(laneId, "start", { ...body, slot: "auto" }); return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`; }, })); // Closing an AI tab asks first (the themed confirm needs App's host): yes. vi.mock("../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true), confirmDiscard: vi.fn(async () => true), })); // An ended session's replay (its own suite): a stub that names its session. vi.mock("../SettingsHub", () => ({ SettingsHub: ({ section }: { section?: string }) => (
{section}
), })); vi.mock("../../components/lanes/SessionReplay", () => ({ SessionReplay: ({ sessionId }: { sessionId: string }) => (
{sessionId}
), })); 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: { // The Changes panel's folder picker reads the workspace's extra folders. sources: vi.fn().mockResolvedValue({ sources: [] }), 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 proxy picker. Only the built-in profile, which is the // shape that renders no picker — nothing here asserts on it. aiProxies: { list: vi.fn().mockImplementation(async () => { recordCall("GET", "/api/ai-proxies"); return { items: [] }; }), }, run: { list: vi.fn().mockImplementation(async () => { recordCall("GET", "/api/run/list"); return { runs: [], 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 }), get: vi.fn().mockImplementation(async (id: string) => ({ session: { id, cwd: "/workspace/a", tool: "claude" }, })), transcript: vi.fn().mockResolvedValue({ messages: [] }), }, }, }; }); vi.mock("../../lib/eventBus", () => ({ eventBus: { subscribe: () => () => {}, publish: () => {}, onConnection: () => () => {}, connected: true, setConnected: () => {}, }, })); vi.mock("../../components/run/PaneChat", () => ({ PaneChat: () =>
, })); vi.mock("../../components/run/TerminalView", () => ({ // `exit-` stands in for the server's exit frame. TerminalView: ({ runId, onExit, autoFocus, }: { runId: string; onExit?: () => void; autoFocus?: boolean; }) => (
), })); import { Workspace } from "../Workspace"; import { api } from "../../lib/api"; import { setSessionChat } from "../../lib/chatUi"; 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) => (