import { test as base, expect, Page } from "@playwright/test"; import { spawn, ChildProcess } from "child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; /** * Starts a fresh Sustenance server against a unique, throwaway database on a * unique port for each test, and tears it down afterwards. This gives every * test a clean DB with no shared state between tests. */ export const test = base.extend<{ server: { baseURL: string }; page: Page }>({ server: [ async ({}, use) => { const dbPath = path.join( os.tmpdir(), `sustenance-e2e-${process.pid}-${Date.now()}-${Math.random() .toString(36) .slice(2)}.db`, ); const port = 3200 + Math.floor(Math.random() * 2000); const baseURL = `http://127.0.0.1:${port}`; const child = spawn( path.resolve(__dirname, "..", "target", "debug", "sustenance"), [], { env: { ...process.env, DATABASE_PATH: dbPath, REGISTRATION_MODE: "open", BIND_ADDRESS: `127.0.0.1:${port}`, // 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", // Run in its own process group so we can kill the whole tree. detached: true, }, ); await waitForServer(baseURL, child); await use({ baseURL }); await killTree(child); // Clean up the DB files (including -wal / -shm). for (const suffix of ["", "-wal", "-shm"]) { fs.rmSync(dbPath + suffix, { force: true }); } }, { 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(); }, }); 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 };