Files
seminarhof-e2e-mrfgld02/e2e/tests/support/hooks.ts
2026-07-10 23:39:57 +02:00

239 lines
7.8 KiB
TypeScript

import { Before, After, BeforeAll, AfterAll, setDefaultTimeout } from "@cucumber/cucumber";
import { cp, mkdir, rm } from "fs/promises";
import { spawn, execFileSync, type ChildProcess } from "child_process";
import { resolve, dirname } from "path";
import { fileURLToPath, pathToFileURL } from "url";
import { createRequire } from "module";
// @pikku/cli locks its `exports` to `.` only, so these db test-helpers aren't
// importable by bare specifier. Resolve the package's real dist dir via the `.`
// export and dynamic-import the file by absolute path — the exports map only
// governs bare specifiers, not absolute paths. Done lazily (not top-level)
// because cucumber transpiles this module to CJS, which bans top-level await.
// Stopgap; needs a public @pikku/cli test-helper export upstream.
async function loadDbHelpers() {
const cliBinDir = dirname(createRequire(import.meta.url).resolve("@pikku/cli"));
return import(pathToFileURL(resolve(cliBinDir, "../src/functions/db/local-db.js")).href);
}
// Seed inline via node:sqlite rather than the CLI's `seed()`: under cucumber's
// registered ESM loader, the CLI's memoized sqlite runtime opens the migrated
// file but reads an empty schema (no such table: venue). A fresh DatabaseSync
// against the same file sees the tables correctly, so do the seed ourselves.
async function seedGoldenDb(dbFile: string, seedFile: string) {
const { readFileSync, existsSync } = await import("fs");
if (!existsSync(seedFile)) return;
const { DatabaseSync } = await (new Function('return import("node:sqlite")')() as Promise<{
DatabaseSync: new (f: string) => { exec(sql: string): void; close(): void };
}>);
const db = new DatabaseSync(dbFile);
db.exec("BEGIN");
try {
db.exec(readFileSync(seedFile, "utf8"));
db.exec("COMMIT");
} catch (err) {
db.exec("ROLLBACK");
throw err;
} finally {
db.close();
}
}
import { config } from "./types.js";
setDefaultTimeout(config.responseTimeout);
let backendProcess: ChildProcess | undefined;
let frontendProcess: ChildProcess | undefined;
let goldenDbPath: string | undefined;
let scenarioDbPath: string | undefined;
async function waitForUrl(url: string, label: string, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.status < 500) return;
} catch {
// not ready yet
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`[e2e] ${label} did not become ready at ${url} within ${timeoutMs}ms`);
}
function startProcess({
cmd,
cwd,
env,
label,
detached = true,
}: {
cmd: string;
cwd: string;
env: NodeJS.ProcessEnv;
label: string;
detached?: boolean;
}) {
const p = spawn(cmd, {
cwd,
env,
stdio: "pipe",
shell: true,
detached,
});
p.stderr?.on("data", (d: Buffer) => process.stderr.write(`[${label}] ${d}`));
p.stdout?.on("data", (d: Buffer) => process.stdout.write(`[${label}] ${d}`));
return p;
}
async function stopProcess(p: ChildProcess | undefined) {
if (!p?.pid) return;
try {
process.kill(-p.pid, "SIGTERM");
} catch {
try {
p.kill("SIGTERM");
} catch {
// ignore
}
}
await new Promise((r) => setTimeout(r, 1000));
}
function killPort(port: number) {
try {
const out = execFileSync("lsof", ["-ti", `tcp:${port}`], {
encoding: "utf8",
}).trim();
if (!out) return;
for (const pid of out.split(/\s+/)) {
if (!pid) continue;
try {
process.kill(Number(pid), "SIGTERM");
} catch {
// ignore
}
}
} catch {
// no listeners / lsof unavailable
}
}
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "../../..");
const runtimeDir = resolve(repoRoot, ".e2e-runtime");
const functionsOutDir = resolve(repoRoot, "packages/functions/.pikku");
const defaultFrontendCmd = "bun run dev";
async function buildGoldenDb() {
const { resolveLocalDb, reset, migrateAndCodegen } = await loadDbHelpers();
await mkdir(runtimeDir, { recursive: true });
goldenDbPath = resolve(runtimeDir, "golden.db");
await rm(goldenDbPath, { force: true });
const resolved = resolveLocalDb(goldenDbPath, repoRoot, functionsOutDir, runtimeDir);
if (!resolved) {
throw new Error("[e2e] failed to resolve local DB config");
}
reset(resolved, repoRoot);
await migrateAndCodegen(resolved);
await seedGoldenDb(resolved.dbFile, resolved.seedFile);
}
async function startScenarioBackend(scenarioId: string) {
if (!goldenDbPath) {
throw new Error("[e2e] golden DB missing");
}
await stopProcess(backendProcess);
killPort(5003);
scenarioDbPath = resolve(runtimeDir, `${scenarioId}.db`);
await rm(scenarioDbPath, { force: true });
await cp(goldenDbPath, scenarioDbPath);
backendProcess = startProcess({
cmd: "bun run dev:backend",
cwd: repoRoot,
env: {
...process.env,
NODE_ENV: "test",
PIKKU_DEV_DB_FILE: scenarioDbPath,
// pikku dev reads DATABASE_URL and treats a non-postgres/libsql/http value
// as a sqlite file PATH (no scheme). A `file:` prefix isn't stripped and
// gets resolveAgainst()'d into a bogus path → fresh empty DB → "no such
// table: user". Pass the plain absolute path.
DATABASE_URL: scenarioDbPath,
// better-auth needs a signing secret to read/issue sessions; provide a
// deterministic test value so the managed backend doesn't 500 on auth.
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? "e2e-test-better-auth-secret",
// Unlocks the testReset RPC ("the app data is reset") on this backend.
E2E_TEST_RESET: "1",
},
label: "backend",
});
await waitForUrl(config.apiUrl, "backend");
}
BeforeAll(async function () {
process.env.API_URL = process.env.API_URL ?? "http://localhost:5003";
process.env.APP_URL = process.env.APP_URL ?? "http://localhost:5001";
if (config.isolatedDb) {
await buildGoldenDb();
}
if (!config.manageServers) {
// Caller is expected to start backend + frontend (e.g. via pm2, docker,
// or a separate `yarn dev` terminal) before running tests.
if (config.isolatedDb) {
throw new Error("[e2e] E2E_ISOLATED_DB=1 requires E2E_MANAGE_SERVERS=1");
}
return;
}
// Project-specific: override these commands by setting E2E_BACKEND_CMD /
// E2E_FRONTEND_CMD env vars. The defaults assume `yarn dev` at the repo
// root spawns the frontend, and the project provides a `dev:backend`
// script for the API.
const frontendCmd = process.env.E2E_FRONTEND_CMD ?? defaultFrontendCmd;
killPort(5001);
frontendProcess = startProcess({
cmd: frontendCmd,
cwd: repoRoot,
env: {
...process.env,
NODE_ENV: "test",
VITE_API_URL: process.env.API_URL,
},
label: "frontend",
});
await waitForUrl(config.appUrl, "frontend");
});
AfterAll(async function () {
await stopProcess(backendProcess);
await stopProcess(frontendProcess);
if (scenarioDbPath) await rm(scenarioDbPath, { force: true }).catch(() => {});
if (goldenDbPath) await rm(goldenDbPath, { force: true }).catch(() => {});
});
// Browser lifecycle (open/close per actor, debug dumps, reset hook) is owned
// by @pikku/cucumber/browser — see register.ts. These hooks only manage the
// per-scenario backend + DB isolation.
// Backend boot occasionally exceeds the default step timeout under load.
Before({ timeout: 90_000 }, async function ({ pickle }) {
if (config.isolatedDb) {
const scenarioId = pickle.id.replace(/[^a-zA-Z0-9_-]/g, "_");
await startScenarioBackend(`scenario-${scenarioId}`);
}
});
After(async function () {
if (config.isolatedDb) {
await stopProcess(backendProcess);
backendProcess = undefined;
if (scenarioDbPath) {
await rm(scenarioDbPath, { force: true }).catch(() => {});
scenarioDbPath = undefined;
}
}
});