feat(ai-tools): Settings → AI Tools, Default/Other lane picker, per-run bypass switch (#40 A1)
Settings → Dashboard gains an AI Tools section (installed tools with Enabled/Disabled and Set default, available ones with a homepage link, Refresh). useAiTools shares one catalog copy across the app and replaces LANE_TOOL_LABEL. The Add-lane modal picks Default (<tool>) or Other…; the router row reads Native (<tool>) and disables AI Router profiles for tools that cannot use one. The run form gives catalog tools resume and a red per-run Bypass permissions switch (Codex too). Claude Code / Codex settings tabs hide while that tool is disabled. The Workspace probes each lane tool's CLI fresh. Docs: CLAUDE.md tool/bypass rule, AGENTS.md, LANES.md, API.md, CLI.md, server/README.md, OpenAPI fragment + regenerated openapi.yaml.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @file AiToolsSettings.test.tsx
|
||||
* @description Settings → AI Tools: installed rows carry Enabled/Disabled and
|
||||
* Set default, available rows only a homepage link, Refresh re-detects, and a
|
||||
* disabled tool's Settings tab disappears from the hub.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { AiToolsSettings } from "../AiToolsSettings";
|
||||
import { api, type AiTool, type AiToolsView } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { aiTools: { list: vi.fn(), refresh: vi.fn(), update: vi.fn() } },
|
||||
};
|
||||
});
|
||||
|
||||
const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool => ({
|
||||
id,
|
||||
label,
|
||||
command: id,
|
||||
homepageUrl: `https://${id}.example/`,
|
||||
installed: true,
|
||||
path: `/usr/bin/${id}`,
|
||||
enabled: true,
|
||||
source: "builtin",
|
||||
launcher: null,
|
||||
prompt: { mode: "argv" },
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
...over,
|
||||
});
|
||||
const VIEW: AiToolsView = {
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", "Claude Code"),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
__resetAiTools();
|
||||
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(VIEW);
|
||||
vi.mocked(api.aiTools.update).mockReset().mockResolvedValue(VIEW);
|
||||
vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW);
|
||||
});
|
||||
|
||||
describe("AiToolsSettings", () => {
|
||||
it("splits installed tools from the ones available to install", async () => {
|
||||
render(<AiToolsSettings />);
|
||||
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Available to install (1)")).toBeInTheDocument();
|
||||
const claude = screen.getByTestId("ai-tool-claude");
|
||||
expect(within(claude).getByText(/Default/)).toBeInTheDocument();
|
||||
const aider = screen.getByTestId("ai-tool-aider");
|
||||
expect(within(aider).queryByRole("switch")).toBeNull();
|
||||
expect(within(aider).queryByText("Set default")).toBeNull();
|
||||
expect(within(aider).getByRole("link")).toHaveAttribute("href", "https://aider.example/");
|
||||
});
|
||||
|
||||
it("toggles a tool and sets the default through the API", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
||||
await user.click(within(gemini).getByText("Set default"));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
|
||||
// A disabled tool cannot become the default.
|
||||
const grok = screen.getByTestId("ai-tool-grok");
|
||||
expect(within(grok).getByText("Set default")).toBeDisabled();
|
||||
expect(within(grok).getByRole("switch")).toHaveTextContent("Disabled");
|
||||
});
|
||||
|
||||
it("refreshes detection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Refresh" }));
|
||||
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsHub — AI tool tabs", () => {
|
||||
it("hides the Codex tab while Codex is disabled", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [tool("claude", "Claude Code"), tool("codex", "Codex", { enabled: false })],
|
||||
});
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user