feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)
This commit is contained in:
@@ -51,6 +51,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
install: null,
|
||||
update: null,
|
||||
...over,
|
||||
});
|
||||
const TOOLS: AiToolsView = {
|
||||
|
||||
@@ -2,23 +2,40 @@
|
||||
* @file AiToolsSettings.tsx
|
||||
* @description Settings → AI Tools: every AI coding CLI in the server's catalog
|
||||
* (GET /api/ai-tools), split into the ones found on PATH — each with
|
||||
* Enabled/Disabled and Set default — and the ones available to install, which
|
||||
* only link to their homepage. Refresh re-reads the login shell's PATH and
|
||||
* re-detects. A disabled tool is hidden from Add lane and the server refuses a
|
||||
* new lane for it; existing lanes keep working.
|
||||
* Enabled/Disabled, Set default and, when the catalog names one, Update with a
|
||||
* new-version badge — and the ones available to install, with Install where
|
||||
* the catalog knows how. Install/Update/Update tools confirm the exact command
|
||||
* first, run as ONE server job at a time and show its log under the row
|
||||
* (polled from GET /api/ai-tools/job). Refresh re-reads the login shell's PATH
|
||||
* and re-detects. Icons are the server's cached favicons, a letter otherwise.
|
||||
* A disabled tool is hidden from Add lane; existing lanes keep working.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { Bot, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import type { AiTool } from "../../lib/api";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { api, type AiTool, type AiToolJob, type AiToolVersion } from "../../lib/api";
|
||||
|
||||
function Avatar({ label }: { label: string }) {
|
||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||
|
||||
function Avatar({ tool }: { tool: AiTool }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (!failed) {
|
||||
return (
|
||||
<img
|
||||
src={api.aiTools.iconUrl(tool.id)}
|
||||
alt=""
|
||||
onError={() => setFailed(true)}
|
||||
className="h-7 w-7 shrink-0 rounded-md bg-surface-4 object-contain p-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary">
|
||||
{label.charAt(0).toUpperCase()}
|
||||
{tool.label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -40,11 +57,44 @@ function Homepage({ tool }: { tool: AiTool }) {
|
||||
);
|
||||
}
|
||||
|
||||
function JobLog({ job }: { job: AiToolJob }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const ref = useRef<HTMLPreElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [job.log]);
|
||||
const state =
|
||||
job.status === "running"
|
||||
? t("aiTools.jobRunning")
|
||||
: job.status === "ok"
|
||||
? t("aiTools.jobOk")
|
||||
: t("aiTools.jobFailed", { code: job.exitCode ?? "?" });
|
||||
return (
|
||||
<div className="mt-2 w-full">
|
||||
<p
|
||||
className={`text-[11px] ${job.status === "failed" ? "text-status-danger" : "text-fg-muted"}`}
|
||||
>
|
||||
{state}
|
||||
</p>
|
||||
<pre
|
||||
ref={ref}
|
||||
data-testid="ai-tool-log"
|
||||
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
{job.log}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiToolsSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { tools, loaded, defaultId, refresh, update } = useAiTools();
|
||||
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [job, setJob] = useState<AiToolJob | null>(null);
|
||||
const [versions, setVersions] = useState<Record<string, AiToolVersion>>({});
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const sectionRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// The Add-lane modal links here as /settings#ai-tools; the section renders
|
||||
@@ -53,6 +103,41 @@ export function AiToolsSettings() {
|
||||
if (window.location.hash === "#ai-tools") sectionRef.current?.scrollIntoView?.();
|
||||
}, []);
|
||||
|
||||
// Deferred like useAiTools' load: an unreachable server (or a mocked api
|
||||
// without these calls) degrades to "no job, no badges", never a throw.
|
||||
const loadVersions = () =>
|
||||
Promise.resolve()
|
||||
.then(() => api.aiTools.versions())
|
||||
.then(setVersions)
|
||||
.catch(() => undefined);
|
||||
|
||||
// A page opened mid-job picks it up; versions are cached a day server-side.
|
||||
useEffect(() => {
|
||||
Promise.resolve()
|
||||
.then(() => api.aiTools.job())
|
||||
.then(setJob)
|
||||
.catch(() => undefined);
|
||||
void loadVersions();
|
||||
}, []);
|
||||
|
||||
const running = job?.status === "running";
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const h = window.setInterval(async () => {
|
||||
try {
|
||||
const j = await api.aiTools.job();
|
||||
setJob(j);
|
||||
if (j?.status !== "running") {
|
||||
void reload();
|
||||
void loadVersions();
|
||||
}
|
||||
} catch {
|
||||
/* next tick */
|
||||
}
|
||||
}, 1000);
|
||||
return () => window.clearInterval(h);
|
||||
}, [running, reload]);
|
||||
|
||||
const run = async (key: string, fn: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
@@ -65,9 +150,52 @@ export function AiToolsSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const npmCmd = (pkg: string) => `npm install -g ${pkg}@latest`;
|
||||
const commandFor = (tool: AiTool, action: "install" | "update"): string => {
|
||||
const inst = tool.install;
|
||||
if (action === "update" && tool.update?.kind === "self") {
|
||||
return [tool.command ?? tool.id, ...tool.update.args].join(" ");
|
||||
}
|
||||
if (inst?.kind === "npm") return npmCmd(inst.package);
|
||||
if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url });
|
||||
return "";
|
||||
};
|
||||
// Mirrors the server's update-all: badged npm tools, then every self updater.
|
||||
const updateAllTools = tools.filter(
|
||||
(x) =>
|
||||
x.installed &&
|
||||
(x.update?.kind === "self" || (x.update?.kind === "npm" && versions[x.id]?.updateAvailable))
|
||||
);
|
||||
const canUpdate = (tool: AiTool) =>
|
||||
!!tool.update && (tool.update.kind !== "npm" || versions[tool.id]?.npmManaged === true);
|
||||
|
||||
const startConfirmed = async () => {
|
||||
const c = confirm;
|
||||
setConfirm(null);
|
||||
if (!c) return;
|
||||
await run("job", async () => {
|
||||
const r =
|
||||
c.kind === "install"
|
||||
? await api.aiTools.install(c.tool.id)
|
||||
: c.kind === "update"
|
||||
? await api.aiTools.updateTool(c.tool.id)
|
||||
: await api.aiTools.updateAll();
|
||||
setJob(r.job);
|
||||
});
|
||||
};
|
||||
|
||||
const jobBusy = running || busy !== null;
|
||||
const logFor = (id: string | null) => (job && job.tool === id ? <JobLog job={job} /> : null);
|
||||
const installed = tools.filter((x) => x.installed);
|
||||
const available = tools.filter((x) => !x.installed);
|
||||
|
||||
const confirmLines =
|
||||
confirm?.kind === "update-all"
|
||||
? updateAllTools.map((x) => commandFor(x, "update"))
|
||||
: confirm
|
||||
? [commandFor(confirm.tool, confirm.kind)]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<section id="ai-tools" ref={sectionRef} className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
@@ -82,42 +210,79 @@ export function AiToolsSettings() {
|
||||
<p className="text-xs font-medium text-fg-secondary">
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void run("refresh", refresh)}
|
||||
disabled={busy !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm({ kind: "update-all" })}
|
||||
disabled={jobBusy || updateAllTools.length === 0}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t("aiTools.updateTools")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void run("refresh", async () => {
|
||||
await refresh();
|
||||
await loadVersions();
|
||||
})
|
||||
}
|
||||
disabled={busy !== null}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
|
||||
{logFor(null)}
|
||||
{loaded && installed.length === 0 && (
|
||||
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
|
||||
)}
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{installed.map((tool) => {
|
||||
const isDefault = tool.id === defaultId;
|
||||
const v = versions[tool.id];
|
||||
return (
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
className={`flex items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
|
||||
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${tool.enabled ? "" : "opacity-60"}`}
|
||||
>
|
||||
<Avatar label={tool.label} />
|
||||
<Avatar tool={tool} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-sm text-fg-primary">
|
||||
{tool.label}
|
||||
<Homepage tool={tool} />
|
||||
{v?.updateAvailable && v.latest && (
|
||||
<span className="rounded bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent">
|
||||
{t("aiTools.newVersion", { version: v.latest })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="truncate font-mono text-[11px] text-fg-muted"
|
||||
title={tool.path ?? ""}
|
||||
>
|
||||
{tool.command}
|
||||
{v?.installed ? ` · v${v.installed}` : ""}
|
||||
{tool.update?.kind === "npm" && v && !v.npmManaged && (
|
||||
<span className="ml-1.5 font-sans">· {t("aiTools.outsideNpm")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canUpdate(tool) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className="rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
{isDefault ? (
|
||||
<span className="text-xs text-accent">✓ {t("aiTools.default")}</span>
|
||||
) : (
|
||||
@@ -147,6 +312,7 @@ export function AiToolsSettings() {
|
||||
>
|
||||
{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
|
||||
</button>
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -163,22 +329,61 @@ export function AiToolsSettings() {
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5"
|
||||
className={`flex flex-wrap items-center gap-2 rounded-md border border-border px-2.5 py-1.5 ${
|
||||
job?.tool === tool.id ? "sm:col-span-2 lg:col-span-3" : ""
|
||||
}`}
|
||||
>
|
||||
<Avatar label={tool.label} />
|
||||
<Avatar tool={tool} />
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{tool.label}
|
||||
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">
|
||||
{tool.command}
|
||||
</span>
|
||||
</span>
|
||||
{tool.install && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "install", tool })}
|
||||
className="rounded-md border border-border px-2 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
{t("aiTools.install")}
|
||||
</button>
|
||||
)}
|
||||
<Homepage tool={tool} />
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
open={confirm !== null}
|
||||
destructive={false}
|
||||
title={
|
||||
confirm?.kind === "update-all"
|
||||
? t("aiTools.confirmUpdateAll")
|
||||
: confirm
|
||||
? t(confirm.kind === "install" ? "aiTools.confirmInstall" : "aiTools.confirmUpdate", {
|
||||
tool: confirm.tool.label,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
message={t("aiTools.confirmBody")}
|
||||
confirmLabel={t("aiTools.run")}
|
||||
cancelLabel={t("aiTools.cancel")}
|
||||
onConfirm={() => void startConfirmed()}
|
||||
onCancel={() => setConfirm(null)}
|
||||
>
|
||||
<pre
|
||||
data-testid="ai-tool-confirm-command"
|
||||
className="whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
|
||||
>
|
||||
{confirmLines.join("\n")}
|
||||
</pre>
|
||||
</ConfirmModal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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