chore: seminarhof customer project
This commit is contained in:
254
e2e/tests/support/hooks.ts
Normal file
254
e2e/tests/support/hooks.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user