chore: seminarhof customer project

This commit is contained in:
e2e
2026-07-10 23:42:33 +02:00
commit 5bf86e6162
188 changed files with 20779 additions and 0 deletions

238
e2e/tests/support/hooks.ts Normal file
View File

@@ -0,0 +1,238 @@
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 { 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";
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: "bun run dev:backend",
cwd: repoRoot,
env: {
...process.env,
NODE_ENV: "test",
PIKKU_DEV_DB_FILE: scenarioDbPath,
// pikku dev reads DATABASE_URL and treats a non-postgres/libsql/http value
// as a sqlite file PATH (no scheme). A `file:` prefix isn't stripped and
// gets resolveAgainst()'d into a bogus path → fresh empty DB → "no such
// table: user". Pass the plain absolute path.
DATABASE_URL: scenarioDbPath,
// better-auth needs a signing secret to read/issue sessions; provide a
// deterministic test value so the managed backend doesn't 500 on auth.
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? "e2e-test-better-auth-secret",
// Unlocks the testReset RPC ("the app data is reset") on this backend.
E2E_TEST_RESET: "1",
},
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(() => {});
});
// Browser lifecycle (open/close per actor, debug dumps, reset hook) is owned
// by @pikku/cucumber/browser — see register.ts. These hooks only manage the
// per-scenario backend + DB isolation.
// Backend boot occasionally exceeds the default step timeout under load.
Before({ timeout: 90_000 }, async function ({ pickle }) {
if (config.isolatedDb) {
const scenarioId = pickle.id.replace(/[^a-zA-Z0-9_-]/g, "_");
await startScenarioBackend(`scenario-${scenarioId}`);
}
});
After(async function () {
if (config.isolatedDb) {
await stopProcess(backendProcess);
backendProcess = undefined;
if (scenarioDbPath) {
await rm(scenarioDbPath, { force: true }).catch(() => {});
scenarioDbPath = undefined;
}
}
});

View File

@@ -0,0 +1,15 @@
import {
Given,
When,
Then,
Before,
After,
defineParameterType,
setDefaultTimeout,
} from "@cucumber/cucumber";
import { registerBrowserSteps, registerBrowserHooks } from "@pikku/cucumber/browser";
// The shared browser vocabulary ({actor} grammar, Mantine drivers, smoke
// sweep) and the browser lifecycle hooks. Project steps live in ../steps.
registerBrowserSteps({ Given, When, Then, defineParameterType });
registerBrowserHooks({ Before, After, setDefaultTimeout });

View File

@@ -0,0 +1,44 @@
// Project-level e2e configuration. Override via env vars per environment.
//
// Local defaults:
// apps/app → vite on 5001
// backend → Pikku API on 5003
export const config = {
/** URL of the running frontend (vite dev server or built preview). */
get appUrl() {
return process.env.APP_URL ?? 'http://localhost:5001'
},
/** URL of the running backend (Pikku API / CFW worker). */
get apiUrl() {
return process.env.API_URL ?? 'http://localhost:5003'
},
/** Per-step Playwright timeout (ms). */
get responseTimeout() {
return Number(process.env.E2E_TIMEOUT ?? 30_000)
},
/** Whether the harness should spawn the dev servers itself. */
get manageServers() {
return process.env.E2E_MANAGE_SERVERS === '1'
},
/** Whether the harness should isolate each scenario with a copied golden DB. */
get isolatedDb() {
return process.env.E2E_ISOLATED_DB === '1'
},
/**
* Reset hook URL — POST here to reset the DB to seed state between scenarios.
* Project must implement this as a sessionless RPC gated behind a test-only
* flag (e.g. NODE_ENV !== 'production'). Leave undefined to skip reset.
*/
get resetUrl() {
return process.env.E2E_RESET_URL
},
/**
* RPC name to invoke at resetUrl. When set, the reset hook posts
* `{rpcName, data: {}}` as the body — matching pikku's public /rpc/:name
* envelope. Leave unset for non-pikku reset endpoints.
*/
get resetRpcName() {
return process.env.E2E_RESET_RPC_NAME
},
} as const

View File

@@ -0,0 +1,73 @@
import { setWorldConstructor } from "@cucumber/cucumber";
import {
BrowserWorld,
browserConfigFromEnv,
type ClientContext,
} from "@pikku/cucumber/browser";
import { PikkuRPC } from "../../../packages/functions-sdk/src/pikku/pikku-rpc.gen.js";
import { PikkuFetch } from "../../../packages/functions-sdk/src/pikku/pikku-fetch.gen.js";
/** The app's generated, fully-typed pikku clients, per actor session. */
interface Clients {
rpc: PikkuRPC;
fetch: PikkuFetch;
}
/**
* Seminarhof world — the shared @pikku/cucumber BrowserWorld (actors, Mantine
* steps, page-issue tracking) plus the seminarhof-specific helpers used by
* the project steps. Everything generic lives in the package.
*/
export class AppWorld extends BrowserWorld<Clients> {
constructor(options?: unknown) {
super(
options,
browserConfigFromEnv({
locale: process.env.E2E_LOCALE ?? "de-DE",
// Seeded accounts (db/sqlite-seed.sql) — actors log in by persona.
personas: {
"the admin": {
email: "christina@seminarhof.example",
password: "admin1234",
name: "Christina Admin",
},
"the owner": {
email: "sarah@seminarhof.example",
password: "owner1234",
name: "Sarah Drawehn",
},
"the client": {
email: "demo@yoga-retreat.example",
password: "demo1234",
name: "Demo Organiser",
},
},
}),
);
}
protected createClients({ apiUrl, cookieHeader }: ClientContext): Clients {
const pikkuFetch = new PikkuFetch();
pikkuFetch.setServerUrl(apiUrl);
if (cookieHeader) pikkuFetch.setHeader("Cookie", cookieHeader);
const rpc = new PikkuRPC();
rpc.setPikkuFetch(pikkuFetch);
return { rpc, fetch: pikkuFetch };
}
/** Runs the booking-lifecycle cron once, as the current actor. */
async runLifecycle() {
const actor = await this.actor(undefined);
const { rpc } = await actor.clients();
return rpc.invoke("runBookingLifecycleNow", {});
}
/** "the app data is reset" → the app's own testReset RPC (E2E-gated). */
override async resetAppData() {
const actor = await this.actor(undefined);
const { rpc } = await actor.clients();
await rpc.invoke("testReset");
}
}
setWorldConstructor(AppWorld);