chore: seminarhof customer project
This commit is contained in:
238
e2e/tests/support/hooks.ts
Normal file
238
e2e/tests/support/hooks.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
15
e2e/tests/support/register.ts
Normal file
15
e2e/tests/support/register.ts
Normal 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 });
|
||||
44
e2e/tests/support/types.ts
Normal file
44
e2e/tests/support/types.ts
Normal 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
|
||||
73
e2e/tests/support/world.ts
Normal file
73
e2e/tests/support/world.ts
Normal 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);
|
||||
Reference in New Issue
Block a user