Files
AgentHub/server/__tests__/ai-tool-jobs.test.js
T
nntrivi2001 c5aba094bd feat(ui): AI Tools modal, one-button lane tool picker, AgentHub settings in Overview, markdown preview
- 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
2026-10-01 10:11:43 +07:00

390 lines
14 KiB
JavaScript

/**
* @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 every npm-managed tool and every self updater, asking npm no version", async () => {
let viewed = 0;
versions.__setExec(async (_f, args) => {
if (args[0] === "view") viewed++;
return { stdout: args[0] === "prefix" ? `${prefix}\n` : "1.0.0\n" };
});
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"]
);
assert.equal(viewed, 0);
});
});