chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* ServiceSupervisor unit tests.
|
||||
*
|
||||
* Uses a real Node.js child process (`node -e "..."`) to test lifecycle
|
||||
* without mocking child_process — this gives realistic signal/exit behavior.
|
||||
*
|
||||
* A tiny HTTP health server is spawned inline for health-check tests.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-supervisor-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
// Import DB core first to trigger migration (creates version_manager with new columns)
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
|
||||
// Seed the tool rows needed by tests
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES ('test-svc', 'stopped', 29999, 0, 0, 0)`
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES ('test-crash', 'stopped', 29998, 0, 0, 0)`
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES ('test-lock', 'stopped', 29997, 0, 0, 0)`
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES ('test-adopt', 'stopped', 29996, 0, 0, 0)`
|
||||
).run();
|
||||
|
||||
const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts");
|
||||
|
||||
/** Starts a tiny HTTP health server on the given port that always returns 200. */
|
||||
function startHealthServer(port: number): http.Server {
|
||||
const server = http.createServer((_, res) => res.writeHead(200).end("ok"));
|
||||
server.listen(port);
|
||||
return server;
|
||||
}
|
||||
|
||||
/** Config for a service that logs "tick" every second and stays alive. */
|
||||
function tickConfig(tool: string, port: number) {
|
||||
return {
|
||||
tool,
|
||||
port,
|
||||
spawnArgs: () => ({
|
||||
command: process.execPath,
|
||||
args: ["-e", "setInterval(() => console.log('tick'), 500)"],
|
||||
env: { ...process.env },
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
healthUrl: () => `http://127.0.0.1:${port}/health`,
|
||||
healthIntervalMs: 500,
|
||||
stopTimeoutMs: 3_000,
|
||||
logsBufferBytes: 1_048_576,
|
||||
};
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("start spawns process and captures logs in ring buffer", async () => {
|
||||
const healthServer = startHealthServer(29999);
|
||||
const sup = new ServiceSupervisor(tickConfig("test-svc", 29999));
|
||||
|
||||
try {
|
||||
const status = await sup.start();
|
||||
assert.equal(status.state, "running");
|
||||
assert.ok(status.pid !== null, "pid should be set");
|
||||
|
||||
// Wait briefly to let ticks accumulate
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
|
||||
const snap = sup.getRingBuffer().snapshot();
|
||||
assert.ok(snap.length > 0, "ring buffer should have log entries");
|
||||
assert.ok(
|
||||
snap.some((e) => e.line.includes("tick")),
|
||||
"should capture stdout lines"
|
||||
);
|
||||
} finally {
|
||||
await sup.stop();
|
||||
healthServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("stop sends SIGTERM and waits, then SIGKILL if needed", async () => {
|
||||
const healthServer = startHealthServer(29999);
|
||||
const sup = new ServiceSupervisor({
|
||||
...tickConfig("test-svc", 29999),
|
||||
stopTimeoutMs: 500,
|
||||
});
|
||||
|
||||
try {
|
||||
await sup.start();
|
||||
const status = await sup.stop();
|
||||
assert.equal(status.state, "stopped");
|
||||
assert.equal(status.pid, null);
|
||||
} finally {
|
||||
healthServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("crash sets state=error and lastError (no auto-restart)", async () => {
|
||||
const healthServer = startHealthServer(29998);
|
||||
const crashConfig = {
|
||||
...tickConfig("test-crash", 29998),
|
||||
spawnArgs: () => ({
|
||||
command: process.execPath,
|
||||
// Exit after 1.5s — health server is up, so start() can return "running" first
|
||||
args: ["-e", "setTimeout(() => process.exit(1), 1500)"],
|
||||
env: { ...process.env },
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
healthIntervalMs: 300,
|
||||
};
|
||||
const sup = new ServiceSupervisor(crashConfig);
|
||||
const stateChanges: string[] = [];
|
||||
sup.on("stateChange", (s) => stateChanges.push(s.state));
|
||||
|
||||
try {
|
||||
await sup.start();
|
||||
assert.equal(sup.getStatus().state, "running");
|
||||
|
||||
// Poll for the crash to be detected (process exits at 1.5s, health checker detects it
|
||||
// within ~3 intervals). A fixed sleep flakes under CPU contention because the child's
|
||||
// exit timer and the health-check intervals all slip; poll with a generous deadline.
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (sup.getStatus().state !== "error" && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
const status = sup.getStatus();
|
||||
assert.equal(status.state, "error", "state should be error after crash");
|
||||
assert.ok(status.lastError !== null, "lastError should be set");
|
||||
assert.ok(
|
||||
!stateChanges.filter((s) => s === "starting").length ||
|
||||
stateChanges[stateChanges.length - 1] !== "starting",
|
||||
"supervisor must not restart after crash"
|
||||
);
|
||||
} finally {
|
||||
healthServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("restart is atomic (concurrent calls serialize)", async () => {
|
||||
const healthServer = startHealthServer(29999);
|
||||
const sup = new ServiceSupervisor(tickConfig("test-svc", 29999));
|
||||
|
||||
try {
|
||||
await sup.start();
|
||||
|
||||
// Fire 3 concurrent restarts — all should resolve without throwing
|
||||
const [s1, s2, s3] = await Promise.all([sup.restart(), sup.restart(), sup.restart()]);
|
||||
|
||||
assert.equal(s1.state, "running");
|
||||
assert.equal(s2.state, "running");
|
||||
assert.equal(s3.state, "running");
|
||||
|
||||
const final = sup.getStatus();
|
||||
assert.equal(final.state, "running");
|
||||
} finally {
|
||||
await sup.stop();
|
||||
healthServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("does NOT auto-restart on crash", async () => {
|
||||
const healthServer = startHealthServer(29998);
|
||||
const crashConfig = {
|
||||
...tickConfig("test-crash", 29998),
|
||||
spawnArgs: () => ({
|
||||
command: process.execPath,
|
||||
// Exit after 1.5s — same as crash test above
|
||||
args: ["-e", "setTimeout(() => process.exit(2), 1500)"],
|
||||
env: { ...process.env },
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
healthIntervalMs: 300,
|
||||
};
|
||||
const sup = new ServiceSupervisor(crashConfig);
|
||||
|
||||
try {
|
||||
await sup.start();
|
||||
// Wait for crash + one more health interval
|
||||
await new Promise((r) => setTimeout(r, 2_200));
|
||||
|
||||
const status = sup.getStatus();
|
||||
// After crash: state must be "error" or "stopped", never "starting" again
|
||||
assert.ok(
|
||||
status.state === "error" || status.state === "stopped",
|
||||
`supervisor should not auto-restart: state was "${status.state}"`
|
||||
);
|
||||
} finally {
|
||||
healthServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
// #6205: when probeBeforeSpawn is enabled and a healthy instance already serves
|
||||
// the port, the supervisor ADOPTS it (marks running, no child spawned) instead
|
||||
// of spawning a duplicate that would die with EADDRINUSE.
|
||||
test("#6205: probeBeforeSpawn adopts a healthy existing instance (no spawn)", async () => {
|
||||
const healthServer = startHealthServer(29996);
|
||||
const cfg = { ...tickConfig("test-adopt", 29996), probeBeforeSpawn: true };
|
||||
const sup = new ServiceSupervisor(cfg);
|
||||
|
||||
try {
|
||||
const status = await sup.start();
|
||||
assert.equal(status.state, "running", "adopted instance is marked running");
|
||||
assert.equal(status.pid, null, "no child process is spawned when adopting");
|
||||
// No child means no captured stdout ticks.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
assert.equal(sup.getRingBuffer().snapshot().length, 0, "no logs — nothing was spawned");
|
||||
} finally {
|
||||
await sup.stop();
|
||||
healthServer.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isLocalOnlyPath } from "../../../src/server/authz/routeGuard.ts";
|
||||
|
||||
test("isLocalOnlyPath: /api/services/bifrost/start is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/start"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/services/bifrost/install is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/install"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/services/bifrost/status is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/status"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/services/bifrost/stop is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/stop"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/services/bifrost/auto-start is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/auto-start"), true);
|
||||
});
|
||||
|
||||
test("isLocalOnlyPath: /api/services/bifrost/logs is local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/bifrost/logs"), true);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Unit tests for §4b routing-layer wiring:
|
||||
* getBifrostRoutingConfig uses supervised instance port when BIFROST_BASE_URL is unset.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { registerSupervisor, unregisterSupervisor } from "../../../src/lib/services/registry.ts";
|
||||
import { ServiceSupervisor } from "../../../src/lib/services/ServiceSupervisor.ts";
|
||||
import { getBifrostRoutingConfig } from "../../../src/app/api/v1/relay/chat/completions/routingBackend.ts";
|
||||
|
||||
test("getBifrostRoutingConfig: returns null when BIFROST_BASE_URL unset and no supervised service", () => {
|
||||
const result = getBifrostRoutingConfig({} as NodeJS.ProcessEnv);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("getBifrostRoutingConfig: uses BIFROST_BASE_URL when set (explicit env wins)", () => {
|
||||
const result = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://localhost:9999",
|
||||
} as NodeJS.ProcessEnv);
|
||||
assert.ok(result !== null);
|
||||
assert.equal(result?.baseUrl, "http://localhost:9999");
|
||||
});
|
||||
|
||||
test("getBifrostRoutingConfig: uses supervised port when BIFROST_BASE_URL unset and bifrost running", () => {
|
||||
// Register a stub supervisor whose getStatus() reports running on port 8080
|
||||
const stub = {
|
||||
getStatus: () => ({
|
||||
tool: "bifrost",
|
||||
state: "running" as const,
|
||||
port: 8080,
|
||||
health: "healthy" as const,
|
||||
pid: 1234,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
}),
|
||||
} as unknown as ServiceSupervisor;
|
||||
|
||||
registerSupervisor(stub);
|
||||
|
||||
try {
|
||||
const result = getBifrostRoutingConfig({} as NodeJS.ProcessEnv);
|
||||
assert.ok(result !== null, "should return config when supervised instance is running");
|
||||
assert.equal(result?.baseUrl, "http://127.0.0.1:8080");
|
||||
assert.equal(result?.enabled, true);
|
||||
} finally {
|
||||
unregisterSupervisor("bifrost");
|
||||
}
|
||||
});
|
||||
|
||||
test("getBifrostRoutingConfig: explicit BIFROST_BASE_URL overrides supervised port", () => {
|
||||
const stub = {
|
||||
getStatus: () => ({
|
||||
tool: "bifrost",
|
||||
state: "running" as const,
|
||||
port: 8080,
|
||||
health: "healthy" as const,
|
||||
pid: 1234,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
}),
|
||||
} as unknown as ServiceSupervisor;
|
||||
|
||||
registerSupervisor(stub);
|
||||
|
||||
try {
|
||||
const result = getBifrostRoutingConfig({
|
||||
BIFROST_BASE_URL: "http://remote-host:9999",
|
||||
} as NodeJS.ProcessEnv);
|
||||
assert.ok(result !== null);
|
||||
// Explicit env wins
|
||||
assert.equal(result?.baseUrl, "http://remote-host:9999");
|
||||
} finally {
|
||||
unregisterSupervisor("bifrost");
|
||||
}
|
||||
});
|
||||
|
||||
test("getBifrostRoutingConfig: stopped supervised instance does NOT provide baseUrl", () => {
|
||||
const stub = {
|
||||
getStatus: () => ({
|
||||
tool: "bifrost",
|
||||
state: "stopped" as const,
|
||||
port: 8080,
|
||||
health: "unknown" as const,
|
||||
pid: null,
|
||||
startedAt: null,
|
||||
lastError: null,
|
||||
}),
|
||||
} as unknown as ServiceSupervisor;
|
||||
|
||||
registerSupervisor(stub);
|
||||
|
||||
try {
|
||||
const result = getBifrostRoutingConfig({} as NodeJS.ProcessEnv);
|
||||
assert.equal(result, null, "stopped supervisor should not yield a baseUrl");
|
||||
} finally {
|
||||
unregisterSupervisor("bifrost");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Tests for comboMetrics memory management — eviction and TTL behavior.
|
||||
*
|
||||
* Verifies that:
|
||||
* - Recording and retrieval work correctly
|
||||
* - MAX_METRICS_ENTRIES (500) cap is enforced via eviction of oldest entries
|
||||
* - Shadow metrics are isolated from production metrics
|
||||
* - resetComboMetrics and resetAllComboMetrics clear state correctly
|
||||
* - getAllComboMetrics returns all entries
|
||||
* - recordComboIntent tracks intent counts
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const mod = await import("../../../open-sse/services/comboMetrics.ts");
|
||||
const {
|
||||
recordComboRequest,
|
||||
recordComboShadowRequest,
|
||||
getComboMetrics,
|
||||
getAllComboMetrics,
|
||||
resetComboMetrics,
|
||||
resetAllComboMetrics,
|
||||
recordComboIntent,
|
||||
} = mod;
|
||||
|
||||
// Reset all state before each test group to avoid cross-test pollution.
|
||||
resetAllComboMetrics();
|
||||
|
||||
// ─── Basic record + retrieve ────────────────────────────────────────────────
|
||||
|
||||
test("recordComboRequest: basic recording and retrieval", () => {
|
||||
resetAllComboMetrics();
|
||||
recordComboRequest("test-combo", "gpt-4", {
|
||||
success: true,
|
||||
latencyMs: 100,
|
||||
});
|
||||
const metrics = getComboMetrics("test-combo");
|
||||
assert.ok(metrics, "metrics should exist for recorded combo");
|
||||
assert.equal(metrics.totalRequests, 1, "should have 1 total request");
|
||||
assert.equal(metrics.totalSuccesses, 1, "should have 1 success");
|
||||
assert.equal(metrics.totalFailures, 0, "should have 0 failures");
|
||||
assert.equal(metrics.successRate, 100, "success rate should be 100%");
|
||||
assert.equal(metrics.avgLatencyMs, 100, "avg latency should be 100ms");
|
||||
});
|
||||
|
||||
test("recordComboRequest: tracks per-model metrics", () => {
|
||||
resetAllComboMetrics();
|
||||
recordComboRequest("combo-1", "gpt-4", { success: true, latencyMs: 100 });
|
||||
recordComboRequest("combo-1", "claude-3", { success: false, latencyMs: 200 });
|
||||
const metrics = getComboMetrics("combo-1");
|
||||
assert.ok(metrics);
|
||||
assert.equal(metrics.totalRequests, 2, "should have 2 total requests");
|
||||
assert.equal(metrics.successRate, 50, "success rate should be 50%");
|
||||
assert.ok(metrics.byModel["gpt-4"], "should have gpt-4 model metrics");
|
||||
assert.ok(metrics.byModel["claude-3"], "should have claude-3 model metrics");
|
||||
assert.equal(metrics.byModel["gpt-4"].successRate, 100);
|
||||
assert.equal(metrics.byModel["claude-3"].successRate, 0);
|
||||
});
|
||||
|
||||
test("recordComboRequest: handles null model string gracefully", () => {
|
||||
resetAllComboMetrics();
|
||||
recordComboRequest("combo-null", null, { success: true, latencyMs: 50 });
|
||||
const metrics = getComboMetrics("combo-null");
|
||||
assert.ok(metrics, "metrics should exist even with null model");
|
||||
assert.equal(metrics.totalRequests, 1);
|
||||
// null model should not create per-model entries
|
||||
assert.equal(Object.keys(metrics.byModel).length, 0, "should have no model entries");
|
||||
});
|
||||
|
||||
test("recordComboRequest: tracks fallback count", () => {
|
||||
resetAllComboMetrics();
|
||||
recordComboRequest("combo-fb", "gpt-4", {
|
||||
success: true,
|
||||
latencyMs: 200,
|
||||
fallbackCount: 2,
|
||||
});
|
||||
const metrics = getComboMetrics("combo-fb");
|
||||
assert.ok(metrics);
|
||||
assert.equal(metrics.totalFallbacks, 2, "should track 2 fallbacks");
|
||||
// fallbackRate = (totalFallbacks / totalRequests) * 100 = (2/1) * 100 = 200
|
||||
assert.equal(metrics.fallbackRate, 200, "fallback rate should be 200% (2 fallbacks on 1 request)");
|
||||
});
|
||||
|
||||
// ─── getComboMetrics returns null for unknown combos ─────────────────────────
|
||||
|
||||
test("getComboMetrics: returns null for unknown combo", () => {
|
||||
const metrics = getComboMetrics("nonexistent-combo-xyz");
|
||||
assert.equal(metrics, null, "unknown combo should return null");
|
||||
});
|
||||
|
||||
// ─── resetComboMetrics ───────────────────────────────────────────────────────
|
||||
|
||||
test("resetComboMetrics: clears a specific combo", () => {
|
||||
resetAllComboMetrics();
|
||||
recordComboRequest("to-keep", "gpt-4", { success: true, latencyMs: 100 });
|
||||
recordComboRequest("to-reset", "gpt-4", { success: true, latencyMs: 100 });
|
||||
resetComboMetrics("to-reset");
|
||||
assert.ok(getComboMetrics("to-keep"), "to-keep should still exist");
|
||||
assert.equal(getComboMetrics("to-reset"), null, "to-reset should be cleared");
|
||||
});
|
||||
|
||||
// ─── resetAllComboMetrics ────────────────────────────────────────────────────
|
||||
|
||||
test("resetAllComboMetrics: clears all production and shadow metrics", () => {
|
||||
recordComboRequest("combo-a", "gpt-4", { success: true, latencyMs: 100 });
|
||||
recordComboShadowRequest("shadow-a", "gpt-4", { success: true, latencyMs: 100 });
|
||||
assert.ok(getComboMetrics("combo-a"), "combo-a should exist before reset");
|
||||
assert.ok(getComboMetrics("shadow-a"), "shadow-a should exist before reset");
|
||||
resetAllComboMetrics();
|
||||
assert.equal(getComboMetrics("combo-a"), null, "combo-a should be cleared");
|
||||
assert.equal(getComboMetrics("shadow-a"), null, "shadow-a should be cleared");
|
||||
});
|
||||
|
||||
// ─── getAllComboMetrics ───────────────────────────────────────────────────────
|
||||
|
||||
test("getAllComboMetrics: returns all production and shadow entries", () => {
|
||||
resetAllComboMetrics();
|
||||
recordComboRequest("combo-x", "gpt-4", { success: true, latencyMs: 100 });
|
||||
recordComboRequest("combo-y", "claude-3", { success: false, latencyMs: 200 });
|
||||
recordComboShadowRequest("shadow-x", "gpt-4", { success: true, latencyMs: 150 });
|
||||
const all = getAllComboMetrics();
|
||||
assert.ok(all["combo-x"], "should include combo-x");
|
||||
assert.ok(all["combo-y"], "should include combo-y");
|
||||
assert.ok(all["shadow-x"], "should include shadow-x from shadow metrics");
|
||||
});
|
||||
|
||||
// ─── Shadow metrics ──────────────────────────────────────────────────────────
|
||||
|
||||
test("recordComboShadowRequest: tracks shadow metrics separately from production", () => {
|
||||
resetAllComboMetrics();
|
||||
recordComboRequest("prod-combo", "gpt-4", { success: true, latencyMs: 100 });
|
||||
recordComboShadowRequest("prod-combo", "claude-3", { success: false, latencyMs: 200 });
|
||||
const metrics = getComboMetrics("prod-combo");
|
||||
assert.ok(metrics);
|
||||
// Production sees only gpt-4
|
||||
assert.equal(metrics.totalRequests, 1, "production should have 1 request");
|
||||
assert.equal(metrics.successRate, 100, "production success rate should be 100%");
|
||||
// Shadow is separate
|
||||
assert.ok(metrics.shadow, "shadow metrics should be present");
|
||||
assert.equal(metrics.shadow.totalRequests, 1, "shadow should have 1 request");
|
||||
assert.equal(metrics.shadow.successRate, 0, "shadow success rate should be 0%");
|
||||
});
|
||||
|
||||
test("recordComboShadowRequest: shadow-only combo returns in getAllComboMetrics", () => {
|
||||
resetAllComboMetrics();
|
||||
recordComboShadowRequest("shadow-only", "gpt-4", { success: true, latencyMs: 50 });
|
||||
const all = getAllComboMetrics();
|
||||
assert.ok(all["shadow-only"], "shadow-only combo should appear in getAllComboMetrics");
|
||||
});
|
||||
|
||||
// ─── recordComboIntent ───────────────────────────────────────────────────────
|
||||
|
||||
test("recordComboIntent: tracks intent counts on existing combo", () => {
|
||||
resetAllComboMetrics();
|
||||
recordComboRequest("intent-combo", "gpt-4", { success: true, latencyMs: 100 });
|
||||
recordComboIntent("intent-combo", "chat");
|
||||
recordComboIntent("intent-combo", "chat");
|
||||
recordComboIntent("intent-combo", "code");
|
||||
const metrics = getComboMetrics("intent-combo");
|
||||
assert.ok(metrics);
|
||||
assert.equal(metrics.intentCounts["chat"], 2, "should count 2 chat intents");
|
||||
assert.equal(metrics.intentCounts["code"], 1, "should count 1 code intent");
|
||||
});
|
||||
|
||||
test("recordComboIntent: creates combo entry if not yet recorded", () => {
|
||||
resetAllComboMetrics();
|
||||
recordComboIntent("new-intent-combo", "search");
|
||||
const metrics = getComboMetrics("new-intent-combo");
|
||||
assert.ok(metrics, "combo should be created by recordComboIntent");
|
||||
assert.equal(metrics.intentCounts["search"], 1);
|
||||
});
|
||||
|
||||
// ─── Eviction: MAX_METRICS_ENTRIES cap ───────────────────────────────────────
|
||||
|
||||
test("eviction: inserting a new combo at capacity evicts the oldest entry", () => {
|
||||
resetAllComboMetrics();
|
||||
const MAX = 500;
|
||||
// Fill to capacity with unique combos
|
||||
for (let i = 0; i < MAX; i++) {
|
||||
recordComboRequest(`fill-${i}`, "gpt-4", { success: true, latencyMs: 10 });
|
||||
}
|
||||
// All 500 should be present
|
||||
assert.ok(getComboMetrics("fill-0"), "first inserted combo should exist at capacity");
|
||||
assert.ok(getComboMetrics(`fill-${MAX - 1}`), "last inserted combo should exist at capacity");
|
||||
|
||||
// Insert one more — should trigger eviction of the oldest entry
|
||||
recordComboRequest("new-after-capacity", "gpt-4", { success: true, latencyMs: 50 });
|
||||
const all = getAllComboMetrics();
|
||||
const totalProduction = Object.keys(all).filter((k) => {
|
||||
const m = all[k];
|
||||
return m && m.totalRequests > 0;
|
||||
}).length;
|
||||
// Map size should not exceed MAX (the new one replaced the oldest)
|
||||
assert.ok(totalProduction <= MAX, `production combos (${totalProduction}) should not exceed cap (${MAX})`);
|
||||
assert.ok(
|
||||
getComboMetrics("new-after-capacity"),
|
||||
"newly inserted combo should exist after eviction"
|
||||
);
|
||||
});
|
||||
|
||||
test("eviction: shadow metrics respect their own MAX_METRICS_ENTRIES cap", () => {
|
||||
resetAllComboMetrics();
|
||||
const MAX = 500;
|
||||
// Fill shadow metrics to capacity
|
||||
for (let i = 0; i < MAX; i++) {
|
||||
recordComboShadowRequest(`shadow-fill-${i}`, "gpt-4", {
|
||||
success: true,
|
||||
latencyMs: 10,
|
||||
});
|
||||
}
|
||||
assert.ok(
|
||||
getComboMetrics("shadow-fill-0"),
|
||||
"first shadow combo should exist at capacity"
|
||||
);
|
||||
|
||||
// Insert one more shadow — should trigger eviction
|
||||
recordComboShadowRequest("shadow-after-capacity", "gpt-4", {
|
||||
success: true,
|
||||
latencyMs: 50,
|
||||
});
|
||||
assert.ok(
|
||||
getComboMetrics("shadow-after-capacity"),
|
||||
"newly inserted shadow combo should exist after eviction"
|
||||
);
|
||||
});
|
||||
|
||||
test("eviction: shadow overflow does not delete production metrics with the same name", () => {
|
||||
resetAllComboMetrics();
|
||||
const MAX = 500;
|
||||
|
||||
recordComboRequest("shared-combo", "gpt-4", { success: true, latencyMs: 10 });
|
||||
|
||||
for (let i = 0; i < MAX; i++) {
|
||||
recordComboShadowRequest(i === 0 ? "shared-combo" : "shadow-fill-" + i, "gpt-4", {
|
||||
success: true,
|
||||
latencyMs: 10,
|
||||
});
|
||||
}
|
||||
|
||||
recordComboShadowRequest("shadow-after-capacity", "gpt-4", {
|
||||
success: true,
|
||||
latencyMs: 50,
|
||||
});
|
||||
|
||||
const metrics = getComboMetrics("shared-combo");
|
||||
assert.ok(metrics, "production metrics must survive shadow-only eviction");
|
||||
assert.equal(metrics.productionTraffic, true);
|
||||
assert.equal(metrics.totalRequests, 1);
|
||||
});
|
||||
|
||||
test("eviction: recordComboIntent respects MAX_METRICS_ENTRIES cap", () => {
|
||||
resetAllComboMetrics();
|
||||
const MAX = 500;
|
||||
// Fill production metrics to capacity
|
||||
for (let i = 0; i < MAX; i++) {
|
||||
recordComboRequest(`intent-fill-${i}`, "gpt-4", { success: true, latencyMs: 10 });
|
||||
}
|
||||
// Adding intent for a NEW combo should trigger eviction
|
||||
recordComboIntent("intent-after-capacity", "chat");
|
||||
assert.ok(
|
||||
getComboMetrics("intent-after-capacity"),
|
||||
"newly created intent combo should exist after eviction"
|
||||
);
|
||||
});
|
||||
|
||||
test("eviction: updating an existing combo at capacity does NOT evict", () => {
|
||||
resetAllComboMetrics();
|
||||
const MAX = 500;
|
||||
// Fill to capacity
|
||||
for (let i = 0; i < MAX; i++) {
|
||||
recordComboRequest(`update-${i}`, "gpt-4", { success: true, latencyMs: 10 });
|
||||
}
|
||||
// Update an existing combo — should NOT trigger eviction since it's not a new key
|
||||
recordComboRequest("update-0", "gpt-4", { success: false, latencyMs: 50 });
|
||||
const metrics = getComboMetrics("update-0");
|
||||
assert.ok(metrics, "updated combo should still exist");
|
||||
assert.equal(metrics.totalRequests, 2, "updated combo should have 2 requests");
|
||||
});
|
||||
|
||||
// ─── Strategy tracking ───────────────────────────────────────────────────────
|
||||
|
||||
test("recordComboRequest: stores the routing strategy", () => {
|
||||
resetAllComboMetrics();
|
||||
recordComboRequest("strat-combo", "gpt-4", {
|
||||
success: true,
|
||||
latencyMs: 100,
|
||||
strategy: "weighted",
|
||||
});
|
||||
const metrics = getComboMetrics("strat-combo");
|
||||
assert.ok(metrics);
|
||||
assert.equal(metrics.strategy, "weighted", "should store the strategy");
|
||||
});
|
||||
|
||||
test("recordComboRequest: defaults strategy to 'priority'", () => {
|
||||
resetAllComboMetrics();
|
||||
recordComboRequest("default-strat", "gpt-4", { success: true, latencyMs: 100 });
|
||||
const metrics = getComboMetrics("default-strat");
|
||||
assert.ok(metrics);
|
||||
assert.equal(metrics.strategy, "priority", "default strategy should be 'priority'");
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* T-07 — embed proxy route handler tests.
|
||||
*
|
||||
* Tests GET/POST/PUT/PATCH/DELETE handlers in
|
||||
* /dashboard/providers/services/[name]/embed/[[...path]]/route.ts.
|
||||
*
|
||||
* Uses registerSupervisor to inject fake supervisors (ESM live bindings
|
||||
* can't be reassigned, so direct module patching is not possible).
|
||||
*/
|
||||
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { registerSupervisor, unregisterSupervisor } from "../../../src/lib/services/registry.ts";
|
||||
import type { ServiceSupervisor } from "../../../src/lib/services/ServiceSupervisor.ts";
|
||||
import {
|
||||
GET,
|
||||
POST,
|
||||
PUT,
|
||||
PATCH,
|
||||
DELETE,
|
||||
HEAD,
|
||||
OPTIONS,
|
||||
} from "../../../src/app/(dashboard)/dashboard/providers/services/[name]/embed/[[...path]]/route.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
unregisterSupervisor("9router");
|
||||
});
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeFakeParams(
|
||||
name: string,
|
||||
path: string[]
|
||||
): { params: Promise<{ name: string; path: string[] }> } {
|
||||
return { params: Promise.resolve({ name, path }) };
|
||||
}
|
||||
|
||||
function registerFake(state: string, port: number): void {
|
||||
const fake = {
|
||||
getStatus: () => ({
|
||||
tool: "9router",
|
||||
state,
|
||||
port,
|
||||
pid: null,
|
||||
health: "unknown" as const,
|
||||
startedAt: null,
|
||||
lastError: null,
|
||||
}),
|
||||
};
|
||||
registerSupervisor(fake as unknown as ServiceSupervisor);
|
||||
}
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("embed proxy route", () => {
|
||||
it("returns 404 for unknown service", async () => {
|
||||
// No supervisor registered — getSupervisor returns null.
|
||||
const req = new Request("http://localhost/dashboard/providers/services/unknown/embed/");
|
||||
const resp = await GET(req, makeFakeParams("unknown", []));
|
||||
assert.equal(resp.status, 404);
|
||||
});
|
||||
|
||||
it("returns 503 when service exists but is not running", async () => {
|
||||
registerFake("stopped", 20130);
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/");
|
||||
const resp = await GET(req, makeFakeParams("9router", []));
|
||||
assert.equal(resp.status, 503);
|
||||
});
|
||||
|
||||
it("proxies GET to the upstream service", async () => {
|
||||
registerFake("running", 20130);
|
||||
let capturedUrl = "";
|
||||
globalThis.fetch = async (input: string | URL | Request) => {
|
||||
capturedUrl = String(input);
|
||||
return new Response("<html>9router UI</html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
};
|
||||
|
||||
const req = new Request(
|
||||
"http://localhost/dashboard/providers/services/9router/embed/ui/index.html"
|
||||
);
|
||||
const resp = await GET(req, makeFakeParams("9router", ["ui", "index.html"]));
|
||||
|
||||
assert.equal(resp.status, 200);
|
||||
assert.ok(capturedUrl.startsWith("http://127.0.0.1:20130/ui/index.html"));
|
||||
assert.ok((await resp.text()).includes("9router UI"));
|
||||
});
|
||||
|
||||
it("forwards query string to upstream", async () => {
|
||||
registerFake("running", 20130);
|
||||
let capturedUrl = "";
|
||||
globalThis.fetch = async (input: string | URL | Request) => {
|
||||
capturedUrl = String(input);
|
||||
return new Response("{}", { status: 200 });
|
||||
};
|
||||
|
||||
const req = new Request(
|
||||
"http://localhost/dashboard/providers/services/9router/embed/api/models?page=2"
|
||||
);
|
||||
await GET(req, makeFakeParams("9router", ["api", "models"]));
|
||||
assert.ok(capturedUrl.includes("?page=2"));
|
||||
});
|
||||
|
||||
it("proxies POST and forwards body", async () => {
|
||||
registerFake("running", 20130);
|
||||
let capturedMethod = "";
|
||||
globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
capturedMethod = init?.method ?? "UNKNOWN";
|
||||
return new Response('{"ok":true}', { status: 200 });
|
||||
};
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/api/v1", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ test: 1 }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const resp = await POST(req, makeFakeParams("9router", ["api", "v1"]));
|
||||
assert.equal(resp.status, 200);
|
||||
assert.equal(capturedMethod, "POST");
|
||||
});
|
||||
|
||||
it("strips hop-by-hop headers from the upstream response", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response("body", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/plain",
|
||||
"transfer-encoding": "chunked",
|
||||
connection: "keep-alive",
|
||||
"x-custom": "kept",
|
||||
},
|
||||
});
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/");
|
||||
const resp = await GET(req, makeFakeParams("9router", []));
|
||||
assert.equal(resp.headers.get("x-custom"), "kept");
|
||||
assert.equal(resp.headers.get("transfer-encoding"), null);
|
||||
assert.equal(resp.headers.get("connection"), null);
|
||||
});
|
||||
|
||||
it("returns 502 on upstream network error", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
};
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/");
|
||||
const resp = await GET(req, makeFakeParams("9router", []));
|
||||
assert.equal(resp.status, 502);
|
||||
});
|
||||
|
||||
it("PUT, PATCH, DELETE are handled", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) =>
|
||||
new Response(null, { status: 204 });
|
||||
|
||||
const params = makeFakeParams("9router", ["resource", "1"]);
|
||||
const reqUrl = "http://localhost/dashboard/providers/services/9router/embed/resource/1";
|
||||
|
||||
assert.equal((await PUT(new Request(reqUrl, { method: "PUT" }), params)).status, 204);
|
||||
assert.equal((await PATCH(new Request(reqUrl, { method: "PATCH" }), params)).status, 204);
|
||||
assert.equal((await DELETE(new Request(reqUrl, { method: "DELETE" }), params)).status, 204);
|
||||
});
|
||||
|
||||
// ─── G-05: cookie/auth strip + response header strip + HTML rewrite ──────────
|
||||
|
||||
it("G-05: strips cookie header before forwarding to upstream", async () => {
|
||||
registerFake("running", 20130);
|
||||
let capturedHeaders: Record<string, string> = {};
|
||||
globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
for (const [k, v] of new Headers(init?.headers as HeadersInit).entries()) {
|
||||
capturedHeaders[k.toLowerCase()] = v;
|
||||
}
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/", {
|
||||
headers: { cookie: "session=abc123; jwt=secret" },
|
||||
});
|
||||
await GET(req, makeFakeParams("9router", []));
|
||||
assert.equal(capturedHeaders["cookie"], undefined, "cookie must not be forwarded upstream");
|
||||
});
|
||||
|
||||
it("G-05: sets Authorization: Bearer on upstream request", async () => {
|
||||
registerFake("running", 20130);
|
||||
let capturedAuth: string | undefined;
|
||||
globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
capturedAuth = new Headers(init?.headers as HeadersInit).get("authorization") ?? undefined;
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/");
|
||||
await GET(req, makeFakeParams("9router", []));
|
||||
assert.ok(capturedAuth, "authorization header must be present");
|
||||
assert.ok(capturedAuth!.startsWith("Bearer "), "authorization must be a Bearer token");
|
||||
});
|
||||
|
||||
it("G-05: strips client Authorization before forwarding, injects service key instead", async () => {
|
||||
registerFake("running", 20130);
|
||||
let capturedAuth: string | undefined;
|
||||
globalThis.fetch = async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
capturedAuth = new Headers(init?.headers as HeadersInit).get("authorization") ?? undefined;
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/", {
|
||||
headers: { authorization: "Bearer client-token-should-not-leak" },
|
||||
});
|
||||
await GET(req, makeFakeParams("9router", []));
|
||||
assert.ok(capturedAuth, "authorization header must be set");
|
||||
assert.notEqual(
|
||||
capturedAuth,
|
||||
"Bearer client-token-should-not-leak",
|
||||
"client authorization must not be forwarded as-is"
|
||||
);
|
||||
});
|
||||
|
||||
it("G-05: strips set-cookie from upstream response", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response("ok", {
|
||||
status: 200,
|
||||
headers: { "set-cookie": "session=upstream; Path=/", "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/"),
|
||||
makeFakeParams("9router", [])
|
||||
);
|
||||
assert.equal(resp.headers.get("set-cookie"), null, "set-cookie must be stripped from response");
|
||||
});
|
||||
|
||||
it("G-05: strips x-frame-options from upstream response", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response("ok", {
|
||||
status: 200,
|
||||
headers: { "x-frame-options": "DENY", "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/"),
|
||||
makeFakeParams("9router", [])
|
||||
);
|
||||
assert.equal(resp.headers.get("x-frame-options"), null, "x-frame-options must be stripped");
|
||||
});
|
||||
|
||||
it("G-05: strips content-security-policy from upstream response", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response("ok", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-security-policy": "default-src 'none'",
|
||||
"content-type": "text/plain",
|
||||
},
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/"),
|
||||
makeFakeParams("9router", [])
|
||||
);
|
||||
assert.equal(
|
||||
resp.headers.get("content-security-policy"),
|
||||
null,
|
||||
"content-security-policy must be stripped"
|
||||
);
|
||||
});
|
||||
|
||||
it("G-05: strips cross-origin-* headers from upstream response", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response("ok", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cross-origin-embedder-policy": "require-corp",
|
||||
"cross-origin-opener-policy": "same-origin",
|
||||
"cross-origin-resource-policy": "same-site",
|
||||
"content-type": "text/plain",
|
||||
},
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/"),
|
||||
makeFakeParams("9router", [])
|
||||
);
|
||||
assert.equal(
|
||||
resp.headers.get("cross-origin-embedder-policy"),
|
||||
null,
|
||||
"cross-origin-embedder-policy must be stripped"
|
||||
);
|
||||
assert.equal(
|
||||
resp.headers.get("cross-origin-opener-policy"),
|
||||
null,
|
||||
"cross-origin-opener-policy must be stripped"
|
||||
);
|
||||
assert.equal(
|
||||
resp.headers.get("cross-origin-resource-policy"),
|
||||
null,
|
||||
"cross-origin-resource-policy must be stripped"
|
||||
);
|
||||
});
|
||||
|
||||
it("G-05: HTML response is rewritten — contains injected <base href>", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response('<html><head></head><body><a href="/dashboard">link</a></body></html>', {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/"),
|
||||
makeFakeParams("9router", [])
|
||||
);
|
||||
const body = await resp.text();
|
||||
assert.ok(
|
||||
body.includes('<base href="/dashboard/providers/services/9router/embed/">'),
|
||||
`Expected <base href> in rewritten HTML. Got: ${body.substring(0, 200)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("G-05: HTML response rewrites path-absolute links to go through proxy", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response('<html><head></head><body><a href="/ui/page">x</a></body></html>', {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/"),
|
||||
makeFakeParams("9router", [])
|
||||
);
|
||||
const body = await resp.text();
|
||||
assert.ok(
|
||||
body.includes('href="/dashboard/providers/services/9router/embed/ui/page"'),
|
||||
`Expected rewritten href. Got: ${body.substring(0, 300)}`
|
||||
);
|
||||
});
|
||||
|
||||
it("G-05: JSON response is NOT rewritten (streaming pass-through)", async () => {
|
||||
registerFake("running", 20130);
|
||||
const jsonPayload = '{"models":["gpt-4","claude-3"]}';
|
||||
globalThis.fetch = async () =>
|
||||
new Response(jsonPayload, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const resp = await GET(
|
||||
new Request("http://localhost/dashboard/providers/services/9router/embed/api/models"),
|
||||
makeFakeParams("9router", ["api", "models"])
|
||||
);
|
||||
const body = await resp.text();
|
||||
assert.equal(body, jsonPayload, "JSON response must pass through unchanged");
|
||||
});
|
||||
|
||||
it("G-05: HEAD method is handled", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response(null, { status: 200, headers: { "content-type": "text/html" } });
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/", {
|
||||
method: "HEAD",
|
||||
});
|
||||
const resp = await HEAD(req, makeFakeParams("9router", []));
|
||||
assert.equal(resp.status, 200);
|
||||
});
|
||||
|
||||
it("G-05: OPTIONS method is handled", async () => {
|
||||
registerFake("running", 20130);
|
||||
globalThis.fetch = async () =>
|
||||
new Response(null, {
|
||||
status: 204,
|
||||
headers: { allow: "GET, HEAD, POST, OPTIONS" },
|
||||
});
|
||||
|
||||
const req = new Request("http://localhost/dashboard/providers/services/9router/embed/", {
|
||||
method: "OPTIONS",
|
||||
});
|
||||
const resp = await OPTIONS(req, makeFakeParams("9router", []));
|
||||
assert.equal(resp.status, 204);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* T-08 — embedWsProxy unit tests.
|
||||
*
|
||||
* Tests the internal helper functions of embedWsProxy.ts without starting
|
||||
* a real server (avoids port binding in CI). Focuses on:
|
||||
* - writeError writes a valid HTTP error response to the socket
|
||||
* - proxyUpgrade rejects unknown services (404)
|
||||
* - proxyUpgrade rejects non-running services (503)
|
||||
* - proxyUpgrade connects to the right upstream port for known services
|
||||
* - G-06: rejects 51st concurrent connection with 503
|
||||
* - G-06: strips cookie/authorization/origin from upgrade headers
|
||||
* - G-06: injects Bearer apiKey into upgrade headers
|
||||
*/
|
||||
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import net from "node:net";
|
||||
|
||||
import {
|
||||
registerSupervisor,
|
||||
unregisterSupervisor,
|
||||
getSupervisor,
|
||||
} from "../../../src/lib/services/registry.ts";
|
||||
const registryPublicApi = await import("../../../src/lib/services/registry.ts");
|
||||
import type { ServiceSupervisor } from "../../../src/lib/services/ServiceSupervisor.ts";
|
||||
import {
|
||||
activeConnections,
|
||||
registerConnection,
|
||||
unregisterConnection,
|
||||
buildUpstreamHeaders,
|
||||
MAX_CONNECTIONS_PER_SERVICE,
|
||||
} from "../../../src/lib/services/embedWsProxy.ts";
|
||||
|
||||
afterEach(() => {
|
||||
unregisterSupervisor("9router");
|
||||
// Clean up any connections left by G-06 tests
|
||||
activeConnections.delete("test-service");
|
||||
activeConnections.delete("9router");
|
||||
});
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function registerFake(state: string, port: number): void {
|
||||
registerSupervisor({
|
||||
getStatus: () => ({
|
||||
tool: "9router",
|
||||
state,
|
||||
port,
|
||||
pid: null,
|
||||
health: "unknown" as const,
|
||||
startedAt: null,
|
||||
lastError: null,
|
||||
}),
|
||||
} as unknown as ServiceSupervisor);
|
||||
}
|
||||
|
||||
/** Creates a mock socket that captures written bytes and emits "connect". */
|
||||
function makeSocket(): { socket: net.Socket; received: Buffer[] } {
|
||||
const received: Buffer[] = [];
|
||||
const socket = new net.Socket();
|
||||
(socket as { write: (chunk: Buffer | string) => void }).write = (chunk: Buffer | string) => {
|
||||
received.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
};
|
||||
(socket as { end: (chunk?: Buffer | string) => void }).end = (chunk?: Buffer | string) => {
|
||||
if (chunk) received.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
};
|
||||
Object.defineProperty(socket, "writable", { get: () => true });
|
||||
Object.defineProperty(socket, "destroyed", { get: () => false });
|
||||
return { socket, received };
|
||||
}
|
||||
|
||||
/** Reads all received buffers as a single string. */
|
||||
function joined(received: Buffer[]): string {
|
||||
return Buffer.concat(received).toString();
|
||||
}
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("embedWsProxy", () => {
|
||||
it("registry public surface excludes unused supervisor listing helper", () => {
|
||||
assert.equal("listSupervisors" in registryPublicApi, false);
|
||||
});
|
||||
|
||||
it("idempotent — initEmbedWsProxy does not bind twice", async () => {
|
||||
// Reset the global flag so we can test it from scratch
|
||||
const prev = globalThis.__omnirouteEmbedWsStarted;
|
||||
globalThis.__omnirouteEmbedWsStarted = true;
|
||||
|
||||
const { initEmbedWsProxy } = await import("../../../src/lib/services/embedWsProxy.ts");
|
||||
|
||||
// Should return immediately without creating a server (already started)
|
||||
assert.doesNotThrow(() => initEmbedWsProxy());
|
||||
|
||||
globalThis.__omnirouteEmbedWsStarted = prev;
|
||||
});
|
||||
|
||||
it("PATH_RE: /9router/path correctly identifies name and rest", () => {
|
||||
// Test the path regex logic by simulating what proxyUpgrade does.
|
||||
const PATH_RE = /^\/([^/?#]+)(\/.*)?$/;
|
||||
|
||||
const m1 = PATH_RE.exec("/9router/ui/index.html");
|
||||
assert.ok(m1);
|
||||
assert.equal(m1[1], "9router");
|
||||
assert.equal(m1[2], "/ui/index.html");
|
||||
|
||||
const m2 = PATH_RE.exec("/9router");
|
||||
assert.ok(m2);
|
||||
assert.equal(m2[1], "9router");
|
||||
assert.equal(m2[2], undefined);
|
||||
|
||||
assert.equal(PATH_RE.exec("/"), null);
|
||||
assert.equal(PATH_RE.exec(""), null);
|
||||
});
|
||||
|
||||
it("writeError sends a well-formed HTTP error response", () => {
|
||||
const { socket, received } = makeSocket();
|
||||
|
||||
// Simulate what writeError does (same logic as the module)
|
||||
const status = 404;
|
||||
const message = "Service 'foo' not found";
|
||||
const body = Buffer.from(JSON.stringify({ error: message }), "utf8");
|
||||
const lines = [
|
||||
`HTTP/1.1 ${status} Not Found`,
|
||||
"Connection: close",
|
||||
"Content-Type: application/json; charset=utf-8",
|
||||
`Content-Length: ${body.length}`,
|
||||
"",
|
||||
"",
|
||||
];
|
||||
socket.write(lines.join("\r\n"));
|
||||
socket.end(body);
|
||||
|
||||
const raw = joined(received);
|
||||
assert.ok(raw.startsWith("HTTP/1.1 404 Not Found\r\n"), "starts with status line");
|
||||
assert.ok(raw.includes("Content-Type: application/json"), "has content-type");
|
||||
assert.ok(raw.includes(message), "body contains message");
|
||||
});
|
||||
|
||||
it("getSupervisor lookup fails for unregistered name → null", () => {
|
||||
assert.equal(getSupervisor("nonexistent"), null);
|
||||
});
|
||||
|
||||
it("service registered as stopped is detectable via getStatus", () => {
|
||||
registerFake("stopped", 20130);
|
||||
const sup = getSupervisor("9router");
|
||||
assert.ok(sup !== null);
|
||||
const status = sup.getStatus();
|
||||
assert.equal(status.state, "stopped");
|
||||
assert.equal(status.port, 20130);
|
||||
});
|
||||
|
||||
it("service registered as running is detectable via getStatus", () => {
|
||||
registerFake("running", 20130);
|
||||
const sup = getSupervisor("9router");
|
||||
assert.ok(sup !== null);
|
||||
assert.equal(sup.getStatus().state, "running");
|
||||
});
|
||||
|
||||
// ─── G-06 tests ──────────────────────────────────────────────────────────
|
||||
|
||||
it("G-06: rejects 51st concurrent connection with 503", () => {
|
||||
const serviceName = "test-service";
|
||||
|
||||
// Fill up to the limit
|
||||
const sockets: net.Socket[] = [];
|
||||
for (let i = 0; i < MAX_CONNECTIONS_PER_SERVICE; i++) {
|
||||
const { socket } = makeSocket();
|
||||
const accepted = registerConnection(serviceName, socket);
|
||||
assert.ok(accepted, `connection ${i + 1} should be accepted`);
|
||||
sockets.push(socket);
|
||||
}
|
||||
|
||||
// The 51st should be rejected
|
||||
const { socket: socket51, received: received51 } = makeSocket();
|
||||
const rejected = registerConnection(serviceName, socket51);
|
||||
assert.equal(rejected, false, "51st connection must be rejected");
|
||||
|
||||
// The response written to the 51st socket must be a 503
|
||||
const raw = joined(received51);
|
||||
assert.ok(raw.startsWith("HTTP/1.1 503"), "rejected socket gets 503 status line");
|
||||
assert.ok(raw.includes("connection limit"), "503 body mentions connection limit");
|
||||
|
||||
// Clean up
|
||||
for (const s of sockets) {
|
||||
unregisterConnection(serviceName, s);
|
||||
}
|
||||
});
|
||||
|
||||
it("G-06: strips cookie, authorization, and origin from upgrade headers", () => {
|
||||
const rawHeaders = [
|
||||
"Host",
|
||||
"localhost:3000",
|
||||
"Connection",
|
||||
"Upgrade",
|
||||
"Upgrade",
|
||||
"websocket",
|
||||
"Cookie",
|
||||
"session=abc123",
|
||||
"Authorization",
|
||||
"Bearer client-token",
|
||||
"Origin",
|
||||
"http://localhost:3000",
|
||||
"Sec-WebSocket-Key",
|
||||
"dGhlIHNhbXBsZSBub25jZQ==",
|
||||
"Sec-WebSocket-Version",
|
||||
"13",
|
||||
];
|
||||
|
||||
const headers = buildUpstreamHeaders(rawHeaders, 20130, "nr_injectedkey");
|
||||
const headerStr = headers.join("\r\n").toLowerCase();
|
||||
|
||||
assert.ok(!headerStr.includes("cookie:"), "cookie must be stripped");
|
||||
assert.ok(
|
||||
!headerStr.includes("bearer client-token"),
|
||||
"original authorization must be stripped"
|
||||
);
|
||||
assert.ok(!headerStr.includes("origin:"), "origin must be stripped");
|
||||
|
||||
// Non-stripped headers must remain
|
||||
assert.ok(headerStr.includes("upgrade: websocket"), "upgrade header must be preserved");
|
||||
assert.ok(headerStr.includes("sec-websocket-key:"), "sec-websocket-key must be preserved");
|
||||
});
|
||||
|
||||
it("G-06: injects Bearer apiKey into upgrade headers replacing any client Authorization", () => {
|
||||
const apiKey = "nr_testapikey1234";
|
||||
const rawHeaders = [
|
||||
"Host",
|
||||
"localhost",
|
||||
"Authorization",
|
||||
"Bearer old-client-token",
|
||||
"Upgrade",
|
||||
"websocket",
|
||||
];
|
||||
|
||||
const headers = buildUpstreamHeaders(rawHeaders, 20130, apiKey);
|
||||
const authHeaders = headers.filter((h) => h.toLowerCase().startsWith("authorization:"));
|
||||
|
||||
// Must have exactly one Authorization header
|
||||
assert.equal(authHeaders.length, 1, "exactly one Authorization header must be present");
|
||||
assert.ok(
|
||||
authHeaders[0].includes(`Bearer ${apiKey}`),
|
||||
`Authorization must be 'Bearer ${apiKey}', got: ${authHeaders[0]}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const previousDataDir = process.env.DATA_DIR;
|
||||
const previousDisableSqliteAutoBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-emergency-fallback-"));
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const { setFeatureFlagOverride, removeFeatureFlagOverride } =
|
||||
await import("../../../src/lib/db/featureFlags.ts");
|
||||
const {
|
||||
shouldUseFallback,
|
||||
isEmergencyFallbackEnvEnabled,
|
||||
EMERGENCY_FALLBACK_CONFIG,
|
||||
resetEmergencyFallbackEnvCache,
|
||||
setEmergencyFallbackFeatureFlagResolverForTest,
|
||||
} = await import("../../../open-sse/services/emergencyFallback.ts");
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function resetTestState() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
delete process.env.OMNIROUTE_EMERGENCY_FALLBACK;
|
||||
resetEmergencyFallbackEnvCache();
|
||||
setEmergencyFallbackFeatureFlagResolverForTest(null);
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
resetEmergencyFallbackEnvCache();
|
||||
setEmergencyFallbackFeatureFlagResolverForTest(null);
|
||||
delete process.env.OMNIROUTE_EMERGENCY_FALLBACK;
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
restoreEnv("DATA_DIR", previousDataDir);
|
||||
restoreEnv("DISABLE_SQLITE_AUTO_BACKUP", previousDisableSqliteAutoBackup);
|
||||
});
|
||||
|
||||
function withEnv(value: string | undefined, fn: () => void) {
|
||||
const previous = process.env.OMNIROUTE_EMERGENCY_FALLBACK;
|
||||
if (value === undefined) {
|
||||
delete process.env.OMNIROUTE_EMERGENCY_FALLBACK;
|
||||
} else {
|
||||
process.env.OMNIROUTE_EMERGENCY_FALLBACK = value;
|
||||
}
|
||||
resetEmergencyFallbackEnvCache();
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
restoreEnv("OMNIROUTE_EMERGENCY_FALLBACK", previous);
|
||||
resetEmergencyFallbackEnvCache();
|
||||
}
|
||||
}
|
||||
|
||||
function withFeatureFlagOverride(value: string, fn: () => void) {
|
||||
try {
|
||||
setFeatureFlagOverride("OMNIROUTE_EMERGENCY_FALLBACK", value);
|
||||
resetEmergencyFallbackEnvCache();
|
||||
fn();
|
||||
} finally {
|
||||
removeFeatureFlagOverride("OMNIROUTE_EMERGENCY_FALLBACK");
|
||||
resetEmergencyFallbackEnvCache();
|
||||
}
|
||||
}
|
||||
|
||||
test("emergency fallback stays enabled when the env switch is unset (default behavior)", () => {
|
||||
withEnv(undefined, () => {
|
||||
assert.equal(isEmergencyFallbackEnvEnabled(), true);
|
||||
const decision = shouldUseFallback(402, "", false);
|
||||
assert.equal(decision.shouldFallback, true);
|
||||
if (decision.shouldFallback) {
|
||||
assert.equal(decision.provider, EMERGENCY_FALLBACK_CONFIG.provider);
|
||||
assert.equal(decision.model, EMERGENCY_FALLBACK_CONFIG.model);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("budget keywords trigger fallback when the env switch is unset", () => {
|
||||
withEnv(undefined, () => {
|
||||
const decision = shouldUseFallback(429, "All accounts quota exceeded", false);
|
||||
assert.equal(decision.shouldFallback, true);
|
||||
});
|
||||
});
|
||||
|
||||
test("OMNIROUTE_EMERGENCY_FALLBACK=false disables the 402 redirect", () => {
|
||||
withEnv("false", () => {
|
||||
assert.equal(isEmergencyFallbackEnvEnabled(), false);
|
||||
const decision = shouldUseFallback(402, "", false);
|
||||
assert.equal(decision.shouldFallback, false);
|
||||
assert.match(decision.reason, /OMNIROUTE_EMERGENCY_FALLBACK/);
|
||||
});
|
||||
});
|
||||
|
||||
test("OMNIROUTE_EMERGENCY_FALLBACK=0 disables the budget-keyword redirect", () => {
|
||||
withEnv("0", () => {
|
||||
const decision = shouldUseFallback(429, "quota exceeded for account", false);
|
||||
assert.equal(decision.shouldFallback, false);
|
||||
assert.match(decision.reason, /OMNIROUTE_EMERGENCY_FALLBACK/);
|
||||
});
|
||||
});
|
||||
|
||||
test("DB feature flag override can disable an env-enabled fallback", () => {
|
||||
withEnv("true", () => {
|
||||
withFeatureFlagOverride("false", () => {
|
||||
const decision = shouldUseFallback(402, "", false);
|
||||
assert.equal(isEmergencyFallbackEnvEnabled(), false);
|
||||
assert.equal(decision.shouldFallback, false);
|
||||
assert.match(decision.reason, /OMNIROUTE_EMERGENCY_FALLBACK/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("DB feature flag override can enable an env-disabled fallback", () => {
|
||||
withEnv("false", () => {
|
||||
withFeatureFlagOverride("true", () => {
|
||||
const decision = shouldUseFallback(402, "", false);
|
||||
assert.equal(isEmergencyFallbackEnvEnabled(), true);
|
||||
assert.equal(decision.shouldFallback, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("raw env fallback is used when feature flag resolution throws", () => {
|
||||
withEnv("0", () => {
|
||||
const warnings: unknown[][] = [];
|
||||
const previousWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warnings.push(args);
|
||||
};
|
||||
setEmergencyFallbackFeatureFlagResolverForTest(() => {
|
||||
throw new Error("feature flag store unavailable");
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(isEmergencyFallbackEnvEnabled(), false);
|
||||
const decision = shouldUseFallback(402, "", false);
|
||||
assert.equal(decision.shouldFallback, false);
|
||||
assert.match(decision.reason, /OMNIROUTE_EMERGENCY_FALLBACK/);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(String(warnings[0]?.[0]), /Feature flag resolution failed/);
|
||||
} finally {
|
||||
console.warn = previousWarn;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("explicit truthy values keep the fallback enabled", () => {
|
||||
withEnv("true", () => {
|
||||
assert.equal(isEmergencyFallbackEnvEnabled(), true);
|
||||
assert.equal(shouldUseFallback(402, "", false).shouldFallback, true);
|
||||
});
|
||||
});
|
||||
|
||||
test("env switch does not override config.enabled=false", () => {
|
||||
withEnv("true", () => {
|
||||
const decision = shouldUseFallback(402, "", false, {
|
||||
...EMERGENCY_FALLBACK_CONFIG,
|
||||
enabled: false,
|
||||
});
|
||||
assert.equal(decision.shouldFallback, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("tool-bearing requests are still skipped regardless of env switch", () => {
|
||||
withEnv(undefined, () => {
|
||||
const decision = shouldUseFallback(402, "", true);
|
||||
assert.equal(decision.shouldFallback, false);
|
||||
assert.match(decision.reason, /tools/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* G-12: End-to-end shape consistency tests for embedded service routes.
|
||||
*
|
||||
* Tests the response shape of the 7 core routes for both 9router and cliproxy
|
||||
* by importing handlers directly and mocking their dependencies.
|
||||
*
|
||||
* No real HTTP server required — handlers are invoked as plain async functions.
|
||||
*
|
||||
* Covered routes:
|
||||
* 9router: status (GET), start (POST), stop (POST), install (POST)
|
||||
* cliproxy: status (GET), start (POST), stop (POST), install (POST)
|
||||
*/
|
||||
|
||||
import { describe, it, before, after, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Isolated test DB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-e2e-shape-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
process.env.NINEROUTER_PORT = "20130";
|
||||
|
||||
// Boot DB before any route imports so the schema is ready.
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const { upsertVersionManagerTool } = await import("../../../src/lib/db/versionManager.ts");
|
||||
|
||||
// Seed both services as "stopped" (installed) so lifecycle routes proceed.
|
||||
await upsertVersionManagerTool({ tool: "9router", status: "stopped" });
|
||||
await upsertVersionManagerTool({ tool: "cliproxy", status: "stopped" });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal mock supervisor that shapes match what the handlers produce
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeRunningStatus = (tool: string, port: number) => ({
|
||||
tool,
|
||||
state: "running" as const,
|
||||
pid: 12345,
|
||||
port,
|
||||
health: "healthy" as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const makeStoppedStatus = (tool: string, port: number) => ({
|
||||
tool,
|
||||
state: "stopped" as const,
|
||||
pid: null,
|
||||
port,
|
||||
health: "unknown" as const,
|
||||
startedAt: null,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: parse a Response body safely
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function jsonBody(res: Response): Promise<unknown> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: assert common error shape { error: { message, type }, requestId }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function assertErrorShape(body: unknown): void {
|
||||
const b = body as Record<string, unknown>;
|
||||
assert.ok("error" in b, "Error response must have 'error' field");
|
||||
const err = b.error as Record<string, unknown>;
|
||||
assert.ok(typeof err.message === "string", "error.message must be a string");
|
||||
assert.ok(typeof err.type === "string", "error.type must be a string");
|
||||
assert.ok(typeof b.requestId === "string", "Error response must have 'requestId' field");
|
||||
// Hard rule #12: no stack trace exposure
|
||||
const bodyStr = JSON.stringify(body);
|
||||
assert.ok(!bodyStr.includes("at /"), "Error body must not expose stack trace");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: assert ServiceStatus shape { tool, state, pid, port, health, ... }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function assertServiceStatusShape(body: unknown, expectedTool: string): void {
|
||||
const b = body as Record<string, unknown>;
|
||||
assert.ok(typeof b.tool === "string", `body.tool must be string, got ${typeof b.tool}`);
|
||||
assert.equal(b.tool, expectedTool, `body.tool must be '${expectedTool}'`);
|
||||
assert.ok(typeof b.state === "string", `body.state must be string, got ${typeof b.state}`);
|
||||
assert.ok(
|
||||
b.pid === null || typeof b.pid === "number",
|
||||
`body.pid must be null or number, got ${typeof b.pid}`
|
||||
);
|
||||
assert.ok(typeof b.port === "number", `body.port must be number, got ${typeof b.port}`);
|
||||
assert.ok(typeof b.health === "string", `body.health must be string, got ${typeof b.health}`);
|
||||
assert.ok(
|
||||
b.startedAt === null || typeof b.startedAt === "string",
|
||||
`body.startedAt must be null or string, got ${typeof b.startedAt}`
|
||||
);
|
||||
assert.ok(
|
||||
b.lastError === null || typeof b.lastError === "string",
|
||||
`body.lastError must be null or string, got ${typeof b.lastError}`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9router routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("9router — response shapes", () => {
|
||||
describe("GET /api/services/9router/status", () => {
|
||||
it("200 response has enriched status shape including installedVersion and apiKeyMasked", async () => {
|
||||
const { GET } =
|
||||
await import("../../../src/app/api/services/9router/status/route.ts?t=shape-status-1");
|
||||
const res = await GET();
|
||||
assert.equal(res.status, 200);
|
||||
const body = await jsonBody(res);
|
||||
const b = body as Record<string, unknown>;
|
||||
|
||||
assertServiceStatusShape(body, "9router");
|
||||
// 9router/status extends ServiceStatus with extra fields
|
||||
assert.ok("installedVersion" in b, "9router status must include installedVersion");
|
||||
assert.ok("updateAvailable" in b, "9router status must include updateAvailable");
|
||||
assert.ok("apiKeyMasked" in b, "9router status must include apiKeyMasked");
|
||||
assert.ok("autoStart" in b, "9router status must include autoStart");
|
||||
assert.ok("providerExpose" in b, "9router status must include providerExpose");
|
||||
assert.ok(typeof b.updateAvailable === "boolean", "updateAvailable must be boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/services/9router/start", () => {
|
||||
it("200 response has ServiceStatus shape when supervisor.start() resolves", async () => {
|
||||
const { getSupervisor, registerSupervisor } =
|
||||
await import("../../../src/lib/services/registry.ts");
|
||||
const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts");
|
||||
|
||||
// Register a mock supervisor that returns running status immediately.
|
||||
const sup = new ServiceSupervisor({
|
||||
tool: "9router",
|
||||
port: 20130,
|
||||
spawnArgs: () => ({
|
||||
command: process.execPath,
|
||||
args: ["-e", "setTimeout(() => {}, 60000)"],
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
healthUrl: () => "http://127.0.0.1:20130/api/health",
|
||||
healthIntervalMs: 500,
|
||||
stopTimeoutMs: 500,
|
||||
logsBufferBytes: 1_048_576,
|
||||
});
|
||||
|
||||
mock.method(sup, "start", async () => makeRunningStatus("9router", 20130));
|
||||
registerSupervisor(sup);
|
||||
|
||||
const { POST } =
|
||||
await import("../../../src/app/api/services/9router/start/route.ts?t=shape-start-1");
|
||||
const res = await POST();
|
||||
assert.equal(res.status, 200);
|
||||
const body = await jsonBody(res);
|
||||
assertServiceStatusShape(body, "9router");
|
||||
assert.equal((body as Record<string, unknown>).state, "running");
|
||||
|
||||
mock.restoreAll();
|
||||
});
|
||||
|
||||
it("409 response has error shape when service is not_installed", async () => {
|
||||
await upsertVersionManagerTool({ tool: "9router", status: "not_installed" });
|
||||
|
||||
const { POST } =
|
||||
await import("../../../src/app/api/services/9router/start/route.ts?t=shape-start-2");
|
||||
const res = await POST();
|
||||
assert.equal(res.status, 409);
|
||||
const body = await jsonBody(res);
|
||||
assertErrorShape(body);
|
||||
const b = body as Record<string, unknown>;
|
||||
const err = b.error as Record<string, unknown>;
|
||||
assert.equal(err.type, "conflict");
|
||||
|
||||
// Restore for other tests
|
||||
await upsertVersionManagerTool({ tool: "9router", status: "stopped" });
|
||||
});
|
||||
|
||||
it("503 response has error shape when supervisor throws", async () => {
|
||||
const { registerSupervisor } = await import("../../../src/lib/services/registry.ts");
|
||||
const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts");
|
||||
|
||||
const sup = new ServiceSupervisor({
|
||||
tool: "9router",
|
||||
port: 20130,
|
||||
spawnArgs: () => ({
|
||||
command: process.execPath,
|
||||
args: ["-e", ""],
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
healthUrl: () => "http://127.0.0.1:20130/api/health",
|
||||
healthIntervalMs: 500,
|
||||
stopTimeoutMs: 500,
|
||||
logsBufferBytes: 1_048_576,
|
||||
});
|
||||
|
||||
mock.method(sup, "start", async () => {
|
||||
throw new Error("ENOENT: spawn failed");
|
||||
});
|
||||
registerSupervisor(sup);
|
||||
|
||||
const { POST } =
|
||||
await import("../../../src/app/api/services/9router/start/route.ts?t=shape-start-3");
|
||||
const res = await POST();
|
||||
assert.equal(res.status, 503);
|
||||
const body = await jsonBody(res);
|
||||
assertErrorShape(body);
|
||||
const b = body as Record<string, unknown>;
|
||||
const err = b.error as Record<string, unknown>;
|
||||
assert.equal(err.type, "server_error");
|
||||
|
||||
mock.restoreAll();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/services/9router/stop", () => {
|
||||
it("200 response has { tool, state } shape when supervisor is absent", async () => {
|
||||
// No supervisor registered — stop falls back gracefully.
|
||||
const { POST } =
|
||||
await import("../../../src/app/api/services/9router/stop/route.ts?t=shape-stop-1");
|
||||
const res = await POST();
|
||||
assert.equal(res.status, 200);
|
||||
const body = await jsonBody(res);
|
||||
const b = body as Record<string, unknown>;
|
||||
assert.ok(typeof b.tool === "string", "stop response must have tool");
|
||||
assert.equal(b.tool, "9router");
|
||||
assert.ok(typeof b.state === "string", "stop response must have state");
|
||||
assert.ok(
|
||||
["stopped", "running", "error"].includes(b.state as string),
|
||||
`unexpected state: ${b.state}`
|
||||
);
|
||||
});
|
||||
|
||||
it("200 response is ServiceStatus shape when supervisor.stop() resolves", async () => {
|
||||
const { registerSupervisor } = await import("../../../src/lib/services/registry.ts");
|
||||
const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts");
|
||||
|
||||
const sup = new ServiceSupervisor({
|
||||
tool: "9router",
|
||||
port: 20130,
|
||||
spawnArgs: () => ({
|
||||
command: process.execPath,
|
||||
args: ["-e", "setTimeout(() => {}, 60000)"],
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
healthUrl: () => "http://127.0.0.1:20130/api/health",
|
||||
healthIntervalMs: 500,
|
||||
stopTimeoutMs: 500,
|
||||
logsBufferBytes: 1_048_576,
|
||||
});
|
||||
|
||||
mock.method(sup, "stop", async () => makeStoppedStatus("9router", 20130));
|
||||
registerSupervisor(sup);
|
||||
|
||||
const { POST } =
|
||||
await import("../../../src/app/api/services/9router/stop/route.ts?t=shape-stop-2");
|
||||
const res = await POST();
|
||||
assert.equal(res.status, 200);
|
||||
const body = await jsonBody(res);
|
||||
assertServiceStatusShape(body, "9router");
|
||||
assert.equal((body as Record<string, unknown>).state, "stopped");
|
||||
|
||||
mock.restoreAll();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/services/9router/install", () => {
|
||||
it("400 response has error shape for invalid JSON", async () => {
|
||||
const { POST } =
|
||||
await import("../../../src/app/api/services/9router/install/route.ts?t=shape-install-1");
|
||||
const req = new Request("http://localhost/api/services/9router/install", {
|
||||
method: "POST",
|
||||
body: "not-json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 400);
|
||||
const body = await jsonBody(res);
|
||||
assertErrorShape(body);
|
||||
});
|
||||
|
||||
it("200 response has { ok: true, installedVersion, installPath, durationMs } when install succeeds (mock)", async () => {
|
||||
// We test the shape by verifying what install() returns when mocked.
|
||||
// A successful InstallResult is: { installedVersion, installPath, durationMs }
|
||||
// The route wraps it as: { ok: true, ...result }
|
||||
const expectedShape = {
|
||||
ok: true,
|
||||
installedVersion: "0.4.59",
|
||||
installPath: "/home/user/.omniroute/bin",
|
||||
durationMs: 1200,
|
||||
};
|
||||
// Shape assertion (structural, not calling install which spawns npm):
|
||||
assert.strictEqual(expectedShape.ok, true);
|
||||
assert.ok(typeof expectedShape.installedVersion === "string");
|
||||
assert.ok(typeof expectedShape.installPath === "string");
|
||||
assert.ok(typeof expectedShape.durationMs === "number");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cliproxy routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("cliproxy — response shapes", () => {
|
||||
describe("GET /api/services/cliproxy/status", () => {
|
||||
it("200 response has ServiceStatus shape plus installedVersion and updateAvailable", async () => {
|
||||
const { GET } =
|
||||
await import("../../../src/app/api/services/cliproxy/status/route.ts?t=shape-cliproxy-status-1");
|
||||
const res = await GET();
|
||||
assert.equal(res.status, 200);
|
||||
const body = await jsonBody(res);
|
||||
const b = body as Record<string, unknown>;
|
||||
|
||||
assertServiceStatusShape(body, "cliproxy");
|
||||
assert.ok("installedVersion" in b, "cliproxy status must include installedVersion");
|
||||
assert.ok("updateAvailable" in b, "cliproxy status must include updateAvailable");
|
||||
assert.ok("autoStart" in b, "cliproxy status must include autoStart");
|
||||
assert.ok(typeof b.updateAvailable === "boolean", "updateAvailable must be boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/services/cliproxy/start", () => {
|
||||
it("200 response has ServiceStatus shape when supervisor.start() resolves", async () => {
|
||||
const { registerSupervisor } = await import("../../../src/lib/services/registry.ts");
|
||||
const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts");
|
||||
|
||||
const sup = new ServiceSupervisor({
|
||||
tool: "cliproxy",
|
||||
port: 8317,
|
||||
spawnArgs: () => ({
|
||||
command: process.execPath,
|
||||
args: ["-e", "setTimeout(() => {}, 60000)"],
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
healthUrl: () => "http://127.0.0.1:8317/v1/models",
|
||||
healthIntervalMs: 500,
|
||||
stopTimeoutMs: 500,
|
||||
logsBufferBytes: 1_048_576,
|
||||
});
|
||||
|
||||
mock.method(sup, "start", async () => makeRunningStatus("cliproxy", 8317));
|
||||
registerSupervisor(sup);
|
||||
|
||||
const { POST } =
|
||||
await import("../../../src/app/api/services/cliproxy/start/route.ts?t=shape-cliproxy-start-1");
|
||||
const res = await POST();
|
||||
assert.equal(res.status, 200);
|
||||
const body = await jsonBody(res);
|
||||
assertServiceStatusShape(body, "cliproxy");
|
||||
assert.equal((body as Record<string, unknown>).state, "running");
|
||||
|
||||
mock.restoreAll();
|
||||
});
|
||||
|
||||
it("409 response has error shape when service is not_installed", async () => {
|
||||
await upsertVersionManagerTool({ tool: "cliproxy", status: "not_installed" });
|
||||
|
||||
const { POST } =
|
||||
await import("../../../src/app/api/services/cliproxy/start/route.ts?t=shape-cliproxy-start-2");
|
||||
const res = await POST();
|
||||
assert.equal(res.status, 409);
|
||||
const body = await jsonBody(res);
|
||||
assertErrorShape(body);
|
||||
const err = (body as Record<string, unknown>).error as Record<string, unknown>;
|
||||
assert.equal(err.type, "conflict");
|
||||
|
||||
// Restore
|
||||
await upsertVersionManagerTool({ tool: "cliproxy", status: "stopped" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/services/cliproxy/stop", () => {
|
||||
it("200 response has { tool, state } shape when supervisor is absent", async () => {
|
||||
const { POST } =
|
||||
await import("../../../src/app/api/services/cliproxy/stop/route.ts?t=shape-cliproxy-stop-1");
|
||||
const res = await POST();
|
||||
assert.equal(res.status, 200);
|
||||
const body = await jsonBody(res);
|
||||
const b = body as Record<string, unknown>;
|
||||
assert.ok(typeof b.tool === "string", "cliproxy stop response must have tool");
|
||||
assert.equal(b.tool, "cliproxy");
|
||||
assert.ok(typeof b.state === "string", "cliproxy stop response must have state");
|
||||
});
|
||||
|
||||
it("200 response is ServiceStatus shape when supervisor.stop() resolves", async () => {
|
||||
const { registerSupervisor } = await import("../../../src/lib/services/registry.ts");
|
||||
const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts");
|
||||
|
||||
const sup = new ServiceSupervisor({
|
||||
tool: "cliproxy",
|
||||
port: 8317,
|
||||
spawnArgs: () => ({
|
||||
command: process.execPath,
|
||||
args: ["-e", "setTimeout(() => {}, 60000)"],
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
healthUrl: () => "http://127.0.0.1:8317/v1/models",
|
||||
healthIntervalMs: 500,
|
||||
stopTimeoutMs: 500,
|
||||
logsBufferBytes: 1_048_576,
|
||||
});
|
||||
|
||||
mock.method(sup, "stop", async () => makeStoppedStatus("cliproxy", 8317));
|
||||
registerSupervisor(sup);
|
||||
|
||||
const { POST } =
|
||||
await import("../../../src/app/api/services/cliproxy/stop/route.ts?t=shape-cliproxy-stop-2");
|
||||
const res = await POST();
|
||||
assert.equal(res.status, 200);
|
||||
const body = await jsonBody(res);
|
||||
assertServiceStatusShape(body, "cliproxy");
|
||||
assert.equal((body as Record<string, unknown>).state, "stopped");
|
||||
|
||||
mock.restoreAll();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/services/cliproxy/install", () => {
|
||||
it("400 response has error shape for invalid JSON", async () => {
|
||||
const { POST } =
|
||||
await import("../../../src/app/api/services/cliproxy/install/route.ts?t=shape-cliproxy-install-1");
|
||||
const req = new Request("http://localhost/api/services/cliproxy/install", {
|
||||
method: "POST",
|
||||
body: "not-json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const res = await POST(req);
|
||||
assert.equal(res.status, 400);
|
||||
const body = await jsonBody(res);
|
||||
assertErrorShape(body);
|
||||
});
|
||||
|
||||
it("200 response shape is { ok: true, installedVersion, installPath, durationMs } (structural)", () => {
|
||||
// Structural assertion — avoids spawning npm install in tests.
|
||||
const expectedShape = {
|
||||
ok: true,
|
||||
installedVersion: "6.1.2",
|
||||
installPath: "/home/user/.omniroute/bin",
|
||||
durationMs: 800,
|
||||
};
|
||||
assert.strictEqual(expectedShape.ok, true);
|
||||
assert.ok(typeof expectedShape.installedVersion === "string");
|
||||
assert.ok(typeof expectedShape.installPath === "string");
|
||||
assert.ok(typeof expectedShape.durationMs === "number");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-service shape consistency
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Cross-service shape consistency", () => {
|
||||
it("both services produce identical top-level keys in 200 status response", async () => {
|
||||
const { GET: getRouter } =
|
||||
await import("../../../src/app/api/services/9router/status/route.ts?t=shape-cross-1");
|
||||
const { GET: getCliproxy } =
|
||||
await import("../../../src/app/api/services/cliproxy/status/route.ts?t=shape-cross-2");
|
||||
|
||||
const [r1, r2] = await Promise.all([getRouter(), getCliproxy()]);
|
||||
assert.equal(r1.status, 200);
|
||||
assert.equal(r2.status, 200);
|
||||
|
||||
const b1 = (await r1.json()) as Record<string, unknown>;
|
||||
const b2 = (await r2.json()) as Record<string, unknown>;
|
||||
|
||||
// Both must have these shared keys
|
||||
const sharedKeys = [
|
||||
"tool",
|
||||
"state",
|
||||
"pid",
|
||||
"port",
|
||||
"health",
|
||||
"startedAt",
|
||||
"lastError",
|
||||
"installedVersion",
|
||||
"updateAvailable",
|
||||
"autoStart",
|
||||
];
|
||||
for (const key of sharedKeys) {
|
||||
assert.ok(key in b1, `9router status missing key: ${key}`);
|
||||
assert.ok(key in b2, `cliproxy status missing key: ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("stop routes for both services return { tool, state } at minimum", async () => {
|
||||
const { POST: stop9r } =
|
||||
await import("../../../src/app/api/services/9router/stop/route.ts?t=shape-cross-stop-1");
|
||||
const { POST: stopCp } =
|
||||
await import("../../../src/app/api/services/cliproxy/stop/route.ts?t=shape-cross-stop-2");
|
||||
|
||||
const [r1, r2] = await Promise.all([stop9r(), stopCp()]);
|
||||
assert.equal(r1.status, 200);
|
||||
assert.equal(r2.status, 200);
|
||||
|
||||
const b1 = (await r1.json()) as Record<string, unknown>;
|
||||
const b2 = (await r2.json()) as Record<string, unknown>;
|
||||
|
||||
assert.ok("tool" in b1, "9router stop must have tool");
|
||||
assert.ok("state" in b1, "9router stop must have state");
|
||||
assert.ok("tool" in b2, "cliproxy stop must have tool");
|
||||
assert.ok("state" in b2, "cliproxy stop must have state");
|
||||
|
||||
assert.equal(b1.tool, "9router");
|
||||
assert.equal(b2.tool, "cliproxy");
|
||||
});
|
||||
|
||||
it("error responses never contain stack trace (hard rule #12)", async () => {
|
||||
// Trigger a 400 from install with invalid JSON on both services.
|
||||
const { POST: install9r } =
|
||||
await import("../../../src/app/api/services/9router/install/route.ts?t=shape-cross-err-1");
|
||||
const { POST: installCp } =
|
||||
await import("../../../src/app/api/services/cliproxy/install/route.ts?t=shape-cross-err-2");
|
||||
|
||||
const badReq = () =>
|
||||
new Request("http://localhost/install", {
|
||||
method: "POST",
|
||||
body: "bad json!!",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const [r1, r2] = await Promise.all([install9r(badReq()), installCp(badReq())]);
|
||||
for (const res of [r1, r2]) {
|
||||
const text = await res.text();
|
||||
assert.ok(!text.includes("at /"), `Stack trace leaked in response: ${text.slice(0, 200)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
getModelRpd,
|
||||
getModelRpm,
|
||||
incrementRequestCount,
|
||||
getDailyRequestCount,
|
||||
getMinuteRequestCount,
|
||||
isRpdExhausted,
|
||||
isRpmExhausted,
|
||||
resetCounters,
|
||||
} from "../../../open-sse/services/geminiRateLimitTracker.ts";
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetCounters();
|
||||
});
|
||||
|
||||
// ── getModelRpd ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("getModelRpd returns known RPD for exact model match", () => {
|
||||
assert.equal(getModelRpd("gemini-2.5-flash"), 20);
|
||||
});
|
||||
|
||||
test("getModelRpd returns known RPD for model with gemini/ prefix", () => {
|
||||
assert.equal(getModelRpd("gemini/gemini-2.5-flash"), 20);
|
||||
});
|
||||
|
||||
test("getModelRpd returns 0 for model with zero RPD (Gemini 2.5 Pro)", () => {
|
||||
assert.equal(getModelRpd("gemini-2.5-pro"), 0);
|
||||
});
|
||||
|
||||
test("getModelRpd returns 0 for unknown model", () => {
|
||||
assert.equal(getModelRpd("gemini/fake-model-not-in-list"), 0);
|
||||
});
|
||||
|
||||
test("getModelRpd returns 0 for empty string", () => {
|
||||
assert.equal(getModelRpd(""), 0);
|
||||
});
|
||||
|
||||
test("getModelRpd matches gemma-4-31b-it via suffix fallback", () => {
|
||||
// The JSON has "gemma-4-31b-it", and callers may pass "gemma-4-31b-it"
|
||||
assert.equal(getModelRpd("gemma-4-31b-it"), 1500);
|
||||
});
|
||||
|
||||
test("getModelRpd strips gemma- prefix correctly for gemma models", () => {
|
||||
// stripModelPrefix("gemini/gemma-4-31b-it") → "gemma-4-31b-it"
|
||||
assert.equal(getModelRpd("gemini/gemma-4-31b-it"), 1500);
|
||||
});
|
||||
|
||||
test("getModelRpd handles image-generation models (no RPM value, -1)", () => {
|
||||
// RPD is 25 for imagen models; RPM is -1 in the JSON
|
||||
assert.equal(getModelRpd("imagen-4-generate"), 25);
|
||||
});
|
||||
|
||||
test("getModelRpd handles models with unlimited RPD (-1)", () => {
|
||||
// gemini-3.5-live-translate has rpd: -1
|
||||
assert.equal(getModelRpd("gemini-3.5-live-translate"), 0);
|
||||
});
|
||||
|
||||
test("getModelRpd returns 0 for null input", () => {
|
||||
assert.equal(getModelRpd(null as unknown as string), 0);
|
||||
});
|
||||
|
||||
test("getModelRpd returns 0 for undefined input", () => {
|
||||
assert.equal(getModelRpd(undefined as unknown as string), 0);
|
||||
});
|
||||
|
||||
// ── incrementRequestCount / getDailyRequestCount ─────────────────────────────
|
||||
|
||||
test("incrementRequestCount starts at 1 for first request", () => {
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(getDailyRequestCount("gemini-2.5-flash"), 1);
|
||||
});
|
||||
|
||||
test("incrementRequestCount increments sequentially", () => {
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(getDailyRequestCount("gemini-2.5-flash"), 3);
|
||||
});
|
||||
|
||||
test("incrementRequestCount treats gemini/ prefix and bare name as same model", () => {
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
incrementRequestCount("gemini/gemini-2.5-flash");
|
||||
assert.equal(getDailyRequestCount("gemini-2.5-flash"), 2);
|
||||
assert.equal(getDailyRequestCount("gemini/gemini-2.5-flash"), 2);
|
||||
});
|
||||
|
||||
test("models have independent counters", () => {
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
incrementRequestCount("gemma-4-31b-it");
|
||||
assert.equal(getDailyRequestCount("gemini-2.5-flash"), 2);
|
||||
assert.equal(getDailyRequestCount("gemma-4-31b-it"), 1);
|
||||
});
|
||||
|
||||
test("getDailyRequestCount returns 0 for model with no requests", () => {
|
||||
assert.equal(getDailyRequestCount("gemini-2.5-flash"), 0);
|
||||
});
|
||||
|
||||
test("incrementRequestCount does nothing for empty model ID", () => {
|
||||
incrementRequestCount("");
|
||||
assert.equal(getDailyRequestCount("gemini-2.5-flash"), 0);
|
||||
});
|
||||
|
||||
test("resetCounters clears all state between tests", () => {
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
incrementRequestCount("gemma-4-31b-it");
|
||||
resetCounters();
|
||||
assert.equal(getDailyRequestCount("gemini-2.5-flash"), 0);
|
||||
assert.equal(getDailyRequestCount("gemma-4-31b-it"), 0);
|
||||
});
|
||||
|
||||
// ── isRpdExhausted ───────────────────────────────────────────────────────────
|
||||
|
||||
test("isRpdExhausted returns false when count is below RPD limit", () => {
|
||||
// gemini-2.5-flash has RPD=20
|
||||
for (let i = 0; i < 19; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), false);
|
||||
});
|
||||
|
||||
test("isRpdExhausted returns true when count equals RPD limit", () => {
|
||||
// gemini-2.5-flash has RPD=20
|
||||
for (let i = 0; i < 20; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), true);
|
||||
});
|
||||
|
||||
test("isRpdExhausted returns true when count exceeds RPD limit", () => {
|
||||
for (let i = 0; i < 25; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), true);
|
||||
});
|
||||
|
||||
test("isRpdExhausted returns false for model with RPD=0", () => {
|
||||
// gemini-2.5-pro has rpd=0
|
||||
incrementRequestCount("gemini-2.5-pro");
|
||||
assert.equal(isRpdExhausted("gemini-2.5-pro"), false);
|
||||
});
|
||||
|
||||
test("isRpdExhausted returns false for unknown model", () => {
|
||||
incrementRequestCount("gemini/unknown-model");
|
||||
assert.equal(isRpdExhausted("gemini/unknown-model"), false);
|
||||
});
|
||||
|
||||
test("isRpdExhausted returns false for model with unlimited RPD (-1, no data)", () => {
|
||||
// gemini-3.5-live-translate has rpd: -1
|
||||
incrementRequestCount("gemini-3.5-live-translate");
|
||||
assert.equal(isRpdExhausted("gemini-3.5-live-translate"), false);
|
||||
});
|
||||
|
||||
test("isRpdExhausted works with gemini/ prefix for the model", () => {
|
||||
for (let i = 0; i < 20; i++) incrementRequestCount("gemini/gemini-2.5-flash");
|
||||
assert.equal(isRpdExhausted("gemini/gemini-2.5-flash"), true);
|
||||
});
|
||||
|
||||
// ── Integration: Gemma 4 RPM scenario ────────────────────────────────────────
|
||||
|
||||
test("Gemma 4: 15 RPM hits never trigger quota_exhausted (RPD=1500)", () => {
|
||||
// Gemma 4 has RPD=1500, so 15 RPM hits should not trigger RPD exhaustion
|
||||
for (let i = 0; i < 15; i++) incrementRequestCount("gemini/gemma-4-31b-it");
|
||||
assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), false);
|
||||
assert.equal(getDailyRequestCount("gemini/gemma-4-31b-it"), 15);
|
||||
});
|
||||
|
||||
test("Gemma 4: RPD exhaustion requires 1500 requests", () => {
|
||||
for (let i = 0; i < 1499; i++) incrementRequestCount("gemini/gemma-4-31b-it");
|
||||
assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), false);
|
||||
|
||||
incrementRequestCount("gemini/gemma-4-31b-it");
|
||||
assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), true);
|
||||
});
|
||||
|
||||
// ── Integration: Gemini 2.5 Flash RPD scenario ───────────────────────────────
|
||||
|
||||
test("Gemini 2.5 Flash: first 19 requests do NOT exhaust RPD, 20th does", () => {
|
||||
for (let i = 0; i < 19; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), false, "19 requests < 20 RPD");
|
||||
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), true, "20 requests = 20 RPD");
|
||||
});
|
||||
|
||||
test("Gemini 2.5 Flash: excess requests stay exhausted", () => {
|
||||
for (let i = 0; i < 22; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), true);
|
||||
assert.equal(getDailyRequestCount("gemini-2.5-flash"), 22);
|
||||
});
|
||||
|
||||
// ── getModelRpm ───────────────────────────────────────────────────────────────
|
||||
|
||||
test("getModelRpm returns known RPM for exact model match", () => {
|
||||
assert.equal(getModelRpm("gemini-2.5-flash"), 5);
|
||||
});
|
||||
|
||||
test("getModelRpm returns known RPM for model with gemini/ prefix", () => {
|
||||
assert.equal(getModelRpm("gemini/gemini-2.5-flash"), 5);
|
||||
});
|
||||
|
||||
test("getModelRpm returns 0 for model with zero RPM", () => {
|
||||
assert.equal(getModelRpm("gemini-2.5-pro"), 0);
|
||||
});
|
||||
|
||||
test("getModelRpm returns 0 for unknown model", () => {
|
||||
assert.equal(getModelRpm("gemini/fake-model-not-in-list"), 0);
|
||||
});
|
||||
|
||||
test("getModelRpm returns 0 for empty string", () => {
|
||||
assert.equal(getModelRpm(""), 0);
|
||||
});
|
||||
|
||||
test("getModelRpm returns 0 for models with RPM=-1 (imagen)", () => {
|
||||
assert.equal(getModelRpm("imagen-4-generate"), 0);
|
||||
});
|
||||
|
||||
test("getModelRpm returns 0 for null input", () => {
|
||||
assert.equal(getModelRpm(null as unknown as string), 0);
|
||||
});
|
||||
|
||||
// ── incrementMinuteRequestCount / getMinuteRequestCount ───────────────────────
|
||||
|
||||
test("getMinuteRequestCount returns 0 before first request", () => {
|
||||
assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 0);
|
||||
});
|
||||
|
||||
test("incrementMinuteRequestCount starts at 1", () => {
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 1);
|
||||
});
|
||||
|
||||
test("incrementMinuteRequestCount increments with each call", () => {
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 3);
|
||||
});
|
||||
|
||||
test("incrementMinuteRequestCount normalizes gemini/ prefix", () => {
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
incrementRequestCount("gemini/gemini-2.5-flash");
|
||||
assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 2);
|
||||
});
|
||||
|
||||
test("minute counters are independent per model", () => {
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
incrementRequestCount("gemma-4-31b-it");
|
||||
assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 2);
|
||||
assert.equal(getMinuteRequestCount("gemma-4-31b-it"), 1);
|
||||
});
|
||||
|
||||
test("incrementRequestCount does nothing for empty model ID", () => {
|
||||
incrementRequestCount("");
|
||||
assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 0);
|
||||
});
|
||||
|
||||
test("resetCounters clears minute windows", () => {
|
||||
incrementRequestCount("gemini-2.5-flash");
|
||||
incrementRequestCount("gemma-4-31b-it");
|
||||
resetCounters();
|
||||
assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 0);
|
||||
assert.equal(getMinuteRequestCount("gemma-4-31b-it"), 0);
|
||||
});
|
||||
|
||||
// ── isRpmExhausted ────────────────────────────────────────────────────────────
|
||||
|
||||
test("isRpmExhausted returns false below RPM limit", () => {
|
||||
// gemini-2.5-flash has RPM=5
|
||||
for (let i = 0; i < 4; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpmExhausted("gemini-2.5-flash"), false);
|
||||
});
|
||||
|
||||
test("isRpmExhausted returns true at RPM limit", () => {
|
||||
for (let i = 0; i < 5; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpmExhausted("gemini-2.5-flash"), true);
|
||||
});
|
||||
|
||||
test("isRpmExhausted returns true above RPM limit", () => {
|
||||
for (let i = 0; i < 8; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpmExhausted("gemini-2.5-flash"), true);
|
||||
});
|
||||
|
||||
test("isRpmExhausted returns false for model with RPM=0", () => {
|
||||
incrementRequestCount("gemini-2.5-pro");
|
||||
assert.equal(isRpmExhausted("gemini-2.5-pro"), false);
|
||||
});
|
||||
|
||||
test("isRpmExhausted returns false for unknown model", () => {
|
||||
incrementRequestCount("gemini/unknown-model");
|
||||
assert.equal(isRpmExhausted("gemini/unknown-model"), false);
|
||||
});
|
||||
|
||||
test("isRpmExhausted returns false for model with RPM=-1 (imagen)", () => {
|
||||
incrementRequestCount("imagen-4-generate");
|
||||
assert.equal(isRpmExhausted("imagen-4-generate"), false);
|
||||
});
|
||||
|
||||
test("isRpmExhausted works with gemini/ prefix", () => {
|
||||
for (let i = 0; i < 5; i++) incrementRequestCount("gemini/gemini-2.5-flash");
|
||||
assert.equal(isRpmExhausted("gemini/gemini-2.5-flash"), true);
|
||||
});
|
||||
|
||||
// ── Integration: RPM + RPD work independently ─────────────────────────────────
|
||||
|
||||
test("RPM and RPD limits are tracked independently", () => {
|
||||
// Gemma 4: RPM=15, RPD=1500
|
||||
// After 15 requests, RPM is exhausted but RPD is not
|
||||
for (let i = 0; i < 15; i++) incrementRequestCount("gemini/gemma-4-31b-it");
|
||||
assert.equal(isRpmExhausted("gemini/gemma-4-31b-it"), true);
|
||||
assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), false);
|
||||
});
|
||||
|
||||
test("Gemini 2.5 Flash: RPM=5 always hits before RPD=20", () => {
|
||||
// After 5 requests, RPM is exhausted; after 20, RPD is exhausted
|
||||
for (let i = 0; i < 5; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpmExhausted("gemini-2.5-flash"), true);
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), false);
|
||||
|
||||
incrementRequestCount("gemini-2.5-flash"); // 6
|
||||
incrementRequestCount("gemini-2.5-flash"); // 7
|
||||
incrementRequestCount("gemini-2.5-flash"); // 8
|
||||
|
||||
// Still at RPD=8 which is < 20
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), false);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Unit tests for src/lib/services/htmlRewriter.ts
|
||||
*
|
||||
* Tests HTML rewriting for the embedded-service reverse proxy:
|
||||
* - <base href> injection
|
||||
* - Path-absolute URL rewriting in <a>, <link>, <script>, <img>, <form>, <iframe>, <source>
|
||||
* - Non-rewrite cases: external URLs, protocol-relative, mailto:, javascript:
|
||||
* - Edge cases: HTML without <head>, empty href, multi-URL srcset
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { rewriteHtml } from "../../../src/lib/services/htmlRewriter.ts";
|
||||
|
||||
const PREFIX = "/dashboard/providers/services/9router/embed";
|
||||
|
||||
// Helper: assert string contains substring
|
||||
function assertContains(haystack: string, needle: string, msg?: string): void {
|
||||
assert.ok(
|
||||
haystack.includes(needle),
|
||||
msg ?? `Expected output to contain: ${JSON.stringify(needle)}\nActual: ${haystack}`
|
||||
);
|
||||
}
|
||||
|
||||
function assertNotContains(haystack: string, needle: string, msg?: string): void {
|
||||
assert.ok(
|
||||
!haystack.includes(needle),
|
||||
msg ?? `Expected output NOT to contain: ${JSON.stringify(needle)}\nActual: ${haystack}`
|
||||
);
|
||||
}
|
||||
|
||||
// ─── base tag injection ───────────────────────────────────────────────────────
|
||||
|
||||
describe("rewriteHtml — <base> injection", () => {
|
||||
it("inserts <base href> as first child of <head>", () => {
|
||||
const input = "<html><head></head><body></body></html>";
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, `<base href="${PREFIX}/">`);
|
||||
});
|
||||
|
||||
it("inserts <base href> before other head children", () => {
|
||||
const input = "<html><head><title>T</title></head><body></body></html>";
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
const baseIdx = out.indexOf("<base ");
|
||||
const titleIdx = out.indexOf("<title>");
|
||||
assert.ok(
|
||||
baseIdx < titleIdx,
|
||||
`<base> should appear before <title>; base=${baseIdx}, title=${titleIdx}`
|
||||
);
|
||||
});
|
||||
|
||||
it("strips trailing slash from prefix before forming base href", () => {
|
||||
const out = rewriteHtml("<html><head></head><body></body></html>", `${PREFIX}/`);
|
||||
assertContains(out, `<base href="${PREFIX}/">`);
|
||||
// Should not have double slash
|
||||
assertNotContains(out, `<base href="${PREFIX}//">`);
|
||||
});
|
||||
|
||||
it("handles HTML without <head> by creating one", () => {
|
||||
const out = rewriteHtml("<html><body><p>hello</p></body></html>", PREFIX);
|
||||
assertContains(out, `<base href="${PREFIX}/">`);
|
||||
});
|
||||
|
||||
it("updates existing <base> href instead of inserting a second one", () => {
|
||||
const input = '<html><head><base href="/old/"></head><body></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, `<base href="${PREFIX}/">`);
|
||||
assertNotContains(out, 'href="/old/"');
|
||||
// Exactly one <base tag
|
||||
const matches = out.match(/<base /g);
|
||||
assert.equal(matches?.length ?? 0, 1, "should have exactly one <base> element");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── link rewriting ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("rewriteHtml — <a href> rewriting", () => {
|
||||
it("rewrites path-absolute <a href> to prefixed URL", () => {
|
||||
const input = '<html><head></head><body><a href="/foo">link</a></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, `href="${PREFIX}/foo"`);
|
||||
});
|
||||
|
||||
it("does NOT rewrite https:// external URL", () => {
|
||||
const input = '<html><head></head><body><a href="https://example.com/foo">x</a></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, 'href="https://example.com/foo"');
|
||||
});
|
||||
|
||||
it("does NOT rewrite http:// external URL", () => {
|
||||
const input = '<html><head></head><body><a href="http://external.com">x</a></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, 'href="http://external.com"');
|
||||
});
|
||||
|
||||
it("does NOT rewrite protocol-relative URL (//cdn)", () => {
|
||||
const input = '<html><head></head><body><a href="//cdn.example.com/x">x</a></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, 'href="//cdn.example.com/x"');
|
||||
});
|
||||
|
||||
it("does NOT rewrite mailto: URL", () => {
|
||||
const input =
|
||||
'<html><head></head><body><a href="mailto:admin@example.com">email</a></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, 'href="mailto:admin@example.com"');
|
||||
});
|
||||
|
||||
it("does NOT rewrite javascript: URL", () => {
|
||||
const input = '<html><head></head><body><a href="javascript:void(0)">x</a></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, 'href="javascript:void(0)"');
|
||||
});
|
||||
|
||||
it("does NOT rewrite relative URL (no leading slash)", () => {
|
||||
const input = '<html><head></head><body><a href="relative/path">x</a></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, 'href="relative/path"');
|
||||
});
|
||||
|
||||
it("does NOT rewrite anchor-only href", () => {
|
||||
const input = '<html><head></head><body><a href="#section">x</a></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, 'href="#section"');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── other element rewriting ──────────────────────────────────────────────────
|
||||
|
||||
describe("rewriteHtml — other element attributes", () => {
|
||||
it("rewrites <link href='/stylesheet.css'>", () => {
|
||||
const input =
|
||||
'<html><head><link rel="stylesheet" href="/styles.css"></head><body></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, `href="${PREFIX}/styles.css"`);
|
||||
});
|
||||
|
||||
it("rewrites <script src='/app.js'>", () => {
|
||||
const input = '<html><head></head><body><script src="/app.js"></script></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, `src="${PREFIX}/app.js"`);
|
||||
});
|
||||
|
||||
it("rewrites <img src='/logo.png'>", () => {
|
||||
const input = '<html><head></head><body><img src="/logo.png"></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, `src="${PREFIX}/logo.png"`);
|
||||
});
|
||||
|
||||
it("rewrites <form action='/submit'>", () => {
|
||||
const input =
|
||||
'<html><head></head><body><form action="/submit"><button>Go</button></form></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, `action="${PREFIX}/submit"`);
|
||||
});
|
||||
|
||||
it("rewrites <iframe src='/frame'>", () => {
|
||||
const input = '<html><head></head><body><iframe src="/frame"></iframe></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, `src="${PREFIX}/frame"`);
|
||||
});
|
||||
|
||||
it("rewrites <source src='/video.mp4'>", () => {
|
||||
const input = '<html><head></head><body><source src="/video.mp4"></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, `src="${PREFIX}/video.mp4"`);
|
||||
});
|
||||
|
||||
it("does NOT rewrite img srcset with commas (multi-URL — v1 limitation)", () => {
|
||||
const input =
|
||||
'<html><head></head><body><img srcset="/img.png 1x, /img@2x.png 2x"></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
// Original srcset must be preserved unchanged
|
||||
assertContains(out, 'srcset="/img.png 1x, /img@2x.png 2x"');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── non-anchor markup preservation ──────────────────────────────────────────
|
||||
|
||||
describe("rewriteHtml — preserves non-affected markup", () => {
|
||||
it("preserves text content inside elements", () => {
|
||||
const input = "<html><head></head><body><p>Hello World</p></body></html>";
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, "Hello World");
|
||||
});
|
||||
|
||||
it("preserves existing non-rewritten attributes", () => {
|
||||
const input =
|
||||
'<html><head></head><body><a href="/foo" class="nav" id="link1">x</a></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, 'class="nav"');
|
||||
assertContains(out, 'id="link1"');
|
||||
});
|
||||
|
||||
it("preserves data attributes unchanged", () => {
|
||||
const input =
|
||||
'<html><head></head><body><div data-url="/foo" data-val="bar"></div></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
// data-url is not in REWRITABLE_ATTRS — stays as-is
|
||||
assertContains(out, 'data-url="/foo"');
|
||||
});
|
||||
|
||||
it("preserves empty href unchanged", () => {
|
||||
const input = '<html><head></head><body><a href="">empty</a></body></html>';
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, 'href=""');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── edge cases ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("rewriteHtml — edge cases", () => {
|
||||
it("handles empty HTML string gracefully", () => {
|
||||
const out = rewriteHtml("", PREFIX);
|
||||
assertContains(out, `<base href="${PREFIX}/">`);
|
||||
});
|
||||
|
||||
it("handles HTML fragment (no <html> tag) — parse5 auto-completes tree", () => {
|
||||
const out = rewriteHtml('<a href="/foo">link</a>', PREFIX);
|
||||
assertContains(out, `<base href="${PREFIX}/">`);
|
||||
assertContains(out, `href="${PREFIX}/foo"`);
|
||||
});
|
||||
|
||||
it("multiple path-absolute URLs in same document are all rewritten", () => {
|
||||
const input = [
|
||||
"<html><head>",
|
||||
'<link href="/a.css">',
|
||||
"</head><body>",
|
||||
'<a href="/page1">1</a>',
|
||||
'<a href="/page2">2</a>',
|
||||
'<script src="/app.js"></script>',
|
||||
"</body></html>",
|
||||
].join("");
|
||||
const out = rewriteHtml(input, PREFIX);
|
||||
assertContains(out, `href="${PREFIX}/a.css"`);
|
||||
assertContains(out, `href="${PREFIX}/page1"`);
|
||||
assertContains(out, `href="${PREFIX}/page2"`);
|
||||
assertContains(out, `src="${PREFIX}/app.js"`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
handleServiceInstall,
|
||||
readServiceInstallVersion,
|
||||
} from "../../../src/app/api/services/_shared/installRoute.ts";
|
||||
import { InstallError } from "../../../src/lib/services/installers/utils.ts";
|
||||
|
||||
async function readJson(response: Response) {
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
test("readServiceInstallVersion defaults empty requests to latest", async () => {
|
||||
const result = await readServiceInstallVersion(
|
||||
new Request("http://localhost/api/services/example/install", { method: "POST" })
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { ok: true, version: "latest" });
|
||||
});
|
||||
|
||||
test("readServiceInstallVersion returns the requested version", async () => {
|
||||
const result = await readServiceInstallVersion(
|
||||
new Request("http://localhost/api/services/example/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ version: "1.2.3" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { ok: true, version: "1.2.3" });
|
||||
});
|
||||
|
||||
test("readServiceInstallVersion rejects malformed versions (#5495 SERVICE_VERSION_PATTERN guard)", async () => {
|
||||
const result = await readServiceInstallVersion(
|
||||
new Request("http://localhost/api/services/example/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ version: "../../malicious" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) throw new Error("expected version validation failure");
|
||||
assert.equal(result.response.status, 400);
|
||||
});
|
||||
|
||||
test("handleServiceInstall never reaches the installer for a malformed version (#5495)", async () => {
|
||||
const calls: string[] = [];
|
||||
const response = await handleServiceInstall(
|
||||
new Request("http://localhost/api/services/example/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ version: "v1; rm -rf /" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
async (version) => {
|
||||
calls.push(version);
|
||||
return { installedVersion: version, installPath: "/tmp/service", durationMs: 1 };
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(response.status, 400);
|
||||
});
|
||||
|
||||
test("readServiceInstallVersion preserves invalid JSON error shape", async () => {
|
||||
const result = await readServiceInstallVersion(
|
||||
new Request("http://localhost/api/services/example/install", {
|
||||
method: "POST",
|
||||
body: "not-json",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (result.ok) throw new Error("expected parse failure");
|
||||
assert.equal(result.response.status, 400);
|
||||
const body = await readJson(result.response);
|
||||
assert.equal((body.error as Record<string, unknown>).message, "Invalid JSON body");
|
||||
});
|
||||
|
||||
test("handleServiceInstall wraps successful installer results", async () => {
|
||||
const calls: string[] = [];
|
||||
const response = await handleServiceInstall(
|
||||
new Request("http://localhost/api/services/example/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ version: "2.0.0" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
async (version) => {
|
||||
calls.push(version);
|
||||
return {
|
||||
installedVersion: version,
|
||||
installPath: "/tmp/service",
|
||||
durationMs: 42,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["2.0.0"]);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await readJson(response), {
|
||||
ok: true,
|
||||
installedVersion: "2.0.0",
|
||||
installPath: "/tmp/service",
|
||||
durationMs: 42,
|
||||
});
|
||||
});
|
||||
|
||||
test("handleServiceInstall maps InstallError to its friendly message and status", async () => {
|
||||
const response = await handleServiceInstall(
|
||||
new Request("http://localhost/api/services/example/install", { method: "POST" }),
|
||||
async () => {
|
||||
throw new InstallError("raw command failed", "Friendly install failure", 503);
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(response.status, 503);
|
||||
const body = await readJson(response);
|
||||
assert.equal((body.error as Record<string, unknown>).message, "Friendly install failure");
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-bifrost-installer-"));
|
||||
const FAKE_BIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-bifrost-fake-bin-"));
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
const originalPath = process.env.PATH ?? "";
|
||||
process.env.PATH = `${FAKE_BIN_DIR}:${originalPath}`;
|
||||
|
||||
const INSTALL_DIR = path.join(TEST_DATA_DIR, "services", "bifrost");
|
||||
const fakeNpmScript = `#!/bin/sh
|
||||
set -e
|
||||
CMD="$1"
|
||||
shift
|
||||
if [ "$CMD" = "install" ]; then
|
||||
PREFIX=""
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = "--prefix" ]; then PREFIX="$2"; shift 2; else shift; fi
|
||||
done
|
||||
if [ -z "$PREFIX" ]; then PREFIX="$npm_config_prefix"; fi
|
||||
PKG_DIR="$PREFIX/node_modules/@maximhq/bifrost"
|
||||
mkdir -p "$PKG_DIR"
|
||||
echo '{"name":"@maximhq/bifrost","version":"1.6.3"}' > "$PKG_DIR/package.json"
|
||||
touch "$PKG_DIR/bin.js"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$CMD" = "view" ]; then
|
||||
echo "1.6.3"
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
`;
|
||||
const fakeNpmPath = path.join(FAKE_BIN_DIR, "npm");
|
||||
fs.writeFileSync(fakeNpmPath, fakeNpmScript, { mode: 0o755 });
|
||||
|
||||
execSync("which npm", { env: process.env });
|
||||
|
||||
// DB bootstrap (must be before bifrost import due to db/core eager init)
|
||||
const core = await import("../../../../src/lib/db/core.ts");
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES ('bifrost', 'not_installed', 8080, 0, 1, 1)`
|
||||
).run();
|
||||
|
||||
const {
|
||||
install,
|
||||
update,
|
||||
getInstalledVersion,
|
||||
getLatestVersion,
|
||||
resolveSpawnArgs,
|
||||
BIFROST_DEFAULT_PORT,
|
||||
BIFROST_INSTALL_DIR,
|
||||
} = await import("../../../../src/lib/services/installers/bifrost.ts");
|
||||
|
||||
test.after(() => {
|
||||
process.env.PATH = originalPath;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(FAKE_BIN_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("BIFROST_DEFAULT_PORT is 8080", () => {
|
||||
assert.equal(BIFROST_DEFAULT_PORT, 8080);
|
||||
});
|
||||
|
||||
test("install creates host package.json structure", async () => {
|
||||
const result = await install("1.6.3");
|
||||
|
||||
const hostPkg = path.join(BIFROST_INSTALL_DIR, "package.json");
|
||||
assert.ok(fs.existsSync(hostPkg), "host package.json should exist");
|
||||
const parsedHost = JSON.parse(fs.readFileSync(hostPkg, "utf8")) as {
|
||||
name: string;
|
||||
private: boolean;
|
||||
};
|
||||
assert.equal(parsedHost.name, "omniroute-bifrost-host");
|
||||
assert.ok(parsedHost.private);
|
||||
|
||||
assert.equal(result.installedVersion, "1.6.3");
|
||||
assert.equal(result.installPath, BIFROST_INSTALL_DIR);
|
||||
assert.ok(result.durationMs >= 0);
|
||||
});
|
||||
|
||||
test("getInstalledVersion reads from node_modules/@maximhq/bifrost/package.json", async () => {
|
||||
const ver = await getInstalledVersion();
|
||||
assert.equal(ver, "1.6.3", "should read version from installed package");
|
||||
});
|
||||
|
||||
test("update calls npm install with latest (idempotent)", async () => {
|
||||
const result = await update();
|
||||
assert.equal(result.installedVersion, "1.6.3");
|
||||
});
|
||||
|
||||
test("getLatestVersion returns version string from npm view", async () => {
|
||||
const ver = await getLatestVersion();
|
||||
assert.equal(ver, "1.6.3");
|
||||
});
|
||||
|
||||
test("resolveSpawnArgs shape: command is node, bin.js path, Go single-dash flags", () => {
|
||||
const args = resolveSpawnArgs(8080);
|
||||
|
||||
assert.equal(args.command, process.execPath, "command must be current node binary");
|
||||
assert.ok(args.args[0]?.includes("bin.js"), "args[0] should point to bin.js");
|
||||
|
||||
// Go-style single-dash flags
|
||||
const portIdx = args.args.indexOf("-port");
|
||||
assert.ok(portIdx !== -1, "must have -port flag");
|
||||
assert.equal(args.args[portIdx + 1], "8080");
|
||||
|
||||
const hostIdx = args.args.indexOf("-host");
|
||||
assert.ok(hostIdx !== -1, "must have -host flag");
|
||||
assert.equal(args.args[hostIdx + 1], "127.0.0.1");
|
||||
|
||||
const appDirIdx = args.args.indexOf("-app-dir");
|
||||
assert.ok(appDirIdx !== -1, "must have -app-dir flag");
|
||||
assert.ok(args.args[appDirIdx + 1]?.includes("bifrost"), "-app-dir must point into bifrost dir");
|
||||
|
||||
const logLevelIdx = args.args.indexOf("-log-level");
|
||||
assert.ok(logLevelIdx !== -1, "must have -log-level flag");
|
||||
assert.equal(args.args[logLevelIdx + 1], "warn");
|
||||
|
||||
// BIFROST_TRANSPORT_VERSION must be set in env
|
||||
assert.ok(
|
||||
typeof args.env.BIFROST_TRANSPORT_VERSION === "string" &&
|
||||
args.env.BIFROST_TRANSPORT_VERSION.length > 0,
|
||||
"BIFROST_TRANSPORT_VERSION must be set in env"
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSpawnArgs with different port passes correct -port value", () => {
|
||||
const args = resolveSpawnArgs(9090);
|
||||
const portIdx = args.args.indexOf("-port");
|
||||
assert.ok(portIdx !== -1);
|
||||
assert.equal(args.args[portIdx + 1], "9090");
|
||||
});
|
||||
|
||||
test("INSTALL_DIR constant points into DATA_DIR/services/bifrost", () => {
|
||||
assert.ok(BIFROST_INSTALL_DIR.includes("bifrost"), "install dir must include 'bifrost'");
|
||||
assert.ok(
|
||||
BIFROST_INSTALL_DIR.startsWith(TEST_DATA_DIR),
|
||||
"install dir must be under TEST_DATA_DIR"
|
||||
);
|
||||
assert.equal(INSTALL_DIR, BIFROST_INSTALL_DIR);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* T-11 — cliproxy installer unit tests.
|
||||
*
|
||||
* All tests are pure-logic: no real file I/O, no network, no DB.
|
||||
* We test:
|
||||
* - getInstalledVersion parses directory name from binary path
|
||||
* - install() wires the version_manager row correctly
|
||||
* - resolveSpawnArgs builds correct command/args/env
|
||||
* - path traversal in configPath is impossible (no user input)
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a fake binary path that mimics what binaryManager returns:
|
||||
* $DATA_DIR/bin/cliproxyapi-{version}/cli-proxy-api
|
||||
*/
|
||||
function fakeBinaryPath(version: string, dataDir = "/fake"): string {
|
||||
return path.join(dataDir, "bin", `cliproxyapi-${version}`, "cli-proxy-api");
|
||||
}
|
||||
|
||||
// ── getInstalledVersion ───────────────────────────────────────────────────────
|
||||
|
||||
describe("getInstalledVersion", () => {
|
||||
it("returns version extracted from parent directory name", () => {
|
||||
// The function reads the dirname of the binary path and regex-matches it.
|
||||
// We replicate that logic here to verify it handles various versions.
|
||||
function extractVersion(binaryPath: string): string | null {
|
||||
const dirName = path.basename(path.dirname(binaryPath));
|
||||
const m = dirName.match(/^cliproxyapi-(.+)$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
assert.equal(extractVersion(fakeBinaryPath("1.0.0")), "1.0.0");
|
||||
assert.equal(extractVersion(fakeBinaryPath("2.3.14")), "2.3.14");
|
||||
assert.equal(extractVersion(fakeBinaryPath("0.0.1-beta")), "0.0.1-beta");
|
||||
});
|
||||
|
||||
it("returns null when directory does not match expected pattern", () => {
|
||||
function extractVersion(binaryPath: string): string | null {
|
||||
const dirName = path.basename(path.dirname(binaryPath));
|
||||
const m = dirName.match(/^cliproxyapi-(.+)$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
assert.equal(extractVersion("/some/other/dir/cli-proxy-api"), null);
|
||||
assert.equal(extractVersion("/bin/cli-proxy-api"), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveSpawnArgs ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveSpawnArgs", () => {
|
||||
it("uses the correct binary symlink path and config flag", () => {
|
||||
// Simulate what resolveSpawnArgs does without hitting the real filesystem.
|
||||
// The key contract: command is at $DATA_DIR/bin/cliproxyapi and args are ["-c", configPath].
|
||||
const fakeDataDir = "/fake";
|
||||
const port = 8317;
|
||||
|
||||
const binDir = path.join(fakeDataDir, "bin");
|
||||
const configDir = path.join(fakeDataDir, "services", "cliproxy");
|
||||
const expectedCommand = path.join(binDir, "cliproxyapi");
|
||||
const expectedConfigPath = path.join(configDir, "config.yaml");
|
||||
|
||||
// Verify path construction logic
|
||||
assert.equal(expectedCommand, "/fake/bin/cliproxyapi");
|
||||
assert.equal(expectedConfigPath, "/fake/services/cliproxy/config.yaml");
|
||||
assert.ok(port > 0 && port < 65536);
|
||||
});
|
||||
|
||||
it("config.yaml content includes the correct port and host", () => {
|
||||
const port = 9000;
|
||||
const configContent = `port: ${port}\nhost: 127.0.0.1\nlog_level: warn\n`;
|
||||
|
||||
assert.ok(configContent.includes(`port: ${port}`));
|
||||
assert.ok(configContent.includes("host: 127.0.0.1"));
|
||||
assert.ok(configContent.includes("log_level: warn"));
|
||||
});
|
||||
|
||||
it("CLIPROXY_DEFAULT_PORT is 8317", async () => {
|
||||
// Verify the exported constant directly
|
||||
const { CLIPROXY_DEFAULT_PORT } =
|
||||
await import("../../../../src/lib/services/installers/cliproxy.ts");
|
||||
assert.equal(CLIPROXY_DEFAULT_PORT, 8317);
|
||||
});
|
||||
});
|
||||
|
||||
// ── path safety ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("path safety", () => {
|
||||
it("config path stays within DATA_DIR — no user-controlled input reaches path.join", () => {
|
||||
// resolveSpawnArgs(port: number) takes only a numeric port.
|
||||
// All paths are derived from DATA_DIR + constants, never from user input.
|
||||
// This test documents that contract.
|
||||
const port = 8317;
|
||||
assert.equal(typeof port, "number", "port must always be a number, not a string");
|
||||
|
||||
// The config.yaml line uses String(port) which cannot contain path separators
|
||||
const portStr = String(port);
|
||||
assert.ok(!portStr.includes("/"), "port string cannot contain path separator");
|
||||
assert.ok(!portStr.includes(".."), "port string cannot contain traversal");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Mux installer unit tests.
|
||||
*
|
||||
* All tests are pure-logic: no real file I/O, no network, no DB.
|
||||
* resolveSpawnArgs() performs fs.mkdirSync as a side effect (creating
|
||||
* MUX_ROOT under DATA_DIR), so — mirroring cliproxy.test.ts — we replicate
|
||||
* its pure argument-building contract here instead of invoking the real
|
||||
* function, keeping this suite side-effect-free.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
|
||||
// ── exported constants ────────────────────────────────────────────────────────
|
||||
|
||||
describe("mux installer — exports", () => {
|
||||
it("MUX_DEFAULT_PORT is 8322", async () => {
|
||||
const { MUX_DEFAULT_PORT } = await import("../../../../src/lib/services/installers/mux.ts");
|
||||
assert.equal(MUX_DEFAULT_PORT, 8322);
|
||||
});
|
||||
|
||||
it("MUX_PACKAGE is the npm package name 'mux'", async () => {
|
||||
const { MUX_PACKAGE } = await import("../../../../src/lib/services/installers/mux.ts");
|
||||
assert.equal(MUX_PACKAGE, "mux");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getInstalledVersion ───────────────────────────────────────────────────────
|
||||
|
||||
describe("getInstalledVersion", () => {
|
||||
it("reads version from node_modules/mux/package.json", () => {
|
||||
// Replicates the logic in getInstalledVersion(): reads a JSON file at a
|
||||
// DATA_DIR-scoped, non-user-controlled path and pulls out `.version`.
|
||||
const fakePkg = JSON.stringify({ name: "mux", version: "0.27.0" });
|
||||
const parsed = JSON.parse(fakePkg) as { version?: string };
|
||||
assert.equal(parsed.version, "0.27.0");
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveSpawnArgs (pure argument-building contract) ─────────────────────────
|
||||
|
||||
describe("resolveSpawnArgs — argument-building contract", () => {
|
||||
const MUX_INSTALL_DIR = path.join("/fake", "services", "mux");
|
||||
|
||||
function buildArgs(apiKey: string, port: number) {
|
||||
const serverPath = path.join(MUX_INSTALL_DIR, "node_modules", "mux", "dist", "cli", "index.js");
|
||||
return {
|
||||
command: "node",
|
||||
args: [serverPath, "server", "--host", "127.0.0.1", "--port", String(port)],
|
||||
env: { MUX_SERVER_AUTH_TOKEN: apiKey },
|
||||
cwd: MUX_INSTALL_DIR,
|
||||
};
|
||||
}
|
||||
|
||||
it("binds host to 127.0.0.1 explicitly — never 0.0.0.0", () => {
|
||||
const spawnArgs = buildArgs("mx_fake_token", 8322);
|
||||
const hostIdx = spawnArgs.args.indexOf("--host");
|
||||
assert.ok(hostIdx !== -1);
|
||||
assert.equal(spawnArgs.args[hostIdx + 1], "127.0.0.1");
|
||||
});
|
||||
|
||||
it("passes the port via --port flag as a string", () => {
|
||||
const spawnArgs = buildArgs("mx_fake_token", 9001);
|
||||
const portIdx = spawnArgs.args.indexOf("--port");
|
||||
assert.ok(portIdx !== -1);
|
||||
assert.equal(spawnArgs.args[portIdx + 1], "9001");
|
||||
});
|
||||
|
||||
it("invokes the 'server' subcommand", () => {
|
||||
const spawnArgs = buildArgs("mx_fake_token", 8322);
|
||||
assert.ok(spawnArgs.args.includes("server"));
|
||||
});
|
||||
|
||||
it("passes the auth token via MUX_SERVER_AUTH_TOKEN env var, never as an argv entry", () => {
|
||||
const token = "mx_super_secret_token_value";
|
||||
const spawnArgs = buildArgs(token, 8322);
|
||||
|
||||
assert.equal(spawnArgs.env.MUX_SERVER_AUTH_TOKEN, token);
|
||||
assert.ok(
|
||||
!spawnArgs.args.some((a) => a.includes(token)),
|
||||
"token must never appear in argv (would leak via `ps`)"
|
||||
);
|
||||
});
|
||||
|
||||
it("targets the installed server entry point under node_modules/mux/dist/cli", () => {
|
||||
const spawnArgs = buildArgs("mx_fake_token", 8322);
|
||||
assert.ok(spawnArgs.args[0].endsWith(path.join("dist", "cli", "index.js")));
|
||||
assert.ok(spawnArgs.args[0].includes(path.join("node_modules", "mux")));
|
||||
});
|
||||
});
|
||||
|
||||
// ── path safety ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("path safety", () => {
|
||||
it("resolveSpawnArgs takes only (apiKey: string, port: number) — no arbitrary path input", () => {
|
||||
// resolveSpawnArgs never accepts a user-controlled path; every filesystem
|
||||
// path it builds is derived from DATA_DIR + static path segments.
|
||||
const port = 8322;
|
||||
assert.equal(typeof port, "number", "port must always be a number, not a string");
|
||||
const portStr = String(port);
|
||||
assert.ok(!portStr.includes("/"), "port string cannot contain path separator");
|
||||
assert.ok(!portStr.includes(".."), "port string cannot contain traversal");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-installer-"));
|
||||
const FAKE_BIN_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fake-bin-"));
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
// Prepend fake bin dir to PATH so our fake `npm` is found by runNpm
|
||||
const originalPath = process.env.PATH ?? "";
|
||||
process.env.PATH = `${FAKE_BIN_DIR}:${originalPath}`;
|
||||
|
||||
// Fake npm script: for `install` — creates expected package.json;
|
||||
// for `view` — prints a version; for other args — no-op.
|
||||
const INSTALL_DIR = path.join(TEST_DATA_DIR, "services", "9router");
|
||||
const fakeNpmScript = `#!/bin/sh
|
||||
set -e
|
||||
CMD="$1"
|
||||
shift
|
||||
if [ "$CMD" = "install" ]; then
|
||||
# Resolve the install prefix like real npm: an explicit --prefix arg wins,
|
||||
# otherwise fall back to the npm_config_prefix env var (#5379 passes the
|
||||
# prefix via env instead of argv so paths with spaces survive the win shell).
|
||||
PREFIX=""
|
||||
while [ $# -gt 0 ]; do
|
||||
if [ "$1" = "--prefix" ]; then PREFIX="$2"; shift 2; else shift; fi
|
||||
done
|
||||
if [ -z "$PREFIX" ]; then PREFIX="$npm_config_prefix"; fi
|
||||
PKG_DIR="$PREFIX/node_modules/9router"
|
||||
mkdir -p "$PKG_DIR/app"
|
||||
echo '{"name":"9router","version":"0.4.59"}' > "$PKG_DIR/package.json"
|
||||
touch "$PKG_DIR/app/server.js"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$CMD" = "view" ]; then
|
||||
echo "0.4.59"
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
`;
|
||||
const fakeNpmPath = path.join(FAKE_BIN_DIR, "npm");
|
||||
fs.writeFileSync(fakeNpmPath, fakeNpmScript, { mode: 0o755 });
|
||||
|
||||
// Verify fake npm is on PATH
|
||||
execSync("which npm", { env: process.env });
|
||||
|
||||
// DB bootstrap (must be before ninerouter import due to db/core eager init)
|
||||
const core = await import("../../../../src/lib/db/core.ts");
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES ('9router', 'not_installed', 20130, 0, 1, 1)`
|
||||
).run();
|
||||
|
||||
const {
|
||||
install,
|
||||
update,
|
||||
uninstall,
|
||||
getInstalledVersion,
|
||||
getLatestVersion,
|
||||
resolveSpawnArgs,
|
||||
NINEROUTER_INSTALL_DIR,
|
||||
} = await import("../../../../src/lib/services/installers/ninerouter.ts");
|
||||
|
||||
test.after(() => {
|
||||
process.env.PATH = originalPath;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.rmSync(FAKE_BIN_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("install creates package.json structure", async () => {
|
||||
const result = await install("0.4.59");
|
||||
|
||||
// Host package.json must exist
|
||||
const hostPkg = path.join(NINEROUTER_INSTALL_DIR, "package.json");
|
||||
assert.ok(fs.existsSync(hostPkg), "host package.json should exist");
|
||||
const parsedHost = JSON.parse(fs.readFileSync(hostPkg, "utf8")) as {
|
||||
name: string;
|
||||
private: boolean;
|
||||
};
|
||||
assert.equal(parsedHost.name, "omniroute-9router-host");
|
||||
assert.ok(parsedHost.private);
|
||||
|
||||
assert.equal(result.installedVersion, "0.4.59");
|
||||
assert.equal(result.installPath, NINEROUTER_INSTALL_DIR);
|
||||
assert.ok(result.durationMs >= 0);
|
||||
});
|
||||
|
||||
test("install captures real version from node_modules/9router/package.json", async () => {
|
||||
const ver = await getInstalledVersion();
|
||||
assert.equal(ver, "0.4.59", "should read version from installed package");
|
||||
});
|
||||
|
||||
test("update calls npm install with latest (idempotent)", async () => {
|
||||
const result = await update();
|
||||
assert.equal(result.installedVersion, "0.4.59");
|
||||
});
|
||||
|
||||
test("uninstall removes node_modules and marks not_installed in DB", async () => {
|
||||
const nmDir = path.join(NINEROUTER_INSTALL_DIR, "node_modules");
|
||||
// Ensure node_modules exists from previous install tests
|
||||
assert.ok(fs.existsSync(nmDir), "node_modules should exist before uninstall");
|
||||
|
||||
await uninstall();
|
||||
|
||||
assert.ok(!fs.existsSync(nmDir), "node_modules should be removed");
|
||||
|
||||
const { getVersionManagerTool } = await import("../../../../src/lib/db/versionManager.ts");
|
||||
const row = await getVersionManagerTool("9router");
|
||||
assert.equal(row?.status, "not_installed");
|
||||
assert.equal(row?.installedVersion, null);
|
||||
assert.equal(row?.binaryPath, null);
|
||||
|
||||
// package.json host file should remain (preserves metadata)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(NINEROUTER_INSTALL_DIR, "package.json")),
|
||||
"host package.json should be kept after uninstall"
|
||||
);
|
||||
});
|
||||
|
||||
test("getLatestVersion returns version string from npm view", async () => {
|
||||
const ver = await getLatestVersion();
|
||||
assert.equal(ver, "0.4.59");
|
||||
});
|
||||
|
||||
test("resolveSpawnArgs returns expected env and command", () => {
|
||||
const args = resolveSpawnArgs("sk-test-api-key", 20130);
|
||||
|
||||
assert.equal(args.command, process.execPath, "command must be current node binary");
|
||||
assert.equal(args.args[0], "--max-old-space-size=6144");
|
||||
assert.ok(args.args[1]?.includes("server.js"), "args[1] should point to server.js");
|
||||
assert.equal(args.env.PORT, "20130");
|
||||
assert.equal(args.env.HOSTNAME, "127.0.0.1");
|
||||
assert.equal(args.env.API_KEY_SECRET, "sk-test-api-key");
|
||||
assert.ok(args.env.DATA_DIR?.includes("9router"), "DATA_DIR should be scoped to 9router");
|
||||
assert.equal(args.env.NODE_ENV, "production");
|
||||
assert.equal(args.env.DISABLE_MITM, "true");
|
||||
assert.equal(args.env.DISABLE_TUNNEL, "true");
|
||||
});
|
||||
|
||||
test("EACCES error returns friendly InstallError", async () => {
|
||||
const { InstallError } = await import("../../../../src/lib/services/installers/utils.ts");
|
||||
|
||||
// Simulate EACCES by making the install dir read-only
|
||||
const lockedDir = path.join(TEST_DATA_DIR, "locked-services", "9router");
|
||||
fs.mkdirSync(lockedDir, { recursive: true });
|
||||
fs.chmodSync(path.join(TEST_DATA_DIR, "locked-services"), 0o444);
|
||||
|
||||
process.env.DATA_DIR = path.join(TEST_DATA_DIR, "locked-services");
|
||||
|
||||
try {
|
||||
await import("../../../../src/lib/services/installers/ninerouter.ts?locked=1");
|
||||
} catch {
|
||||
// Re-import after DATA_DIR change fails due to caching — that's fine
|
||||
}
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
// Restore for cleanup
|
||||
fs.chmodSync(path.join(TEST_DATA_DIR, "locked-services"), 0o755);
|
||||
|
||||
// At minimum, InstallError should carry httpStatus and friendly fields
|
||||
const err = new InstallError("raw error", "friendly message", 403);
|
||||
assert.equal(err.httpStatus, 403);
|
||||
assert.equal(err.friendly, "friendly message");
|
||||
assert.ok(err instanceof Error);
|
||||
assert.equal(err.name, "InstallError");
|
||||
});
|
||||
|
||||
test("InstallError timeout shape has correct httpStatus 504", async () => {
|
||||
const { InstallError: IE } = await import("../../../../src/lib/services/installers/utils.ts");
|
||||
const err = new IE("npm process killed", "Instalação demorou demais. Tente novamente.", 504);
|
||||
assert.equal(err.httpStatus, 504);
|
||||
assert.equal(err.friendly, "Instalação demorou demais. Tente novamente.");
|
||||
assert.ok(err instanceof Error);
|
||||
assert.equal(err.name, "InstallError");
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Regression tests for #5379 — `spawn EINVAL` installing embedded services
|
||||
* (9Router / CLIProxy) on Windows + Node.js 24+.
|
||||
*
|
||||
* Node 24 no longer lets `child_process.execFile()` run `.cmd` batch files on
|
||||
* Windows without a shell (nodejs/node#52554). npm on Windows is `npm.cmd`, so
|
||||
* `runNpm()` threw `EINVAL` immediately. The fix flips `shell` on win32.
|
||||
*
|
||||
* Because `shell: true` makes the shell — not execFile — parse the command line,
|
||||
* NO runtime value may be interpolated into argv (Hard Rule #13). The install
|
||||
* `--prefix` (a DATA_DIR path that can contain spaces, e.g.
|
||||
* `C:\Users\John Doe\.omniroute\…`) is therefore passed via the
|
||||
* `npm_config_prefix` environment variable instead of an argv entry, and the
|
||||
* user-supplied install `version` is constrained by SERVICE_VERSION_PATTERN.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildNpmExecOptions,
|
||||
SERVICE_VERSION_PATTERN,
|
||||
} from "../../../../src/lib/services/installers/utils.ts";
|
||||
|
||||
test("buildNpmExecOptions: win32 enables shell so npm.cmd runs on Node 24 (#5379)", () => {
|
||||
const opts = buildNpmExecOptions("win32", { timeoutMs: 1000 });
|
||||
assert.equal(opts.shell, true);
|
||||
});
|
||||
|
||||
test("buildNpmExecOptions: non-win32 platforms never enable shell", () => {
|
||||
for (const platform of ["linux", "darwin", "freebsd"] as NodeJS.Platform[]) {
|
||||
const opts = buildNpmExecOptions(platform, { timeoutMs: 1000 });
|
||||
assert.equal(opts.shell, undefined, `${platform} must not use a shell`);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildNpmExecOptions: prefix is passed via npm_config_prefix env, never argv (Hard Rule #13)", () => {
|
||||
const prefix = "C:\\Users\\John Doe\\.omniroute\\services\\9router";
|
||||
const opts = buildNpmExecOptions("win32", { timeoutMs: 1000, prefix });
|
||||
assert.equal(opts.env.npm_config_prefix, prefix);
|
||||
});
|
||||
|
||||
test("buildNpmExecOptions: without a prefix npm_config_prefix is left untouched", () => {
|
||||
const inherited = process.env.npm_config_prefix;
|
||||
const opts = buildNpmExecOptions("linux", { timeoutMs: 1000 });
|
||||
assert.equal(opts.env.npm_config_prefix, inherited);
|
||||
});
|
||||
|
||||
test("buildNpmExecOptions: carries cwd, timeout and maxBuffer through", () => {
|
||||
const opts = buildNpmExecOptions("linux", { cwd: "/tmp/install", timeoutMs: 4242 });
|
||||
assert.equal(opts.cwd, "/tmp/install");
|
||||
assert.equal(opts.timeout, 4242);
|
||||
assert.equal(opts.maxBuffer, 10 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test("SERVICE_VERSION_PATTERN: accepts dist-tags and semver", () => {
|
||||
for (const v of ["latest", "next", "1.2.3", "1.2.3-beta.1", "1.2.3+build.5", "0.4.59"]) {
|
||||
assert.ok(SERVICE_VERSION_PATTERN.test(v), `${v} should be valid`);
|
||||
}
|
||||
});
|
||||
|
||||
test("SERVICE_VERSION_PATTERN: rejects shell metacharacters (injection guard)", () => {
|
||||
for (const v of [
|
||||
"latest && calc",
|
||||
"1.2.3; rm -rf /",
|
||||
"$(whoami)",
|
||||
"`id`",
|
||||
"a|b",
|
||||
"a b",
|
||||
"",
|
||||
"-flag",
|
||||
]) {
|
||||
assert.equal(SERVICE_VERSION_PATTERN.test(v), false, `${JSON.stringify(v)} must be rejected`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* T-04 lifecycle endpoint tests.
|
||||
*
|
||||
* Tests HTTP handlers directly (bypassing Next.js routing) with a real
|
||||
* ServiceSupervisor whose start/stop methods are patched via mock.fn().
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mock } from "node:test";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-lifecycle-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
// Bootstrap DB
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
// Seed service rows
|
||||
db.prepare(
|
||||
`INSERT OR IGNORE INTO version_manager (tool, status, port, auto_start, auto_update, provider_expose)
|
||||
VALUES ('9router', 'stopped', 20130, 0, 1, 1)`
|
||||
).run();
|
||||
|
||||
const { updateVersionManagerTool, getVersionManagerTool } =
|
||||
await import("../../../src/lib/db/versionManager.ts");
|
||||
|
||||
// Set version so installer functions have something to read
|
||||
await updateVersionManagerTool("9router", { installedVersion: "0.4.59", status: "stopped" });
|
||||
|
||||
const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts");
|
||||
const { registerSupervisor, getSupervisor } = await import("../../../src/lib/services/registry.ts");
|
||||
|
||||
/** Creates a minimal ServiceSupervisor with fake spawnArgs (no real process). */
|
||||
function makeFakeSup(tool: string) {
|
||||
return new ServiceSupervisor({
|
||||
tool,
|
||||
port: 20130,
|
||||
spawnArgs: () => ({
|
||||
command: process.execPath,
|
||||
args: ["-e", "setTimeout(() => {}, 60000)"],
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
healthUrl: () => `http://127.0.0.1:20130/api/health`,
|
||||
healthIntervalMs: 500,
|
||||
stopTimeoutMs: 500,
|
||||
logsBufferBytes: 1_048_576,
|
||||
});
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ─── status endpoint ────────────────────────────────────────────────────────
|
||||
|
||||
test("GET /status returns not_installed when no installer version", async () => {
|
||||
await updateVersionManagerTool("9router", { installedVersion: null, status: "not_installed" });
|
||||
|
||||
const { GET } = await import("../../../src/app/api/services/9router/status/route.ts?t=status-1");
|
||||
const resp = await GET();
|
||||
assert.equal(resp.status, 200);
|
||||
const body = await resp.json();
|
||||
assert.ok(["not_installed", "unknown", "stopped"].includes(body.state as string));
|
||||
|
||||
// restore
|
||||
await updateVersionManagerTool("9router", { installedVersion: "0.4.59", status: "stopped" });
|
||||
});
|
||||
|
||||
test("GET /status returns enriched shape", async () => {
|
||||
const { GET } = await import("../../../src/app/api/services/9router/status/route.ts?t=status-2");
|
||||
const resp = await GET();
|
||||
assert.equal(resp.status, 200);
|
||||
const body = await resp.json();
|
||||
assert.ok("state" in body, "should have state");
|
||||
assert.ok("installedVersion" in body, "should have installedVersion");
|
||||
assert.ok("updateAvailable" in body, "should have updateAvailable");
|
||||
assert.ok("apiKeyMasked" in body, "should have apiKeyMasked");
|
||||
});
|
||||
|
||||
// ─── start endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
test("POST /start returns 409 when not_installed", async () => {
|
||||
await updateVersionManagerTool("9router", { status: "not_installed", installedVersion: null });
|
||||
|
||||
const { POST } = await import("../../../src/app/api/services/9router/start/route.ts?t=start-1");
|
||||
const resp = await POST();
|
||||
assert.equal(resp.status, 409);
|
||||
const body = await resp.json();
|
||||
assert.ok(body.error?.message?.includes("instalado"), "should explain not installed");
|
||||
|
||||
// restore
|
||||
await updateVersionManagerTool("9router", { status: "stopped", installedVersion: "0.4.59" });
|
||||
});
|
||||
|
||||
test("POST /start returns 200 when already running", async () => {
|
||||
const sup = makeFakeSup("9router");
|
||||
|
||||
// Patch start() to return immediately with running state
|
||||
const origStart = sup.start.bind(sup);
|
||||
void origStart;
|
||||
mock.method(sup, "start", async () => ({
|
||||
tool: "9router",
|
||||
state: "running" as const,
|
||||
pid: 12345,
|
||||
port: 20130,
|
||||
health: "healthy" as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastError: null,
|
||||
}));
|
||||
|
||||
registerSupervisor(sup);
|
||||
await updateVersionManagerTool("9router", { status: "stopped", installedVersion: "0.4.59" });
|
||||
|
||||
const { POST } = await import("../../../src/app/api/services/9router/start/route.ts?t=start-2");
|
||||
const resp = await POST();
|
||||
assert.equal(resp.status, 200);
|
||||
const body = await resp.json();
|
||||
assert.equal(body.state, "running");
|
||||
|
||||
mock.restoreAll();
|
||||
});
|
||||
|
||||
// ─── stop endpoint ───────────────────────────────────────────────────────────
|
||||
|
||||
test("POST /stop returns stopped even if supervisor absent", async () => {
|
||||
// Remove from registry by re-importing fresh
|
||||
const { POST } = await import("../../../src/app/api/services/9router/stop/route.ts?t=stop-1");
|
||||
const resp = await POST();
|
||||
assert.equal(resp.status, 200);
|
||||
const body = await resp.json();
|
||||
assert.ok(["stopped", "running", "error"].includes(body.state as string));
|
||||
});
|
||||
|
||||
// ─── auto-start endpoint ─────────────────────────────────────────────────────
|
||||
|
||||
test("POST /auto-start toggles DB field", async () => {
|
||||
const { POST } = await import("../../../src/app/api/services/9router/auto-start/route.ts");
|
||||
|
||||
const reqEnable = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
});
|
||||
const resp1 = await POST(reqEnable);
|
||||
assert.equal(resp1.status, 204);
|
||||
|
||||
const row1 = await getVersionManagerTool("9router");
|
||||
assert.ok(row1?.autoStart === true, "autoStart should be true after enable");
|
||||
|
||||
const reqDisable = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
});
|
||||
const resp2 = await POST(reqDisable);
|
||||
assert.equal(resp2.status, 204);
|
||||
|
||||
const row2 = await getVersionManagerTool("9router");
|
||||
assert.ok(row2?.autoStart === false, "autoStart should be false after disable");
|
||||
});
|
||||
|
||||
test("POST /auto-start returns 400 for invalid body", async () => {
|
||||
const { POST } =
|
||||
await import("../../../src/app/api/services/9router/auto-start/route.ts?t=as-bad");
|
||||
const req = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled: "yes" }), // should be boolean
|
||||
});
|
||||
const resp = await POST(req);
|
||||
assert.equal(resp.status, 400);
|
||||
});
|
||||
|
||||
// ─── rotate-key endpoint ─────────────────────────────────────────────────────
|
||||
|
||||
test("POST /rotate-key does not leak plain key in response", async () => {
|
||||
const { POST } = await import("../../../src/app/api/services/9router/rotate-key/route.ts");
|
||||
const resp = await POST();
|
||||
assert.equal(resp.status, 200);
|
||||
const body = await resp.json();
|
||||
assert.ok(body.keyRotated === true, "keyRotated should be true");
|
||||
assert.ok(!("key" in body), "response must not contain 'key' field");
|
||||
assert.ok(!("apiKey" in body), "response must not contain 'apiKey' field");
|
||||
assert.ok(!("plainKey" in body), "response must not contain 'plainKey' field");
|
||||
// Ensure no string in the response looks like our key prefix
|
||||
const bodyStr = JSON.stringify(body);
|
||||
assert.ok(!bodyStr.includes("nr_"), "response must not contain plain key value");
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* T-09 — SSE logs endpoint tests.
|
||||
*
|
||||
* Tests the GET /api/services/[name]/logs handler directly.
|
||||
* Uses a real RingBuffer pushed with synthetic log lines.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { RingBuffer } from "../../../src/lib/services/ringBuffer.ts";
|
||||
import type { LogLine } from "../../../src/lib/services/types.ts";
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeLogLine(line: string, stream: "stdout" | "stderr" = "stdout"): LogLine {
|
||||
return { ts: Date.now(), stream, line };
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal request factory for the SSE route handler.
|
||||
* Returns a Request whose signal can be aborted via the returned AbortController.
|
||||
*/
|
||||
function makeRequest(searchParams: Record<string, string> = {}): {
|
||||
req: Request;
|
||||
abort: AbortController;
|
||||
} {
|
||||
const abort = new AbortController();
|
||||
const qs = new URLSearchParams(searchParams).toString();
|
||||
const url = `http://localhost/api/services/9router/logs${qs ? `?${qs}` : ""}`;
|
||||
const req = new Request(url, { signal: abort.signal });
|
||||
return { req, abort };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads all SSE events from a Response body until the stream ends or
|
||||
* the caller aborts. Returns parsed {event, data} pairs.
|
||||
*/
|
||||
async function drainEvents(
|
||||
resp: Response,
|
||||
opts: { maxEvents?: number; abort?: AbortController } = {}
|
||||
): Promise<Array<{ event: string; data: unknown }>> {
|
||||
const { maxEvents = 50, abort } = opts;
|
||||
const events: Array<{ event: string; data: unknown }> = [];
|
||||
const reader = resp.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let buf = "";
|
||||
let currentEvent = "message";
|
||||
|
||||
while (events.length < maxEvents) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
|
||||
const parts = buf.split("\n\n");
|
||||
buf = parts.pop() ?? "";
|
||||
|
||||
for (const block of parts) {
|
||||
const lines = block.split("\n");
|
||||
let eventName = currentEvent;
|
||||
let dataLine = "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) eventName = line.slice(7).trim();
|
||||
else if (line.startsWith("data: ")) dataLine = line.slice(6);
|
||||
}
|
||||
|
||||
if (dataLine) {
|
||||
try {
|
||||
events.push({ event: eventName, data: JSON.parse(dataLine) });
|
||||
} catch {
|
||||
events.push({ event: eventName, data: dataLine });
|
||||
}
|
||||
}
|
||||
currentEvent = "message";
|
||||
}
|
||||
}
|
||||
|
||||
abort?.abort();
|
||||
reader.cancel().catch(() => {});
|
||||
return events;
|
||||
}
|
||||
|
||||
// ─── import route handler after helpers ──────────────────────────────────────
|
||||
|
||||
// Registry is module-level state; import it so we can register test supervisors.
|
||||
const { registerSupervisor, getSupervisor } = await import("../../../src/lib/services/registry.ts");
|
||||
const { ServiceSupervisor } = await import("../../../src/lib/services/ServiceSupervisor.ts");
|
||||
|
||||
function makeTestSupervisor(tool: string): ServiceSupervisor {
|
||||
return new ServiceSupervisor({
|
||||
tool,
|
||||
port: 29999,
|
||||
spawnArgs: () => ({
|
||||
command: process.execPath,
|
||||
args: ["-e", "setTimeout(() => {}, 999999)"],
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
healthUrl: () => `http://127.0.0.1:29999/health`,
|
||||
healthIntervalMs: 500,
|
||||
stopTimeoutMs: 500,
|
||||
logsBufferBytes: 1_048_576,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test("GET /logs returns 404 for unknown service", async () => {
|
||||
const { GET } = await import("../../../src/app/api/services/[name]/logs/route.ts");
|
||||
|
||||
const { req, abort } = makeRequest();
|
||||
const resp = await GET(req as any, { params: Promise.resolve({ name: "no-such-service" }) });
|
||||
abort.abort();
|
||||
assert.equal(resp.status, 404);
|
||||
const body = await resp.json();
|
||||
assert.ok(typeof body.error === "object" && body.error !== null, "error must be an object");
|
||||
assert.ok(body.error.message?.includes("not found"), "error.message must mention 'not found'");
|
||||
assert.equal(body.error.type, "not_found");
|
||||
assert.ok(typeof body.requestId === "string", "requestId must be present");
|
||||
});
|
||||
|
||||
test("GET /logs returns 400 when filter exceeds max length", async () => {
|
||||
const sup = makeTestSupervisor("9router-filter-test");
|
||||
registerSupervisor(sup);
|
||||
|
||||
const { GET } = await import("../../../src/app/api/services/[name]/logs/route.ts?t=filter-len");
|
||||
|
||||
const longFilter = "a".repeat(201);
|
||||
const { req, abort } = makeRequest({ filter: longFilter });
|
||||
const resp = await GET(req as any, {
|
||||
params: Promise.resolve({ name: "9router-filter-test" }),
|
||||
});
|
||||
abort.abort();
|
||||
assert.equal(resp.status, 400);
|
||||
const body = await resp.json();
|
||||
assert.ok(typeof body.error === "object" && body.error !== null, "error must be an object");
|
||||
assert.ok(body.error.message?.includes("filter"), "error.message must mention 'filter'");
|
||||
assert.equal(body.error.type, "invalid_request");
|
||||
assert.ok(typeof body.requestId === "string", "requestId must be present");
|
||||
});
|
||||
|
||||
test("GET /logs sends snapshot event with buffered lines", async () => {
|
||||
const sup = makeTestSupervisor("9router-snap-test");
|
||||
registerSupervisor(sup);
|
||||
|
||||
const rb = sup.getRingBuffer();
|
||||
rb.push(makeLogLine("line one"));
|
||||
rb.push(makeLogLine("line two"));
|
||||
rb.push(makeLogLine("line three"));
|
||||
|
||||
const { GET } = await import("../../../src/app/api/services/[name]/logs/route.ts?t=snap");
|
||||
|
||||
const { req, abort } = makeRequest();
|
||||
const resp = await GET(req as any, {
|
||||
params: Promise.resolve({ name: "9router-snap-test" }),
|
||||
});
|
||||
|
||||
assert.equal(resp.headers.get("Content-Type"), "text/event-stream");
|
||||
assert.equal(resp.headers.get("X-Accel-Buffering"), "no");
|
||||
|
||||
const events = await drainEvents(resp, { maxEvents: 1, abort });
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].event, "snapshot");
|
||||
const lines = events[0].data as LogLine[];
|
||||
assert.equal(lines.length, 3);
|
||||
assert.equal(lines[0].line, "line one");
|
||||
});
|
||||
|
||||
test("GET /logs applies substring filter to snapshot", async () => {
|
||||
const sup = makeTestSupervisor("9router-filt-snap");
|
||||
registerSupervisor(sup);
|
||||
|
||||
const rb = sup.getRingBuffer();
|
||||
rb.push(makeLogLine("[ERROR] something broke"));
|
||||
rb.push(makeLogLine("[INFO] all good"));
|
||||
rb.push(makeLogLine("[ERROR] another error"));
|
||||
|
||||
const { GET } = await import("../../../src/app/api/services/[name]/logs/route.ts?t=filt-snap");
|
||||
|
||||
const { req, abort } = makeRequest({ filter: "ERROR" });
|
||||
const resp = await GET(req as any, {
|
||||
params: Promise.resolve({ name: "9router-filt-snap" }),
|
||||
});
|
||||
|
||||
const events = await drainEvents(resp, { maxEvents: 1, abort });
|
||||
const lines = events[0].data as LogLine[];
|
||||
assert.equal(lines.length, 2);
|
||||
assert.ok(lines.every((l) => l.line.includes("ERROR")));
|
||||
});
|
||||
|
||||
test("GET /logs respects tail parameter", async () => {
|
||||
const sup = makeTestSupervisor("9router-tail-test");
|
||||
registerSupervisor(sup);
|
||||
|
||||
const rb = sup.getRingBuffer();
|
||||
for (let i = 0; i < 10; i++) rb.push(makeLogLine(`line ${i}`));
|
||||
|
||||
const { GET } = await import("../../../src/app/api/services/[name]/logs/route.ts?t=tail");
|
||||
|
||||
const { req, abort } = makeRequest({ tail: "3" });
|
||||
const resp = await GET(req as any, {
|
||||
params: Promise.resolve({ name: "9router-tail-test" }),
|
||||
});
|
||||
|
||||
const events = await drainEvents(resp, { maxEvents: 1, abort });
|
||||
const lines = events[0].data as LogLine[];
|
||||
assert.equal(lines.length, 3);
|
||||
assert.equal(lines[0].line, "line 7");
|
||||
assert.equal(lines[2].line, "line 9");
|
||||
});
|
||||
|
||||
test("GET /logs delivers live log events after snapshot", async () => {
|
||||
const sup = makeTestSupervisor("9router-live-test");
|
||||
registerSupervisor(sup);
|
||||
|
||||
const rb = sup.getRingBuffer();
|
||||
rb.push(makeLogLine("existing"));
|
||||
|
||||
const { GET } = await import("../../../src/app/api/services/[name]/logs/route.ts?t=live");
|
||||
|
||||
const { req, abort } = makeRequest();
|
||||
const resp = await GET(req as any, {
|
||||
params: Promise.resolve({ name: "9router-live-test" }),
|
||||
});
|
||||
|
||||
// Schedule a live push slightly after the stream starts consuming
|
||||
setImmediate(() => rb.push(makeLogLine("live event")));
|
||||
|
||||
const events = await drainEvents(resp, { maxEvents: 2, abort });
|
||||
const liveEvents = events.filter((e) => e.event === "log");
|
||||
assert.equal(liveEvents.length, 1);
|
||||
assert.equal((liveEvents[0].data as LogLine).line, "live event");
|
||||
});
|
||||
|
||||
test("GET /logs unsubscribes from buffer on abort", async () => {
|
||||
const sup = makeTestSupervisor("9router-unsub-test");
|
||||
registerSupervisor(sup);
|
||||
|
||||
const rb = sup.getRingBuffer();
|
||||
|
||||
const { GET } = await import("../../../src/app/api/services/[name]/logs/route.ts?t=unsub");
|
||||
|
||||
const { req, abort } = makeRequest();
|
||||
const resp = await GET(req as any, {
|
||||
params: Promise.resolve({ name: "9router-unsub-test" }),
|
||||
});
|
||||
|
||||
// Read snapshot then abort
|
||||
await drainEvents(resp, { maxEvents: 1, abort });
|
||||
|
||||
// After abort, pushing a new line should not increment any internal counter
|
||||
// (there are no live subscribers). We verify by checking subscriber count via
|
||||
// a subscribe-then-immediately-unsubscribe round-trip — if our original
|
||||
// subscriber is gone, count stays at 0 after unsubscribing.
|
||||
let liveCallCount = 0;
|
||||
const unsub = rb.subscribe(() => {
|
||||
liveCallCount++;
|
||||
});
|
||||
rb.push(makeLogLine("post-abort line"));
|
||||
unsub();
|
||||
assert.equal(liveCallCount, 1, "only the probe subscriber should fire after abort");
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Tests for src/lib/services/modelSync.ts
|
||||
*
|
||||
* Uses an isolated DB (DATA_DIR override) and mocks globalThis.fetch.
|
||||
*/
|
||||
|
||||
import { describe, it, before, after, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-model-sync-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
||||
|
||||
// Imports after env is set up so the DB initialises with test path.
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const { syncServiceModels, scheduleServiceModelSync, stopServiceModelSync, getServiceModels } =
|
||||
await import("../../../src/lib/services/modelSync.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
// Always clean up any scheduled sync to avoid cross-test interference.
|
||||
stopServiceModelSync("9router");
|
||||
});
|
||||
|
||||
after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeFetch(
|
||||
status: number,
|
||||
body: unknown
|
||||
): (input: string | URL | Request, init?: RequestInit) => Promise<Response> {
|
||||
return async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("syncServiceModels", () => {
|
||||
it("returns model count and persists models on success", async () => {
|
||||
const models = [
|
||||
{ id: "9r/gemma-3n-e4b", object: "model", owned_by: "google" },
|
||||
{ id: "9r/llama-3.3-70b", object: "model", owned_by: "meta" },
|
||||
];
|
||||
globalThis.fetch = makeFetch(200, { data: models }) as typeof fetch;
|
||||
|
||||
const count = await syncServiceModels("tool-success", "http://127.0.0.1:20130", "nr_test");
|
||||
|
||||
assert.equal(count, 2);
|
||||
const stored = getServiceModels("tool-success");
|
||||
// Pruning marks available=true; count includes only current sync (no prior state).
|
||||
const available = stored.filter((m) => m.available !== false);
|
||||
assert.equal(available.length, 2);
|
||||
assert.equal(available[0].id, "tool-success/9r/gemma-3n-e4b");
|
||||
});
|
||||
|
||||
it("saves models with 9router/ prefix in id", async () => {
|
||||
// Upstream returns raw ids without prefix.
|
||||
globalThis.fetch = makeFetch(200, {
|
||||
data: [
|
||||
{ id: "cx/gpt-5-mini", object: "model" },
|
||||
{ id: "auto/sonnet", object: "model" },
|
||||
],
|
||||
}) as typeof fetch;
|
||||
|
||||
await syncServiceModels("9router-prefix-test", "http://127.0.0.1:20130", "nr_test");
|
||||
|
||||
const stored = getServiceModels("9router-prefix-test");
|
||||
const available = stored.filter((m) => m.available !== false);
|
||||
assert.equal(available.length, 2);
|
||||
assert.equal(
|
||||
available[0].id,
|
||||
"9router-prefix-test/cx/gpt-5-mini",
|
||||
"model id must be prefixed with tool name"
|
||||
);
|
||||
assert.equal(
|
||||
available[1].id,
|
||||
"9router-prefix-test/auto/sonnet",
|
||||
"model id must be prefixed with tool name"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double-prefix if upstream already returns prefixed ids", async () => {
|
||||
globalThis.fetch = makeFetch(200, {
|
||||
data: [{ id: "9router/cx/gpt-5-mini", object: "model" }],
|
||||
}) as typeof fetch;
|
||||
|
||||
await syncServiceModels("9router", "http://127.0.0.1:20130", "nr_test");
|
||||
|
||||
const stored = getServiceModels("9router");
|
||||
const available = stored.filter((m) => m.available !== false);
|
||||
assert.equal(available.length, 1);
|
||||
assert.equal(
|
||||
available[0].id,
|
||||
"9router/cx/gpt-5-mini",
|
||||
"already-prefixed ids must not be double-prefixed"
|
||||
);
|
||||
});
|
||||
|
||||
it("getServiceModels returns prefixed ids", async () => {
|
||||
globalThis.fetch = makeFetch(200, {
|
||||
data: [{ id: "cx/gpt-5-mini" }, { id: "auto/sonnet" }],
|
||||
}) as typeof fetch;
|
||||
|
||||
await syncServiceModels("9router-getmodels-test", "http://127.0.0.1:20130", "nr_test");
|
||||
|
||||
const stored = getServiceModels("9router-getmodels-test");
|
||||
assert.ok(
|
||||
stored.every((m) => m.id.startsWith("9router-getmodels-test/")),
|
||||
"all returned model ids must start with tool name prefix"
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts top-level array response format", async () => {
|
||||
const models = [{ id: "9r/model-a" }, { id: "9r/model-b" }];
|
||||
globalThis.fetch = makeFetch(200, models) as typeof fetch;
|
||||
|
||||
const count = await syncServiceModels("tool-arr", "http://127.0.0.1:20130", "nr_test");
|
||||
|
||||
assert.equal(count, 2);
|
||||
});
|
||||
|
||||
it("filters out entries without an id field", async () => {
|
||||
globalThis.fetch = makeFetch(200, {
|
||||
data: [{ id: "valid" }, { name: "no-id" }, null, 42],
|
||||
}) as typeof fetch;
|
||||
|
||||
const count = await syncServiceModels("9router", "http://127.0.0.1:20130", "nr_test");
|
||||
|
||||
assert.equal(count, 1);
|
||||
});
|
||||
|
||||
it("returns -1 on non-2xx without throwing", async () => {
|
||||
globalThis.fetch = makeFetch(503, { error: "unavailable" }) as typeof fetch;
|
||||
|
||||
const count = await syncServiceModels("9router", "http://127.0.0.1:20130", "nr_test");
|
||||
|
||||
assert.equal(count, -1);
|
||||
});
|
||||
|
||||
it("returns -1 on network error without throwing", async () => {
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
};
|
||||
|
||||
const count = await syncServiceModels("9router", "http://127.0.0.1:20130", "nr_test");
|
||||
|
||||
assert.equal(count, -1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduleServiceModelSync", () => {
|
||||
it("is idempotent — calling twice does not throw or duplicate timers", async () => {
|
||||
// Use a fetch that resolves immediately so the immediate sync completes.
|
||||
globalThis.fetch = makeFetch(200, { data: [{ id: "m1" }] }) as typeof fetch;
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
scheduleServiceModelSync("9router", "http://127.0.0.1:20130", "nr_test", 60_000);
|
||||
scheduleServiceModelSync("9router", "http://127.0.0.1:20130", "nr_test", 60_000);
|
||||
});
|
||||
});
|
||||
|
||||
it("fires an immediate sync", async () => {
|
||||
let fetchCalls = 0;
|
||||
globalThis.fetch = async () => {
|
||||
fetchCalls++;
|
||||
return new Response(JSON.stringify({ data: [] }), { status: 200 });
|
||||
};
|
||||
|
||||
scheduleServiceModelSync("9router", "http://127.0.0.1:20130", "nr_test", 60_000);
|
||||
|
||||
// Yield to the microtask queue so the immediate async sync can run.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.ok(fetchCalls >= 1, "Immediate sync should have called fetch at least once");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopServiceModelSync", () => {
|
||||
it("does not throw when called for a tool that was never scheduled", () => {
|
||||
assert.doesNotThrow(() => stopServiceModelSync("never-scheduled"));
|
||||
});
|
||||
|
||||
it("clears the timer after scheduling", async () => {
|
||||
globalThis.fetch = makeFetch(200, { data: [] }) as typeof fetch;
|
||||
|
||||
scheduleServiceModelSync("9router", "http://127.0.0.1:20130", "nr_test", 60_000);
|
||||
stopServiceModelSync("9router");
|
||||
|
||||
// Calling stop again after clear should be a no-op (no double-clear error).
|
||||
assert.doesNotThrow(() => stopServiceModelSync("9router"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
recordProviderCooldown,
|
||||
isProviderInCooldown,
|
||||
getRemainingCooldownMs,
|
||||
recordProviderSuccess,
|
||||
clearCooldownState,
|
||||
getCooldownEntryCount,
|
||||
cleanupExpiredCooldownEntries,
|
||||
} from "../../../open-sse/services/providerCooldownTracker.ts";
|
||||
import {
|
||||
resolveResilienceSettings,
|
||||
DEFAULT_RESILIENCE_SETTINGS,
|
||||
} from "../../../src/lib/resilience/settings.ts";
|
||||
|
||||
test("global provider cooldown is OFF by default (opt-in)", () => {
|
||||
// This global cross-request cooldown overlaps the existing Connection Cooldown
|
||||
// / Provider Circuit Breaker layers, so it must default to disabled. Operators
|
||||
// opt in via PROVIDER_COOLDOWN_ENABLED=true.
|
||||
assert.equal(DEFAULT_RESILIENCE_SETTINGS.providerCooldown.enabled, false);
|
||||
// resolving with no stored settings inherits the default (disabled).
|
||||
assert.equal(resolveResilienceSettings(null).providerCooldown.enabled, false);
|
||||
// an explicit stored value still wins.
|
||||
assert.equal(
|
||||
resolveResilienceSettings({ resilienceSettings: { providerCooldown: { enabled: true } } })
|
||||
.providerCooldown.enabled,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
function makeSettings(minMs = 5000, maxMs = 300000) {
|
||||
return resolveResilienceSettings({
|
||||
resilienceSettings: {
|
||||
providerCooldown: {
|
||||
enabled: true,
|
||||
minRetryCooldownMs: minMs,
|
||||
maxRetryCooldownMs: maxMs,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
clearCooldownState();
|
||||
});
|
||||
|
||||
test("recordProviderCooldown creates entry for new provider", () => {
|
||||
const settings = makeSettings();
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
|
||||
assert.equal(getCooldownEntryCount(), 1);
|
||||
assert.ok(isProviderInCooldown("openai", "conn-1", settings));
|
||||
});
|
||||
|
||||
test("recordProviderCooldown increments failure count", () => {
|
||||
const settings = makeSettings();
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
|
||||
assert.equal(getCooldownEntryCount(), 1);
|
||||
assert.ok(isProviderInCooldown("openai", "conn-1", settings));
|
||||
});
|
||||
|
||||
test("isProviderInCooldown returns false for unknown provider", () => {
|
||||
const settings = makeSettings();
|
||||
assert.equal(isProviderInCooldown("unknown", "conn-1", settings), false);
|
||||
});
|
||||
|
||||
test("isProviderInCooldown returns false for empty provider", () => {
|
||||
const settings = makeSettings();
|
||||
assert.equal(isProviderInCooldown("", "conn-1", settings), false);
|
||||
});
|
||||
|
||||
test("isProviderInCooldown returns false when not recorded", () => {
|
||||
const settings = makeSettings();
|
||||
assert.equal(isProviderInCooldown("openai", "conn-1", settings), false);
|
||||
});
|
||||
|
||||
test("getRemainingCooldownMs returns 0 for unknown provider", () => {
|
||||
const settings = makeSettings();
|
||||
assert.equal(getRemainingCooldownMs("unknown", "conn-1", settings), 0);
|
||||
});
|
||||
|
||||
test("getRemainingCooldownMs returns 0 when not recorded", () => {
|
||||
const settings = makeSettings();
|
||||
assert.equal(getRemainingCooldownMs("openai", "conn-1", settings), 0);
|
||||
});
|
||||
|
||||
test("recordProviderSuccess resets failure count", () => {
|
||||
const settings = makeSettings();
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
assert.ok(isProviderInCooldown("openai", "conn-1", settings));
|
||||
|
||||
recordProviderSuccess("openai", "conn-1");
|
||||
assert.equal(isProviderInCooldown("openai", "conn-1", settings), false);
|
||||
});
|
||||
|
||||
test("recordProviderSuccess does nothing for unknown provider", () => {
|
||||
recordProviderSuccess("unknown", "conn-1");
|
||||
assert.equal(getCooldownEntryCount(), 0);
|
||||
});
|
||||
|
||||
test("clearCooldownState removes all entries", () => {
|
||||
const settings = makeSettings();
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
recordProviderCooldown("anthropic", "conn-2", settings);
|
||||
assert.equal(getCooldownEntryCount(), 2);
|
||||
|
||||
clearCooldownState();
|
||||
assert.equal(getCooldownEntryCount(), 0);
|
||||
});
|
||||
|
||||
test("cooldown scales exponentially with failure count", () => {
|
||||
const settings = makeSettings(5000);
|
||||
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
const remaining1 = getRemainingCooldownMs("openai", "conn-1", settings);
|
||||
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
const remaining2 = getRemainingCooldownMs("openai", "conn-1", settings);
|
||||
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
const remaining3 = getRemainingCooldownMs("openai", "conn-1", settings);
|
||||
|
||||
assert.ok(remaining1 > 0, "first failure has cooldown");
|
||||
assert.ok(remaining2 > remaining1, "second failure has longer cooldown");
|
||||
assert.ok(remaining3 > remaining2, "third failure has even longer cooldown");
|
||||
});
|
||||
|
||||
test("cooldown respects maxRetryCooldownMs cap", () => {
|
||||
const settings = makeSettings(5000, 20000);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
}
|
||||
|
||||
const remaining = getRemainingCooldownMs("openai", "conn-1", settings);
|
||||
assert.ok(remaining <= 20000, "cooldown capped at maxRetryCooldownMs");
|
||||
});
|
||||
|
||||
test("cleanup keeps entries for configured long maxRetryCooldownMs", () => {
|
||||
const settings = makeSettings(5000, 60 * 60 * 1000);
|
||||
const originalNow = Date.now;
|
||||
|
||||
try {
|
||||
let fakeNow = Date.now();
|
||||
Date.now = () => fakeNow;
|
||||
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
assert.equal(getCooldownEntryCount(), 1);
|
||||
|
||||
fakeNow += 31 * 60 * 1000;
|
||||
cleanupExpiredCooldownEntries();
|
||||
assert.equal(
|
||||
getCooldownEntryCount(),
|
||||
1,
|
||||
"cleanup must not evict entries before configured maxRetryCooldownMs"
|
||||
);
|
||||
|
||||
fakeNow += 30 * 60 * 1000;
|
||||
cleanupExpiredCooldownEntries();
|
||||
assert.equal(getCooldownEntryCount(), 0);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("different connections have independent cooldowns", () => {
|
||||
const settings = makeSettings();
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
recordProviderCooldown("openai", "conn-2", settings);
|
||||
|
||||
assert.ok(isProviderInCooldown("openai", "conn-1", settings));
|
||||
assert.ok(isProviderInCooldown("openai", "conn-2", settings));
|
||||
|
||||
recordProviderSuccess("openai", "conn-1");
|
||||
assert.equal(isProviderInCooldown("openai", "conn-1", settings), false);
|
||||
assert.ok(isProviderInCooldown("openai", "conn-2", settings));
|
||||
});
|
||||
|
||||
test("provider-only key works without connectionId", () => {
|
||||
const settings = makeSettings();
|
||||
recordProviderCooldown("openai", undefined, settings);
|
||||
|
||||
assert.ok(isProviderInCooldown("openai", undefined, settings));
|
||||
assert.equal(isProviderInCooldown("openai", "conn-1", settings), false);
|
||||
});
|
||||
|
||||
test("cooldown entry count tracks correctly", () => {
|
||||
assert.equal(getCooldownEntryCount(), 0);
|
||||
|
||||
const settings = makeSettings();
|
||||
recordProviderCooldown("openai", "conn-1", settings);
|
||||
assert.equal(getCooldownEntryCount(), 1);
|
||||
|
||||
recordProviderCooldown("anthropic", "conn-2", settings);
|
||||
assert.equal(getCooldownEntryCount(), 2);
|
||||
|
||||
clearCooldownState();
|
||||
assert.equal(getCooldownEntryCount(), 0);
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { RingBuffer } from "../../../src/lib/services/ringBuffer.ts";
|
||||
import type { LogLine } from "../../../src/lib/services/types.ts";
|
||||
|
||||
function makeLine(line: string, stream: "stdout" | "stderr" = "stdout"): LogLine {
|
||||
return { ts: Date.now(), stream, line };
|
||||
}
|
||||
|
||||
test("push respects byte limit (evicts oldest)", () => {
|
||||
// 100-byte limit: each entry is ~40 bytes overhead + line bytes
|
||||
const buf = new RingBuffer(100);
|
||||
|
||||
buf.push(makeLine("first")); // ~45 bytes
|
||||
buf.push(makeLine("second")); // ~46 bytes — total ~91
|
||||
buf.push(makeLine("third")); // would exceed → evict "first"
|
||||
|
||||
const snap = buf.snapshot();
|
||||
assert.ok(!snap.some((e) => e.line === "first"), "first should be evicted");
|
||||
assert.ok(
|
||||
snap.some((e) => e.line === "second"),
|
||||
"second should be retained"
|
||||
);
|
||||
assert.ok(
|
||||
snap.some((e) => e.line === "third"),
|
||||
"third should be retained"
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshot returns isolated copy", () => {
|
||||
const buf = new RingBuffer();
|
||||
buf.push(makeLine("a"));
|
||||
const snap1 = buf.snapshot();
|
||||
buf.push(makeLine("b"));
|
||||
const snap2 = buf.snapshot();
|
||||
|
||||
assert.equal(snap1.length, 1, "first snapshot unchanged");
|
||||
assert.equal(snap2.length, 2, "second snapshot includes new entry");
|
||||
});
|
||||
|
||||
test("subscribe receives new lines", () => {
|
||||
const buf = new RingBuffer();
|
||||
const received: LogLine[] = [];
|
||||
const unsub = buf.subscribe((l) => received.push(l));
|
||||
|
||||
buf.push(makeLine("hello"));
|
||||
buf.push(makeLine("world"));
|
||||
|
||||
assert.equal(received.length, 2);
|
||||
assert.equal(received[0].line, "hello");
|
||||
assert.equal(received[1].line, "world");
|
||||
|
||||
unsub();
|
||||
buf.push(makeLine("after-unsub"));
|
||||
assert.equal(received.length, 2, "no more events after unsubscribe");
|
||||
});
|
||||
|
||||
test("flush writes to file when path set", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ring-"));
|
||||
const filePath = path.join(tmpDir, "ring.log");
|
||||
|
||||
try {
|
||||
const buf = new RingBuffer();
|
||||
buf.setFlushPath(filePath);
|
||||
buf.push(makeLine("line-one"));
|
||||
buf.push({ ts: Date.now(), stream: "stderr", line: "err-line" });
|
||||
|
||||
// Force flush by calling private method indirectly — dispose triggers cleanup
|
||||
// We access the private method via prototype cast for testing purposes.
|
||||
(buf as unknown as { flushToDisk: () => void }).flushToDisk();
|
||||
|
||||
assert.ok(fs.existsSync(filePath), "flush file should exist");
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
assert.ok(content.includes("line-one"), "flush file should contain log entry");
|
||||
assert.ok(content.includes("[stderr]"), "flush file should contain stderr entry");
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("dispose clears subscribers and stops flush timer", () => {
|
||||
const buf = new RingBuffer();
|
||||
const received: LogLine[] = [];
|
||||
buf.subscribe((l) => received.push(l));
|
||||
buf.dispose();
|
||||
|
||||
buf.push(makeLine("after-dispose"));
|
||||
assert.equal(received.length, 0, "no events after dispose");
|
||||
});
|
||||
Reference in New Issue
Block a user