Files
AgentHub/client/src/pages/__tests__/Workspace.test.tsx
T
nntrivi2001 b7efba8747 feat(workspace): the console tab bar is the session view's only header
The status/name/back-arrow row is gone; Tools sits at the tab bar's right
end and the pipeline/feature pickers show only with a template.
LaneConsolePane is reduced to the terminal (no title, no Active runs, no
New run): RunHistory.tsx, api.run.history and the page's active-sessions
poll are removed.
2026-10-03 12:13:11 +07:00

701 lines
25 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act, 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;
};
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: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`,
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 [0, 1, 2, 3].find((s) => !busy.has(s)) ?? null;
},
}));
// An ended session's replay (its own suite): a stub that names its session.
vi.mock("../../components/lanes/SessionReplay", () => ({
SessionReplay: ({ sessionId }: { sessionId: string }) => (
<div data-testid="session-replay">{sessionId}</div>
),
}));
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: {
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<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 };
}),
},
},
// 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: [] };
}),
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));
});
}
/** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */
function Picker() {
const [params, setParams] = useSearchParams();
return (
<>
<span data-testid="lane-param">{params.get("lane")}</span>
{[1, 2].map((id) => (
<button
key={id}
data-testid={`pick-${id}`}
onClick={() => setParams({ lane: String(id) })}
/>
))}
</>
);
}
async function renderWorkspace() {
const utils = render(
<MemoryRouter initialEntries={["/run"]}>
<Picker />
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await settle();
return utils;
}
beforeEach(() => {
recordedCalls.length = 0;
// The selected workspace is remembered in localStorage; each test starts on the first.
window.localStorage.clear();
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("has no lane strip: it opens the first workspace and names it in the URL", async () => {
await renderWorkspace();
expect(screen.queryByTestId("lane-strip")).toBeNull();
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
expect(screen.getByTestId("lane-param").textContent).toBe("1");
});
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();
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
// The sidebar names the workspace in the URL; the page follows it.
await user.click(screen.getByTestId("pick-2"));
await settle();
// Only the selected lane gets a full card.
expect(screen.getByTestId("lane-card-2")).toBeInTheDocument();
expect(screen.queryByTestId("lane-card-1")).toBeNull();
expect(screen.queryByTestId("lane-card-1")).toBeNull();
});
});
describe("Workspace layout", () => {
it("opens the add-workspace modal from the empty banner when there is none", async () => {
const user = userEvent.setup();
lanesToReturn = [];
await renderWorkspace();
await user.click(screen.getByTestId("lane-empty"));
expect(
await screen.findByText("Create a workspace from a working directory")
).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);
});
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);
});
// A SECOND render site for the console is what let `currentLane` flipping
// (lanes arriving on mount, the selected lane removed, a refetch re-picking
// the selection) unmount the terminal and wipe a half-typed prompt. It must
// sit inside `lane-detail` in BOTH states, so React never has to move it.
it("keeps the console at one render site whether or not a lane is selected", async () => {
lanesToReturn = [];
countsToReturn = { total: 0, running: 0, needs_you: 0, dead: 0 };
await renderWorkspace();
const detail = screen.getByTestId("lane-detail");
expect(detail.contains(screen.getByTestId("console-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("console tabs", () => {
const live = (slot: number, tool = "claude") => ({
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
laneId: 1,
slot,
status: "running",
tool,
model: null,
promptPreview: null,
});
const original = vi.mocked(api.run.list).getMockImplementation();
afterEach(() => {
vi.mocked(api.run.list).mockImplementation(original!);
});
const withRuns = (items: ReturnType<typeof live>[]) =>
vi.mocked(api.run.list).mockResolvedValue({ runs: [], items } as never);
const open = async (url: string) => {
render(
<MemoryRouter initialEntries={[url]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
};
it("shows a tab per live session and keeps every tab's pane mounted, showing only the active one", async () => {
withRuns([live(0), live(2, "shell")]);
await open("/workspace?lane=1&run=agenthub-lane-1-2");
expect(screen.getByTestId("console-tab-0")).toHaveAttribute("aria-selected", "false");
expect(screen.getByTestId("console-tab-2")).toHaveAttribute("aria-selected", "true");
expect(screen.getByTestId("console-tab-2")).toHaveTextContent("Terminal");
const bodies = screen.getAllByTestId("console-body");
expect(bodies).toHaveLength(2);
expect(bodies.filter((b) => b.closest(".hidden"))).toHaveLength(1);
});
it("opens a new terminal in the first free slot from the + menu", async () => {
withRuns([live(0)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
const user = userEvent.setup();
await user.click(screen.getByTestId("console-tab-new"));
await user.click(within(screen.getByTestId("console-tab-menu")).getByText("New terminal"));
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 1, shell: true });
});
it("disables + once all four slots are taken", async () => {
withRuns([live(0), live(1), live(2), live(3)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
expect(screen.getByTestId("console-tab-new")).toBeDisabled();
});
it("closing the active tab stops its session and opens the next one", async () => {
withRuns([live(0), live(1)]);
await open("/workspace?lane=1&run=agenthub-lane-1");
await userEvent.setup().click(screen.getByTestId("console-tab-close-0"));
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1");
await waitFor(() =>
expect(screen.getByTestId("console-tab-1")).toHaveAttribute("aria-selected", "true")
);
});
});
describe("Workspace — home and session view", () => {
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
await renderWorkspace();
expect(screen.getByTestId("workspace-home")).toBeInTheDocument();
expect(screen.getByTestId("lane-detail").parentElement).toHaveClass("hidden");
});
it("opens the session view for ?run=, headed by the tab bar alone", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByTestId("workspace-home")).toBeNull();
expect(screen.getByTestId("lane-detail").parentElement).not.toHaveClass("hidden");
// No header row (status, back arrow, name) and no pane header: the tab bar is it.
expect(screen.queryByTestId("workspace-back-home")).toBeNull();
expect(screen.queryByText(/^Run /)).toBeNull();
expect(screen.getByTestId("console-tab-new")).toBeInTheDocument();
});
it("opens an ended session's replay for ?session=, hiding (not unmounting) the console", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "1");
render(
<MemoryRouter initialEntries={["/workspace?lane=1&session=s-ended"]}>
<Workspace />
<Routes>
<Route path="/sessions/:id" element={<div data-testid="analyst-page" />} />
<Route path="*" element={null} />
</Routes>
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByTestId("workspace-home")).toBeNull();
expect(screen.getByTestId("session-replay")).toHaveTextContent("s-ended");
expect(screen.getByTestId("console-body").closest(".hidden")).not.toBeNull();
// Analyst, beside Files and Changes, opens the session's analysis page.
await userEvent.setup().click(screen.getByTestId("panel-analyst"));
expect(await screen.findByTestId("analyst-page")).toBeInTheDocument();
});
it("has no Analyst target on a pane with no session yet", async () => {
window.localStorage.setItem("agenthub.workspace.changesOpen", "1");
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.getByTestId("panel-analyst")).toBeDisabled();
});
it("has no run form in a pane: an empty pane's New session opens the home", async () => {
// Nothing live in slot 0: its pane is empty.
vi.mocked(api.run.get).mockRejectedValueOnce(new Error("gone"));
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
expect(screen.queryByPlaceholderText(/absolute path/)).toBeNull();
const empty = screen.getByTestId("pane-no-run");
await userEvent.setup().click(within(empty).getByRole("button", { name: "New session" }));
expect(await screen.findByTestId("workspace-home")).toBeInTheDocument();
expect(recordedCalls.some((c) => c.path.includes("/start"))).toBe(false);
});
it("points the Changes panel at the opened run's worktree session", async () => {
localStorage.setItem("agenthub.workspace.changesOpen", "1");
const original = vi.mocked(api.run.list).getMockImplementation();
vi.mocked(api.run.list).mockResolvedValue({
runs: [],
items: [
{
id: "agenthub-lane-1-1",
laneId: 1,
slot: 1,
status: "running",
sessionId: "s-wt",
worktree: true,
},
],
} as never);
render(
<MemoryRouter initialEntries={["/workspace?lane=1&run=agenthub-lane-1-1"]}>
<Workspace />
</MemoryRouter>
);
await settle();
await settle();
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, session: "s-wt" }));
vi.mocked(api.run.list).mockImplementation(original!);
localStorage.removeItem("agenthub.workspace.changesOpen");
});
});