feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file Route tests for /api/ai-tools: list shape, enable/disable, default
|
||||
* errors, the same-origin guard, and /api/run/binary resolving through the
|
||||
* catalog.
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,7 @@ 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"]) {
|
||||
for (const name of ["claude", "agy", "npm"]) {
|
||||
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, name), 0o755);
|
||||
}
|
||||
@@ -133,3 +133,144 @@ describe("lanes on catalog tools", () => {
|
||||
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: "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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user