feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user