Files
AgentHub/client/src/components/lanes/__tests__/AddLaneModal.test.tsx
T
nntrivi2001 1b561c22d9 feat(lanes,ui): per-lane git surface — Changes panel, VS Code-style diff, branch-aware Add lane, redesigned tooltips (#34, #35)
- 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.
2026-09-23 09:18:17 +07:00

780 lines
30 KiB
TypeScript

/**
* @file AddLaneModal.test.tsx
* @description Pins the "+ Add lane" flow's two modes: "Worktree" (default -
* pick a source repo, fork a branch, type a new branch name, submit through
* the provisioning endpoint) and "Repo" (adopt a directory as-is through
* `ensure`, no branch fields). Also covers the branch-lookup debounce, the
* unresolvable-path degrade, server-error handling, the auto-setup summary,
* the provisioning-wait race, and the folder-browse modal.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AddLaneModal } from "../AddLaneModal";
import { api } from "../../../lib/api";
import type { CwdSuggestion } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
vi.mock("../../../lib/api", () => ({
api: {
lanes: {
branches: vi.fn(),
pipelines: vi.fn(),
worktree: vi.fn(),
ensure: vi.fn(),
browse: vi.fn(),
get: vi.fn(),
profileInit: vi.fn(),
agentsInstall: vi.fn(),
mcpSync: vi.fn(),
routerApply: vi.fn(),
},
aiRouters: { list: vi.fn() },
run: { start: vi.fn() },
},
}));
function laneFixture(over: 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: 9,
title: "",
cwd: "/lanes/repo__feature",
branch: "feat/feature",
kind: "managed",
source_repo: null,
pipeline: "default",
session_id: null,
run_id: null,
stage: "idle",
stage_since: null,
status: "provisioning",
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: {},
...over,
} as Lane;
}
const SUGGESTIONS: CwdSuggestion[] = [
{ kind: "home", path: "/Users/tester", label: "Home" },
{ kind: "recent", path: "/Users/tester/projects/repo", label: "repo" },
];
function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
return render(
<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={SUGGESTIONS} {...over} />
);
}
/** ConfirmModal focuses its Cancel button on a 0ms timer after mount, which
* races userEvent.type() and can eat the first keystroke. Let that timer fire,
* then click the field to reclaim focus before typing. */
async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElement) {
await new Promise((r) => setTimeout(r, 0));
await user.click(el);
}
/** Fills the default "Worktree" mode's form up through a resolved branch
* list, title, and new-branch name - everything Add lane needs to enable. */
async function fillWorktreeForm(
user: ReturnType<typeof userEvent.setup>,
{ repo = "/Users/tester/projects/repo", title = "demo", branch = "feat/demo" } = {}
) {
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, repo);
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), title);
await user.type(screen.getByLabelText("New branch name"), branch);
}
beforeEach(() => {
vi.mocked(api.lanes.branches).mockReset();
vi.mocked(api.lanes.worktree).mockReset();
vi.mocked(api.lanes.ensure).mockReset();
vi.mocked(api.lanes.browse).mockReset();
// Provisioning finishes instantly by default - tests that care about the
// provisioning-in-progress race override this per-test.
vi.mocked(api.lanes.get)
.mockReset()
.mockResolvedValue({ lane: laneFixture({ status: "idle" }) });
vi.mocked(api.lanes.profileInit)
.mockReset()
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
vi.mocked(api.lanes.routerApply).mockReset().mockResolvedValue({
tool: "claude",
file: null,
restart_required: false,
run_id: null,
profile: "native",
});
// Only the built-in profile by default, so the router radio stays on Native
// and no test has to opt out of it.
vi.mocked(api.aiRouters.list)
.mockReset()
.mockResolvedValue({ items: [nativeRouter()] });
});
/** The built-in profile, exactly as `GET /api/ai-routers` reports it. */
function nativeRouter() {
return {
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",
},
};
}
/** A saved router profile, for the tests that need the radio to have a target. */
function savedRouter(over: Record<string, unknown> = {}) {
return {
...nativeRouter(),
name: "9router",
label: "9Router",
builtin: false,
hasKey: true,
baseUrl: "http://localhost:20128/v1",
model: "Highest",
...over,
};
}
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
const withWorktrees = {
branches: ["main", "feat/other", "feat/held"],
current: "main",
worktrees: [
{ path: "/Users/tester/projects/repo", branch: "main" },
{ path: "/lanes/repo__held", branch: "feat/held" },
],
};
it("lists the repo's existing worktrees once the path resolves", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
const panel = await screen.findByTestId("add-lane-worktrees");
expect(panel).toHaveTextContent("/lanes/repo__held");
expect(panel).toHaveTextContent("feat/held");
});
it("checks an existing branch out, sending no base", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "Reuse a branch");
await user.click(screen.getByRole("button", { name: "Existing branch" }));
const select = await screen.findByLabelText("Branch to check out");
await user.selectOptions(select, "feat/other");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
expect.objectContaining({ branch: "feat/other", base: undefined })
)
);
});
it("greys out a branch another worktree already holds", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.click(screen.getByRole("button", { name: "Existing branch" }));
const held = await screen.findByRole("option", { name: /feat\/held/ });
expect(held).toBeDisabled();
expect(held).toHaveTextContent("/lanes/repo__held");
expect(screen.getByRole("option", { name: "feat/other" })).toBeEnabled();
});
it("lets the server name a new branch when the field is left empty", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByLabelText("Branch to fork from");
await user.type(screen.getByLabelText("Title"), "Auto named");
// The placeholder previews the name the server will pick.
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
"placeholder",
"feat/auto-named"
);
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.worktree).toHaveBeenCalledWith(
expect.objectContaining({ branch: undefined, base: "main" })
)
);
});
});
describe("AddLaneModal — worktree mode (default)", () => {
// The branch NAME is no longer part of this list: an empty one means "let
// the server name it" (issue #34).
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
renderModal();
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
});
it("looks up branches once the repo path settles, and shows them as a picker", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main", "feat/other"],
current: "main",
worktrees: [],
});
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await waitFor(() =>
expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo")
);
const base = await screen.findByLabelText("Branch to fork from");
expect(base).toHaveValue("main"); // the repo's current branch is preselected
expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument();
});
it("stays disabled and shows a quiet hint when the path is not a resolvable repo", async () => {
vi.mocked(api.lanes.branches).mockRejectedValue(new Error("EBADSOURCEREPO"));
renderModal();
const user = userEvent.setup();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/not/a/repo");
await waitFor(() => expect(api.lanes.branches).toHaveBeenCalled());
expect(await screen.findByText(/Not a git repository/)).toBeInTheDocument();
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
});
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onClose, onAdded });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => {
expect(api.lanes.worktree).toHaveBeenCalledWith({
sourceRepo: "/Users/tester/projects/repo",
title: "New feature",
base: "main",
branch: "feat/new-feature",
tool: "claude",
});
});
expect(onAdded).toHaveBeenCalledWith(
expect.objectContaining({ id: 9, status: "provisioning" })
);
// Setup came back clean, so there is nothing to report and the dialog is
// done. Leaving it up read as "still working" — its confirm button is dead
// once the lane exists — which is how it looked stuck.
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("shows a server error and leaves the modal open instead of closing silently", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "New feature" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
it("renders nothing when closed", () => {
renderModal({ open: false });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
});
const onAdded = vi.fn();
renderModal({ onAdded });
const user = userEvent.setup();
await fillWorktreeForm(user);
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42));
expect(api.lanes.agentsInstall).toHaveBeenCalledWith(42);
expect(api.lanes.mcpSync).toHaveBeenCalledWith(42);
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("still calls onAdded even when every setup call fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
});
vi.mocked(api.lanes.profileInit).mockRejectedValue(new Error("boom"));
vi.mocked(api.lanes.agentsInstall).mockRejectedValue(new Error("boom"));
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo2" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(onClose).not.toHaveBeenCalled();
});
it("keeps the summary up when a setup step FAILED, and closes on the button that replaces Add lane", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane,
});
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo3" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
// A failure is the one case worth reading before the dialog goes away.
expect(await screen.findByText("Setup")).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
// And then the primary button must close, not sit there disabled: the lane
// already exists, so "Add lane" has nothing left to do.
expect(screen.queryByRole("button", { name: "Add lane" })).toBeNull();
await user.click(screen.getByRole("button", { name: "Close" }));
expect(onClose).toHaveBeenCalled();
});
it("waits for the background worktree provisioning to finish before running setup, and skips setup if it fails", async () => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["main"],
current: "main",
worktrees: [],
});
vi.mocked(api.lanes.worktree).mockResolvedValue({
lane: { id: 45, title: "demo4", cwd: "/lanes/demo4", status: "provisioning" } as Lane,
});
// First poll still provisioning, second poll reports the background
// `git worktree add` failed.
vi.mocked(api.lanes.get)
.mockReset()
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "provisioning" }) })
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "failed" }) });
const onAdded = vi.fn();
renderModal({ onAdded });
const user = userEvent.setup();
await fillWorktreeForm(user, { title: "demo4" });
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
expect(api.lanes.profileInit).not.toHaveBeenCalled();
expect(api.lanes.agentsInstall).not.toHaveBeenCalled();
expect(api.lanes.mcpSync).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — repo mode (adopt)", () => {
it("hides the branch fields and enables confirm on a path alone", async () => {
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
expect(screen.queryByLabelText("Branch to fork from")).toBeNull();
expect(screen.queryByLabelText("New branch name")).toBeNull();
expect(api.lanes.branches).not.toHaveBeenCalled();
});
it("submits through ensure, not worktree, and closes immediately", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
created: true,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/Users/tester/projects/repo");
await user.type(screen.getByLabelText("Title"), "main repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith({
cwd: "/Users/tester/projects/repo",
title: "main repo",
tool: "claude",
})
);
expect(api.lanes.worktree).not.toHaveBeenCalled();
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 23 })));
// Repo mode never runs the profile/agents/mcp setup summary - it should
// close right away like the old adopt flow did.
expect(onClose).toHaveBeenCalled();
});
});
describe("AddLaneModal — folder browse", () => {
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: "/Users",
entries: [{ name: "projects", path: "/Users/tester/projects", isGitRepo: false }],
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
expect(await screen.findByText("projects")).toBeInTheDocument();
await user.click(screen.getByText("projects"));
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
});
it("Escape closes only the folder browser, not the whole modal", async () => {
vi.mocked(api.lanes.browse).mockResolvedValue({
path: "/Users/tester",
parent: null,
entries: [],
});
const onClose = vi.fn();
renderModal({ onClose });
const user = userEvent.setup();
await user.click(screen.getByTitle("Browse for a folder"));
await screen.findByRole("dialog", { name: "Browse for a folder" });
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "Browse for a folder" })).toBeNull();
expect(
screen.getByRole("dialog", { name: "Create a lane from a working directory" })
).toBeInTheDocument();
expect(onClose).not.toHaveBeenCalled();
});
});
describe("AddLaneModal — create/update readme", () => {
it("no longer offers it here — it moved to the lane's Tools menu", async () => {
// Creating a lane and writing its docs are separate decisions, and the
// second one is worth re-running later; see LaneToolsMenu.
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
expect(screen.queryByLabelText("Create/update readme file")).toBeNull();
});
});
describe("AddLaneModal — AI Router", () => {
it("applies native without asking, so a lane always has a declared endpoint", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "native"));
});
it("sends the picked profile once AI Router is chosen", async () => {
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(9, "9router"));
});
it("keeps the radio on Native when no profile is saved, and says why", async () => {
// Choosing "AI Router" with nothing to point at would create a lane whose
// declared endpoint does not exist.
renderModal();
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: "Native" })).toBeChecked();
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
});
it("creates the lane anyway when the profile list cannot be fetched", async () => {
vi.mocked(api.aiRouters.list).mockRejectedValue(new Error("offline"));
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
renderModal();
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(api.lanes.ensure).toHaveBeenCalled());
});
});
describe("AddLaneModal — a created lane is never lost to a setup failure", () => {
it("reports the adopted lane even when applying the router fails", async () => {
// `ensure` already created it server-side; skipping onAdded would hide a
// real lane from the list until some unrelated refetch found it.
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("no router profile named 'gone'"));
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
const onAdded = vi.fn();
renderModal({ onAdded });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const repoField = screen.getByLabelText("Directory");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await user.click(screen.getByRole("button", { name: "Add lane" }));
await waitFor(() => expect(onAdded).toHaveBeenCalled());
// …and the failure is still surfaced, on a modal left open.
expect(await screen.findByText(/no router profile named/)).toBeInTheDocument();
});
});
describe("AddLaneModal — a router profile that disappeared", () => {
it("falls back to Native when the saved profile is gone by the time the list loads", async () => {
// Deleted in another tab while this modal sat open: the select would show
// no matching option while state still held the dead name.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter(), savedRouter()] });
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ status: "idle", cwd: "/Users/tester/projects/repo" }),
created: true,
});
const { rerender } = renderModal();
const user = userEvent.setup();
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
expect(screen.getByRole("radio", { name: "AI Router" })).toBeChecked();
// Re-open with the profile gone from the server's answer.
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [nativeRouter()] });
rerender(<AddLaneModal open={false} onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
rerender(<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
await waitFor(() => expect(screen.getByRole("radio", { name: "Native" })).toBeChecked());
});
it("does not offer AI Router until the profile list has arrived", async () => {
// Clicking it early resolved back to Native with no feedback, which reads
// as a dead control.
let resolveList: (v: { items: ReturnType<typeof nativeRouter>[] }) => void = () => {};
vi.mocked(api.aiRouters.list).mockReturnValue(
new Promise((resolve) => {
resolveList = resolve;
}) as never
);
renderModal();
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
resolveList({ items: [nativeRouter(), savedRouter()] });
await waitFor(() => expect(screen.getByRole("radio", { name: "AI Router" })).toBeEnabled());
});
});
describe("AddLaneModal — tool selection", () => {
it("sends the picked tool when adopting a repo", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
created: true,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
await user.click(screen.getByRole("button", { name: /codex/i }));
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "codex" }))
);
});
it("defaults to claude code", async () => {
vi.mocked(api.lanes.ensure).mockResolvedValue({
lane: laneFixture({ id: 24, kind: "adopted", status: "idle" }),
created: true,
});
const onAdded = vi.fn();
const onClose = vi.fn();
renderModal({ onAdded, onClose });
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Repo" }));
const dirField = screen.getByLabelText("Directory");
await focusField(user, dirField);
await user.type(dirField, "/tmp/demo");
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
await waitFor(() =>
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" }))
);
});
});
describe("AddLaneModal — working straight on main (no worktree)", () => {
beforeEach(() => {
vi.mocked(api.lanes.branches).mockResolvedValue({
branches: ["development", "main"],
current: "development",
worktrees: [],
});
});
it("jumps to Repo mode from the branch choice", async () => {
const user = userEvent.setup();
renderModal();
const repoField = screen.getByLabelText("Source repository");
await focusField(user, repoField);
await user.type(repoField, "/Users/tester/projects/repo");
await screen.findByTestId("add-lane-branch-section");
await user.click(screen.getByRole("button", { name: "Current branch" }));
// Repo mode adopts the directory as it stands — no branch section left to
// answer, because there is no fork to configure.
expect(screen.queryByTestId("add-lane-branch-section")).toBeNull();
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
});
it("prefills the repo and branch it was opened with", async () => {
renderModal({
prefill: { sourceRepo: "/Users/tester/projects/repo", branch: "development" },
});
expect(await screen.findByTestId("add-lane-branch-section")).toBeInTheDocument();
expect(screen.getByLabelText("Source repository")).toHaveValue("/Users/tester/projects/repo");
expect(screen.getByRole("button", { name: "Existing branch" })).toHaveAttribute(
"aria-pressed",
"true"
);
});
});