0a86dfb8c5
A new **AI Router** tab in Settings, next to Codex, stores named router
endpoints (9Router, OpenRouter, a self-hosted gateway) as profiles: a base
URL, an API key, a default model and an optional per-tier override. The tab
only stores them; a lane CHOOSES one in the Add-lane modal, via a Native /
AI Router radio, and that choice is written into the lane's own config.
The two CLIs are configured differently, and the difference is theirs:
- a claude lane gets eight `env` keys merged into its own
`.claude/settings.local.json` (`.local` because the value holds a key);
- a codex lane gets `$CODEX_HOME/<name>.config.toml` — the layer
`codex -p <name>` loads — because Codex has NO project-level config.
Its base `config.toml` is never touched.
Native is a built-in profile rather than a code branch, so "go back to
native" is the same write path. It blanks the two connection variables
instead of removing them (an absent key at project scope lets a USER-level
router apply) and removes the model names instead of writing them (writing
them would override the user's own global model choice).
Key material never leaves the server: profiles live one file each under
`<CLAUDE_HOME>/agenthub-routers/` (0700/0600), `GET /api/ai-routers`
reports only `hasKey`, and there is deliberately no route returning one
profile in full.
Three things this had to get right, each found by review and verified
against the real tools rather than assumed:
- `wire_api = "chat"` is rejected outright by codex-cli 0.149 ("no longer
supported"), so every codex lane on a router would have failed to start.
The layer writes "responses", and writes `env_key` only when the profile
actually has a key — naming an unset variable is itself a startup error.
- The key must never be TYPED into a pane. `tmux.sendCommand` used
`send-keys` with a `KEY='…' cmd` prefix, which the dashboard streams to
every attached browser and bash appends to history; it now takes over the
idle pane with `respawn-pane -k -e`, passing env through argv the way
`newSession` always has. Verified on tmux 3.4: multi-element argv arrives
intact and the pane renders no trace of the value.
- Attaching COPIES a profile, so editing or deleting one in Settings has to
reach the lanes already using it. An edit re-applies it (a rotated key
would otherwise never reach the lane still holding the revoked one); a
delete resets those lanes to native and removes the Codex layer file.
Both report per-lane failures, which the tab surfaces.
`.claude/settings.local.json` is added to the lane's git exclude, for both
native and router lanes: `agenthub lanes reset` runs `git clean -fd` without
`-x`, so an un-excluded settings file is deleted on reset — and untracked
meant the key was committable.
DB: `lanes.router_profile`, nullable, additive. Written only by
`setRouterProfile` beside the disk write, never through `PATCHABLE`.
700 lines
27 KiB
TypeScript
700 lines
27 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.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();
|
|
// 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, 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 — worktree mode (default)", () => {
|
|
it("disables confirm until a repo, a title, a resolved branch list, and a new branch name 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, with the typed branch name", 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();
|
|
|
|
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",
|
|
pipeline: "default",
|
|
tool: "claude",
|
|
});
|
|
});
|
|
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();
|
|
|
|
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" });
|
|
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" });
|
|
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("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();
|
|
|
|
await fillWorktreeForm(user, { 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();
|
|
|
|
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",
|
|
pipeline: "default",
|
|
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("opens the new lane's terminal on the docs skill only when the box is ticked", async () => {
|
|
vi.mocked(api.run.start)
|
|
.mockReset()
|
|
.mockResolvedValue({} as never);
|
|
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.getByLabelText("Create/update readme file"));
|
|
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.run.start).toHaveBeenCalledWith({
|
|
laneId: 9,
|
|
cwd: "/Users/tester/projects/repo",
|
|
initialPrompt: "/sg-all:sg-md-writing",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("is hidden for a codex lane, which has no slash commands", async () => {
|
|
renderModal();
|
|
const user = userEvent.setup();
|
|
await user.click(screen.getByRole("button", { name: "Codex" }));
|
|
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 — 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();
|
|
});
|
|
});
|
|
|
|
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" }))
|
|
);
|
|
});
|
|
});
|