Files
2026-07-13 13:39:12 +08:00

266 lines
9.4 KiB
TypeScript

/**
* 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");
});