6f22aed47c
- Extract ConsoleArea helper to eliminate ~60 lines of duplicated layout toggle + grid rendering shared between currentLane and !currentLane branches - Fix persistence test to validate the component's actual write path instead of manually re-seeding localStorage (proves writeSplitViewState is called) - Remove orphaned grid/pane code left by incomplete merge
691 lines
24 KiB
TypeScript
691 lines
24 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, fireEvent } 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;
|
|
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<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 };
|
|
}),
|
|
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 };
|
|
}),
|
|
},
|
|
},
|
|
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 }) => (
|
|
<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));
|
|
});
|
|
}
|
|
|
|
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("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);
|
|
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);
|
|
});
|
|
});
|
|
|
|
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("split terminal view", () => {
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
});
|
|
|
|
it("defaults to a single pane with no layout toggle pressed state implying 2 or 4", async () => {
|
|
await renderWorkspace();
|
|
expect(screen.getAllByTestId("console-body")).toHaveLength(1);
|
|
expect(screen.queryAllByTestId("pane-lane-select")).toHaveLength(0);
|
|
});
|
|
|
|
it("switching to 2-pane layout renders two independent panes with lane pickers", async () => {
|
|
await renderWorkspace();
|
|
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
|
|
await settle();
|
|
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(2);
|
|
expect(screen.getAllByTestId("pane-lane-select")).toHaveLength(2);
|
|
});
|
|
|
|
it("switching to 4-pane layout renders four panes", async () => {
|
|
await renderWorkspace();
|
|
fireEvent.click(screen.getByRole("button", { name: /4.*pane/i }));
|
|
await settle();
|
|
expect(screen.getAllByTestId(/console-body|pane-empty/)).toHaveLength(4);
|
|
});
|
|
|
|
it("persists the layout and pane selections to localStorage across remounts", async () => {
|
|
const { unmount } = await renderWorkspace();
|
|
fireEvent.click(screen.getByRole("button", { name: /2.*pane/i }));
|
|
await settle();
|
|
const selects = screen.getAllByTestId("pane-lane-select");
|
|
const select = selects[1];
|
|
expect(select).toBeDefined();
|
|
fireEvent.change(select!, { target: { value: String(lanesToReturn[1]!.id) } });
|
|
await settle();
|
|
unmount();
|
|
|
|
await renderWorkspace();
|
|
const persistedSelects = screen.getAllByTestId("pane-lane-select");
|
|
expect(persistedSelects).toHaveLength(2);
|
|
expect((persistedSelects[1] as HTMLSelectElement).value).toBe(String(lanesToReturn[1]!.id));
|
|
});
|
|
|
|
it("falls back to unselected when a persisted lane id no longer exists", async () => {
|
|
localStorage.setItem(
|
|
"ccam.workspace.splitView",
|
|
JSON.stringify({ layout: 2, paneLaneIds: [9999, null] })
|
|
);
|
|
await renderWorkspace();
|
|
// Lane 9999 doesn't exist, so it falls back to null (unselected).
|
|
// The second pane is already null. Both render as pane-empty.
|
|
expect(screen.getAllByTestId("pane-empty")).toHaveLength(2);
|
|
});
|
|
});
|