feat(lanes): let Add Lane choose the pipeline template
Creation is the only point the UI could ever set a lane's template, and it never offered the choice — so every lane added from "+ Add lane" was born on `default` and rendered an 8-node map for a 16-node workflow, with no screen able to change it afterwards. That is the defect that made the ship-feature template unreachable from the browser. The modal now shows a *Pipeline template* select fed by `GET /api/lanes/pipelines`, labelled with each template's node count so the consequence of the choice is visible. A failed fetch degrades to a `default` option rather than blocking lane creation. `pipeline` was already accepted by `POST /api/lanes` but silently dropped by `/ensure` and `/worktree`, which build their own createLane payloads; both now pass it through, and both map `EBADPIPELINE` to 400 like `EBADCWD`.
This commit is contained in:
@@ -21,6 +21,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
lanes: {
|
||||
branches: vi.fn(),
|
||||
pipelines: vi.fn(),
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
@@ -101,6 +102,18 @@ async function fillWorktreeForm(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.pipelines)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
pipelines: [
|
||||
{ id: "default", name: "Default feature pipeline", nodes: new Array(8).fill({ id: "n" }) },
|
||||
{
|
||||
id: "ship-feature",
|
||||
name: "Ship feature (lane pipeline)",
|
||||
nodes: new Array(16).fill({ id: "n" }),
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
@@ -174,6 +187,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
title: "New feature",
|
||||
base: "main",
|
||||
branch: "feat/new-feature",
|
||||
pipeline: "default",
|
||||
});
|
||||
});
|
||||
expect(onAdded).toHaveBeenCalledWith(
|
||||
@@ -327,6 +341,7 @@ describe("AddLaneModal — repo mode (adopt)", () => {
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith({
|
||||
cwd: "/Users/tester/projects/repo",
|
||||
title: "main repo",
|
||||
pipeline: "default",
|
||||
})
|
||||
);
|
||||
expect(api.lanes.worktree).not.toHaveBeenCalled();
|
||||
@@ -376,3 +391,47 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — pipeline template", () => {
|
||||
it("offers every template the server reports and sends the chosen one when adopting a repo", async () => {
|
||||
// Creation is the ONLY point the UI can set a template, so a lane born on
|
||||
// `default` renders 8 nodes for a 16-node workflow with no way back from
|
||||
// any screen.
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ status: "idle" }),
|
||||
created: true,
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("option", { name: /Ship feature/ })).toBeInTheDocument()
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("option", { name: /Default feature pipeline \(8\)/ })
|
||||
).toBeInTheDocument();
|
||||
|
||||
const repoField = screen.getByLabelText("Directory");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await user.selectOptions(select, "ship-feature");
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pipeline: "ship-feature" })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("still renders a usable select, and still creates the lane, when the template list cannot be fetched", async () => {
|
||||
vi.mocked(api.lanes.pipelines).mockRejectedValue(new Error("offline"));
|
||||
renderModal();
|
||||
|
||||
const select = await screen.findByLabelText("Pipeline template");
|
||||
await waitFor(() => expect(select).toHaveValue("default"));
|
||||
expect(screen.getByRole("option", { name: "default" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user