diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index ad191a6..9127743 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -12,41 +12,12 @@ import * as path from "path"; 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); + const server = await startServer(); + await use({ baseURL: server.baseURL }); + await killTree(server.child); // Clean up the DB files (including -wal / -shm). for (const suffix of ["", "-wal", "-shm"]) { - fs.rmSync(dbPath + suffix, { force: true }); + fs.rmSync(server.dbPath + suffix, { force: true }); } }, { scope: "test", auto: true }, @@ -61,6 +32,60 @@ export const test = base.extend<{ server: { baseURL: string }; page: Page }>({ }, }); +/** 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 dbPath = path.join( + os.tmpdir(), + `sustenance-e2e-${process.pid}-${Date.now()}-${Math.random() + .toString(36) + .slice(2)}.db`, + ); + const port = 20000 + Math.floor(Math.random() * 30000); + 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", "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, dbPath }; + } catch (error) { + // The server may have failed to bind (port collision). Clean up and retry. + await killTree(child); + for (const suffix of ["", "-wal", "-shm"]) { + fs.rmSync(dbPath + suffix, { force: true }); + } + 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) { diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index eb71193..c5992d3 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", timeout: 30_000, - retries: 0, + retries: 2, use: { trace: "on-first-retry", },