import { BeforeAll, AfterAll, Before, After, setDefaultTimeout } from '@cucumber/cucumber' import { spawn, spawnSync, type ChildProcess } from 'child_process' import { rmSync, mkdirSync } from 'fs' import { resolve, dirname } from 'path' import { fileURLToPath } from 'url' import { browserConfig } from './browser-config.js' import type { BrowserWorld } from './browser-world.js' setDefaultTimeout(browserConfig.timeout) const __dirname = dirname(fileURLToPath(import.meta.url)) const repoRoot = resolve(__dirname, '../../..') // Provision the isolated e2e db with `pikku db reset` (migrations + seed); // PIKKU_SQLITE_DB points createConfig at this file. function provisionDb(dbFile: string): void { const res = spawnSync('npx', ['pikku', 'db', 'reset'], { cwd: repoRoot, env: { ...process.env, PIKKU_SQLITE_DB: dbFile }, encoding: 'utf8', }) if (res.status !== 0) { throw new Error( `pikku db reset failed (${res.status}):\n${res.stdout ?? ''}\n${res.stderr ?? ''}` ) } } let backendProcess: ChildProcess | undefined let frontendProcess: ChildProcess | undefined async function waitForServer(url: string, label: string, timeoutMs = 90_000) { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { try { const res = await fetch(url) if (res.ok || res.status < 500) { console.log(`[${label}] ready on ${url}`) return } } catch { // not ready yet } await new Promise((r) => setTimeout(r, 500)) } throw new Error(`[browser-e2e] ${label} not ready within ${timeoutMs / 1000}s on ${url}`) } function startProcess(cmd: string, cwd: string, env: Record, label: string) { const p = spawn(cmd, { cwd, env, stdio: 'pipe', shell: true, detached: true }) p.stderr?.on('data', (d: Buffer) => process.stderr.write(`[${label}] ${d}`)) p.stdout?.on('data', (d: Buffer) => process.stdout.write(`[${label}] ${d}`)) return p } function stopProcess(p: ChildProcess | undefined) { if (!p?.pid) return try { process.kill(-p.pid, 'SIGTERM') } catch { try { p.kill('SIGTERM') } catch { // already dead } } } BeforeAll(async function () { const backendPort = new URL(browserConfig.backendUrl).port || '6002' // Fresh SQLite file per run, provisioned by `pikku db reset`. for (const suffix of ['', '-wal', '-shm']) { rmSync(`${browserConfig.dbFile}${suffix}`, { force: true }) } mkdirSync(dirname(browserConfig.dbFile), { recursive: true }) provisionDb(browserConfig.dbFile) console.log('[browser-e2e] starting backend…') backendProcess = startProcess( 'npx tsx backend/bin/start-e2e.ts', repoRoot, { ...(process.env as Record), PORT: backendPort, E2E_DB_FILE: browserConfig.dbFile, // 32+ chars keeps better-auth quiet; BETTER_AUTH_URL = the app origin so // better-auth trusts the browser's Origin (requests arrive via the vite // same-origin proxy). BETTER_AUTH_SECRET: 'e2e-browser-secret-32-characters-long', BETTER_AUTH_URL: browserConfig.appUrl, AUTH_SECRET: 'e2e-browser-secret-32-characters-long', }, 'backend' ) await waitForServer(`${browserConfig.backendUrl}/health-check`, 'backend') // Admin (admin@perauset.org / "test") comes from db/sqlite-seed.sql via // `pikku db reset`. console.log('[browser-e2e] starting frontend…') frontendProcess = startProcess( 'yarn dev', resolve(repoRoot, 'apps/app'), { ...(process.env as Record), // Absolute, app-origin API base: SSR-safe and keeps browser calls // same-origin (no CORS). The vite proxy maps /api/auth → backend/api/auth // and /api/* → backend root. VITE_API_URL: `${browserConfig.appUrl}/api`, VITE_BACKEND_URL: browserConfig.backendUrl, }, 'frontend' ) await waitForServer(`${browserConfig.appUrl}/login`, 'frontend') }) AfterAll(async function () { stopProcess(backendProcess) stopProcess(frontendProcess) backendProcess = undefined frontendProcess = undefined }) Before(async function (this: BrowserWorld) { await this.openBrowser() }) After(async function (this: BrowserWorld, scenario) { if (scenario.result?.status === 'FAILED' && this.page) { const safe = scenario.pickle.name.replace(/[^a-z0-9]+/gi, '-').toLowerCase() await this.page .screenshot({ path: `/tmp/e2e-fail-${safe}.png`, fullPage: true }) .catch(() => {}) } await this.closeBrowser() })