4d9a385c5e
Client-side rendering for the per-lane runtime facts (slot, ports, database, Redis index, service liveness) added in the server-side A1/A2 work, plus the doc updates (README, CLAUDE.md, docs/API.md, client/server READMEs) describing the new profile.env keys, hook environment contract, and REST endpoints.
535 lines
18 KiB
TypeScript
535 lines
18 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 } 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<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 };
|
|
}),
|
|
},
|
|
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<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("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);
|
|
});
|
|
});
|