Files
sustenance/e2e/fixtures.ts
T

113 lines
3.5 KiB
TypeScript

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 };