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