/** * @file RunSetup.test.tsx * @description Pins the props-only boundary of `RunSetup` after its move out of * `pages/Run.tsx` (where it was `ConfigCard`): every picker the panel owns — * mode, prompt, cwd, model, permission mode, effort — reports its selection * through the matching callback and nowhere else, `onStart` fires from the Run * button, and a missing `claude` binary is surfaced purely from the * `binaryFound` prop (the panel runs no probe of its own). * * @author Nguyễn Ngọc Trí Vĩ */ import { MemoryRouter } from "react-router-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import i18n from "i18next"; import { RunSetup } from "../RunSetup"; import type { CwdSuggestion } from "../../../lib/api"; import { api } from "../../../lib/api"; vi.mock("../../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); const r = (value: unknown) => vi.fn().mockResolvedValue(value); return { ...actual, api: { aiTools: { list: r({ default: "claude", tools: [ { id: "claude", label: "Claude Code", resume: false, bypass: false }, { id: "codex", label: "Codex", resume: false, bypass: true }, { id: "antigravity", label: "Antigravity", resume: true, bypass: true }, { id: "gemini", label: "Gemini", resume: false, bypass: false }, ].map((x) => ({ ...x, installed: true, enabled: true, command: x.id })), }), }, run: { files: r({ items: [] }) }, sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) }, ccConfig: { file: r({ text: "" }), commands: r({ items: [ { scope: "project", name: "ship-it", frontmatter: { description: "Ship the change" }, preview: "", }, ], }), skills: r({ items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }], }), }, }, }; }); const SUGGESTIONS: CwdSuggestion[] = [ { kind: "home", path: "/Users/tester", label: "Home" }, { kind: "recent", path: "/Users/tester/projects/other", label: "other" }, ]; type Spies = ReturnType["spies"]; function renderSetup(overrides: Partial> = {}) { const spies = { onPromptChange: vi.fn(), onCwdChange: vi.fn(), onModelChange: vi.fn(), onPermissionModeChange: vi.fn(), onEffortChange: vi.fn(), onStart: vi.fn(), onResumeSessionChange: vi.fn(), onResumeFromHistory: vi.fn(), onSandboxChange: vi.fn(), onApprovalChange: vi.fn(), onResumeLast: vi.fn(), }; const utils = render( ); return { ...utils, spies }; } /** Open the `Select` whose trigger currently shows `currentLabel`, then pick * the option labelled `optionLabel`. */ function pickFromSelect(currentLabel: string, optionLabel: string) { fireEvent.click(screen.getByText(currentLabel)); fireEvent.click(screen.getByText(optionLabel)); } /** Every callback except the named ones must stay untouched — a selection that * leaks into a sibling prop is exactly the wiring bug a move can introduce. */ function onlyCalled(spies: Spies, ...called: (keyof Spies)[]) { for (const [name, spy] of Object.entries(spies)) { if (called.includes(name as keyof Spies)) continue; expect(spy, `${name} should not have fired`).not.toHaveBeenCalled(); } } beforeEach(() => { i18n.changeLanguage("en"); vi.clearAllMocks(); }); describe("RunSetup — selections report through callbacks", () => { it("reports the prompt from the editor", () => { const { spies } = renderSetup({ prompt: "" }); const box = screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholder")); fireEvent.change(box, { target: { value: "review the diff" } }); expect(spies.onPromptChange).toHaveBeenCalledWith("review the diff"); onlyCalled(spies, "onPromptChange"); }); it("reports the cwd from typing and from a suggestion", () => { const { spies } = renderSetup(); const input = screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder")); fireEvent.change(input, { target: { value: "/tmp/pro" } }); expect(spies.onCwdChange).toHaveBeenCalledWith("/tmp/pro"); fireEvent.click(screen.getByText("/Users/tester/projects/other")); expect(spies.onCwdChange).toHaveBeenLastCalledWith("/Users/tester/projects/other"); onlyCalled(spies, "onCwdChange"); }); it("reports the model from the picker, including a custom id", () => { const { spies } = renderSetup(); // The picker offers the CLI's "latest" aliases, not pinned ids — that is // what keeps the list from going stale on every model release. pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)"); expect(spies.onModelChange).toHaveBeenCalledWith("sonnet"); // "Custom…" is a sentinel, not a model id — it must not be reported as one; // the free-text box it reveals is what reports. fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel"))); fireEvent.click(screen.getByText(i18n.t("run:fields.modelCustom"))); expect(spies.onModelChange).toHaveBeenCalledTimes(1); fireEvent.change(screen.getByPlaceholderText(i18n.t("run:fields.modelCustomPlaceholder")), { target: { value: "claude-opus-5" }, }); expect(spies.onModelChange).toHaveBeenLastCalledWith("claude-opus-5"); onlyCalled(spies, "onModelChange"); }); it("reports the permission mode and the effort level", () => { const { spies } = renderSetup(); pickFromSelect(i18n.t("run:fields.permissionAcceptEdits"), i18n.t("run:fields.permissionPlan")); expect(spies.onPermissionModeChange).toHaveBeenCalledWith("plan"); pickFromSelect("Default (model decides)", "Medium"); expect(spies.onEffortChange).toHaveBeenCalledWith("medium"); onlyCalled(spies, "onPermissionModeChange", "onEffortChange"); }); it("fires onStart from the Run button", () => { const { spies } = renderSetup(); fireEvent.click(screen.getByText(i18n.t("run:actions.start"))); expect(spies.onStart).toHaveBeenCalledTimes(1); onlyCalled(spies, "onStart"); }); }); describe("RunSetup — missing binary and other blocked states", () => { /** The Run button, found by its label rather than by DOM position. */ function runButton(): HTMLButtonElement { return screen.getByText(i18n.t("run:actions.start")).closest("button") as HTMLButtonElement; } it("disables Run when the claude binary was not found", () => { renderSetup({ binaryFound: false }); expect(runButton().disabled).toBe(true); }); it("enables Run when the binary is found and the form is complete", () => { renderSetup(); expect(runButton().disabled).toBe(false); }); it("still disables Run without a prompt or without a cwd", () => { const { unmount } = renderSetup({ prompt: " " }); expect(runButton().disabled).toBe(true); unmount(); const noCwd = renderSetup({ cwd: "" }); expect(runButton().disabled).toBe(true); noCwd.unmount(); }); it("shows the Starting… label while busy", () => { renderSetup({ busy: true }); expect(screen.getByText(i18n.t("run:actions.starting"))).toBeTruthy(); }); }); describe("RunSetup — resume picker scopes sessions to the selected lane", () => { it("passes the lane's cwd as a filter and lists only that directory's sessions", async () => { const { api } = await import("../../../lib/api"); vi.mocked(api.sessions.list).mockResolvedValue({ sessions: [ { id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" }, ], total: 1, limit: 100, offset: 0, } as never); renderSetup({ laneCwd: "/Users/tester/lane-a" }); fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption"))); fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession"))); await screen.findByText("/Users/tester/lane-a"); // The API call itself is what enforces the scope - the server filters by // cwd, so the picker must never fetch without one when a lane is selected. expect(api.sessions.list).toHaveBeenCalledWith( expect.objectContaining({ cwd: "/Users/tester/lane-a" }) ); }); it("never offers a Codex session, which `claude --resume` cannot resume", async () => { const { api } = await import("../../../lib/api"); vi.mocked(api.sessions.list).mockResolvedValue({ sessions: [ { id: "sess-claude", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" }, ], total: 1, limit: 100, offset: 0, } as never); renderSetup({ laneCwd: "/Users/tester/lane-a" }); fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption"))); fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession"))); await screen.findByText("/Users/tester/lane-a"); // Filtered by the server, before its limit: a busy Codex lane at the same // directory cannot push Claude sessions off the page. expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" })); }); it("starts the resume immediately when a session is picked", async () => { const { api } = await import("../../../lib/api"); vi.mocked(api.sessions.list).mockResolvedValue({ sessions: [ { id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" }, ], total: 1, limit: 100, offset: 0, } as never); const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "" }); fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption"))); fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession"))); fireEvent.click(await screen.findByText("/Users/tester/lane-a")); expect(spies.onResumeSessionChange).toHaveBeenCalledWith( expect.objectContaining({ id: "sess-in-lane" }) ); // No prompt typed, no Run click - the pick itself is the start, and it // carries the session's own cwd rather than the form's. expect(spies.onStart).toHaveBeenCalledWith( expect.objectContaining({ resumeSessionId: "sess-in-lane", cwd: "/Users/tester/lane-a" }) ); }); it("sends no prompt when resuming, even with text in the box", async () => { const { api } = await import("../../../lib/api"); vi.mocked(api.sessions.list).mockResolvedValue({ sessions: [ { id: "sess-in-lane", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" }, ], total: 1, limit: 100, offset: 0, } as never); const { spies } = renderSetup({ laneCwd: "/Users/tester/lane-a", prompt: "leftover text" }); fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption"))); fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession"))); fireEvent.click(await screen.findByText("/Users/tester/lane-a")); // The resumed session is the input; anything left in the box would be an // extra turn the user never asked for. expect(spies.onStart.mock.calls[0]?.[0]?.initialPrompt).toBeUndefined(); }); it("falls back to the typed cwd when no lane is selected", async () => { const { api } = await import("../../../lib/api"); renderSetup({ laneCwd: undefined, cwd: "/Users/tester" }); fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption"))); fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession"))); await new Promise((r) => setTimeout(r, 0)); expect(api.sessions.list).toHaveBeenCalledWith( expect.objectContaining({ cwd: "/Users/tester" }) ); }); it("lists everything when no lane is selected and cwd is empty", async () => { const { api } = await import("../../../lib/api"); renderSetup({ laneCwd: undefined, cwd: "" }); fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption"))); fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession"))); await new Promise((r) => setTimeout(r, 0)); expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ cwd: undefined })); }); it("re-fetches with the new cwd when the selected lane changes", async () => { const { api } = await import("../../../lib/api"); const { rerender } = renderSetup({ laneCwd: "/Users/tester/lane-a" }); fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption"))); fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession"))); await new Promise((r) => setTimeout(r, 0)); rerender( ); await new Promise((r) => setTimeout(r, 0)); expect(api.sessions.list).toHaveBeenCalledWith( expect.objectContaining({ cwd: "/Users/tester/lane-b" }) ); }); }); describe("RunSetup — per-tool fields", () => { it("shows sandbox and approval instead of permission mode for a codex lane", () => { renderSetup({ tool: "codex" }); expect(screen.getByText(/sandbox/i)).toBeInTheDocument(); expect(screen.getByText(/approval/i)).toBeInTheDocument(); expect(screen.queryByText(/permission mode/i)).not.toBeInTheDocument(); }); it("keeps permission mode for a claude lane", () => { renderSetup({ tool: "claude" }); expect(screen.getByText(/permission mode/i)).toBeInTheDocument(); expect(screen.queryByText(/sandbox/i)).not.toBeInTheDocument(); }); it("sends codex fields on start", async () => { const onStart = vi.fn(); const user = userEvent.setup(); renderSetup({ tool: "codex", onStart, prompt: "go" }); await user.click(screen.getByText(i18n.t("run:actions.start"))); expect(onStart).toHaveBeenCalledWith( expect.objectContaining({ sandbox: "workspace-write", approval: "on-request" }) ); expect(onStart.mock.calls[0]?.[0]?.permissionMode).toBeUndefined(); }); it("calls onResumeLast when a codex lane's resume-last button is clicked", async () => { const onResumeLast = vi.fn(); const user = userEvent.setup(); renderSetup({ tool: "codex", onResumeLast }); // Switch to resume mode fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption"))); // Click the resume-last button await user.click(screen.getByText(i18n.t("run:resume.lastOption"))); expect(onResumeLast).toHaveBeenCalled(); }); }); describe("RunSetup — / and @ suggestions in the prompt box", () => { const box = () => screen.getByPlaceholderText(/ anything…$/); it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => { const { spies } = renderSetup({ prompt: "" }); fireEvent.change(box(), { target: { value: "/rev" } }); // Both prefix matches; the shorter name ranks first. const options = await screen.findAllByRole("option"); expect(options.map((o) => o.textContent)).toEqual([ expect.stringContaining("/review"), expect.stringContaining("/review-pr"), ]); fireEvent.keyDown(box(), { key: "ArrowDown" }); // Read for the directory the run will start in, not the server's own. expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester"); expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester"); fireEvent.keyDown(box(), { key: "Enter" }); expect(spies.onPromptChange).toHaveBeenLastCalledWith("/review-pr "); // Enter picked the suggestion; it did not send the prompt. expect(spies.onStart).not.toHaveBeenCalled(); }); it("suggests files under the cwd on @", async () => { vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] }); // The prompt is controlled by the page: put the cursor at the end of it. const { spies } = renderSetup({ prompt: "look at @ma" }); (box() as HTMLTextAreaElement).setSelectionRange(11, 11); fireEvent.select(box()); fireEvent.click(await screen.findByText("src/main.ts")); expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma"); expect(spies.onPromptChange).toHaveBeenLastCalledWith("look at @src/main.ts "); }); it("offers no slash commands on a codex lane", async () => { renderSetup({ prompt: "", tool: "codex" }); fireEvent.change(box(), { target: { value: "/rev" } }); await new Promise((r) => setTimeout(r, 20)); expect(screen.queryByTestId("prompt-autocomplete")).toBeNull(); expect(api.ccConfig.commands).not.toHaveBeenCalled(); }); }); describe("RunSetup — antigravity lanes", () => { it("names the lane's CLI in the prompt box and drops the flags `agy` lacks", () => { const { spies } = renderSetup({ tool: "antigravity", model: "opus", effort: "high" }); expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy(); expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull(); expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull(); fireEvent.click(screen.getByText(i18n.t("run:actions.start"))); expect(spies.onStart).toHaveBeenCalledWith( expect.objectContaining({ model: undefined, effort: undefined, permissionMode: undefined }) ); }); }); describe("RunSetup — codex lanes", () => { it("starts a fresh codex run instead of resuming the last one", () => { const { spies } = renderSetup({ tool: "codex" }); fireEvent.click(screen.getByText(i18n.t("run:actions.start"))); expect(spies.onStart).toHaveBeenCalledOnce(); expect(spies.onStart.mock.calls[0]?.[0].resumeLast).toBeUndefined(); }); }); describe("RunSetup — catalog tools", () => { it("offers a per-run bypass switch with a warning, and sends it", async () => { const { spies } = renderSetup({ tool: "antigravity", prompt: "go" }); const box = await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") }); expect(box).not.toBeChecked(); expect(screen.queryByText(/without asking/)).toBeNull(); fireEvent.click(box); expect(screen.getByText(/Antigravity will run every command/)).toBeTruthy(); fireEvent.click(screen.getByText(i18n.t("run:actions.start"))); expect(spies.onStart.mock.calls[0]?.[0].bypass).toBe(true); // Resume is offered because the catalog gives the tool a resume flag. expect(screen.getByText(i18n.t("run:resume.resumeOption"))).toBeTruthy(); }); it("leaves out resume and bypass for a tool with neither", async () => { const { spies } = renderSetup({ tool: "gemini", prompt: "go" }); expect(await screen.findByPlaceholderText("Ask Gemini anything…")).toBeTruthy(); expect(screen.queryByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeNull(); expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull(); fireEvent.click(screen.getByText(i18n.t("run:actions.start"))); expect(spies.onStart.mock.calls[0]?.[0].bypass).toBeUndefined(); }); it("gives Codex the switch but not Claude", async () => { renderSetup({ tool: "codex" }); expect(await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeTruthy(); }); it("gives Claude no bypass switch (it has the bypassPermissions mode)", async () => { renderSetup({ tool: "claude" }); await screen.findByPlaceholderText("Ask Claude Code anything…"); expect(screen.queryByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeNull(); }); });