1b561c22d9
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
666 lines
27 KiB
TypeScript
666 lines
27 KiB
TypeScript
/**
|
||
* @file Regression test for the lane card's status badge. Every lane status
|
||
* the server can set must have a real translated word behind
|
||
* `t("status." + lane.status)`; before this test existed, no locale defined
|
||
* any `status.*` key, and i18next's default missing-key behavior (return the
|
||
* key itself) hid that from the `||` fallback, so the badge showed literal
|
||
* text like `status.active`.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
|
||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||
import { api } from "../../../lib/api";
|
||
|
||
vi.mock("../../../lib/api", () => ({
|
||
api: {
|
||
lanes: {
|
||
git: vi.fn(),
|
||
runtime: vi.fn(),
|
||
up: vi.fn(),
|
||
down: vi.fn(),
|
||
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||
agentsInstall: vi.fn(),
|
||
mcpSync: vi.fn(),
|
||
syncBaseCheck: vi.fn(),
|
||
routerApply: vi.fn(),
|
||
},
|
||
locks: {
|
||
list: vi.fn(),
|
||
},
|
||
aiRouters: {
|
||
list: vi.fn(),
|
||
},
|
||
run: {
|
||
kill: vi.fn(),
|
||
start: vi.fn(),
|
||
},
|
||
},
|
||
}));
|
||
|
||
beforeEach(() => {
|
||
vi.mocked(api.lanes.git).mockReset();
|
||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||
// A lane with no profile is the default fixture: most lanes never run a
|
||
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
||
vi.mocked(api.lanes.runtime).mockReset();
|
||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||
// Only the built-in profile by default: a user who never opened the AI Router
|
||
// tab has nothing to switch between, so the row stays out of every other test.
|
||
vi.mocked(api.aiRouters.list).mockReset();
|
||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
|
||
});
|
||
vi.mocked(api.locks.list).mockReset();
|
||
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||
|
||
function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
||
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
||
// base object below still lists all of them, so the cast asserts what the
|
||
// literal already proves.
|
||
return {
|
||
id: 1,
|
||
title: "demo",
|
||
cwd: "/work/demo",
|
||
branch: "lane/demo",
|
||
kind: "adopted",
|
||
tool: "claude",
|
||
source_repo: null,
|
||
pipeline: "default",
|
||
session_id: null,
|
||
run_id: null,
|
||
stage: "plan",
|
||
stage_since: null,
|
||
status: "idle",
|
||
gate_decision: null,
|
||
ci_status: null,
|
||
needs_action: null,
|
||
links: {},
|
||
stages: {},
|
||
notes: null,
|
||
pipeline_name: "Default",
|
||
pipeline_nodes: [],
|
||
progress: 0,
|
||
stage_seconds: null,
|
||
last_event_seconds: null,
|
||
liveness: "idle",
|
||
detected_stage: null,
|
||
detected_signal: null,
|
||
slot: null,
|
||
ports: {},
|
||
...overrides,
|
||
} as Lane;
|
||
}
|
||
|
||
describe("LaneCard lane tool badge", () => {
|
||
it("badges a codex lane and leaves a claude lane unbadged", () => {
|
||
const { rerender } = render(
|
||
<LaneCard {...{ lane: makeLane({ tool: "codex" }), onAction: vi.fn() }} />
|
||
);
|
||
expect(screen.getByText("Codex")).toBeInTheDocument();
|
||
rerender(<LaneCard {...{ lane: makeLane({ tool: "claude" }), onAction: vi.fn() }} />);
|
||
expect(screen.queryByText("Codex")).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe("LaneCard child worktrees", () => {
|
||
it("lists worktrees provisioned from this lane and jumps to one on click", async () => {
|
||
const onSelectLane = vi.fn();
|
||
const worktree = makeLane({ id: 8, title: "Worktree A", status: "running" });
|
||
render(
|
||
<LaneCard
|
||
lane={makeLane({ id: 1 })}
|
||
onAction={vi.fn()}
|
||
childWorktrees={[worktree]}
|
||
onSelectLane={onSelectLane}
|
||
/>
|
||
);
|
||
|
||
expect(screen.getByTestId("lane-child-worktrees")).toBeInTheDocument();
|
||
const user = userEvent.setup();
|
||
await user.click(screen.getByRole("button", { name: /Worktree A/ }));
|
||
expect(onSelectLane).toHaveBeenCalledWith(8);
|
||
});
|
||
|
||
it("renders nothing when there are no child worktrees", () => {
|
||
render(<LaneCard lane={makeLane({ id: 1 })} onAction={vi.fn()} />);
|
||
expect(screen.queryByTestId("lane-child-worktrees")).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
const pipelineNodes: Lane["pipeline_nodes"] = [
|
||
{ id: "intake", label: "intake", icon: "📥", gate: false, state: "done" },
|
||
{ id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" },
|
||
{ id: "implement", label: "implement", icon: "🛠", gate: false, state: "current" },
|
||
{ id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending" },
|
||
];
|
||
|
||
describe("LaneCard rebuilt layout", () => {
|
||
const full = (over: Partial<Lane> = {}) =>
|
||
makeLane({
|
||
id: 7,
|
||
title: "Rename Metric to Rule",
|
||
kind: "managed",
|
||
status: "running",
|
||
liveness: "active",
|
||
stage: "plan",
|
||
stage_seconds: 152,
|
||
progress: 48,
|
||
ci_status: "green",
|
||
pipeline_nodes: pipelineNodes,
|
||
...over,
|
||
});
|
||
|
||
it("labels the card with the lane id", () => {
|
||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||
expect(screen.getByTestId("lane-card-7")).toBeInTheDocument();
|
||
});
|
||
|
||
// The pipeline map below this card draws the same progress as named nodes,
|
||
// so the card no longer repeats it as a bar and a percent.
|
||
it("leaves progress to the pipeline map rather than drawing its own bar", () => {
|
||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||
expect(screen.queryByTestId("lane-progress-fill")).toBeNull();
|
||
expect(screen.queryByText("48%")).toBeNull();
|
||
});
|
||
|
||
it("surfaces a needs-you message", () => {
|
||
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
|
||
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
|
||
});
|
||
|
||
it("keeps reset behind the ⋯ menu rather than showing it directly", async () => {
|
||
render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
||
|
||
await userEvent.setup().click(screen.getByTestId("lane-more"));
|
||
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
||
});
|
||
|
||
it("routes reset through the confirmation modal rather than firing it", async () => {
|
||
const onAction = vi.fn();
|
||
render(<LaneCard lane={full()} onAction={onAction} />);
|
||
const user = userEvent.setup();
|
||
await user.click(screen.getByTestId("lane-more"));
|
||
await user.click(screen.getByTestId("lane-action-reset"));
|
||
expect(onAction).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("offers the ⋯ menu only for a managed lane", async () => {
|
||
const user = userEvent.setup();
|
||
const { unmount } = render(<LaneCard lane={full()} onAction={vi.fn()} />);
|
||
await user.click(screen.getByTestId("lane-more"));
|
||
expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument();
|
||
unmount();
|
||
|
||
// An adopted lane has no worktree to reset, so there is no ⋯ to open.
|
||
render(<LaneCard lane={full({ kind: "adopted" })} onAction={vi.fn()} />);
|
||
expect(screen.queryByTestId("lane-more")).toBeNull();
|
||
expect(screen.queryByTestId("lane-action-reset")).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("LaneCard git block", () => {
|
||
const facts = {
|
||
available: true as const,
|
||
branch: "feat/rename-metric",
|
||
head: "9b3e74a",
|
||
subject: "free-text rule mode in the form",
|
||
dirty: 2,
|
||
untracked: 1,
|
||
files: [
|
||
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
|
||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||
],
|
||
// Tracking facts ride along on the same endpoint; a branch that has never
|
||
// been pushed reports nulls rather than zeros.
|
||
hasRemote: true,
|
||
upstream: null,
|
||
ahead: null,
|
||
behind: null,
|
||
};
|
||
|
||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||
render(<LaneCard lane={makeLane({ branch: "stale/recorded" })} onAction={vi.fn()} />);
|
||
|
||
const block = await screen.findByTestId("lane-git");
|
||
expect(block.textContent).toContain("feat/rename-metric");
|
||
expect(block.textContent).toContain("9b3e74a");
|
||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||
expect(block.textContent).toContain("2");
|
||
// The live branch replaces the lane's recorded one rather than doubling it.
|
||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||
});
|
||
|
||
it("names the files behind the counts on hover, with each one's line delta", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||
|
||
const counts = await screen.findByTestId("lane-git-counts");
|
||
// Hidden until asked for: the row is a one-line summary until you hover.
|
||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||
|
||
fireEvent.mouseEnter(counts);
|
||
const panel = screen.getByTestId("lane-git-files");
|
||
expect(panel.textContent).toContain("alerts.js");
|
||
expect(panel.textContent).toContain("+12");
|
||
expect(panel.textContent).toContain("−3");
|
||
// Totals across the listed files.
|
||
expect(panel.textContent).toContain("+13");
|
||
// An untracked file has no delta to report and says so rather than "+0".
|
||
expect(panel.textContent).toContain("new");
|
||
expect(panel.textContent).toContain("3 files changed");
|
||
|
||
// Closing is delayed so the pointer can travel into the panel and scroll
|
||
// it — the panel is portalled to the body, away from its trigger.
|
||
fireEvent.mouseLeave(counts);
|
||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||
});
|
||
|
||
it("stays open while the pointer is inside the panel", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||
|
||
const counts = await screen.findByTestId("lane-git-counts");
|
||
fireEvent.mouseEnter(counts);
|
||
fireEvent.mouseLeave(counts);
|
||
// The pointer landed on the panel before the pending close fired.
|
||
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
|
||
await new Promise((r) => setTimeout(r, 200));
|
||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||
|
||
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
|
||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||
});
|
||
|
||
it("says how many files the server-side cap left out", async () => {
|
||
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||
|
||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||
});
|
||
|
||
it("omits the uncommitted line when the tree is clean", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||
const block = await screen.findByTestId("lane-git");
|
||
expect(block.textContent).not.toContain("modified");
|
||
});
|
||
|
||
it("renders the card with no git block and no error when git is unavailable", async () => {
|
||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||
render(<LaneCard lane={makeLane({ id: 2 })} onAction={vi.fn()} />);
|
||
|
||
expect(await screen.findByTestId("lane-card-2")).toBeInTheDocument();
|
||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||
});
|
||
|
||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||
render(<LaneCard lane={makeLane({ id: 3 })} onAction={vi.fn()} />);
|
||
|
||
expect(await screen.findByTestId("lane-card-3")).toBeInTheDocument();
|
||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||
expect(screen.queryByText(/network down/)).toBeNull();
|
||
});
|
||
|
||
it("stops polling once the card unmounts", async () => {
|
||
vi.useFakeTimers();
|
||
try {
|
||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||
const { unmount } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||
unmount();
|
||
vi.advanceTimersByTime(120_000);
|
||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("LaneCard — the lane's own application stack", () => {
|
||
const upRuntime = {
|
||
available: true as const,
|
||
provisioned: true as const,
|
||
slot: 3,
|
||
kind: "managed" as const,
|
||
hooks: ["boot", "health"],
|
||
profileDir: "/work/demo/.agenthub/profile",
|
||
services: [{ name: "web", pid: 4242, alive: true }],
|
||
ports: { api: { port: 8003, expected: 8003, listening: true } },
|
||
steppedAside: false,
|
||
up: true,
|
||
healthy: true,
|
||
logs: ["boot.log"],
|
||
logDir: "/lanes/.state/lane3/logs",
|
||
lastError: null,
|
||
};
|
||
|
||
it("shows nothing at all for a lane whose repo declares no profile", async () => {
|
||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||
|
||
expect(await screen.findByTestId("lane-card-1")).toBeInTheDocument();
|
||
expect(screen.queryByTestId("lane-runtime-1")).toBeNull();
|
||
});
|
||
|
||
it("lists each declared port with its listening state", async () => {
|
||
vi.mocked(api.lanes.runtime).mockResolvedValue(upRuntime);
|
||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||
|
||
expect(await screen.findByTestId("lane-runtime-1")).toBeInTheDocument();
|
||
expect(screen.getByText(":8003")).toBeInTheDocument();
|
||
expect(screen.getByTestId("lane-runtime-state")).toHaveTextContent(/healthy/i);
|
||
});
|
||
|
||
it("flags a port that stepped aside from its base, showing the expected number", async () => {
|
||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||
...upRuntime,
|
||
steppedAside: true,
|
||
ports: { api: { port: 8103, expected: 8003, listening: true } },
|
||
});
|
||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||
|
||
expect(await screen.findByText(":8103")).toBeInTheDocument();
|
||
expect(screen.getByText(/8003/)).toBeInTheDocument();
|
||
});
|
||
|
||
it("surfaces the last boot error when nothing is streaming", async () => {
|
||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||
...upRuntime,
|
||
up: false,
|
||
healthy: false,
|
||
lastError: { at: "2026-08-03T00:00:00Z", code: "EUNHEALTHY", message: "health check failed" },
|
||
});
|
||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||
|
||
expect(await screen.findByText(/EUNHEALTHY/)).toBeInTheDocument();
|
||
expect(screen.getByText(/health check failed/)).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe("LaneCard — named locks", () => {
|
||
it("shows a lock badge when this lane holds a named lock", async () => {
|
||
vi.mocked(api.locks.list).mockResolvedValue({
|
||
locks: [{ name: "build", holder: "lane1", since: 0, ageSec: 120 }],
|
||
});
|
||
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
|
||
|
||
expect(await screen.findByTestId("lane-locks-1")).toBeInTheDocument();
|
||
expect(screen.getByText(/lock/i)).toBeInTheDocument();
|
||
});
|
||
|
||
it("shows no lock badge when locks belong to a different lane", async () => {
|
||
vi.mocked(api.locks.list).mockResolvedValue({
|
||
locks: [{ name: "build", holder: "lane99", since: 0, ageSec: 120 }],
|
||
});
|
||
render(<LaneCard lane={makeLane({ slot: 1 })} onAction={vi.fn()} />);
|
||
|
||
expect(screen.queryByTestId("lane-locks-1")).not.toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe("LaneToolsMenu", () => {
|
||
beforeEach(() => {
|
||
vi.mocked(api.lanes.runtime).mockResolvedValue({
|
||
available: true as const,
|
||
provisioned: true as const,
|
||
up: false,
|
||
slot: 1,
|
||
profileDir: "/work/demo/.agenthub/profile",
|
||
ports: {},
|
||
} as unknown as LaneRuntime);
|
||
});
|
||
|
||
it("clicking Install agents calls the API and shows the result", async () => {
|
||
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
|
||
installed: ["qc-local.md", "senior-gate-reviewer.md"],
|
||
});
|
||
render(<LaneToolsMenu lane={makeLane()} />);
|
||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||
await userEvent.click(await screen.findByTestId("lane-agents-install"));
|
||
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
|
||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
|
||
});
|
||
|
||
it("clicking Sync MCP calls the API and shows the result", async () => {
|
||
vi.mocked(api.lanes.mcpSync).mockResolvedValue({
|
||
servers: ["playwright"],
|
||
profilesSeeded: [],
|
||
});
|
||
render(<LaneToolsMenu lane={makeLane()} />);
|
||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||
await userEvent.click(await screen.findByTestId("lane-mcp-sync"));
|
||
await waitFor(() => expect(api.lanes.mcpSync).toHaveBeenCalledWith(1));
|
||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("playwright");
|
||
});
|
||
|
||
it("clicking Check dev sync reports a clean result", async () => {
|
||
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
|
||
code: 0,
|
||
devDelta: ["a.txt", "b.txt"],
|
||
overlap: [],
|
||
});
|
||
render(<LaneToolsMenu lane={makeLane()} />);
|
||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||
await userEvent.click(await screen.findByTestId("lane-sync-check"));
|
||
await waitFor(() => expect(api.lanes.syncBaseCheck).toHaveBeenCalledWith(1));
|
||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("2");
|
||
});
|
||
|
||
it("clicking Check dev sync reports a migration collision", async () => {
|
||
vi.mocked(api.lanes.syncBaseCheck).mockResolvedValue({
|
||
code: 5,
|
||
collisions: [{ file: "002_a.sql", collidesWith: "002_b.sql", suggestion: "003_a.sql" }],
|
||
});
|
||
render(<LaneToolsMenu lane={makeLane()} />);
|
||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||
await userEvent.click(await screen.findByTestId("lane-sync-check"));
|
||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("003_a.sql");
|
||
});
|
||
|
||
it("clicking Create/update readme opens the lane's terminal on the docs skill", async () => {
|
||
// Moved here from the Add-lane modal: a skill only runs inside a CLI
|
||
// session, and writing the docs is worth re-running long after creation.
|
||
vi.mocked(api.run.start).mockResolvedValue({} as never);
|
||
render(<LaneToolsMenu lane={makeLane({ cwd: "/work/demo" })} />);
|
||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||
await userEvent.click(await screen.findByTestId("lane-write-docs"));
|
||
await waitFor(() =>
|
||
expect(api.run.start).toHaveBeenCalledWith({
|
||
laneId: 1,
|
||
cwd: "/work/demo",
|
||
initialPrompt: "/sg-all:sg-md-writing",
|
||
})
|
||
);
|
||
});
|
||
|
||
it("hides Create/update readme on a codex lane, which has no slash commands", async () => {
|
||
render(<LaneToolsMenu lane={makeLane({ tool: "codex" })} />);
|
||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||
expect(await screen.findByTestId("lane-mcp-sync")).toBeInTheDocument();
|
||
expect(screen.queryByTestId("lane-write-docs")).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("renders nothing when the lane has no profile", async () => {
|
||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||
const { container } = render(<LaneToolsMenu lane={makeLane()} />);
|
||
await waitFor(() => expect(api.lanes.runtime).toHaveBeenCalled());
|
||
expect(container).toBeEmptyDOMElement();
|
||
});
|
||
});
|
||
|
||
describe("LaneRouterRow — the Model picker", () => {
|
||
const native = {
|
||
name: "native",
|
||
label: "Native (Claude / Codex)",
|
||
builtin: true,
|
||
baseUrl: "",
|
||
hasKey: false,
|
||
model: "claude-sonnet-5",
|
||
models: {
|
||
opus: "claude-opus-5",
|
||
sonnet: "claude-sonnet-5",
|
||
haiku: "claude-haiku-4-5-20251001",
|
||
smallFast: "claude-haiku-4-5-20251001",
|
||
subagent: "claude-haiku-4-5-20251001",
|
||
},
|
||
};
|
||
const nineRouter = { ...native, name: "9router", label: "9Router", builtin: false };
|
||
|
||
it("stays out of the card when native is the only profile", async () => {
|
||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native] });
|
||
render(<LaneRouterRow lane={makeLane()} />);
|
||
await waitFor(() => expect(api.aiRouters.list).toHaveBeenCalled());
|
||
expect(screen.queryByTestId("lane-router-1")).toBeNull();
|
||
});
|
||
|
||
it("shows the lane's current profile selected", async () => {
|
||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||
render(<LaneRouterRow lane={makeLane({ router_profile: "9router" })} />);
|
||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||
});
|
||
|
||
it("applies a switch through the router route, not a lane PATCH", async () => {
|
||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||
tool: "claude",
|
||
file: "/work/demo/.claude/settings.local.json",
|
||
profile: "9router",
|
||
restart_required: false,
|
||
run_id: null,
|
||
});
|
||
render(<LaneRouterRow lane={makeLane()} />);
|
||
|
||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||
"9router",
|
||
]);
|
||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
|
||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||
});
|
||
|
||
it("offers a restart when a live pane still holds the old router's models", async () => {
|
||
// Claude Code re-applies `env` to a running session but keeps the model it
|
||
// resolved at startup, so the switch is only half-applied until the pane is
|
||
// restarted — the server says so and the row must act on it.
|
||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||
tool: "claude",
|
||
file: "/work/demo/.claude/settings.local.json",
|
||
profile: "9router",
|
||
restart_required: true,
|
||
run_id: "agenthub-lane-1",
|
||
});
|
||
vi.mocked(api.run.kill).mockResolvedValue({ ok: true });
|
||
vi.mocked(api.run.start).mockResolvedValue({} as never);
|
||
render(<LaneRouterRow lane={makeLane()} />);
|
||
|
||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||
"9router",
|
||
]);
|
||
|
||
const restart = await screen.findByRole("button", { name: "Restart" });
|
||
await userEvent.click(restart);
|
||
|
||
await waitFor(() => expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1"));
|
||
// Started by lane id alone: replaying the old run's --model would name a
|
||
// model of the router the user just left.
|
||
expect(api.run.start).toHaveBeenCalledWith({ laneId: 1 });
|
||
});
|
||
|
||
it("leaves the restart button out when no pane is running", async () => {
|
||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||
tool: "claude",
|
||
file: "/work/demo/.claude/settings.local.json",
|
||
profile: "9router",
|
||
restart_required: false,
|
||
run_id: null,
|
||
});
|
||
render(<LaneRouterRow lane={makeLane()} />);
|
||
|
||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||
"9router",
|
||
]);
|
||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
|
||
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
|
||
});
|
||
|
||
it("does not carry a pick made on one lane back to the lane selected before it", async () => {
|
||
// One LaneRouterRow instance serves every lane (it lives in the Workspace's
|
||
// lane-detail header), so its selected value is component state that
|
||
// outlives a lane switch unless the sync effect puts the new lane's own
|
||
// profile back.
|
||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||
tool: "claude",
|
||
file: "/work/b/.claude/settings.local.json",
|
||
profile: "9router",
|
||
restart_required: false,
|
||
run_id: null,
|
||
});
|
||
const laneA = makeLane({ id: 1, router_profile: null });
|
||
const laneB = makeLane({ id: 2, router_profile: null });
|
||
const { rerender } = render(<LaneRouterRow lane={laneA} />);
|
||
await screen.findByDisplayValue("Native (Claude / Codex)");
|
||
|
||
rerender(<LaneRouterRow lane={laneB} />);
|
||
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
|
||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||
|
||
rerender(<LaneRouterRow lane={laneA} />);
|
||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||
});
|
||
|
||
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
|
||
// The row is one instance shared by every lane, so an apply still in flight
|
||
// when the user moves on would paint its rollback and its error on whoever
|
||
// is on screen by then — lane B's failure changing lane A's picker.
|
||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||
let reject: (e: Error) => void = () => {};
|
||
vi.mocked(api.lanes.routerApply).mockImplementation(
|
||
() => new Promise((_, r) => (reject = r)) as ReturnType<typeof api.lanes.routerApply>
|
||
);
|
||
const laneA = makeLane({ id: 1, router_profile: null });
|
||
const laneB = makeLane({ id: 2, router_profile: null });
|
||
|
||
const { rerender } = render(<LaneRouterRow lane={laneB} />);
|
||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||
"9router",
|
||
]);
|
||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||
|
||
rerender(<LaneRouterRow lane={laneA} />);
|
||
await act(async () => {
|
||
reject(new Error("EBADJSON"));
|
||
await Promise.resolve();
|
||
});
|
||
|
||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||
expect(screen.queryByTitle("EBADJSON")).toBeNull();
|
||
// And the picker must not be left locked by a request for another lane.
|
||
expect(screen.getByRole("combobox")).not.toBeDisabled();
|
||
});
|
||
|
||
it("puts the selection back when the apply fails", async () => {
|
||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
|
||
render(<LaneRouterRow lane={makeLane()} />);
|
||
|
||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||
"9router",
|
||
]);
|
||
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
|
||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||
});
|
||
});
|