137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
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<string, string>, 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<string, string>),
|
|
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<string, string>),
|
|
// 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()
|
|
})
|