import { nanoid } from "nanoid"; import assert, { AssertionError } from "node:assert"; import { type ChildProcess, fork, type Serializable } from "node:child_process"; import { after, before, beforeEach } from "node:test"; export interface TestServer { connection(): TestServerConnection; kill(): Promise; revive(): Promise; } export interface TestServerConnection { url: URL; clientId: string; assertConnected(expected: boolean): Promise; assertRequestCountStrictEquals(expected: number): Promise; } export function withTestServer(): TestServer { let server: ChildProcess | undefined; let port: string | undefined; let killPromise: Promise | undefined; before(async () => { [server, port] = await startTestServer(); console.log("[test client] Ready to run tests"); }); beforeEach(() => revive()); after(async () => { if (server) { await stopTestServer(server); server = undefined; } }); function getConnection() { const url = new URL(`ws://localhost:${port}`); const clientId = nanoid(); url.searchParams.append("clientId", clientId); return { url, clientId }; } async function kill() { if (!server) { return; } if (!killPromise) { killPromise = (async () => { try { await killTestServer(server); console.log("[test client] Server is killed"); server = undefined; } finally { killPromise = undefined; } })(); } return killPromise; } async function revive() { await killPromise; if (server) { return; } [server, port] = await startTestServer(); console.log("[test client] Server is back up"); } return { connection: () => { const { url, clientId } = getConnection(); return { url, clientId, assertConnected: async (expected) => { if (!server) { throw new AssertionError({ message: "Test server is not running" }); } let [status] = await checkClientStatus(server, clientId); if (status && !expected) { // When closing connection from the client, the order // of "close" events between the client and the server is // unpredictable. In case "close" event fires on the client (and // resolves the `await close()` promise) before it does on // the server, `checkClientStatus()` can report stale connection // status. In that case we wait a little before asserting. await new Promise((resolve) => setTimeout(resolve, 100)); [status] = await checkClientStatus(server, clientId); } assert.strictEqual(status, expected); }, assertRequestCountStrictEquals: async (expected) => { if (!server) { throw new AssertionError({ message: "Test server is not running" }); } const [, requests] = await checkClientStatus(server, clientId); assert.strictEqual(requests, expected); }, }; }, kill, revive, }; } function startTestServer(): Promise<[process: ChildProcess, port: string]> { return new Promise((resolve) => { const server = fork("./src/fixtures/server.ts"); const handleMessage = (message: Serializable) => { if (typeof message === "string" && message.startsWith("ready")) { const [, port] = message.split(":"); server.off("message", handleMessage); resolve([server, port!]); } }; server.on("message", handleMessage); }); } function stopTestServer(server: ChildProcess): Promise { return new Promise((resolve) => { server.kill("SIGINT"); server.on("exit", () => resolve()); }); } function killTestServer(server: ChildProcess): Promise { return new Promise((resolve) => { server.kill("SIGKILL"); server.on("exit", () => resolve()); }); } function checkClientStatus( server: ChildProcess, clientId: string, ): Promise<[status: boolean, requestCount: number]> { return new Promise((resolve) => { const handleMessage = (message: Serializable) => { if (typeof message === "string" && message.startsWith(clientId)) { const [, status, requestCount] = message.split(":"); server.off("message", handleMessage); resolve([status === "connected", Number.parseInt(requestCount!, 10)] as const); } }; server.on("message", handleMessage); server.send(clientId); }); }