Files
germantax-e2e-mrg0vbxr/e2e/tests/support/hooks.ts
2026-07-11 09:07:34 +02:00

249 lines
7.0 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'
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 = 'bun run dev'
// @pikku/cli locks its `exports` to `.` only, so these db test-helpers aren't
// importable by bare specifier. Resolve the package dir via the `.` export and
// dynamic-import the file by absolute path — the exports map only governs bare
// specifiers, not absolute paths. Lazy (not top-level) because cucumber
// transpiles this module to CJS, which bans top-level await.
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 reads an empty schema;
// a fresh DatabaseSync against the same file sees the tables correctly.
async function seedGoldenDb(dbFile: string, seedFile: string) {
const { readFileSync, existsSync } = await import('fs')
if (!seedFile || !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()
}
}
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(4003)
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,
DATABASE_URL: `file:${scenarioDbPath}`,
},
label: 'backend',
})
await waitForUrl(config.apiUrl, 'backend')
}
BeforeAll(async function () {
process.env.API_URL = process.env.API_URL ?? 'http://localhost:4003'
process.env.APP_URL = process.env.APP_URL ?? 'http://localhost:7104'
if (config.isolatedDb) {
await buildGoldenDb()
}
if (!config.manageServers) {
if (config.isolatedDb) {
throw new Error('[e2e] E2E_ISOLATED_DB=1 requires E2E_MANAGE_SERVERS=1')
}
return
}
const frontendCmd = process.env.E2E_FRONTEND_CMD ?? defaultFrontendCmd
killPort(7104)
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
}
}
})