/** * @file AiToolsSettings.test.tsx * @description The AI Tools modal: the Settings tab bar's button and the * `#ai-tools` deep link open it, Escape closes it; installed rows carry an * enable switch and Set default, available rows fold behind a disclosure with * a homepage link, Refresh re-detects, a disabled tool's Settings tab * disappears from the hub, every updater gets Update without a version * lookup, and Install/Update confirm the exact command, then show the polled * job log, folded once it is done. * @author Nguyễn Ngọc Trí Vĩ */ import { MemoryRouter } from "react-router-dom"; import { act, fireEvent, 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 AiToolJob, type AiToolsView } from "../../../lib/api"; import { __resetAiTools } from "../../../hooks/useAiTools"; vi.mock("../../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, api: { aiTools: { list: vi.fn(), refresh: vi.fn(), update: vi.fn(), install: vi.fn(), updateTool: vi.fn(), updateAll: vi.fn(), job: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon`, }, }, }; }); const tool = (id: string, label: string, over: Partial = {}): 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, install: null, update: null, modelFlag: false, defaultModel: null, modelVendor: null, configFiles: 0, ...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); vi.mocked(api.aiTools.job).mockReset().mockResolvedValue(null); vi.mocked(api.aiTools.install).mockReset(); vi.mocked(api.aiTools.updateTool).mockReset(); vi.mocked(api.aiTools.updateAll).mockReset(); }); const job = (over: Partial = {}): AiToolJob => ({ id: 1, tool: "qwen", action: "install", status: "running", startedAt: "2026-09-28T00:00:00Z", endedAt: null, exitCode: null, log: "$ npm install -g @qwen-code/qwen-code@latest\n", ...over, }); describe("AiToolsSettings", () => { it("splits installed tools from the ones available to install", async () => { render(); expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" })); 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(); 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(); await screen.findByTestId("ai-tool-claude"); await user.click(screen.getByRole("button", { name: "Refresh" })); expect(api.aiTools.refresh).toHaveBeenCalledOnce(); }); }); describe("AiToolsSettings — install and update", () => { const A2: AiToolsView = { default: "claude", tools: [ tool("claude", "Claude Code", { update: { kind: "self", args: ["update"] } }), tool("codex", "Codex", { install: { kind: "npm", package: "@openai/codex" }, update: { kind: "npm" }, }), tool("gemini", "Gemini", { install: { kind: "npm", package: "@google/gemini-cli" }, update: { kind: "npm" }, }), tool("qwen", "Qwen", { installed: false, path: null, install: { kind: "npm", package: "@qwen-code/qwen-code" }, }), tool("cursor", "Cursor", { installed: false, path: null, install: { kind: "script", url: "https://cursor.com/install" }, }), ], }; beforeEach(() => { vi.mocked(api.aiTools.list).mockResolvedValue(A2); }); const openAvailable = async () => userEvent.click(await screen.findByRole("button", { name: /Available to install/ })); it("confirms the exact npm command, then polls and shows the log", async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); try { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); vi.mocked(api.aiTools.install).mockResolvedValue({ job: job() }); vi.mocked(api.aiTools.job) .mockResolvedValueOnce(null) .mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" })); render(); await user.click(await screen.findByRole("button", { name: /Available to install/ })); const qwen = await screen.findByTestId("ai-tool-qwen"); await user.click(within(qwen).getByRole("button", { name: "Install" })); expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent( "npm install -g @qwen-code/qwen-code@latest" ); expect(api.aiTools.install).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "Run" })); expect(api.aiTools.install).toHaveBeenCalledWith("qwen"); expect(await within(qwen).findByTestId("ai-tool-log")).toHaveTextContent("npm install -g"); await act(async () => { vi.advanceTimersByTime(1100); }); expect(await within(qwen).findByText("Done")).toBeInTheDocument(); expect(within(qwen).queryByTestId("ai-tool-log")).toBeNull(); // folded once done await user.click(within(qwen).getByRole("button", { name: "Show log" })); expect(within(qwen).getByTestId("ai-tool-log")).toHaveTextContent("added 1 package"); const calls = vi.mocked(api.aiTools.job).mock.calls.length; await act(async () => { vi.advanceTimersByTime(3000); }); expect(vi.mocked(api.aiTools.job).mock.calls.length).toBe(calls); // polling stopped } finally { vi.useRealTimers(); } }); it("names the vendor script for a script install", async () => { const user = userEvent.setup(); render(); await openAvailable(); const cursor = await screen.findByTestId("ai-tool-cursor"); await user.click(within(cursor).getByRole("button", { name: "Install" })); expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent( "Download https://cursor.com/install and run it with bash" ); }); it("offers Update on every installed tool with an updater", async () => { render(); for (const id of ["claude", "codex", "gemini"]) { const row = await screen.findByTestId(`ai-tool-${id}`); expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument(); } }); it("Update all lists every installed tool's updater", async () => { const user = userEvent.setup(); render(); await user.click(await screen.findByRole("button", { name: "Update all (3)" })); const cmd = screen.getByTestId("ai-tool-confirm-command"); expect(cmd).toHaveTextContent("claude update"); expect(cmd).toHaveTextContent("npm install -g @google/gemini-cli@latest"); expect(cmd).toHaveTextContent("npm install -g @openai/codex@latest"); }); it("hides Update all when no tool has an updater", async () => { vi.mocked(api.aiTools.list).mockResolvedValue(VIEW); render(); await screen.findByTestId("ai-tool-claude"); expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull(); }); it("falls back to the letter when the icon fails", async () => { const { container } = render(); await openAvailable(); await screen.findByTestId("ai-tool-qwen"); const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!; expect(img).toBeTruthy(); fireEvent.error(img); expect(within(screen.getByTestId("ai-tool-qwen")).getByText("Q")).toBeInTheDocument(); }); }); 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( ); expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument(); expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull(); }); it("gives every installed, enabled catalog tool its own tab, and none to the rest", async () => { vi.mocked(api.aiTools.list).mockResolvedValue({ default: "claude", tools: [ tool("claude", "Claude Code", { launcher: "claude" }), tool("gemini", "Gemini"), tool("qwen-code", "Qwen Code", { enabled: false }), tool("aider", "Aider", { installed: false }), ], }); vi.doMock("../../../pages/Settings", () => ({ Settings: () => null })); const { SettingsHub } = await import("../../../pages/SettingsHub"); render( ); expect(await screen.findByRole("tab", { name: /Gemini/ })).toBeInTheDocument(); expect(screen.queryByRole("tab", { name: /Qwen Code/ })).toBeNull(); expect(screen.queryByRole("tab", { name: /Aider/ })).toBeNull(); }); it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => { vi.doMock("../../../pages/Settings", () => ({ Settings: () => null })); const { SettingsHub } = await import("../../../pages/SettingsHub"); render( ); expect(screen.queryByRole("dialog")).toBeNull(); await userEvent.click(await screen.findByRole("button", { name: "AI Tools" })); const dialog = screen.getByRole("dialog", { name: "AI Tools" }); expect(await within(dialog).findByText("Installed (3 detected)")).toBeInTheDocument(); fireEvent.keyDown(window, { key: "Escape" }); expect(screen.queryByRole("dialog")).toBeNull(); }); it("opens the modal for the /settings#ai-tools deep link", async () => { vi.doMock("../../../pages/Settings", () => ({ Settings: () => null })); const { SettingsHub } = await import("../../../pages/SettingsHub"); render( ); expect(await screen.findByRole("dialog", { name: "AI Tools" })).toBeInTheDocument(); }); }); describe("AiToolIcon", () => { it("retries the icon when the same instance shows another tool", async () => { const { AiToolIcon } = await import("../../AiToolIcon"); const { container, rerender } = render(); fireEvent.error(container.querySelector("img")!); expect(screen.getByText("C")).toBeInTheDocument(); rerender(); expect(container.querySelector('img[src="/api/ai-tools/codex/icon"]')).toBeTruthy(); }); });