feat(ai-tools): install/update AI CLIs, remote catalog sync, cached favicons (#40 A2)
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @file Tests for server/lib/ai-tool-jobs.js and ai-tool-versions.js — the
|
||||
* argv each install/update runs (npm from the login shell's PATH first), the
|
||||
* script download guards, one job at a time, the log cap, timeout, EACCES
|
||||
* hint, enable-after-install, update-all, and npm-managed detection by realpath.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, beforeEach, 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 { EventEmitter } = require("node:events");
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ai-jobs-"));
|
||||
const cat = path.join(root, "cat");
|
||||
const empty = path.join(root, "empty");
|
||||
const shellBin = path.join(root, "shellbin"); // the login shell's npm
|
||||
const serverBin = path.join(root, "serverbin"); // the pinned Node's npm
|
||||
const prefix = path.join(root, "prefix");
|
||||
const standalone = path.join(root, "standalone");
|
||||
const data = path.join(root, "data");
|
||||
for (const d of [cat, empty, shellBin, serverBin, prefix, standalone, data]) {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
}
|
||||
|
||||
const saved = {
|
||||
PATH: process.env.PATH,
|
||||
DATA: process.env.DASHBOARD_DATA_DIR,
|
||||
SP: process.env.AGENTHUB_SHELL_PATH,
|
||||
};
|
||||
process.env.DASHBOARD_DATA_DIR = data;
|
||||
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const shell = require("../lib/shell-path");
|
||||
const versions = require("../lib/ai-tool-versions");
|
||||
const jobs = require("../lib/ai-tool-jobs");
|
||||
|
||||
const exe = (p) => {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, "#!/bin/sh\n");
|
||||
fs.chmodSync(p, 0o755);
|
||||
return p;
|
||||
};
|
||||
const write = (name, doc) => fs.writeFileSync(path.join(cat, name), JSON.stringify(doc));
|
||||
const tool = (id, extra) => ({
|
||||
id,
|
||||
label: id,
|
||||
detect: [id],
|
||||
prompt: { mode: "argv" },
|
||||
homepageUrl: "https://docs.example.com/cli",
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** A fake spawn: records argv/env, prints `out`, exits with `code` (or never, for "hang"). */
|
||||
let spawned = [];
|
||||
function fakeSpawn({ code = 0, out = "", hang = false } = {}) {
|
||||
return (file, args, opts) => {
|
||||
spawned.push({ argv: [file, ...args], opts });
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = () => setImmediate(() => child.emit("close", null));
|
||||
if (!hang) {
|
||||
setImmediate(() => {
|
||||
if (out) child.stdout.emit("data", Buffer.from(out));
|
||||
child.emit("close", code);
|
||||
});
|
||||
}
|
||||
return child;
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 400 && jobs.current()?.status === "running"; i++) {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
return jobs.current();
|
||||
}
|
||||
|
||||
let origFetch;
|
||||
before(async () => {
|
||||
origFetch = globalThis.fetch;
|
||||
write(
|
||||
"npmtool.json",
|
||||
tool("npmtool", { install: { kind: "npm", package: "npm-tool" }, update: { kind: "npm" } })
|
||||
);
|
||||
write(
|
||||
"solo.json",
|
||||
tool("solo", { install: { kind: "npm", package: "solo-pkg" }, update: { kind: "npm" } })
|
||||
);
|
||||
write(
|
||||
"fresh.json",
|
||||
tool("fresh", { install: { kind: "npm", package: "fresh-pkg" }, update: { kind: "npm" } })
|
||||
);
|
||||
write(
|
||||
"scripted.json",
|
||||
tool("scripted", { install: { kind: "script", url: "https://example.com/install" } })
|
||||
);
|
||||
write("selfy.json", tool("selfy", { update: { kind: "self", args: ["update", "--yes"] } }));
|
||||
write("plain.json", tool("plain"));
|
||||
exe(path.join(shellBin, "npm"));
|
||||
exe(path.join(serverBin, "npm"));
|
||||
// npmtool: npm-managed (bin symlink into <prefix>/lib/node_modules)
|
||||
exe(path.join(prefix, "lib", "node_modules", "npm-tool", "cli.js"));
|
||||
fs.mkdirSync(path.join(prefix, "bin"));
|
||||
fs.symlinkSync(
|
||||
path.join(prefix, "lib", "node_modules", "npm-tool", "cli.js"),
|
||||
path.join(prefix, "bin", "npmtool")
|
||||
);
|
||||
// solo: installed outside npm
|
||||
exe(path.join(standalone, "solo"));
|
||||
fs.symlinkSync(path.join(standalone, "solo"), path.join(serverBin, "solo"));
|
||||
exe(path.join(serverBin, "selfy"));
|
||||
process.env.PATH = [serverBin, path.join(prefix, "bin")].join(path.delimiter);
|
||||
delete process.env.AGENTHUB_SHELL_PATH;
|
||||
// The login shell prints its own PATH: its npm first.
|
||||
await shell.hydrateShellPath({
|
||||
spawn: (_c, _a, _o, cb) => {
|
||||
setImmediate(() => cb(null, `__AGENTHUB_PATH__${shellBin}__AGENTHUB_PATH__`));
|
||||
return { stdin: { end() {} } };
|
||||
},
|
||||
});
|
||||
// Later hydrations (at job end) must not spawn a real shell.
|
||||
process.env.AGENTHUB_SHELL_PATH = "0";
|
||||
aiTools.__setCatalogDirs([cat, empty]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
spawned = [];
|
||||
jobs.__reset();
|
||||
jobs.__setSpawn(fakeSpawn());
|
||||
versions.__setExec(async (file, args) => {
|
||||
if (args[0] === "prefix") return { stdout: `${prefix}\n` };
|
||||
if (args[0] === "view") return { stdout: args[1] === "npm-tool" ? "2.0.0\n" : "1.0.0\n" };
|
||||
if (args[0] === "--version") return { stdout: "npmtool v1.4.2 (build x)\n" };
|
||||
throw new Error("unexpected " + file);
|
||||
});
|
||||
fs.rmSync(path.join(data, "ai-tools.json"), { force: true });
|
||||
aiTools.refresh();
|
||||
globalThis.fetch = origFetch;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
globalThis.fetch = origFetch;
|
||||
process.env.PATH = saved.PATH;
|
||||
for (const [k, v] of [
|
||||
["DASHBOARD_DATA_DIR", saved.DATA],
|
||||
["AGENTHUB_SHELL_PATH", saved.SP],
|
||||
]) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
jobs.__setSpawn(null);
|
||||
versions.__setExec(null);
|
||||
aiTools.__setCatalogDirs(null);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("stepsFor", () => {
|
||||
it("installs with the login shell's npm, not the server's", async () => {
|
||||
assert.deepEqual(await jobs.stepsFor("fresh", "install"), [
|
||||
{ argv: [path.join(shellBin, "npm"), "install", "-g", "fresh-pkg@latest"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs a self updater by its detected path", async () => {
|
||||
assert.deepEqual(await jobs.stepsFor("selfy", "update"), [
|
||||
{ argv: [path.join(serverBin, "selfy"), "update", "--yes"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses what the catalog or the machine does not allow", async () => {
|
||||
await assert.rejects(jobs.stepsFor("nope", "install"), { code: "ENOTOOL" });
|
||||
await assert.rejects(jobs.stepsFor("plain", "install"), { code: "ENOINSTALL" });
|
||||
await assert.rejects(jobs.stepsFor("npmtool", "install"), { code: "EINSTALLED" });
|
||||
await assert.rejects(jobs.stepsFor("plain", "update"), { code: "ENOUPDATE" });
|
||||
await assert.rejects(jobs.stepsFor("fresh", "update"), { code: "ENOTINSTALLED" });
|
||||
await assert.rejects(jobs.stepsFor("solo", "update"), { code: "ENOTNPM" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("versions", () => {
|
||||
it("marks npm-managed by realpath and badges a newer version", async () => {
|
||||
const v = await versions.versions({ fresh: true });
|
||||
assert.deepEqual(v.npmtool, {
|
||||
npmManaged: true,
|
||||
installed: "1.4.2",
|
||||
latest: "2.0.0",
|
||||
updateAvailable: true,
|
||||
});
|
||||
assert.equal(v.solo.npmManaged, false);
|
||||
assert.equal(v.solo.updateAvailable, false);
|
||||
assert.equal(v.fresh, undefined); // not installed
|
||||
});
|
||||
|
||||
it("shows no badge when npm view fails (offline)", async () => {
|
||||
versions.__setExec(async (_f, args) => {
|
||||
if (args[0] === "prefix") return { stdout: prefix };
|
||||
if (args[0] === "--version") return { stdout: "1.4.2" };
|
||||
throw new Error("ENOTFOUND");
|
||||
});
|
||||
const v = await versions.versions({ fresh: true });
|
||||
assert.equal(v.npmtool.latest, null);
|
||||
assert.equal(v.npmtool.updateAvailable, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jobs", () => {
|
||||
it("runs the argv with stdin closed and the shell PATH first, then enables the tool", async () => {
|
||||
aiTools.setEnabled("fresh", false);
|
||||
const started = await jobs.start("fresh", "install");
|
||||
assert.equal(started.status, "running");
|
||||
assert.equal(started.child, undefined);
|
||||
// The fake install does not create a binary; pretend it did before the end.
|
||||
exe(path.join(serverBin, "fresh"));
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.equal(spawned[0].opts.stdio[0], "ignore");
|
||||
assert.ok(spawned[0].opts.env.PATH.startsWith(shellBin + path.delimiter));
|
||||
assert.equal(aiTools.isEnabled("fresh"), true);
|
||||
fs.rmSync(path.join(serverBin, "fresh"));
|
||||
});
|
||||
|
||||
it("allows one job at a time", async () => {
|
||||
jobs.__setSpawn(fakeSpawn({ hang: true }));
|
||||
jobs.__reset({ timeoutMs: 50 });
|
||||
await jobs.start("fresh", "install");
|
||||
await assert.rejects(jobs.start("selfy", "update"), { code: "EBUSY" });
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.match(done.log, /ETIMEDOUT/);
|
||||
});
|
||||
|
||||
it("caps the log, keeping the tail", async () => {
|
||||
jobs.__setSpawn(fakeSpawn({ out: "x".repeat(300 * 1024) + "END" }));
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
assert.ok(done.log.length <= 256 * 1024);
|
||||
assert.ok(done.log.endsWith("END"));
|
||||
});
|
||||
|
||||
it("adds a hint when npm fails with EACCES", async () => {
|
||||
jobs.__setSpawn(fakeSpawn({ code: 243, out: "npm error code EACCES\n" }));
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.equal(done.exitCode, 243);
|
||||
assert.match(done.log, /user-level Node/);
|
||||
});
|
||||
|
||||
it("downloads a script to a temp file, runs it with bash, then deletes it", async () => {
|
||||
globalThis.fetch = async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "https://downloads.example.com/install.sh",
|
||||
arrayBuffer: async () => Buffer.from("echo hi\n"),
|
||||
_url: url,
|
||||
});
|
||||
await jobs.start("scripted", "install");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
const [bash, file] = spawned[0].argv;
|
||||
assert.equal(bash, "bash");
|
||||
assert.equal(fs.existsSync(file), false);
|
||||
});
|
||||
|
||||
it("refuses a script that redirects off its site or is too big", async () => {
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "https://evil.example.net/x.sh",
|
||||
arrayBuffer: async () => Buffer.from("x"),
|
||||
});
|
||||
await jobs.start("scripted", "install");
|
||||
let done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.match(done.log, /redirected off example\.com/);
|
||||
assert.equal(spawned.length, 0);
|
||||
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "https://example.com/install",
|
||||
arrayBuffer: async () => Buffer.alloc(1024 * 1024 + 1),
|
||||
});
|
||||
await jobs.start("scripted", "install");
|
||||
done = await settle();
|
||||
assert.match(done.log, /larger than 1 MB/);
|
||||
});
|
||||
|
||||
it("reports the end only after re-detecting, so the first poll sees the installed tool", async () => {
|
||||
// A slow login-shell read at the end: the job must not look finished before it.
|
||||
jobs.__setHydrate(() => new Promise((r) => setTimeout(() => r(false), 40)));
|
||||
jobs.__setSpawn((file, args) => {
|
||||
spawned.push({ argv: [file, ...args] });
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
setImmediate(() => {
|
||||
exe(path.join(serverBin, "fresh")); // what npm install -g would leave behind
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
aiTools.setEnabled("fresh", false);
|
||||
try {
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.equal(aiTools.detect("fresh").installed, true);
|
||||
assert.equal(aiTools.isEnabled("fresh"), true);
|
||||
} finally {
|
||||
jobs.__setHydrate(null);
|
||||
fs.rmSync(path.join(serverBin, "fresh"), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ends a timed-out job even when a grandchild keeps the pipes open", async () => {
|
||||
const signals = [];
|
||||
jobs.__setSpawn((file, args) => {
|
||||
spawned.push({ argv: [file, ...args] });
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
// The direct child dies, but 'close' never comes: the pipes are still held.
|
||||
child.kill = (sig) => {
|
||||
signals.push(sig);
|
||||
setImmediate(() => child.emit("exit", null, sig));
|
||||
};
|
||||
return child;
|
||||
});
|
||||
jobs.__reset({ timeoutMs: 30 });
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "failed");
|
||||
assert.match(done.log, /ETIMEDOUT/);
|
||||
assert.equal(signals[0], "SIGTERM");
|
||||
await assert.doesNotReject(jobs.start("selfy", "update"), "the slot is free again");
|
||||
await settle();
|
||||
});
|
||||
|
||||
it("survives a failure while finishing and still frees the slot", async (t) => {
|
||||
const unhandled = [];
|
||||
const onUnhandled = (e) => unhandled.push(e);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
t.mock.method(aiTools, "setEnabled", () => {
|
||||
throw new Error("EROFS: read-only data dir");
|
||||
});
|
||||
try {
|
||||
jobs.__setSpawn(() => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
setImmediate(() => {
|
||||
exe(path.join(serverBin, "fresh"));
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
await jobs.start("fresh", "install");
|
||||
const done = await settle();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.deepEqual(unhandled, []);
|
||||
assert.notEqual(done.status, "running");
|
||||
assert.match(done.log, /EROFS/);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
fs.rmSync(path.join(serverBin, "fresh"), { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("update-all runs the badged npm tools and every self updater", async () => {
|
||||
await jobs.startUpdateAll();
|
||||
const done = await settle();
|
||||
assert.equal(done.status, "ok");
|
||||
assert.deepEqual(
|
||||
spawned.map((s) => s.argv.slice(1).join(" ")),
|
||||
["install -g npm-tool@latest", "update --yes"]
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user