Files
AgentHub/client/src/components/settings/__tests__/AiToolsSettings.test.tsx
T
nntrivi2001 b7f3735b29 feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
2026-10-08 23:04:57 +07:00

322 lines
13 KiB
TypeScript

/**
* @file AiToolsSettings.test.tsx
* @description The AI Tools page: installed rows carry an
* enable switch and Set default, available rows fold behind a disclosure with
* a homepage link, Refresh re-detects, 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. An installed row's right-click menu runs
* the same Set default / enable switch as its controls. A click on a row (or
* its name link) opens the tool's settings page; its buttons do not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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 { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
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<Record<string, unknown>>();
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> = {}): 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,
proxyCapable: 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> = {}): 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,
});
function Where() {
const loc = useLocation();
return <span data-testid="where">{loc.pathname}</span>;
}
describe("AiToolsSettings", () => {
it("opens a tool's settings from its row or name, never from its buttons", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/settings/agents"]}>
<Routes>
<Route
path="*"
element={
<>
<AiToolsSettings
settingsHref={(id) => (id === "gemini" ? `/settings/agents/${id}` : undefined)}
/>
<Where />
</>
}
/>
</Routes>
</MemoryRouter>
);
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.click(within(gemini).getByText("Set default"));
expect(screen.getByTestId("where").textContent).toBe("/settings/agents");
await user.click(gemini);
expect(screen.getByTestId("where").textContent).toBe("/settings/agents/gemini");
expect(within(gemini).getByRole("link", { name: "Gemini" })).toHaveAttribute(
"href",
"/settings/agents/gemini"
);
// A tool without a page has no link.
expect(
within(screen.getByTestId("ai-tool-claude")).queryByRole("link", { name: "Claude Code" })
).toBeNull();
});
it("splits installed tools from the ones available to install", async () => {
render(<AiToolsSettings />, { wrapper: MemoryRouter });
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(<AiToolsSettings />, { wrapper: MemoryRouter });
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")).toHaveAttribute("aria-checked", "false");
});
it("refreshes detection", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />, { wrapper: MemoryRouter });
await screen.findByTestId("ai-tool-claude");
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
});
it("sets the default and disables a tool from its right-click menu", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />, { wrapper: MemoryRouter });
const gemini = await screen.findByTestId("ai-tool-gemini");
await user.pointer({ keys: "[MouseRight]", target: gemini });
await user.click(screen.getByRole("menuitem", { name: "Set default" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
await user.pointer({ keys: "[MouseRight]", target: gemini });
await user.click(screen.getByRole("menuitem", { name: "Disable" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
// A disabled tool cannot become the default from the menu either.
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-grok") });
expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
});
});
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(<AiToolsSettings />, { wrapper: MemoryRouter });
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(<AiToolsSettings />, { wrapper: MemoryRouter });
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(<AiToolsSettings />, { wrapper: MemoryRouter });
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(<AiToolsSettings />, { wrapper: MemoryRouter });
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(<AiToolsSettings />, { wrapper: MemoryRouter });
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(<AiToolsSettings />, { wrapper: MemoryRouter });
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("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(
<AiToolIcon tool={{ id: "opencode", label: "OpenCode" }} />
);
fireEvent.error(container.querySelector("img")!);
expect(screen.getByText("O")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
});
it("uses the shipped mark, one for every theme", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
expect(container.querySelector('img[src="/ai-tools/claude.svg"]')).toBeTruthy();
rerender(<AiToolIcon tool={{ id: "hermes", label: "Hermes" }} />);
expect(container.querySelectorAll("img")).toHaveLength(1);
expect(container.querySelector('img[src="/ai-tools/hermes.svg"]')).toBeTruthy();
});
});