// 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 { closeSync, openSync, readFileSync } from "node:fs"; import { setTimeout as sleep } from "node:timers/promises"; const BOOT_LOG_TAIL_BYTES = 8_000; const HTTP_PROBE_TIMEOUT_MS = 5_000; /** A long-lived boot process exited before its service became ready. */ export class BootProcessExitError extends Error { readonly _tag = "BootProcessExitError"; constructor( readonly command: string, readonly exitCode: number | null, readonly signal: NodeJS.Signals | null, readonly logTail: string, ) { const outcome = exitCode === null ? `signal ${signal ?? "unknown"}` : `code ${exitCode}`; const diagnostic = logTail ? `\nLast boot log output:\n${logTail}` : ""; super(`Boot process ${JSON.stringify(command)} exited with ${outcome}${diagnostic}`); this.name = "BootProcessExitError"; } } /** A service process stayed alive but did not become HTTP-ready in time. */ export class BootReadinessTimeoutError extends Error { readonly _tag = "BootReadinessTimeoutError"; constructor( readonly url: string, readonly timeoutMs: number, readonly lastError: unknown, ) { super(`Timed out after ${timeoutMs}ms waiting for ${url}: ${String(lastError)}`); this.name = "BootReadinessTimeoutError"; } } /** Whether an acquisition failed because a live process never became ready. */ export const isBootReadinessTimeout = (error: unknown): boolean => error instanceof BootReadinessTimeoutError; export interface BootedProcesses { readonly teardown: () => Promise; /** Process-group leader pids — what an external `down` must signal. */ readonly pids: ReadonlyArray; } /** A spawned process tree that can report an exit during startup. */ export interface MonitoredBootedProcesses extends BootedProcesses { /** Rejects as soon as any child exits before readiness/teardown. */ readonly unexpectedExit: Promise; } const readLogTail = (logFile: string | undefined): string => { if (!logFile) return ""; try { return readFileSync(logFile, "utf8").slice(-BOOT_LOG_TAIL_BYTES).trim(); } catch { return ""; } }; 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 }, ): MonitoredBootedProcesses => { const children: ChildProcess[] = []; let tearingDown = false; const unexpectedExits: Array> = []; 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, }); if (log !== undefined) closeSync(log); unexpectedExits.push( new Promise((_resolve, reject) => { child.once("error", (cause) => { if (tearingDown) return; // oxlint-disable-next-line executor/no-promise-reject -- boundary: adapt the child-process error event to the startup race reject( new BootProcessExitError( [proc.cmd, ...proc.args].join(" "), child.exitCode, child.signalCode, `${readLogTail(proc.logFile)}\n${String(cause)}`.trim(), ), ); }); child.once("exit", (code, signal) => { if (tearingDown) return; const error = new BootProcessExitError( [proc.cmd, ...proc.args].join(" "), code, signal, readLogTail(proc.logFile), ); console.error(`[e2e:${options.label}] ${error.message}`); // oxlint-disable-next-line executor/no-promise-reject -- boundary: adapt the child-process exit event to the startup race reject(error); }); }), ); 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])), unexpectedExit: Promise.race(unexpectedExits), }; }; /** * Wait for a boot probe while also observing the spawned process tree. The * losing readiness probe is aborted, so an early process exit neither hides * behind the full HTTP timeout nor leaves a polling timer alive. */ export const waitForBoot = async ( processes: MonitoredBootedProcesses, ready: (signal: AbortSignal) => Promise, ): Promise => { const controller = new AbortController(); try { return await Promise.race([ready(controller.signal), processes.unexpectedExit]); } finally { controller.abort(); } }; export const waitForHttp = async ( url: string, options: { readonly timeoutMs?: number; readonly expectRedirect?: boolean; readonly signal?: AbortSignal; } = {}, ): Promise => { const timeoutMs = options.timeoutMs ?? 90_000; const deadline = performance.now() + timeoutMs; let lastError: unknown; while (performance.now() < deadline) { options.signal?.throwIfAborted(); try { const remainingMs = Math.max(1, deadline - performance.now()); const probeTimeout = AbortSignal.timeout(Math.min(HTTP_PROBE_TIMEOUT_MS, remainingMs)); const signal = options.signal ? AbortSignal.any([options.signal, probeTimeout]) : probeTimeout; const response = await fetch(url, { redirect: "manual", signal }); // 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) { options.signal?.throwIfAborted(); lastError = error; } await sleep(400, undefined, { signal: options.signal }); } throw new BootReadinessTimeoutError(url, timeoutMs, 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`); };