/** * @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ĩ */ 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 { // `as Lane`: spreading a Partial 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> = {}) { return render( ); } /** 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, 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, { 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 = {}) { 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(); rerender(); 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[] }) => 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" ); }); });