f887c16309
Pricing: the LiteLLM sync now adds every current Claude / GPT (>= 5) /
Gemini (>= 3) chat model before it is used and keeps its own rows
(model_pricing.source = 'sync', the Auto badge) at the published rate. A
user's row (any PUT) and a deleted pattern (model_pricing_dismissed) are
never touched; a deleted built-in row is not re-seeded at the next start; a
reset re-applies the list at once; retired Claude models are priced only
once used.
Models: GET /api/ai-tools/:id/models reads each tool's own list (Codex's
models_cache.json, Claude's aliases named after the newest version plus
priced versions, a catalog tool's vendor rows plus the models its sessions
used). A catalog `model: {flag, vendor?}` field passes `<flag> <id>`
(validated: never a flag or shell text); a per-tool default model starts
each run, and a resume keeps the session's own model.
Settings: every installed, enabled catalog tool without its own tab gets
one (AiToolConfig.tsx): path, homepage, Update, sessions/cost/lanes,
default model, vendor pricing, and the config files its release catalog
lists, edited over the opened sha, syntax-checked, backed up, written
atomically; credential-looking names refused. Antigravity keeps its hooks
card there.
Lanes: a just-started run no longer reads "dead" (age counts the run's
start while the old session is still bound); a second dashboard on the
same database re-broadcasts the lanes the other changed (PRAGMA
data_version, every 2 s); a lane start with a bad model answers 400.
DB: every migration runs under a cross-process lock (<db>-migrate.lock), so
two dashboards started at once on a fresh database both come up.
324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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<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,
|
|
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> = {}): 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(<AiToolsSettings />);
|
|
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 />);
|
|
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(<AiToolsSettings />);
|
|
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(<AiToolsSettings />);
|
|
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 />);
|
|
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 />);
|
|
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 />);
|
|
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 />);
|
|
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 />);
|
|
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(
|
|
<MemoryRouter>
|
|
<SettingsHub />
|
|
</MemoryRouter>
|
|
);
|
|
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(
|
|
<MemoryRouter>
|
|
<SettingsHub />
|
|
</MemoryRouter>
|
|
);
|
|
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(
|
|
<MemoryRouter>
|
|
<SettingsHub />
|
|
</MemoryRouter>
|
|
);
|
|
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(
|
|
<MemoryRouter initialEntries={["/settings#ai-tools"]}>
|
|
<SettingsHub />
|
|
</MemoryRouter>
|
|
);
|
|
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(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
|
|
fireEvent.error(container.querySelector("img")!);
|
|
expect(screen.getByText("C")).toBeInTheDocument();
|
|
rerender(<AiToolIcon tool={{ id: "codex", label: "Codex" }} />);
|
|
expect(container.querySelector('img[src="/api/ai-tools/codex/icon"]')).toBeTruthy();
|
|
});
|
|
});
|