// Process glue for the per-target globalsetups: spawn the app's own dev // server, wait until it answers HTTP, and hand vitest a teardown. The apps own // what runs (their dev stack, their stub flags); this file only owns process // lifecycle, so it stays target-agnostic. import { spawn, type ChildProcess } from "node:child_process"; import { openSync } from "node:fs"; export interface BootedProcesses { readonly teardown: () => Promise; /** Process-group leader pids — what an external `down` must signal. */ readonly pids: ReadonlyArray; } export const bootProcesses = ( procs: ReadonlyArray<{ readonly cmd: string; readonly args: ReadonlyArray; readonly cwd: string; readonly env?: Record; /** Append stdout+stderr here (long-lived boots need inspectable logs). */ readonly logFile?: string; }>, options: { readonly label: string }, ): BootedProcesses => { const children: ChildProcess[] = []; let tearingDown = false; for (const proc of procs) { const log = proc.logFile ? openSync(proc.logFile, "a") : undefined; const child = spawn(proc.cmd, [...proc.args], { cwd: proc.cwd, env: { ...process.env, ...proc.env }, stdio: log !== undefined ? ["ignore", log, log] : process.env.E2E_VERBOSE ? "inherit" : "ignore", // Own process group, so teardown can signal the whole tree — `bunx vite` // is a wrapper whose actual server child would otherwise outlive the // kill and squat the port into the NEXT invocation's waitForHttp. detached: true, }); child.on("exit", (code) => { if (code !== 0 && code !== null && !tearingDown) { console.error(`[e2e:${options.label}] ${proc.cmd} exited with ${code}`); } }); children.push(child); } // Signal the process GROUP (negative pid); fall back to the direct child // when the group is already gone. const signalTree = (child: ChildProcess, signal: NodeJS.Signals) => { if (child.pid === undefined || child.exitCode !== null) return; try { process.kill(-child.pid, signal); } catch { child.kill(signal); } }; const exited = (child: ChildProcess): Promise => child.exitCode !== null || child.signalCode !== null ? Promise.resolve() : new Promise((resolve) => child.once("exit", () => resolve())); return { teardown: async () => { tearingDown = true; const allExited = Promise.all(children.map(exited)); for (const child of children) signalTree(child, "SIGTERM"); // Wait for a REAL exit (not a fixed sleep) — a lingering server would // answer the next invocation's waitForHttp as a half-dead zombie. const graceful = await Promise.race([ allExited.then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), 5_000)), ]); if (!graceful) { for (const child of children) signalTree(child, "SIGKILL"); await Promise.race([allExited, new Promise((resolve) => setTimeout(resolve, 2_000))]); } }, pids: children.flatMap((child) => (child.pid === undefined ? [] : [child.pid])), }; }; export const waitForHttp = async ( url: string, options: { readonly timeoutMs?: number; readonly expectRedirect?: boolean } = {}, ): Promise => { const deadline = Date.now() + (options.timeoutMs ?? 90_000); let lastError: unknown; while (Date.now() < deadline) { try { const response = await fetch(url, { redirect: "manual" }); // During a cold vite compile /api/* falls back to the SPA's 200 HTML — // expectRedirect waits for the real handler (302) instead. if (options.expectRedirect ? response.status === 302 : response.status < 500) return; lastError = new Error(`status ${response.status}`); } catch (error) { lastError = error; } await new Promise((resolve) => setTimeout(resolve, 400)); } throw new Error(`timed out waiting for ${url}: ${String(lastError)}`); }; /** * Reboot DOWN-GATE: wait until `url` stops answering (fetch rejects — connection * refused/reset). An orderly OS shutdown keeps the daemon serving for several * seconds, and a reconnecting tunnel re-establishes the forward, so polling for * "up" right after a reboot command can false-pass a reboot that never happened. * Gating on the server actually going DOWN first makes restart-persistence prove * a real reboot. Throws if it never goes down within the deadline. */ export const waitForHttpDown = async ( url: string, options: { readonly timeoutMs?: number } = {}, ): Promise => { const deadline = Date.now() + (options.timeoutMs ?? 120_000); while (Date.now() < deadline) { try { await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2000) }); } catch { return; // the server (or its tunnel) is gone — the reboot took } await new Promise((resolve) => setTimeout(resolve, 500)); } throw new Error(`${url} never became unreachable — the reboot may not have taken`); };