54299f119e
POST /worktree answers with 202 before the background git worktree add finishes, so profileInit/agentsInstall/mcpSync were racing the lane's own directory into existence and mostly failing. Poll GET /api/lanes/:id until provisioning leaves "provisioning" first.
295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
/**
|
|
* @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ĩ <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(),
|
|
worktree: vi.fn(),
|
|
get: vi.fn(),
|
|
profileInit: vi.fn(),
|
|
agentsInstall: vi.fn(),
|
|
mcpSync: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
function laneFixture(over: Partial<Lane> = {}): 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<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);
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|