969b06253c
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from Settings → Dashboard to the Claude Code tab's AgentHub sub-tab (ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub. A home that is or contains the OS home is refused (EBADHOME). Settings persistence: dashboard-saved settings go to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin cache an update wipes. Load order: real env > the checkout's .env > that file. The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put. C1: codex-usage.js reads Codex rollout journals into sessions (new column tool) and token_usage, tracked per file in codex_rollouts; forks deduped by total vector, sub-agent threads folded into the root; pricing-sync also prices OpenAI models; PricingSettings with a vendor filter. C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on a click, through a stable forwarder; Codex trust is never written. The hook route creates tool='codex' sessions and folds sub-agent hooks into the root. D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab; Gemini pricing. Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=, clear-data covering the new tables, Codex child-thread events moving no status, hooks owning status only while current, EFOREIGN for a forwarder that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE honoured from .env. Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
516 lines
21 KiB
TypeScript
516 lines
21 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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<Record<string, unknown>>();
|
|
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<typeof renderSetup>["spies"];
|
|
|
|
function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
|
|
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(
|
|
<MemoryRouter>
|
|
<RunSetup
|
|
laneId={1}
|
|
prompt="do the thing"
|
|
cwd="/Users/tester"
|
|
cwdSuggestions={SUGGESTIONS}
|
|
model=""
|
|
permissionMode="acceptEdits"
|
|
effort=""
|
|
binaryFound
|
|
busy={false}
|
|
activeRuns={null}
|
|
resumeSession={null}
|
|
runHistory={[]}
|
|
tool="claude"
|
|
sandbox="workspace-write"
|
|
approval="on-request"
|
|
{...spies}
|
|
{...overrides}
|
|
/>
|
|
</MemoryRouter>
|
|
);
|
|
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(
|
|
<MemoryRouter>
|
|
<RunSetup
|
|
laneId={1}
|
|
prompt="do the thing"
|
|
cwd="/Users/tester"
|
|
cwdSuggestions={SUGGESTIONS}
|
|
model=""
|
|
permissionMode="acceptEdits"
|
|
effort=""
|
|
binaryFound
|
|
busy={false}
|
|
activeRuns={null}
|
|
resumeSession={null}
|
|
runHistory={[]}
|
|
laneCwd="/Users/tester/lane-b"
|
|
tool="claude"
|
|
sandbox="workspace-write"
|
|
approval="on-request"
|
|
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()}
|
|
/>
|
|
</MemoryRouter>
|
|
);
|
|
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();
|
|
});
|
|
});
|