b7f3735b29
- 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.
322 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|