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

162 lines
5.1 KiB
TypeScript

// Integration test (relocated from tests/unit/cli): it spawns the real
// start-ws-server.mjs subprocess, which boots a full WebSocket server + SQLite
// and eagerly warms the SSE auth module (~7s under tsx). Running it in the unit
// suite under --test-concurrency=20 made it flaky/red because the heavy subprocess
// boot contended for CPU; it belongs in the serial (--test-concurrency=1)
// integration runner. It still guards #4004's same-origin cookie-parse fix on
// every PR via the integration CI job.
import assert from "node:assert/strict";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { SignJWT } from "jose";
import net from "node:net";
import test from "node:test";
import WebSocket from "ws";
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
server.close(() => {
if (address && typeof address === "object") resolve(address.port);
else reject(new Error("Failed to allocate a local port"));
});
});
});
}
function terminateTree(child: ChildProcessWithoutNullStreams): void {
if (!child.pid) return;
try {
process.kill(-child.pid, "SIGTERM");
} catch {
child.kill("SIGTERM");
}
}
function waitForStartup(
child: ChildProcessWithoutNullStreams,
getOutput: () => string
): Promise<void> {
return new Promise((resolve, reject) => {
// Startup eagerly warms the SSE auth module (see liveServer.ts), which takes
// several seconds under tsx, so "listening" can appear ~7s after spawn. 30s
// leaves headroom for a loaded CI runner.
const timeout = setTimeout(() => {
reject(new Error(`LiveWS startup timed out. Output:\n${getOutput()}`));
}, 30_000);
const onData = () => {
const output = getOutput();
if (output.includes("Dashboard WebSocket server listening")) {
cleanup();
resolve();
}
};
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
cleanup();
reject(
new Error(`LiveWS exited before listening: code=${code} signal=${signal}\n${getOutput()}`)
);
};
const cleanup = () => {
clearTimeout(timeout);
child.stdout.off("data", onData);
child.stderr.off("data", onData);
child.off("exit", onExit);
};
child.stdout.on("data", onData);
child.stderr.on("data", onData);
child.once("exit", onExit);
onData();
});
}
test(
"LiveWS startup script boots on current Node and accepts API-key WebSocket clients",
{ timeout: 45_000 },
async () => {
const port = await getFreePort();
const apiKey = "test-live-ws-key";
const jwtSecret = "test-live-ws-jwt-secret";
const origin = "http://localhost";
let output = "";
const child = spawn(process.execPath, ["scripts/start-ws-server.mjs"], {
cwd: process.cwd(),
detached: process.platform !== "win32",
env: {
...process.env,
NODE_ENV: "test",
OMNIROUTE_API_KEY: apiKey,
JWT_SECRET: jwtSecret,
LIVE_WS_HOST: "127.0.0.1",
LIVE_WS_PORT: String(port),
LIVE_WS_ALLOWED_ORIGINS: origin,
},
});
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
output += chunk;
});
child.stderr.on("data", (chunk) => {
output += chunk;
});
try {
await waitForStartup(child, () => output);
assert.doesNotMatch(output, /tsx must be loaded with --import/i);
assert.doesNotMatch(output, /EADDRINUSE/i);
async function expectLiveWsOpen(headers: Record<string, string>): Promise<void> {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(`Timed out waiting for LiveWS connection. Output:\n${output}`));
}, 4_000);
const ws = new WebSocket(`ws://127.0.0.1:${port}/live-ws`, { headers });
ws.once("open", () => {
clearTimeout(timeout);
ws.close(1000);
resolve();
});
ws.once("error", (error) => {
clearTimeout(timeout);
reject(new Error(`LiveWS client failed: ${error.message}. Output:\n${output}`));
});
});
}
await expectLiveWsOpen({
Authorization: `Bearer ${apiKey}`,
Origin: origin,
});
const dashboardToken = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("5m")
.sign(new TextEncoder().encode(jwtSecret));
// Send auth_token preceded by another cookie (the real browser case: "a=1; auth_token=…").
// Guards #4004's cookie-parse regex: a literal-"s" bug (\s vs \\s) only matched auth_token
// when it was the FIRST cookie, silently breaking same-origin reverse-proxy auth otherwise.
await expectLiveWsOpen({
Cookie: `omni_pref=dark; auth_token=${dashboardToken}`,
Origin: origin,
});
} finally {
terminateTree(child);
}
}
);