import { test as base, expect, Page } from "@playwright/test"; import { spawn, ChildProcess } from "child_process"; import * as os from "os"; import * as path from "path"; /** * Starts a fresh Sustenance server against an in-memory SQLite database on a * unique port for each test, and tears it down afterwards. Every test gets a * clean DB with no shared state between tests, and nothing is written to disk. */ export const test = base.extend<{ server: { baseURL: string }; page: Page }>({ server: [ async ({}, use) => { const server = await startServer(); await use({ baseURL: server.baseURL }); await killTree(server.child); }, { scope: "test", auto: true }, ], // Provide a page whose baseURL points at this test's server. page: async ({ browser, server }, use) => { const context = await browser.newContext({ baseURL: server.baseURL }); const page = await context.newPage(); await use(page); await context.close(); }, }); /** Starts a server, retrying on a fresh port if the first attempt fails to bind. */ async function startServer() { for (let attempt = 0; attempt < 5; attempt++) { const port = 20000 + Math.floor(Math.random() * 30000); const baseURL = `http://localhost:${port}`; const child = spawn( path.resolve(__dirname, "..", "target", "debug", "sustenance"), [], { env: { ...process.env, DATABASE_IN_MEMORY: "1", REGISTRATION_MODE: "open", BIND_ADDRESS: `127.0.0.1:${port}`, PUBLIC_BASE_URL: baseURL, // WebAuthn requires a valid domain for the RP ID; localhost is allowed. RP_ID: "localhost", // Point SEED_CONFIG at a nonexistent file so no default user is created. SEED_CONFIG: path.join(os.tmpdir(), "sustenance-e2e-no-seed.json"), }, stdio: ["ignore", "ignore", "pipe"], // Run in its own process group so we can kill the whole tree. detached: true, }, ); let stderr = ""; child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); try { await waitForServer(baseURL, child); return { baseURL, child }; } catch (error) { // The server may have failed to bind (port collision). Clean up and retry. await killTree(child); if (attempt === 4) { throw new Error( `server failed to start after retries; last stderr:\n${stderr}\n${error}`, ); } } } throw new Error("unreachable"); } async function waitForServer(baseURL: string, child: ChildProcess) { const deadline = Date.now() + 60_000; while (Date.now() < deadline) { if (child.exitCode !== null) { throw new Error(`server exited early with code ${child.exitCode}`); } try { const res = await fetch(baseURL + "/login"); if (res.ok) return; } catch { // not up yet } await new Promise((r) => setTimeout(r, 200)); } throw new Error("timed out waiting for server to start"); } async function killTree(child: ChildProcess) { try { process.kill(-child.pid!, "SIGTERM"); } catch { child.kill("SIGTERM"); } // Give it a moment to shut down gracefully, then force-kill if needed. const exited = new Promise((resolve) => child.once("exit", resolve)); const timeout = new Promise((resolve) => setTimeout(resolve, 5000)); await Promise.race([exited, timeout]); try { process.kill(-child.pid!, "SIGKILL"); } catch { /* already gone */ } } export { expect };