/** * @file AddLaneModal.test.tsx * @description Pins the "+ Add lane" flow after it was rebuilt around a source * repo instead of an existing folder: picking or typing a repo path triggers a * branch lookup, the base-branch picker only appears once that lookup resolves, * confirm submits through the provisioning endpoint (not the adopt/ensure one), * an unresolvable path degrades to a quiet hint instead of blocking the form, * and a server error surfaces instead of closing the 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(), worktree: vi.fn(), get: vi.fn(), profileInit: vi.fn(), agentsInstall: vi.fn(), mcpSync: vi.fn(), }, }, })); function laneFixture(over: Partial = {}): Lane { return { id: 9, title: "", cwd: "/lanes/repo__feature", branch: "feat/feature", kind: "managed", 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, }; } 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); } beforeEach(() => { vi.mocked(api.lanes.branches).mockReset(); vi.mocked(api.lanes.worktree).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: [] }); }); describe("AddLaneModal", () => { 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", }); 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, not ensure", async () => { vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); 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(); 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"), "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", }); }); expect(onAdded).toHaveBeenCalledWith( expect.objectContaining({ id: 9, status: "provisioning" }) ); // The modal stays open showing the setup summary until dismissed - it // does not close itself just because the lane was added. expect(onClose).not.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" }); vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION")); const onClose = vi.fn(); renderModal({ onClose }); 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"), "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" }); 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(); 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"), "demo"); 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" }); 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(); 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"), "demo2"); await user.click(screen.getByRole("button", { name: "Add lane" })); await waitFor(() => expect(onAdded).toHaveBeenCalled()); expect(onClose).not.toHaveBeenCalled(); }); it("shows the setup summary and lets the user dismiss it manually", async () => { vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: { id: 44, title: "demo3", cwd: "/lanes/demo3", status: "provisioning" } as Lane, }); const onClose = vi.fn(); renderModal({ onClose }); 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"), "demo3"); await user.click(screen.getByRole("button", { name: "Add lane" })); expect(await screen.findByText("Setup")).toBeInTheDocument(); expect(onClose).not.toHaveBeenCalled(); const [dismissButton] = screen.getAllByRole("button", { name: "Cancel" }); if (!dismissButton) throw new Error("Cancel button not found"); await user.click(dismissButton); 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" }); 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(); 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"), "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(); }); });