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 type { AppWorld } from './world.js' 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 = 'yarn 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: 'yarn dev:backend', cwd: repoRoot, env: { ...process.env, NODE_ENV: 'test', PIKKU_DEV_DB_FILE: scenarioDbPath, DATABASE_URL: `file:${scenarioDbPath}`, }, 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(() => {}) }) Before(async function (this: AppWorld, { pickle }) { if (config.isolatedDb) { const scenarioId = pickle.id.replace(/[^a-zA-Z0-9_-]/g, '_') await startScenarioBackend(`scenario-${scenarioId}`) } await this.openBrowser() if (!config.isolatedDb && config.resetUrl) { const body = config.resetRpcName ? JSON.stringify({ rpcName: config.resetRpcName, data: {} }) : undefined await fetch(config.resetUrl, { method: 'POST', headers: body ? { 'content-type': 'application/json' } : undefined, body, }) .then((res) => { if (!res.ok) { process.stderr.write( `[e2e] WARN: reset hook ${config.resetUrl} returned ${res.status}\n`, ) } }) .catch(() => { process.stderr.write(`[e2e] WARN: reset hook ${config.resetUrl} unreachable\n`) }) } }) After(async function (this: AppWorld) { await this.closeBrowser() if (config.isolatedDb) { await stopProcess(backendProcess) backendProcess = undefined if (scenarioDbPath) { await rm(scenarioDbPath, { force: true }).catch(() => {}) scenarioDbPath = undefined } } })