127 lines
3.9 KiB
TypeScript
127 lines
3.9 KiB
TypeScript
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 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(server.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();
|
|
},
|
|
});
|
|
|
|
/** 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://localhost:${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}`,
|
|
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, 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) {
|
|
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 };
|