chore: seminarhof customer project

This commit is contained in:
e2e
2026-07-11 08:06:17 +02:00
commit d34b9cd674
188 changed files with 20779 additions and 0 deletions

7
e2e/tests/cucumber.mjs Normal file
View File

@@ -0,0 +1,7 @@
export default {
requireModule: ['tsx'],
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
paths: ['tests/features/**/*.feature'],
format: ['progress', 'html:.reports/cucumber-report.html'],
forceExit: true,
}

View File

View File

@@ -0,0 +1,102 @@
@admin @bookings
Feature: Admin booking detail — read & edit
The admin booking-detail page replaces the spreadsheet admins must be able
to *edit* every column they used to edit, not just look at it.
Background:
Given "the admin" is logged in
When they visit "/admin/bookings/b_yoga_2026_summer"
# ── Read ────────────────────────────────────────────────────────────────
Scenario: admin opens the summer retreat detail
Then they see "Summer Yoga Retreat 2026"
And they see "Yoga Retreat e.V."
And they see "Teilnehmende"
Scenario: admin sees the seeded participant list
When they click "Teilnehmende"
Then they see "Anja Weber"
And they see "Bernd Krause"
# ── Edit booking metadata ───────────────────────────────────────────────
Scenario: admin edits the course name and persists across reload
When they fill "courseName" with "Summer Yoga Retreat 2026 revised"
And they click "Änderungen speichern"
Then they see "Gespeichert"
When they visit "/admin/bookings/b_yoga_2026_summer"
Then they see "Summer Yoga Retreat 2026 revised"
Scenario: admin updates expected participants
When they fill "expectedPersons" with "32"
And they click "Änderungen speichern"
Then they see "Gespeichert"
Scenario: admin toggles half-house and online listing flags
When they select "Ja" from "halfHouse"
And they select "Ja" from "onlineAd"
And they click "Änderungen speichern"
Then they see "Gespeichert"
Scenario: admin cannot save when no field changed
Then they see "Änderungen speichern"
# Save button is disabled until a field is touched — clicking has no effect.
# ── Edit organisation ──────────────────────────────────────────────────
Scenario: admin edits organisation contact email and phone
When they fill "organisation-email" with "ops@yoga-retreat.example"
And they fill "organisation-phone" with "+49 30 12345678"
And they click "Organisation speichern"
Then they see "Organisation gespeichert"
When they visit "/admin/bookings/b_yoga_2026_summer"
Then the field "organisation-email" has value "ops@yoga-retreat.example"
And the field "organisation-phone" has value "+49 30 12345678"
Scenario: organisation save rejects an invalid email
When they fill "organisation-email" with "not-an-email"
And they click "Organisation speichern"
Then they do not see "Organisation gespeichert"
# ── Booking status transitions ─────────────────────────────────────────
Scenario: admin cancels a booking
# ConfirmButton: first click arms, second click (testid `-confirm`) fires.
When they trigger booking action "action-cancel-booking"
Then they see "Storniert"
And they do not see "Buchung stornieren"
# ── Participants CRUD ──────────────────────────────────────────────────
Scenario: admin adds a participant
When they click "Teilnehmende"
And they fill "new-participant-name" with "Ingrid Vogel"
And they click "Hinzufügen"
Then they see "Ingrid Vogel"
Scenario: admin removes a participant
When they click "Teilnehmende"
And they accept the next confirmation
And they click "remove-participant-p_s_08"
Then they do not see "Hans Becker"
Scenario: admin changes a participant's diet
When they click "Teilnehmende"
And they select "Pescetarisch" from "participant-diet-p_s_06"
Then the field "participant-diet-p_s_06" has value "Pescetarisch"
# ── Allergies CRUD ─────────────────────────────────────────────────────
Scenario: admin adds an allergy to a participant
When they click "Teilnehmende"
And they fill "new-allergy-p_s_02" with "shellfish"
And they click "add-allergy-p_s_02"
Then they see "Meeresfrüchte"
Scenario: admin removes an existing allergy
When they click "Teilnehmende"
And they click "remove-allergy-p_s_01-nuts"
Then they do not see "nuts"
# ── Rooms assign / unassign ────────────────────────────────────────────
Scenario: admin assigns and then unassigns a participant to a room
When they click "Zimmer"
And they select "Anja Weber" from "assign-room-r_1"
Then they see "Anja Weber"
When they click "Entfernen"
Then the field "assign-room-r_1" has value ""

View File

@@ -0,0 +1,39 @@
@auth
Feature: Authentication
Users authenticate with email + password. Sessions are cookie-based.
Roles (client / admin / owner) gate access to portals.
Scenario: client logs in and lands on their portal
When "the client" logs in
Then the URL contains "/"
And they see "Seminarhof Drawehn"
Scenario: admin logs in and reaches the admin bookings list
When "the admin" logs in
And they visit "/admin/bookings"
Then they see "Buchungen"
And they see "Summer Yoga Retreat"
Scenario: owner logs in and reaches the admin bookings list
When "the owner" logs in
And they visit "/admin/bookings"
Then they see "Buchungen"
Scenario: wrong password is rejected
Given "the client" visits "/login"
When they fill "input[type=email]" with "demo@yoga-retreat.example"
And they fill "input[type=password]" with "wrong-password"
And they click "Anmelden"
Then they see "E-Mail oder Passwort ist falsch."
Scenario: client is redirected away from the admin portal
When "the client" logs in
And they visit "/admin/bookings"
Then the URL does not contain "/admin/"
Scenario: logout clears the session
Given "the client" is logged in
When they visit "/logout"
Then they see "Sie wurden abgemeldet. Bis bald."
When they click "Zurück zur Anmeldung"
Then the URL contains "/login"

View File

@@ -0,0 +1,8 @@
@public @availability
Feature: Public availability calendar
Visitors can see which days the venue is free to book.
Scenario: visitor opens the availability calendar
Given "a visitor" visits "/availability"
Then they see "Seminarhof Drawehn"
And they see "2026"

View File

@@ -0,0 +1,43 @@
@booking @flow
Feature: Full booking flow — enquiry through confirmed
A client submits an enquiry via the public form. The admin then drives
the booking through every step: approval, contract, deposit, confirmation.
# Guest: submit enquiry
Scenario: client submits an enquiry via the public form
Given "a guest" visits "/enquiry"
When they fill "courseName" with "E2E Full Flow Test"
And they fill "startDate" with "2028-06-15"
And they fill "endDate" with "2028-06-22"
And they fill "organisationName" with "E2E Organisation"
And they fill "contactEmail" with "e2e@example.com"
And they turn on "agbAccepted"
And they turn on "privacyAccepted"
And they click "Anfrage absenden"
Then they see "Ihre Anfrage ist eingegangen"
# ── Admin: full flow from enquiry to confirmed ──────────────────────────
Scenario: admin drives a booking from enquiry through to confirmed
Given the app data is reset
And "the admin" is logged in
# 1. Open the seeded enquiry directly
When they visit "/admin/bookings/b_lc_enquiry"
# 2. Approve the enquiry → reserved
When they trigger booking action "action-approve-enquiry"
Then they see "Reserviert"
And the audit log tab includes "Reservierung bestätigt"
# 3. Send the contract manually (bypasses the 365-day cron window)
When they trigger booking action "action-send-contract"
Then the audit log tab includes "Vertrag & Anzahlungsrechnung"
# 4. Record the client's contract signature
When they trigger booking action "action-contract-approved"
Then they see "Anzahlung als erhalten markieren"
# 5. Record the deposit received → confirmed
When they trigger booking action "action-deposit-received"
Then they see "Bestätigt"
And the audit log tab includes "Buchung bestätigt"

View File

@@ -0,0 +1,26 @@
@admin @lifecycle
Feature: Booking lifecycle — rule-based cron assertions
Verifies each cron rule fires against pre-seeded bookings whose dates are
computed relative to today in testReset, so the rules always trigger.
Background:
Given the app data is reset
And "the admin" is logged in
# ── R1: contract + deposit email ────────────────────────────────────────
Scenario: lifecycle R1 — cron sends contract and deposit email to a reserved booking
# b_lc_reserved: reserved, no contractSentAt, startDate 200 days away
Then the lifecycle cron sent 1 contracts
And the audit log for "b_lc_reserved" includes "Vertrag & Anzahlungsrechnung"
# ── R2: deposit reminder ─────────────────────────────────────────────────
Scenario: lifecycle R2 — cron sends deposit reminder when deposit is 7+ days unpaid
# b_lc_contract_sent: contractSentAt 8 days ago, deposit invoice unpaid
Then the lifecycle cron sent 1 reminders
And the audit log for "b_lc_contract_sent" includes "Anzahlungserinnerung"
# ── R4: auto-complete ────────────────────────────────────────────────────
Scenario: lifecycle R4 — cron auto-completes a confirmed booking after it ends
# b_lc_confirmed: confirmed, endDate yesterday
Then the lifecycle cron completed 1 bookings
And the booking "b_lc_confirmed" has status "Beendet"

View File

@@ -0,0 +1,13 @@
@public @events
Feature: Public events listing
Anyone can browse the public retreats listing without logging in. Only
bookings flagged for online listing appear here.
Scenario: visitor sees the published summer retreat
Given "a visitor" visits "/events"
Then they see "Summer Yoga Retreat 2026"
And they see "Yoga Retreat e.V."
Scenario: unpublished retreats stay hidden
Given "a visitor" visits "/events"
Then they do not see "Winter Stille 2026"

View File

@@ -0,0 +1,30 @@
@admin @invoices
Feature: Invoice creation and payment recording
Admin issues invoices against a booking and records payments when they
arrive. Both actions create audit trail entries.
Background:
Given "the admin" is logged in
When they visit "/admin/bookings/b_yoga_2026_summer"
And they click "Rechnungen"
Scenario: admin creates a final invoice
When they create an invoice numbered "2026-FIN-002" for 1500.00
Then they see "2026-FIN-002"
And they see "Offen"
Scenario: admin creates an invoice with an attached PDF
When they attach invoice PDF "invoice-sample.pdf"
And they create an invoice numbered "2026-PDF-001" for 500.00
Then they see "PDF öffnen"
Scenario: admin records a payment on an outstanding invoice
When they create an invoice numbered "2026-PAY-001" for 250.00
And they mark invoice "2026-PAY-001" as paid on "2026-05-10"
Then they see "Bezahlt"
Scenario: admin cancels an outstanding invoice
When they create an invoice numbered "2026-CXL-001" for 100.00
And they accept the next confirmation
And they click "invoice-cancel-2026-CXL-001"
Then they see "Storniert"

36
e2e/tests/fixtures/invoice-sample.pdf vendored Normal file
View File

@@ -0,0 +1,36 @@
%PDF-1.1
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT
/F1 18 Tf
36 120 Td
(Invoice Sample) Tj
ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref
0 6
0000000000 65535 f
0000000010 00000 n
0000000063 00000 n
0000000122 00000 n
0000000248 00000 n
0000000342 00000 n
trailer
<< /Root 1 0 R /Size 6 >>
startxref
412
%%EOF

View File

@@ -0,0 +1,60 @@
import { When } from "@cucumber/cucumber";
import path from "node:path";
import type { ActorSession } from "@pikku/cucumber/browser";
import type { AppWorld } from "../support/world.js";
/**
* Project-specific invoice steps. Talk to the Invoices tab on the admin
* booking-detail page (Mantine form). Locale-aware: the form uses German
* labels by default in this app.
*/
When(
"{actor} create(s) an invoice numbered {string} for {float} €",
async function (this: AppWorld, actor: ActorSession, invoiceNumber: string, amountEuro: number) {
// The form sits in the "Neue Rechnung" panel — use data-testid hooks so we
// are not coupled to localized labels.
const numberField = actor.page
.getByTestId("invoice-create-form")
.locator('input[data-testid="invoice-number"], [data-testid="invoice-number"] input')
.first();
await numberField.fill(invoiceNumber);
const amount = actor.page
.getByTestId("invoice-create-form")
.locator('input[data-testid="invoice-amount"], [data-testid="invoice-amount"] input')
.first();
await amount.fill("");
await amount.fill(String(amountEuro.toFixed(2)));
await actor.page
.getByRole("button", { name: /rechnung erstellen|create invoice/i })
.click();
// Wait for the row to appear in the invoices table above the form.
await actor.page.getByText(invoiceNumber, { exact: false }).first().waitFor({
state: "visible",
});
},
);
When(
"{actor} attach(es) invoice PDF {string}",
async function (this: AppWorld, actor: ActorSession, filename: string) {
const filePath = path.resolve(process.cwd(), "tests", "fixtures", filename);
await actor.page.getByTestId("invoice-pdf").setInputFiles(filePath);
},
);
When(
"{actor} mark(s) invoice {string} as paid on {string}",
async function (this: AppWorld, actor: ActorSession, invoiceNumber: string, paidOn: string) {
// Stub the prompt before clicking — the UI uses window.prompt to collect
// the payment date.
await actor.page.evaluate((value) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).prompt = () => value;
}, paidOn);
const row = actor.page.getByRole("row", { name: new RegExp(invoiceNumber) });
await row.getByTestId(`invoice-mark-paid-${invoiceNumber}`).click();
await row.getByText(/bezahlt|paid/i).waitFor({ state: "visible" });
},
);

View File

@@ -0,0 +1,114 @@
import { When, Then } from "@cucumber/cucumber";
import assert from "node:assert/strict";
import type { ActorSession } from "@pikku/cucumber/browser";
import type { AppWorld } from "../support/world.js";
import { config } from "../support/types.js";
When("the lifecycle cron runs", async function (this: AppWorld) {
await this.runLifecycle();
});
Then(
"the lifecycle cron sent {int} contracts",
async function (this: AppWorld, count: number) {
const result = await this.runLifecycle();
assert.equal(result.contractsSent, count);
},
);
Then(
"the lifecycle cron sent {int} reminders",
async function (this: AppWorld, count: number) {
const result = await this.runLifecycle();
assert.equal(result.reminders, count);
},
);
Then(
"the lifecycle cron completed {int} bookings",
async function (this: AppWorld, count: number) {
const result = await this.runLifecycle();
assert.equal(result.ended, count);
},
);
When(
"{actor} search(es) the bookings list for {string}",
async function (this: AppWorld, actor: ActorSession, text: string) {
await actor.page
.getByPlaceholder(/suchen/i)
.first()
.fill(text);
await actor.page.waitForTimeout(400); // debounce
},
);
When(
"{actor} trigger(s) booking action {string}",
async function (this: AppWorld, actor: ActorSession, actionId: string) {
const action = actor.page.getByTestId(actionId).first();
await action.waitFor({ state: "visible", timeout: config.responseTimeout });
await action.click({ timeout: config.responseTimeout });
const confirm = actor.page.getByTestId(`${actionId}-confirm`).first();
await confirm.waitFor({ state: "visible", timeout: config.responseTimeout });
await confirm.click({ timeout: config.responseTimeout });
await actor.page.waitForLoadState("networkidle", { timeout: 1000 }).catch(() => {
// Persistent connections never go idle; the assertion below waits anyway.
});
await actor.page.waitForTimeout(750);
},
);
Then(
"the audit log tab includes {string}",
async function (this: AppWorld, entry: string) {
const actor = await this.actor(undefined);
await actor.page.getByRole("tab", { name: /protokoll|audit/i }).click();
await actor.page
.getByText(entry, { exact: false })
.first()
.waitFor({ state: "visible", timeout: config.responseTimeout });
},
);
When(
"the admin approves the booking {string}",
async function (this: AppWorld, bookingId: string) {
const actor = await this.actor(undefined);
await actor.gotoApp(`/admin/bookings/${bookingId}`);
await actor.page.getByRole("button", { name: /reserv/i }).first().click();
await actor.page.waitForTimeout(500);
},
);
When(
"the admin records the deposit received for {string}",
async function (this: AppWorld, bookingId: string) {
const actor = await this.actor(undefined);
await actor.gotoApp(`/admin/bookings/${bookingId}`);
await actor.page.getByRole("button", { name: /anzahlung/i }).first().click();
await actor.page.waitForTimeout(500);
},
);
Then(
"the booking {string} has status {string}",
async function (this: AppWorld, bookingId: string, status: string) {
const actor = await this.actor(undefined);
await actor.gotoApp(`/admin/bookings/${bookingId}`);
await actor.expectText(status);
},
);
Then(
"the audit log for {string} includes {string}",
async function (this: AppWorld, bookingId: string, entry: string) {
const actor = await this.actor(undefined);
await actor.gotoApp(`/admin/bookings/${bookingId}`);
await actor.page.getByRole("tab", { name: /protokoll|audit/i }).click();
await actor.page
.getByText(entry, { exact: false })
.first()
.waitFor({ state: "visible", timeout: config.responseTimeout });
},
);

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);