Files
AgentHub/server/__tests__/ai-tools-route.test.js
T
nntrivi2001 a9c71cd905 feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)
Usage panel (status bar):
- every account shows its plan (Claude subscriptionType, Codex
  chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier)
- new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity
  (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs
  agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's
  own file, sent only to its own host while valid, never refreshed
- in-flight quota reads are shared between tabs

AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added;
Gemini CLI, Hermes and OpenClaw removed.

Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat
on an editor selection, Settings in three groups, JetBrains-style Changes
panel (ticked files/hunks, ignore, blame, file history), terminal font.

--no-verify: :3000 is taken by another dev server (lane-detect tests);
both suites run by hand — server 2636/2639 (the 3 are :3000), client
1131/1131.
2026-10-08 12:22:14 +07:00

296 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;
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 === "opencode").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/opencode", { 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, "opencode"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "opencode"), 0o755);
const r = await request("POST", "/api/ai-tools/refresh");
assert.equal(r.body.tools.find((t) => t.id === "opencode").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: "opencode" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.tool, "opencode");
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/copilot/install");
assert.equal(r.status, 202);
assert.equal(r.body.job.tool, "copilot");
const done = await settle();
assert.equal(done.status, "ok");
assert.deepEqual(spawned.at(-1), [
path.join(BIN, "npm"),
"install",
"-g",
"@github/copilot@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/antigravity/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");
// opencode was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
assert.equal(notNpm.status, 409);
assert.equal(notNpm.body.error.code, "ENOTNPM");
const cross = await request("POST", "/api/ai-tools/copilot/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.opencode.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;
}
});
});