Files
AgentHub/server/__tests__/ai-routers.test.js
T
nntrivi2001 f0500dc592 feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)
- Changes panel: Files and Changes merged into one panel; the overview's
  groups drawn as a folder tree (compacted chains, file counts, fold,
  stage/unstage a whole folder)
- AI-suggested commit message for what is staged
  (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku)
- AI Router: optional "Behaves as" Claude model id, written as
  modelPicker options into the router's --settings file (issue #56)
- Sidebar: session rows show last activity, live rows their start time;
  a closed Terminal row can be removed (DELETE /api/run/history/:id)
- Every pane's CLI starts only once a client is attached; claude panes
  no longer forced inline; tmux scrollbar one column wide
- Update notifier: "Check for updates" reopens a closed notice
2026-10-06 12:36:06 +07:00

289 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file Tests for the AI Router profile store — the CRUD surface, the built-in
* native profile, and the guarantee that a stored API key never appears in a
* response the browser can read.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ai-routers-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const routers = require("../lib/ai-routers");
const ROUTERS_DIR = path.join(process.env.CLAUDE_HOME, "agenthub-routers");
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
beforeEach(() => fs.rmSync(ROUTERS_DIR, { recursive: true, force: true }));
/** The smallest profile the API accepts. */
function nineRouter(over = {}) {
return {
label: "9Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "Highest",
...over,
};
}
describe("ai-routers — the built-in native profile", () => {
it("is listed even before anything has been saved", () => {
const { items } = routers.listRouters();
assert.equal(items.length, 1);
assert.equal(items[0].name, "native");
assert.equal(items[0].builtin, true);
});
it("leaves the two connection variables empty rather than absent", () => {
// The reference native settings.json sets both to "" — an absent key would
// let a user-level router base URL leak into a lane meant to run native.
const native = routers.getRouter("native");
assert.equal(native.baseUrl, "");
assert.equal(native.apiKey, "");
});
it("refuses to be edited or removed", () => {
assert.throws(() => routers.updateRouter("native", { label: "mine" }), { code: "EBUILTIN" });
assert.throws(() => routers.removeRouter("native"), { code: "EBUILTIN" });
});
});
describe("ai-routers — create", () => {
it("saves a profile and derives its name from the label", () => {
const created = routers.createRouter(nineRouter());
assert.equal(created.name, "9Router".toLowerCase());
assert.equal(created.label, "9Router");
assert.ok(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)));
});
it("stores the file 0600 inside a 0700 directory", () => {
const created = routers.createRouter(nineRouter());
const file = fs.statSync(path.join(ROUTERS_DIR, `${created.name}.json`));
const dir = fs.statSync(ROUTERS_DIR);
assert.equal(file.mode & 0o777, 0o600);
assert.equal(dir.mode & 0o777, 0o700);
});
it("fills every model slot from `model` when no per-tier value is given", () => {
// The 9Router reference sets all six slots to one symbolic value; typing it
// once must be enough.
routers.createRouter(nineRouter());
const saved = routers.getRouter("9router");
assert.equal(saved.models.opus, "Highest");
assert.equal(saved.models.sonnet, "Highest");
assert.equal(saved.models.haiku, "Highest");
assert.equal(saved.models.smallFast, "Highest");
assert.equal(saved.models.subagent, "Highest");
});
it("keeps a per-tier value that was given explicitly", () => {
routers.createRouter(nineRouter({ models: { opus: "PremiumCombo" } }));
const saved = routers.getRouter("9router");
assert.equal(saved.models.opus, "PremiumCombo");
assert.equal(saved.models.sonnet, "Highest");
});
it("gives a second profile with the same label a free name", () => {
const a = routers.createRouter(nineRouter());
const b = routers.createRouter(nineRouter());
assert.notEqual(a.name, b.name);
});
it("rejects a label that is empty or carries control characters", () => {
assert.throws(() => routers.createRouter(nineRouter({ label: " " })), { code: "EBADLABEL" });
assert.throws(() => routers.createRouter(nineRouter({ label: "ab" })), {
code: "EBADLABEL",
});
});
it("rejects a base URL that is not http(s)", () => {
// A `file:` or `javascript:` base URL is never a router; it is either a
// mistake or an attempt to point the CLI somewhere it should not go.
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
code: "EBADURL",
});
assert.throws(() => routers.createRouter(nineRouter({ baseUrl: "not a url" })), {
code: "EBADURL",
});
});
it("refuses to shadow the built-in native name", () => {
assert.throws(() => routers.createRouter(nineRouter({ label: "native" })), {
code: "EBUILTIN",
});
});
});
describe("ai-routers — the key never reaches the browser", () => {
it("is absent from the list, which reports only that one is set", () => {
routers.createRouter(nineRouter());
const { items } = routers.listRouters();
const saved = items.find((i) => i.name === "9router");
assert.equal(saved.hasKey, true);
assert.equal(saved.apiKey, undefined);
assert.ok(!JSON.stringify(items).includes("sk-secret-value"));
});
it("is still absent when the profile has no key at all", () => {
routers.createRouter(nineRouter({ apiKey: "" }));
const { items } = routers.listRouters();
assert.equal(items.find((i) => i.name === "9router").hasKey, false);
});
});
describe("ai-routers — update", () => {
it("changes the label without changing the name a lane already references", () => {
// A lane stores the name. Renaming the label must not orphan that lane.
const created = routers.createRouter(nineRouter());
const updated = routers.updateRouter(created.name, { label: "9Router (nhà)" });
assert.equal(updated.name, created.name);
assert.equal(updated.label, "9Router (nhà)");
});
it("keeps the stored key when the update omits it", () => {
// The edit form cannot show the key, so it cannot send it back either.
const created = routers.createRouter(nineRouter());
routers.updateRouter(created.name, { label: "renamed" });
assert.equal(routers.getRouter(created.name).apiKey, "sk-secret-value");
});
it("replaces the key when a new one is given", () => {
const created = routers.createRouter(nineRouter());
routers.updateRouter(created.name, { apiKey: "sk-rotated" });
assert.equal(routers.getRouter(created.name).apiKey, "sk-rotated");
});
it("reports a name that does not exist", () => {
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
});
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
const created = routers.createRouter(nineRouter({ behavesAs: " claude-opus-5-5 " }));
assert.equal(created.behavesAs, "claude-opus-5-5");
assert.equal(
routers.updateRouter(created.name, { label: "renamed" }).behavesAs,
"claude-opus-5-5"
);
assert.equal(routers.updateRouter(created.name, { behavesAs: "" }).behavesAs, "");
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
assert.throws(() => routers.updateRouter(created.name, { behavesAs: bad }), {
code: "EBADMODEL",
});
}
});
});
describe("ai-routers — remove", () => {
it("deletes the file", () => {
const created = routers.createRouter(nineRouter());
routers.removeRouter(created.name);
assert.equal(fs.existsSync(path.join(ROUTERS_DIR, `${created.name}.json`)), false);
assert.equal(routers.listRouters().items.length, 1); // native only
});
it("reports a name that does not exist", () => {
assert.throws(() => routers.removeRouter("ghost"), { code: "ENOTFOUND" });
});
});
describe("ai-routers — a corrupt file is skipped, never fatal", () => {
it("lists the healthy profiles around it", () => {
routers.createRouter(nineRouter());
fs.writeFileSync(path.join(ROUTERS_DIR, "broken.json"), "{not json");
const { items } = routers.listRouters();
assert.ok(items.some((i) => i.name === "9router"));
assert.ok(!items.some((i) => i.name === "broken"));
});
});
describe("ai-routers — testRouter", () => {
const realFetch = global.fetch;
after(() => {
global.fetch = realFetch;
});
/** Capture the one request testRouter makes, and answer it however asked. */
function stubFetch(answer) {
const calls = [];
global.fetch = async (url, init) => {
calls.push({ url, init });
if (answer instanceof Error) throw answer;
return answer;
};
return calls;
}
const ok = () => ({ ok: true, status: 200, statusText: "OK", text: async () => "{}" });
it("posts one capped message to the endpoint's /v1/messages", async () => {
const calls = stubFetch(ok());
const result = await routers.testRouter(nineRouter());
assert.equal(result.ok, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, "http://localhost:20128/v1/v1/messages");
const body = JSON.parse(calls[0].init.body);
assert.equal(body.max_tokens, 1);
assert.equal(body.model, "Highest");
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
});
it("reports a router that answered with an error rather than throwing", async () => {
stubFetch({ ok: false, status: 401, statusText: "Unauthorized", text: async () => "bad key" });
const result = await routers.testRouter(nineRouter());
assert.deepEqual(result, { ok: false, status: 401, message: "bad key" });
});
it("reports an endpoint that never answered", async () => {
// The shape node's own fetch throws: a useless outer message wrapping the
// one thing the user can act on.
stubFetch(
Object.assign(new Error("fetch failed"), {
cause: new Error("connect ECONNREFUSED 127.0.0.1:20199"),
})
);
const result = await routers.testRouter(nineRouter());
assert.equal(result.ok, false);
assert.equal(result.status, null);
assert.match(result.message, /ECONNREFUSED/);
});
it("falls back to the stored key for the endpoint it was stored against", async () => {
const created = routers.createRouter(nineRouter());
const calls = stubFetch(ok());
await routers.testRouter({
name: created.name,
baseUrl: "http://localhost:20128/v1",
model: "Highest",
});
assert.equal(calls[0].init.headers["x-api-key"], "sk-secret-value");
});
it("never sends the stored key to a different endpoint", async () => {
const created = routers.createRouter(nineRouter());
const calls = stubFetch(ok());
await assert.rejects(
routers.testRouter({
name: created.name,
baseUrl: "https://attacker.example/v1",
model: "Highest",
}),
{ code: "EBADKEY" }
);
assert.equal(calls.length, 0);
});
it("refuses a base URL that is not http(s)", async () => {
await assert.rejects(routers.testRouter(nineRouter({ baseUrl: "file:///etc/passwd" })), {
code: "EBADURL",
});
});
});