feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)

This commit is contained in:
2026-09-29 08:25:37 +07:00
parent fcfebe44f1
commit 7ec5a7cd85
40 changed files with 3145 additions and 92 deletions
@@ -1,25 +1,38 @@
/**
* @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.
* Set default, available rows a homepage link, Refresh re-detects, a
* disabled tool's Settings tab disappears from the hub, and Install/Update
* confirm the exact command, then show the polled job log.
* @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 { 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 AiToolsView } from "../../../lib/api";
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() } },
api: {
aiTools: {
list: vi.fn(),
refresh: vi.fn(),
update: vi.fn(),
install: vi.fn(),
updateTool: vi.fn(),
updateAll: vi.fn(),
job: vi.fn(),
versions: vi.fn(),
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
},
},
};
});
@@ -37,6 +50,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
resume: false,
bypass: false,
routerCapable: false,
install: null,
update: null,
...over,
});
const VIEW: AiToolsView = {
@@ -55,6 +70,23 @@ beforeEach(() => {
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.versions).mockReset().mockResolvedValue({});
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,
});
describe("AiToolsSettings", () => {
@@ -87,8 +119,124 @@ describe("AiToolsSettings", () => {
it("refreshes detection", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await user.click(await screen.findByRole("button", { name: "Refresh" }));
await screen.findByTestId("ai-tool-claude");
const before = vi.mocked(api.aiTools.versions).mock.calls.length;
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
expect(vi.mocked(api.aiTools.versions).mock.calls.length).toBe(before + 1);
});
});
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);
vi.mocked(api.aiTools.versions).mockResolvedValue({
codex: { npmManaged: false, installed: null, latest: null, updateAvailable: false },
gemini: { npmManaged: true, installed: "0.60.0", latest: "0.61.0", updateAvailable: true },
});
});
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 />);
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).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 />);
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("badges a newer npm version and offers no npm update outside npm", async () => {
render(<AiToolsSettings />);
const gemini = await screen.findByTestId("ai-tool-gemini");
expect(await within(gemini).findByText("v0.61.0 available")).toBeInTheDocument();
expect(within(gemini).getByRole("button", { name: "Update" })).toBeInTheDocument();
const codex = screen.getByTestId("ai-tool-codex");
expect(within(codex).getByText(/Installed outside npm/)).toBeInTheDocument();
expect(within(codex).queryByRole("button", { name: "Update" })).toBeNull();
});
it("Update tools lists the badged npm tools and the self updaters", async () => {
const user = userEvent.setup();
render(<AiToolsSettings />);
await screen.findByText("v0.61.0 available");
await user.click(screen.getByRole("button", { name: "Update tools" }));
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).not.toHaveTextContent("@openai/codex");
});
it("disables Update tools when nothing has an update", async () => {
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
render(<AiToolsSettings />);
expect(await screen.findByRole("button", { name: "Update tools" })).toBeDisabled();
});
it("falls back to the letter when the icon fails", async () => {
const { container } = render(<AiToolsSettings />);
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();
});
});