Files
AgentHub/server/__tests__/ai-tools-route.test.js
T
nntrivi2001 c0832182f2 feat(ai-tools): drop OpenCode and Grok Build
- opencode.json / grok.json leave the built-in catalog; opencode-usage.js,
  its poller, transcript branch and table creation go with it (existing
  tables and session rows stay as history).
- A Grok payload reaching the Claude hook route (Grok runs the Claude hooks
  itself) is dropped instead of recorded as tool = 'grok'.
- quota.js no longer reads Grok or OpenCode Go logins.
- Redone on v2.8.22 from c6b2892 (feat/grok-usage, never merged).
2026-10-26 09:31:30 +07:00

316 lines
12 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;
// An npm-installed tool, in the release-synced tier (only a release catalog may
// name an installer): no built-in tool updates through npm.
const REMOTE = path.join(ROOT, "remote-catalog");
fs.mkdirSync(REMOTE);
fs.writeFileSync(
path.join(REMOTE, "npmtool.json"),
JSON.stringify({
id: "npmtool",
label: "npm tool",
detect: ["npmtool"],
prompt: { mode: "flag", flag: "--prompt" },
install: { kind: "npm", package: "npmtool" },
update: { kind: "npm" },
})
);
const { createApp } = require("../index");
require("../lib/ai-tools").__setCatalogDirs([
path.join(__dirname, "..", "data", "ai-tools"),
REMOTE,
path.join(ROOT, "custom-catalog"),
]);
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 === "npmtool").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/npmtool", { 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("answers a tool's model choices and keeps its default model", async () => {
let r = await request("GET", "/api/ai-tools/antigravity/models");
assert.equal(r.status, 200);
assert.equal(r.body.flag, true);
assert.equal(r.body.defaultModel, null);
r = await request("PUT", "/api/ai-tools/antigravity", { defaultModel: "gemini-3.1-pro" });
assert.equal(r.status, 200);
assert.equal(r.body.tools.find((t) => t.id === "antigravity").defaultModel, "gemini-3.1-pro");
r = await request("GET", "/api/ai-tools/antigravity/models");
assert.equal(r.body.defaultModel, "gemini-3.1-pro");
assert.equal(
(await request("PUT", "/api/ai-tools/antigravity", { defaultModel: "-x" })).status,
400
);
r = await request("PUT", "/api/ai-tools/antigravity", { defaultModel: "" });
assert.equal(r.body.tools.find((t) => t.id === "antigravity").defaultModel, null);
assert.equal((await request("GET", "/api/ai-tools/nope/models")).status, 404);
});
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, "npmtool"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "npmtool"), 0o755);
const r = await request("POST", "/api/ai-tools/refresh");
assert.equal(r.body.tools.find((t) => t.id === "npmtool").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: "npmtool" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.tool, "npmtool");
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
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/omp", { enabled: false });
r = await request("POST", "/api/lanes", { cwd: dir("d"), title: "d", tool: "omp" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EDISABLED");
r = await request("POST", "/api/lanes/ensure", { cwd: dir("e"), tool: "omp" });
assert.equal(r.body.error.code, "EDISABLED");
r = await request("POST", "/api/lanes/ensure", { cwd: dir("b"), tool: "omp" });
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: "omp" });
assert.equal(r.body.error.code, "EDISABLED");
await request("PUT", "/api/ai-tools/omp", { 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/codex/install");
assert.equal(r.status, 202);
assert.equal(r.body.job.tool, "codex");
const done = await settle();
assert.equal(done.status, "ok");
assert.deepEqual(spawned.at(-1), [
path.join(BIN, "npm"),
"install",
"-g",
"@openai/codex@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/claude/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");
// npmtool was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/npmtool/update");
assert.equal(notNpm.status, 409);
assert.equal(notNpm.body.error.code, "ENOTNPM");
const cross = await request("POST", "/api/ai-tools/codex/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/codex/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.npmtool.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;
}
});
});