c5aba094bd
- AI Tools moves from a Settings section into a modal behind a button at the Settings tab bar's right edge (AiToolsModal, `actions` on PageTabs, /settings#ai-tools); rows get an enable switch, Default badge, and an Update button for every tool whose catalog names an updater (no version lookup on open); not-installed tools fold behind a disclosure, a finished job's log folds to one line; shared AiToolIcon - codex updates through its own `codex update`, openclaw/hermes gain `update --yes`; update-all runs every installed updater and skips an npm one only when npm does not manage the binary - Add-lane tool picker is one button showing the chosen tool that opens the enabled tools as buttons - Claude Code / Codex AgentHub sub-tab folded into Overview (home editor, hooks, pricing); the path list drops CLAUDE_HOME/CODEX_HOME; HomeDirSetting shows the saved value only while the field differs - Preview/Raw toggle for markdown files in the config FileViewer and the Changes panel's whole-file view (MarkdownPreview: react-markdown + remark-gfm, raw HTML never run, frontmatter as a code block) - docs and the CLAUDE.md marker updated
277 lines
11 KiB
JavaScript
277 lines
11 KiB
JavaScript
/**
|
|
* @file Route tests for /api/ai-tools: list shape, enable/disable, default
|
|
* errors, the same-origin guard, /api/run/binary resolving through the
|
|
* catalog, and the A2 install/update jobs, versions and icon routes.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const http = require("node:http");
|
|
|
|
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ai-tools-route-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
|
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
|
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
|
|
process.env.AGENTHUB_SHELL_PATH = "0";
|
|
const BIN = path.join(ROOT, "bin");
|
|
fs.mkdirSync(BIN);
|
|
for (const name of ["claude", "agy", "npm"]) {
|
|
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
|
fs.chmodSync(path.join(BIN, name), 0o755);
|
|
}
|
|
const origPath = process.env.PATH;
|
|
process.env.PATH = BIN;
|
|
|
|
const { createApp } = require("../index");
|
|
require("../lib/ai-tools").refresh();
|
|
|
|
let app;
|
|
let base;
|
|
const request = (method, p, body, headers = {}) =>
|
|
fetch(`${base}${p}`, {
|
|
method,
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
|
|
|
before(async () => {
|
|
app = http.createServer(createApp());
|
|
await new Promise((r) => app.listen(0, "127.0.0.1", r));
|
|
base = `http://127.0.0.1:${app.address().port}`;
|
|
});
|
|
after(async () => {
|
|
process.env.PATH = origPath;
|
|
await new Promise((r) => app.close(r));
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("/api/ai-tools", () => {
|
|
it("lists the catalog with detection and the default", async () => {
|
|
const { status, body } = await request("GET", "/api/ai-tools");
|
|
assert.equal(status, 200);
|
|
assert.equal(body.default, "claude");
|
|
const agy = body.tools.find((t) => t.id === "antigravity");
|
|
assert.equal(agy.installed, true);
|
|
assert.equal(agy.path, path.join(BIN, "agy"));
|
|
assert.equal(agy.resume, true);
|
|
assert.equal(agy.bypass, true);
|
|
assert.equal(body.tools.find((t) => t.id === "gemini").installed, false);
|
|
assert.equal("args" in agy, false);
|
|
});
|
|
|
|
it("disables and re-enables a tool, and sets the default", async () => {
|
|
let r = await request("PUT", "/api/ai-tools/claude", { enabled: false });
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.tools.find((t) => t.id === "claude").enabled, false);
|
|
assert.equal(r.body.default, "antigravity");
|
|
r = await request("PUT", "/api/ai-tools/claude", { enabled: true });
|
|
assert.equal(r.body.default, "claude");
|
|
r = await request("PUT", "/api/ai-tools/antigravity", { default: true });
|
|
assert.equal(r.body.default, "antigravity");
|
|
});
|
|
|
|
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
|
|
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
|
|
const bad = await request("PUT", "/api/ai-tools/gemini", { default: true });
|
|
assert.equal(bad.status, 400);
|
|
assert.equal(bad.body.error.code, "EBADDEFAULT");
|
|
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
|
|
});
|
|
|
|
it("refuses a cross-origin write", async () => {
|
|
const r = await request(
|
|
"PUT",
|
|
"/api/ai-tools/claude",
|
|
{ enabled: false },
|
|
{ Origin: "https://evil.test" }
|
|
);
|
|
assert.equal(r.status, 403);
|
|
});
|
|
|
|
it("refresh re-detects", async () => {
|
|
fs.writeFileSync(path.join(BIN, "gemini"), "#!/bin/sh\n");
|
|
fs.chmodSync(path.join(BIN, "gemini"), 0o755);
|
|
const r = await request("POST", "/api/ai-tools/refresh");
|
|
assert.equal(r.body.tools.find((t) => t.id === "gemini").installed, true);
|
|
});
|
|
|
|
it("/api/run/binary resolves through the catalog", async () => {
|
|
const r = await request("GET", "/api/run/binary?tool=antigravity");
|
|
assert.deepEqual(r.body, { found: true, path: path.join(BIN, "agy") });
|
|
assert.equal((await request("GET", "/api/run/binary?tool=rm")).status, 400);
|
|
});
|
|
});
|
|
|
|
describe("lanes on catalog tools", () => {
|
|
it("creates a lane for an installed or not-installed tool, refuses unknown/disabled", async () => {
|
|
const dir = (n) => {
|
|
const d = path.join(ROOT, n);
|
|
fs.mkdirSync(d, { recursive: true });
|
|
return d;
|
|
};
|
|
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "gemini" });
|
|
assert.equal(r.status, 201);
|
|
assert.equal(r.body.lane.tool, "gemini");
|
|
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "aider" });
|
|
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
|
|
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
|
|
assert.equal(r.body.error.code, "EBADTOOL");
|
|
await request("PUT", "/api/ai-tools/aider", { enabled: false });
|
|
r = await request("POST", "/api/lanes", { cwd: dir("d"), title: "d", tool: "aider" });
|
|
assert.equal(r.status, 400);
|
|
assert.equal(r.body.error.code, "EDISABLED");
|
|
r = await request("POST", "/api/lanes/ensure", { cwd: dir("e"), tool: "aider" });
|
|
assert.equal(r.body.error.code, "EDISABLED");
|
|
r = await request("POST", "/api/lanes/ensure", { cwd: dir("b"), tool: "aider" });
|
|
assert.equal(r.status, 200, "an existing lane on a disabled tool is still found");
|
|
r = await request("POST", "/api/lanes/worktree", { sourceRepo: dir("f"), tool: "aider" });
|
|
assert.equal(r.body.error.code, "EDISABLED");
|
|
await request("PUT", "/api/ai-tools/aider", { enabled: true });
|
|
});
|
|
});
|
|
|
|
describe("install / update / icons", () => {
|
|
const { EventEmitter } = require("node:events");
|
|
const jobs = require("../lib/ai-tool-jobs");
|
|
let spawned = [];
|
|
let hang = false;
|
|
before(() => {
|
|
jobs.__setSpawn((file, args) => {
|
|
spawned.push([file, ...args]);
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.kill = () => setImmediate(() => child.emit("close", null));
|
|
if (!hang) setImmediate(() => child.emit("close", 0));
|
|
return child;
|
|
});
|
|
});
|
|
after(() => jobs.__setSpawn(null));
|
|
|
|
const settle = async () => {
|
|
for (let i = 0; i < 200; i++) {
|
|
const r = await request("GET", "/api/ai-tools/job");
|
|
if (r.body?.status !== "running") return r.body;
|
|
await new Promise((res) => setTimeout(res, 5));
|
|
}
|
|
};
|
|
|
|
it("lists install/update and the catalog source", async () => {
|
|
const { body } = await request("GET", "/api/ai-tools");
|
|
const codex = body.tools.find((t) => t.id === "codex");
|
|
assert.deepEqual(codex.install, { kind: "npm", package: "@openai/codex" });
|
|
assert.deepEqual(codex.update, { kind: "self", args: ["update"] }); // standalone or npm
|
|
assert.equal(body.catalog.source, "builtin");
|
|
});
|
|
|
|
it("starts an install job and reports it", async () => {
|
|
jobs.__reset();
|
|
assert.equal((await request("GET", "/api/ai-tools/job")).body, null);
|
|
const r = await request("POST", "/api/ai-tools/qwen-code/install");
|
|
assert.equal(r.status, 202);
|
|
assert.equal(r.body.job.tool, "qwen-code");
|
|
const done = await settle();
|
|
assert.equal(done.status, "ok");
|
|
assert.deepEqual(spawned.at(-1), [
|
|
path.join(BIN, "npm"),
|
|
"install",
|
|
"-g",
|
|
"@qwen-code/qwen-code@latest",
|
|
]);
|
|
});
|
|
|
|
it("maps refusals to statuses", async () => {
|
|
jobs.__reset();
|
|
assert.equal((await request("POST", "/api/ai-tools/nope/install")).status, 404);
|
|
const noInstall = await request("POST", "/api/ai-tools/aider/install");
|
|
assert.equal(noInstall.status, 400);
|
|
assert.equal(noInstall.body.error.code, "ENOINSTALL");
|
|
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
|
|
assert.equal(notInstalled.status, 409);
|
|
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
|
|
// gemini was put on PATH by the refresh test, outside any npm prefix.
|
|
const notNpm = await request("POST", "/api/ai-tools/gemini/update");
|
|
assert.equal(notNpm.status, 409);
|
|
assert.equal(notNpm.body.error.code, "ENOTNPM");
|
|
const cross = await request("POST", "/api/ai-tools/qwen-code/install", undefined, {
|
|
Origin: "https://evil.test",
|
|
});
|
|
assert.equal(cross.status, 403);
|
|
});
|
|
|
|
it("answers EBUSY while a job runs", async () => {
|
|
jobs.__reset({ timeoutMs: 50 });
|
|
hang = true;
|
|
assert.equal((await request("POST", "/api/ai-tools/copilot/install")).status, 202);
|
|
const busy = await request("POST", "/api/ai-tools/amp/install");
|
|
assert.equal(busy.status, 409);
|
|
assert.equal(busy.body.error.code, "EBUSY");
|
|
await settle();
|
|
hang = false;
|
|
});
|
|
|
|
it("update-all runs claude's own updater; versions lists npm tools", async () => {
|
|
jobs.__reset();
|
|
const r = await request("POST", "/api/ai-tools/update-all");
|
|
assert.equal(r.status, 202);
|
|
await settle();
|
|
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
|
|
const v = await request("GET", "/api/ai-tools/versions");
|
|
assert.equal(v.status, 200);
|
|
assert.equal(v.body.gemini.npmManaged, false);
|
|
});
|
|
|
|
it("update-all answers ENOTHING when nothing has an update", async () => {
|
|
jobs.__reset();
|
|
await request("PUT", "/api/ai-tools/claude", { enabled: true });
|
|
fs.renameSync(path.join(BIN, "claude"), path.join(BIN, "claude.off"));
|
|
try {
|
|
await request("POST", "/api/ai-tools/refresh");
|
|
const r = await request("POST", "/api/ai-tools/update-all");
|
|
assert.equal(r.status, 400);
|
|
assert.equal(r.body.error.code, "ENOTHING");
|
|
} finally {
|
|
fs.renameSync(path.join(BIN, "claude.off"), path.join(BIN, "claude"));
|
|
await request("POST", "/api/ai-tools/refresh");
|
|
}
|
|
});
|
|
|
|
it("refresh drops the cached versions", async () => {
|
|
const versions = require("../lib/ai-tool-versions");
|
|
let calls = 0;
|
|
versions.__setExec(async () => {
|
|
calls++;
|
|
return { stdout: "" };
|
|
});
|
|
try {
|
|
await request("GET", "/api/ai-tools/versions");
|
|
const before = calls;
|
|
await request("GET", "/api/ai-tools/versions");
|
|
assert.equal(calls, before, "second read is cached");
|
|
await request("POST", "/api/ai-tools/refresh");
|
|
await request("GET", "/api/ai-tools/versions");
|
|
assert.ok(calls > before, "a read after Refresh recomputes");
|
|
} finally {
|
|
versions.__setExec(null);
|
|
}
|
|
});
|
|
|
|
it("answers 204 for a tool without an icon and 404 for an unknown id", async () => {
|
|
const origFetch = globalThis.fetch;
|
|
globalThis.fetch = async () => {
|
|
throw new Error("offline");
|
|
};
|
|
try {
|
|
const r = await origFetch(`${base}/api/ai-tools/claude/icon`);
|
|
assert.equal(r.status, 204);
|
|
assert.equal((await origFetch(`${base}/api/ai-tools/nope/icon`)).status, 404);
|
|
} finally {
|
|
globalThis.fetch = origFetch;
|
|
}
|
|
});
|
|
});
|