/** * @file nine-router-update.test.js * @description The **Update 9router** job (`server/lib/nine-router-update.js`) * and its routes, against a FAKE 9router: a throwaway npm prefix whose `cli.js` * listens on a free port and keeps a renamed child, and a fake `npm` that bumps * its version. PATH is cut down so the machine's real 9router can never be * found, let alone stopped — every test first checks the located install is * the fake one. No systemd: the unit path is a seam turned off here. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after, beforeEach } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const net = require("node:net"); const os = require("node:os"); const path = require("node:path"); const { spawn } = require("node:child_process"); const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "nine-router-update-")); process.env.CLAUDE_HOME = path.join(TMP, ".claude"); const PREFIX = path.join(TMP, "prefix"); const PKG_DIR = path.join(PREFIX, "lib", "node_modules", "9router"); const BIN_DIR = path.join(PREFIX, "bin"); const NPM_DIR = path.join(TMP, "npmbin"); const ORIGINAL_PATH = process.env.PATH; const update = require("../lib/nine-router-update"); const express = require("express"); const router = require("../routes/ai-proxies"); const CLI = `#!/usr/bin/env node const net = require("net"); const { spawn } = require("child_process"); const i = process.argv.indexOf("-p"); const port = Number(process.argv[i + 1]); // A child that renames itself, like 9router's next-server. const child = spawn(process.execPath, ["-e", "process.title='fake-next-server'; setInterval(()=>{},1e6)"], { stdio: "ignore" }); const srv = net.createServer((s) => s.end()).listen(port, "127.0.0.1"); process.on("SIGTERM", () => { try { child.kill("SIGKILL"); } catch {} srv.close(); process.exit(0); }); setInterval(() => {}, 1e6); `; const NPM = `#!/bin/sh # fake npm: --version, or install -g --prefix P ... 9router@latest if [ "$1" = "--version" ]; then echo 10.0.0; exit 0; fi while [ $# -gt 0 ]; do if [ "$1" = "--prefix" ]; then P="$2"; shift; fi shift done [ -f "${TMP}/npm-fail" ] && { echo "boom" >&2; exit 1; } echo '{"name":"9router","version":"9.9.9"}' > "$P/lib/node_modules/9router/package.json" echo installed `; function freePort() { return new Promise((resolve) => { const s = net.createServer().listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => resolve(port)); }); }); } const portOpen = (port) => new Promise((resolve) => { const s = net.connect({ host: "127.0.0.1", port }); s.once("connect", () => (s.destroy(), resolve(true))); s.once("error", () => resolve(false)); }); async function until(fn, ms = 20000) { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await new Promise((r) => setTimeout(r, 100)); } return false; } /** pids whose argv names the fake install — what a test must clean up. */ function fakePids() { const out = []; for (const d of fs.readdirSync("/proc")) { if (!/^\d+$/.test(d)) continue; try { const argv = fs.readFileSync(`/proc/${d}/cmdline`, "utf8"); if (argv.includes(TMP) || argv.includes("fake-next-server")) out.push(Number(d)); } catch { /* gone */ } } return out.filter((p) => p !== process.pid); } function killFakes() { for (const pid of fakePids()) { try { process.kill(pid, "SIGKILL"); } catch { /* gone */ } } } function startFake(port) { const child = spawn(process.execPath, [path.join(PKG_DIR, "cli.js"), "--tray", "-p", String(port)], { stdio: "ignore", detached: true, }); child.unref(); return child; } async function finished() { await until(() => update.current()?.status !== "running", 60000); return update.current(); } let server; let BASE; before(async () => { fs.mkdirSync(PKG_DIR, { recursive: true }); fs.mkdirSync(BIN_DIR, { recursive: true }); fs.mkdirSync(NPM_DIR, { recursive: true }); fs.writeFileSync(path.join(PKG_DIR, "cli.js"), CLI, { mode: 0o755 }); fs.symlinkSync("../lib/node_modules/9router/cli.js", path.join(BIN_DIR, "9router")); fs.writeFileSync(path.join(NPM_DIR, "npm"), NPM, { mode: 0o755 }); // Only the fake bins, node's own dir and the basics — never /usr/local/bin. process.env.PATH = [BIN_DIR, NPM_DIR, path.dirname(process.execPath), "/bin", "/usr/bin"].join(":"); const app = express(); app.use(express.json()); app.use("/api/ai-proxies", router); await new Promise((resolve) => { server = app.listen(0, "127.0.0.1", resolve); }); BASE = `http://127.0.0.1:${server.address().port}`; }); beforeEach(() => { killFakes(); update.__reset(); fs.writeFileSync(path.join(PKG_DIR, "package.json"), JSON.stringify({ name: "9router", version: "1.0.0" })); fs.rmSync(path.join(TMP, "npm-fail"), { force: true }); update.__setSeams({ useSystemd: false, fetchLatest: async () => "2.0.0", timing: { termGraceMs: 2000, portFreeMs: 5000, portUpMs: 15000 }, }); const loc = update.locate(); assert.ok(loc && loc.pkgDir.startsWith(TMP), `refusing to run against ${loc?.pkgDir}`); }); after(() => { killFakes(); process.env.PATH = ORIGINAL_PATH; server?.close(); fs.rmSync(TMP, { recursive: true, force: true }); }); describe("nine-router-update", () => { it("reports the install, the latest version and that nothing runs", async () => { const s = await update.status(); assert.equal(s.installed, true); assert.equal(s.version, "1.0.0"); assert.equal(s.latest, "2.0.0"); assert.equal(s.updateAvailable, true); assert.equal(s.running, false); assert.equal(s.blocker, null); }); it("refuses when already on the latest version", async () => { update.__setSeams({ fetchLatest: async () => "1.0.0" }); const s = await update.status(); assert.equal(s.blocker.code, "EUPTODATE"); await assert.rejects(update.start(), { code: "EUPTODATE" }); }); it("refuses with a manual command when the prefix is not writable and sudo asks", async () => { fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o555); update.__setSeams({ exec: async (file, args) => file === "sudo" ? { code: 1, stdout: "", stderr: "a password is required" } : { code: 0, stdout: "", stderr: "" }, }); try { if (process.getuid?.() === 0) return; // root writes anyway await assert.rejects(update.start(), (e) => e.code === "ENOPERM" && /sudo npm install -g 9router@latest/.test(e.command)); assert.equal(update.current(), null, "no job, so 9router was never stopped"); } finally { fs.chmodSync(path.join(PREFIX, "lib", "node_modules"), 0o755); } }); it("stops a running 9router (child included), updates it, and starts it again hidden", async () => { const port = await freePort(); startFake(port); assert.ok(await until(() => portOpen(port)), "fake 9router came up"); assert.ok(await until(() => fakePids().length >= 2), "with its child"); const before = fakePids(); const job = await update.start(); assert.equal(job.status, "running"); assert.equal(job.managedBy, "process"); const done = await finished(); assert.equal(done.status, "done", done.log + JSON.stringify(done.steps)); assert.deepEqual(done.steps.map((s) => s.status), ["done", "done", "done"]); assert.equal(done.from, "1.0.0"); assert.equal(done.to, "9.9.9"); for (const pid of before) assert.ok(!fakePids().includes(pid), `old pid ${pid} is gone`); assert.ok(await portOpen(port), "relaunched on the same port"); // Relaunched hidden: tray mode, no browser, no self-update. const argv = fakePids() .map((p) => fs.readFileSync(`/proc/${p}/cmdline`, "utf8").split("\0")) .find((a) => a.includes(path.join(PKG_DIR, "cli.js"))); for (const flag of ["--tray", "--no-browser", "--skip-update"]) assert.ok(argv.includes(flag), flag); }); it("leaves 9router off when it was not running", async () => { await update.start(); const done = await finished(); assert.equal(done.status, "done"); assert.deepEqual(done.steps.map((s) => s.status), ["skipped", "done", "skipped"]); assert.equal(fakePids().length, 0); }); it("still relaunches the old version when npm fails", async () => { fs.writeFileSync(path.join(TMP, "npm-fail"), ""); const port = await freePort(); startFake(port); assert.ok(await until(() => portOpen(port))); await update.start(); const done = await finished(); assert.equal(done.status, "failed"); assert.deepEqual(done.steps.map((s) => s.status), ["done", "failed", "done"]); assert.match(done.log, /boom/); assert.ok(await portOpen(port)); }); it("refuses a second start while one runs", async () => { const port = await freePort(); startFake(port); assert.ok(await until(() => portOpen(port))); await update.start(); await assert.rejects(update.start(), { code: "EBUSY" }); await finished(); }); }); describe("routes /api/ai-proxies/9router/update", () => { it("GET reports status, POST starts and answers 202, a refusal is a 409", async () => { const s = await (await fetch(`${BASE}/api/ai-proxies/9router/update`)).json(); assert.equal(s.version, "1.0.0"); const r = await fetch(`${BASE}/api/ai-proxies/9router/update`, { method: "POST" }); assert.equal(r.status, 202); await finished(); update.__setSeams({ fetchLatest: async () => "9.9.9" }); const again = await fetch(`${BASE}/api/ai-proxies/9router/update`, { method: "POST" }); assert.equal(again.status, 409); assert.equal((await again.json()).error.code, "EUPTODATE"); }); });