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.
780 lines
30 KiB
TypeScript
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"
|
|
);
|
|
});
|
|
});
|