f0500dc592
- 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
289 lines
11 KiB
JavaScript
289 lines
11 KiB
JavaScript
/**
|
||
* @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",
|
||
});
|
||
});
|
||
});
|