chore: seminarhof customer project
This commit is contained in:
122
e2e/README.md
Normal file
122
e2e/README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# E2E Test Harness
|
||||
|
||||
Cucumber + Playwright end-to-end tests for the project. The intent is that
|
||||
**every locked workflow has a `.feature` file before it gets a line of
|
||||
implementation code** (see "Phase 7.5 — Acceptance scenarios" in the
|
||||
discovery playbook).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
e2e/
|
||||
package.json — cucumber, @playwright/test, tsx
|
||||
cucumber.mjs — runner config
|
||||
tests/
|
||||
features/*.feature — Gherkin scenarios (one feature per locked workflow)
|
||||
steps/*.steps.ts — step definitions; common.steps.ts is generic
|
||||
support/
|
||||
world.ts — AppWorld (browser, page, login, gotoApp, expectText)
|
||||
hooks.ts — spawns servers (optional), DB reset hook, browser lifecycle
|
||||
types.ts — config (URLs, timeouts) read from env
|
||||
reports/ — generated HTML reports
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
The harness assumes the app is reachable. Either:
|
||||
|
||||
**A. Start servers yourself** (recommended during dev):
|
||||
|
||||
```sh
|
||||
yarn dev # in repo root — starts frontend + backend
|
||||
yarn workspace @project/e2e test
|
||||
```
|
||||
|
||||
**B. Let the harness manage the servers**:
|
||||
|
||||
```sh
|
||||
E2E_MANAGE_SERVERS=1 \
|
||||
E2E_BACKEND_CMD="yarn dev:backend" \
|
||||
E2E_FRONTEND_CMD="yarn dev" \
|
||||
yarn workspace @project/e2e test
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All env vars optional; defaults match the kanban-board-template's ports.
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|----------------------|----------------------------|-----------------------------------------------|
|
||||
| `APP_URL` | `http://localhost:5001` | Vite frontend URL |
|
||||
| `API_URL` | `http://localhost:5003` | Pikku backend URL |
|
||||
| `E2E_TIMEOUT` | `30000` | Per-step Playwright timeout (ms) |
|
||||
| `E2E_MANAGE_SERVERS` | unset | Set to `1` to have hooks spawn dev servers |
|
||||
| `E2E_BACKEND_CMD` | `yarn dev:backend` | Used when `E2E_MANAGE_SERVERS=1` |
|
||||
| `E2E_FRONTEND_CMD` | `yarn dev` | Used when `E2E_MANAGE_SERVERS=1` |
|
||||
| `E2E_RESET_URL` | unset | POST URL that resets DB to seed between tests |
|
||||
| `HEADED` | unset | Set to `1` to run with a visible browser |
|
||||
|
||||
## DB reset between scenarios
|
||||
|
||||
Scenarios pollute each other. To get a deterministic DB state per scenario,
|
||||
implement a `__test_reset` Pikku function gated behind a test-only flag, and
|
||||
point `E2E_RESET_URL` at it. The harness will POST to it in the `Before`
|
||||
hook. If the URL is unset or unreachable the harness logs a warning and
|
||||
continues.
|
||||
|
||||
Example shape:
|
||||
|
||||
```ts
|
||||
export const testReset = pikkuSessionlessFunc({
|
||||
expose: true,
|
||||
description: 'Reset DB to seed state. Test-only — gated by NODE_ENV.',
|
||||
func: async ({ kysely, config }) => {
|
||||
if (config.env !== 'test') throw new Error('forbidden')
|
||||
await kysely.deleteFrom('booking').execute()
|
||||
// ... drop + reseed all mutable tables
|
||||
return { ok: true as const }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Writing a feature
|
||||
|
||||
```gherkin
|
||||
Feature: <one locked workflow>
|
||||
|
||||
Scenario: <one happy path>
|
||||
Given I am logged in as "demo@yoga-retreat.example" with password "demo1234"
|
||||
When I visit "/bookings"
|
||||
Then I see "Summer Yoga Retreat"
|
||||
```
|
||||
|
||||
Generic steps live in `tests/steps/common.steps.ts`:
|
||||
|
||||
- `Given I visit "<path>"`
|
||||
- `Given I am logged in as "<email>" with password "<password>"`
|
||||
- `When I log in as "..." with password "..."`
|
||||
- `When I log out`
|
||||
- `When I fill "<selector>" with "<value>"`
|
||||
- `When I click "<button label>"`
|
||||
- `Then I see "<text>"`
|
||||
- `Then I do not see "<text>"`
|
||||
- `Then the URL contains "<fragment>"`
|
||||
|
||||
Project-specific steps go in their own `*.steps.ts` files (e.g.
|
||||
`bookings.steps.ts`). Keep `common.steps.ts` framework-agnostic so the next
|
||||
project that copies this harness inherits a clean baseline.
|
||||
|
||||
## Tags
|
||||
|
||||
Run a subset by tag:
|
||||
|
||||
```sh
|
||||
yarn workspace @project/e2e test:tag '@auth'
|
||||
yarn workspace @project/e2e test:tag 'not @slow'
|
||||
```
|
||||
|
||||
Mark scenarios as `@skip` to exclude from the default run.
|
||||
|
||||
## Reports
|
||||
|
||||
After a run, open `tests/reports/cucumber-report.html`.
|
||||
20
e2e/package.json
Normal file
20
e2e/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@project/e2e",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "End-to-end test harness — Cucumber + Playwright.",
|
||||
"scripts": {
|
||||
"test": "E2E_MANAGE_SERVERS=1 E2E_ISOLATED_DB=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
|
||||
"test:headed": "HEADED=1 E2E_MANAGE_SERVERS=1 E2E_ISOLATED_DB=1 cucumber-js --config tests/cucumber.mjs --tags 'not @skip'",
|
||||
"test:tag": "cucumber-js --config tests/cucumber.mjs --tags",
|
||||
"tsc": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "^11.0.0",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@types/node": "^22",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.0"
|
||||
}
|
||||
}
|
||||
7
e2e/tests/cucumber.mjs
Normal file
7
e2e/tests/cucumber.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
requireModule: ['tsx'],
|
||||
require: ['tests/support/**/*.ts', 'tests/steps/**/*.ts'],
|
||||
paths: ['tests/features/**/*.feature'],
|
||||
format: ['progress', 'html:tests/reports/cucumber-report.html'],
|
||||
forceExit: true,
|
||||
}
|
||||
0
e2e/tests/features/.gitkeep
Normal file
0
e2e/tests/features/.gitkeep
Normal file
102
e2e/tests/features/admin-booking-detail.feature
Normal file
102
e2e/tests/features/admin-booking-detail.feature
Normal 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 I am logged in as "christina@seminarhof.example" with password "admin1234"
|
||||
When I visit "/admin/bookings/b_yoga_2026_summer"
|
||||
|
||||
# ── Read ────────────────────────────────────────────────────────────────
|
||||
Scenario: admin opens the summer retreat detail
|
||||
Then I see "Summer Yoga Retreat 2026"
|
||||
And I see "Yoga Retreat e.V."
|
||||
And I see "Teilnehmende"
|
||||
|
||||
Scenario: admin sees the seeded participant list
|
||||
When I click "Teilnehmende"
|
||||
Then I see "Anja Weber"
|
||||
And I see "Bernd Krause"
|
||||
|
||||
# ── Edit booking metadata ───────────────────────────────────────────────
|
||||
Scenario: admin edits the course name and persists across reload
|
||||
When I fill "courseName" with "Summer Yoga Retreat 2026 — revised"
|
||||
And I click "Änderungen speichern"
|
||||
Then I see "Gespeichert"
|
||||
When I visit "/admin/bookings/b_yoga_2026_summer"
|
||||
Then I see "Summer Yoga Retreat 2026 — revised"
|
||||
|
||||
Scenario: admin updates expected participants
|
||||
When I fill "expectedPersons" with "32"
|
||||
And I click "Änderungen speichern"
|
||||
Then I see "Gespeichert"
|
||||
|
||||
Scenario: admin toggles half-house and online listing flags
|
||||
When I select "Ja" from "halfHouse"
|
||||
And I select "Ja" from "onlineAd"
|
||||
And I click "Änderungen speichern"
|
||||
Then I see "Gespeichert"
|
||||
|
||||
Scenario: admin cannot save when no field changed
|
||||
Then I 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 I fill "organisation-email" with "ops@yoga-retreat.example"
|
||||
And I fill "organisation-phone" with "+49 30 12345678"
|
||||
And I click "Organisation speichern"
|
||||
Then I see "Organisation gespeichert"
|
||||
When I 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 I fill "organisation-email" with "not-an-email"
|
||||
And I click "Organisation speichern"
|
||||
Then I do not see "Organisation gespeichert"
|
||||
|
||||
# ── Booking status transitions ─────────────────────────────────────────
|
||||
Scenario: admin cancels a booking
|
||||
When I accept the next confirmation
|
||||
And I click "Buchung stornieren"
|
||||
Then I see "Storniert"
|
||||
And I do not see "Buchung stornieren"
|
||||
|
||||
# ── Participants CRUD ──────────────────────────────────────────────────
|
||||
Scenario: admin adds a participant
|
||||
When I click "Teilnehmende"
|
||||
And I fill "new-participant-name" with "Ingrid Vogel"
|
||||
And I click "Hinzufügen"
|
||||
Then I see "Ingrid Vogel"
|
||||
|
||||
Scenario: admin removes a participant
|
||||
When I click "Teilnehmende"
|
||||
And I accept the next confirmation
|
||||
And I click "remove-participant-p_s_08"
|
||||
Then I do not see "Hans Becker"
|
||||
|
||||
Scenario: admin changes a participant's diet
|
||||
When I click "Teilnehmende"
|
||||
And I 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 I click "Teilnehmende"
|
||||
And I fill "new-allergy-p_s_02" with "shellfish"
|
||||
And I click "add-allergy-p_s_02"
|
||||
Then I see "Meeresfrüchte"
|
||||
|
||||
Scenario: admin removes an existing allergy
|
||||
When I click "Teilnehmende"
|
||||
And I click "remove-allergy-p_s_01-nuts"
|
||||
Then I do not see "nuts"
|
||||
|
||||
# ── Rooms assign / unassign ────────────────────────────────────────────
|
||||
Scenario: admin assigns and then unassigns a participant to a room
|
||||
When I click "Zimmer"
|
||||
And I select "Anja Weber" from "assign-room-r_1"
|
||||
Then I see "Anja Weber"
|
||||
When I click "Entfernen"
|
||||
Then the field "assign-room-r_1" has value ""
|
||||
41
e2e/tests/features/auth.feature
Normal file
41
e2e/tests/features/auth.feature
Normal file
@@ -0,0 +1,41 @@
|
||||
@auth
|
||||
Feature: Authentication
|
||||
Users authenticate with email + password. Sessions are cookie-based.
|
||||
Roles (client / admin / owner) gate access to portals.
|
||||
|
||||
Background:
|
||||
Given I visit "/login"
|
||||
|
||||
Scenario: client logs in and lands on their portal
|
||||
When I log in as "demo@yoga-retreat.example" with password "demo1234"
|
||||
Then the URL contains "/"
|
||||
And I see "Seminarhof Drawehn"
|
||||
|
||||
Scenario: admin logs in and reaches the admin bookings list
|
||||
When I log in as "christina@seminarhof.example" with password "admin1234"
|
||||
And I visit "/admin/bookings"
|
||||
Then I see "Buchungen"
|
||||
And I see "Summer Yoga Retreat"
|
||||
|
||||
Scenario: owner logs in and reaches the admin bookings list
|
||||
When I log in as "sarah@seminarhof.example" with password "owner1234"
|
||||
And I visit "/admin/bookings"
|
||||
Then I see "Buchungen"
|
||||
|
||||
Scenario: wrong password is rejected
|
||||
When I fill "input[type=email]" with "demo@yoga-retreat.example"
|
||||
And I fill "input[type=password]" with "wrong-password"
|
||||
And I click "Anmelden"
|
||||
Then I see "E-Mail oder Passwort ist falsch."
|
||||
|
||||
Scenario: client is redirected away from the admin portal
|
||||
When I log in as "demo@yoga-retreat.example" with password "demo1234"
|
||||
And I visit "/admin/bookings"
|
||||
Then the URL does not contain "/admin/"
|
||||
|
||||
Scenario: logout clears the session
|
||||
Given I am logged in as "demo@yoga-retreat.example" with password "demo1234"
|
||||
When I log out
|
||||
Then I see "Sie wurden abgemeldet. Bis bald."
|
||||
When I click "Zurück zur Anmeldung"
|
||||
Then the URL contains "/login"
|
||||
8
e2e/tests/features/availability.feature
Normal file
8
e2e/tests/features/availability.feature
Normal 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 I visit "/availability"
|
||||
Then I see "Seminarhof Drawehn"
|
||||
And I see "2026"
|
||||
42
e2e/tests/features/booking-full-flow.feature
Normal file
42
e2e/tests/features/booking-full-flow.feature
Normal file
@@ -0,0 +1,42 @@
|
||||
@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 I visit "/enquiry"
|
||||
When I fill "courseName" with "E2E Full Flow Test"
|
||||
And I fill "startDate" with "2028-06-15"
|
||||
And I fill "endDate" with "2028-06-22"
|
||||
And I fill "organisationName" with "E2E Organisation"
|
||||
And I fill "contactEmail" with "e2e@example.com"
|
||||
And I check "agbAccepted"
|
||||
And I check "privacyAccepted"
|
||||
And I click "Anfrage absenden"
|
||||
Then I see "Ihre Anfrage ist eingegangen"
|
||||
|
||||
# ── Admin: full flow from enquiry to confirmed ──────────────────────────
|
||||
Scenario: admin drives a booking from enquiry through to confirmed
|
||||
Given I am logged in as "christina@seminarhof.example" with password "admin1234"
|
||||
|
||||
# 1. Open the seeded enquiry directly
|
||||
When I visit "/admin/bookings/b_lc_enquiry"
|
||||
|
||||
# 2. Approve the enquiry → reserved
|
||||
When I trigger booking action "action-approve-enquiry"
|
||||
Then I see "Reserviert"
|
||||
And the audit log tab includes "Reservierung bestätigt"
|
||||
|
||||
# 3. Send the contract manually (bypasses the 365-day cron window)
|
||||
When I trigger booking action "action-send-contract"
|
||||
Then the audit log tab includes "Vertrag & Anzahlungsrechnung"
|
||||
|
||||
# 4. Record the client's contract signature
|
||||
When I trigger booking action "action-contract-approved"
|
||||
Then I see "Anzahlung als erhalten markieren"
|
||||
|
||||
# 5. Record the deposit received → confirmed
|
||||
When I trigger booking action "action-deposit-received"
|
||||
Then I see "Bestätigt"
|
||||
And the audit log tab includes "Buchung bestätigt"
|
||||
25
e2e/tests/features/booking-lifecycle.feature
Normal file
25
e2e/tests/features/booking-lifecycle.feature
Normal file
@@ -0,0 +1,25 @@
|
||||
@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 I am logged in as "christina@seminarhof.example" with password "admin1234"
|
||||
|
||||
# ── 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 2 bookings
|
||||
And the booking "b_lc_confirmed" has status "Beendet"
|
||||
13
e2e/tests/features/events.feature
Normal file
13
e2e/tests/features/events.feature
Normal 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 I visit "/events"
|
||||
Then I see "Summer Yoga Retreat 2026"
|
||||
And I see "Yoga Retreat e.V."
|
||||
|
||||
Scenario: unpublished retreats stay hidden
|
||||
Given I visit "/events"
|
||||
Then I do not see "Winter Stille 2026"
|
||||
30
e2e/tests/features/invoices.feature
Normal file
30
e2e/tests/features/invoices.feature
Normal 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 I am logged in as "christina@seminarhof.example" with password "admin1234"
|
||||
When I visit "/admin/bookings/b_yoga_2026_summer"
|
||||
And I click "Rechnungen"
|
||||
|
||||
Scenario: admin creates a final invoice
|
||||
When I create an invoice numbered "2026-FIN-002" for 1500.00 €
|
||||
Then I see "2026-FIN-002"
|
||||
And I see "Offen"
|
||||
|
||||
Scenario: admin creates an invoice with an attached PDF
|
||||
When I attach invoice PDF "invoice-sample.pdf"
|
||||
And I create an invoice numbered "2026-PDF-001" for 500.00 €
|
||||
Then I see "PDF öffnen"
|
||||
|
||||
Scenario: admin records a payment on an outstanding invoice
|
||||
When I create an invoice numbered "2026-PAY-001" for 250.00 €
|
||||
And I mark invoice "2026-PAY-001" as paid on "2026-05-10"
|
||||
Then I see "Bezahlt"
|
||||
|
||||
Scenario: admin cancels an outstanding invoice
|
||||
When I create an invoice numbered "2026-CXL-001" for 100.00 €
|
||||
And I accept the next confirmation
|
||||
And I click "invoice-cancel-2026-CXL-001"
|
||||
Then I see "Storniert"
|
||||
36
e2e/tests/fixtures/invoice-sample.pdf
vendored
Normal file
36
e2e/tests/fixtures/invoice-sample.pdf
vendored
Normal 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
|
||||
50
e2e/tests/reports/cucumber-report.html
Normal file
50
e2e/tests/reports/cucumber-report.html
Normal file
File diff suppressed because one or more lines are too long
270
e2e/tests/steps/common.steps.ts
Normal file
270
e2e/tests/steps/common.steps.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { AppWorld } from '../support/world.js'
|
||||
|
||||
/**
|
||||
* Generic step library — shared across every project that copies this harness.
|
||||
*
|
||||
* Project-specific business steps (e.g. "I create a booking for <date>")
|
||||
* belong in their own *.steps.ts files. Keep this file framework-agnostic.
|
||||
*/
|
||||
|
||||
Given('I visit {string}', async function (this: AppWorld, path: string) {
|
||||
await this.gotoApp(path)
|
||||
})
|
||||
|
||||
Given(
|
||||
'I am logged in as {string} with password {string}',
|
||||
async function (this: AppWorld, email: string, password: string) {
|
||||
await this.login(email, password)
|
||||
},
|
||||
)
|
||||
|
||||
When('I log in as {string} with password {string}', async function (
|
||||
this: AppWorld,
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
await this.login(email, password)
|
||||
})
|
||||
|
||||
When('I log out', async function (this: AppWorld) {
|
||||
await this.logout()
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve a "field" by trying multiple strategies. CSS selectors win when the
|
||||
* name looks like one. Otherwise: data-testid, then accessible label, then
|
||||
* placeholder. Mantine sometimes parks data-testid on the wrapper div, so we
|
||||
* descend to the inner control when present.
|
||||
*/
|
||||
const looksLikeCss = (s: string) => /[\[#.>:\s]/.test(s) || s.startsWith('input')
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
async function field(world: AppWorld, name: string) {
|
||||
if (looksLikeCss(name)) return world.page.locator(name).first()
|
||||
// Wait for the testid to attach — page often paints before React Query
|
||||
// resolves, so a synchronous count() check fires too early.
|
||||
const byId = world.page.locator(`[data-testid="${name}"]`).first()
|
||||
try {
|
||||
await byId.waitFor({ state: 'attached', timeout: 5000 })
|
||||
const inner = byId.locator('input, textarea, select').first()
|
||||
if (await inner.count()) return inner
|
||||
return byId
|
||||
} catch {
|
||||
// Fall through to label / placeholder.
|
||||
}
|
||||
const byLabel = world.page.getByLabel(name, { exact: false })
|
||||
if (await byLabel.count()) return byLabel.first()
|
||||
return world.page.getByPlaceholder(name, { exact: false }).first()
|
||||
}
|
||||
|
||||
async function isVisibleWithin(
|
||||
target: ReturnType<AppWorld['page']['locator']>,
|
||||
timeout = 5000,
|
||||
) {
|
||||
try {
|
||||
await target.waitFor({ state: 'visible', timeout })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(target: ReturnType<AppWorld['page']['locator']>, timeout = 5000) {
|
||||
try {
|
||||
await target.click({ timeout })
|
||||
return
|
||||
} catch {
|
||||
await target.evaluate((node) => {
|
||||
;(node as HTMLElement).click()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function settleAfterClick(world: AppWorld, previousUrl?: string) {
|
||||
if (previousUrl) {
|
||||
await world.page
|
||||
.waitForURL((url) => url.toString() !== previousUrl, { timeout: 3000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await world.page.waitForTimeout(750)
|
||||
}
|
||||
|
||||
async function clickAndConfirmIfNeeded(world: AppWorld, target: ReturnType<AppWorld['page']['locator']>) {
|
||||
await activate(target)
|
||||
await settleAfterClick(world)
|
||||
|
||||
if (
|
||||
await isVisibleWithin(
|
||||
world.page.getByRole('button', { name: /bestätigen\?|confirm\?/i }).first(),
|
||||
1000,
|
||||
)
|
||||
) {
|
||||
await activate(
|
||||
world.page.getByRole('button', { name: /bestätigen\?|confirm\?/i }).first(),
|
||||
1000,
|
||||
)
|
||||
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await world.page.waitForTimeout(250)
|
||||
return
|
||||
}
|
||||
if (
|
||||
await isVisibleWithin(
|
||||
world.page.getByText(/bestätigen\?|confirm\?/i, { exact: false }).first(),
|
||||
1000,
|
||||
)
|
||||
) {
|
||||
await activate(
|
||||
world.page.getByText(/bestätigen\?|confirm\?/i, { exact: false }).first(),
|
||||
1000,
|
||||
)
|
||||
await world.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await world.page.waitForTimeout(250)
|
||||
}
|
||||
}
|
||||
|
||||
When(
|
||||
'I fill {string} with {string}',
|
||||
async function (this: AppWorld, name: string, value: string) {
|
||||
const target = await field(this, name)
|
||||
await target.fill('')
|
||||
await target.fill(value)
|
||||
},
|
||||
)
|
||||
|
||||
When('I check {string}', async function (this: AppWorld, name: string) {
|
||||
const target = await field(this, name)
|
||||
await target.check()
|
||||
})
|
||||
|
||||
When('I uncheck {string}', async function (this: AppWorld, name: string) {
|
||||
const target = await field(this, name)
|
||||
await target.uncheck()
|
||||
})
|
||||
|
||||
When(
|
||||
'I select {string} from {string}',
|
||||
async function (this: AppWorld, value: string, name: string) {
|
||||
console.log(`[select] resolving "${name}"`)
|
||||
const target = await field(this, name)
|
||||
const tag = await target
|
||||
.evaluate((el) => (el as HTMLElement).tagName.toLowerCase())
|
||||
.catch(() => '')
|
||||
console.log(`[select] resolved → ${tag}`)
|
||||
if (tag === 'select') {
|
||||
await target.selectOption({ label: value })
|
||||
return
|
||||
}
|
||||
console.log(`[select] clicking to open`)
|
||||
await target.click()
|
||||
console.log(`[select] clicking option "${value}"`)
|
||||
await this.page
|
||||
.getByRole('option', { name: value, exact: false })
|
||||
.first()
|
||||
.click()
|
||||
console.log(`[select] done`)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'in row {string} I do not see {string}',
|
||||
async function (this: AppWorld, rowText: string, text: string) {
|
||||
const row = this.page.getByRole('row', { name: new RegExp(rowText) })
|
||||
await expect(row.getByText(text, { exact: false }).first()).toBeHidden()
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'in row {string} I click {string}',
|
||||
async function (this: AppWorld, rowText: string, label: string) {
|
||||
const row = this.page.getByRole('row', { name: new RegExp(rowText) })
|
||||
const roles = ['button', 'link', 'menuitem'] as const
|
||||
const previousUrl = this.page.url()
|
||||
for (const role of roles) {
|
||||
const candidate = row.getByRole(role, { name: new RegExp(label, 'i') }).first()
|
||||
if (await isVisibleWithin(candidate)) {
|
||||
await activate(candidate)
|
||||
await settleAfterClick(this, previousUrl)
|
||||
return
|
||||
}
|
||||
}
|
||||
const byText = row.getByText(label, { exact: false }).first()
|
||||
if (await isVisibleWithin(byText)) {
|
||||
await activate(byText)
|
||||
await settleAfterClick(this, previousUrl)
|
||||
return
|
||||
}
|
||||
throw new Error(`Could not click "${label}" inside row "${rowText}"`)
|
||||
},
|
||||
)
|
||||
|
||||
When('I click {string}', async function (this: AppWorld, label: string) {
|
||||
// 1) data-testid first — feature files use this for testid'd row actions.
|
||||
const byId = this.page.getByTestId(label).first()
|
||||
if (await isVisibleWithin(byId)) {
|
||||
await clickAndConfirmIfNeeded(this, byId)
|
||||
return
|
||||
}
|
||||
// 2) Common interactive roles. Mantine renders tabs with role=tab,
|
||||
// anchors and buttons-as-links with role=link, etc.
|
||||
const fuzzyName = new RegExp(escapeRegex(label), 'i')
|
||||
const roles = ['button', 'tab', 'link', 'menuitem'] as const
|
||||
for (const role of roles) {
|
||||
const candidate = this.page.getByRole(role, { name: fuzzyName }).first()
|
||||
if (await isVisibleWithin(candidate)) {
|
||||
await clickAndConfirmIfNeeded(this, candidate)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 3) Plain text fallback for click-handler-on-div widgets.
|
||||
const byText = this.page.getByText(label, { exact: false }).first()
|
||||
if (await isVisibleWithin(byText)) {
|
||||
await clickAndConfirmIfNeeded(this, byText)
|
||||
return
|
||||
}
|
||||
throw new Error(`Could not click "${label}"`)
|
||||
})
|
||||
|
||||
When('I accept the next confirmation', async function (this: AppWorld) {
|
||||
this.page.once('dialog', (d) => void d.accept())
|
||||
})
|
||||
|
||||
When(
|
||||
'I answer the next prompt with {string}',
|
||||
async function (this: AppWorld, value: string) {
|
||||
await this.page.evaluate((v) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).prompt = () => v
|
||||
}, value)
|
||||
},
|
||||
)
|
||||
|
||||
Then('I see {string}', async function (this: AppWorld, text: string) {
|
||||
await this.expectText(text)
|
||||
})
|
||||
|
||||
Then(
|
||||
'the field {string} has value {string}',
|
||||
async function (this: AppWorld, name: string, value: string) {
|
||||
const target = await field(this, name)
|
||||
await expect(target).toHaveValue(value)
|
||||
},
|
||||
)
|
||||
|
||||
Then('I do not see {string}', async function (this: AppWorld, text: string) {
|
||||
await expect(
|
||||
this.page.getByText(text, { exact: false }).first(),
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
Then('the URL contains {string}', async function (this: AppWorld, fragment: string) {
|
||||
await this.page.waitForURL((url) => url.toString().includes(fragment))
|
||||
})
|
||||
|
||||
Then(
|
||||
'the URL does not contain {string}',
|
||||
async function (this: AppWorld, fragment: string) {
|
||||
await this.page.waitForURL((url) => !url.toString().includes(fragment))
|
||||
},
|
||||
)
|
||||
61
e2e/tests/steps/invoice.steps.ts
Normal file
61
e2e/tests/steps/invoice.steps.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { When } from '@cucumber/cucumber'
|
||||
import path from 'node:path'
|
||||
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(
|
||||
'I create an invoice numbered {string} for {float} €',
|
||||
async function (this: AppWorld, 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 = this.page
|
||||
.getByTestId('invoice-create-form')
|
||||
.locator('input[data-testid="invoice-number"], [data-testid="invoice-number"] input')
|
||||
.first()
|
||||
await numberField.fill(invoiceNumber)
|
||||
const amount = this.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 this.page
|
||||
.getByRole('button', { name: /rechnung erstellen|create invoice/i })
|
||||
.click()
|
||||
// Wait for the row to appear in the invoices table above the form.
|
||||
await this.page.getByText(invoiceNumber, { exact: false }).first().waitFor({
|
||||
state: 'visible',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I attach invoice PDF {string}',
|
||||
async function (this: AppWorld, filename: string) {
|
||||
const filePath = path.resolve(process.cwd(), 'tests', 'fixtures', filename)
|
||||
await this.page.getByTestId('invoice-pdf').setInputFiles(filePath)
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I mark invoice {string} as paid on {string}',
|
||||
async function (this: AppWorld, invoiceNumber: string, paidOn: string) {
|
||||
// Stub the prompt before clicking — the UI uses window.prompt to collect
|
||||
// the payment date.
|
||||
await this.page.evaluate((value) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).prompt = () => value
|
||||
}, paidOn)
|
||||
|
||||
const row = this.page.getByRole('row', { name: new RegExp(invoiceNumber) })
|
||||
await row
|
||||
.getByTestId(`invoice-mark-paid-${invoiceNumber}`)
|
||||
.click()
|
||||
await row.getByText(/bezahlt|paid/i).waitFor({ state: 'visible' })
|
||||
},
|
||||
)
|
||||
104
e2e/tests/steps/lifecycle.steps.ts
Normal file
104
e2e/tests/steps/lifecycle.steps.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { When, Then } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
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()
|
||||
expect(result.contractsSent).toBe(count)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the lifecycle cron sent {int} reminders',
|
||||
async function (this: AppWorld, count: number) {
|
||||
const result = await this.runLifecycle()
|
||||
expect(result.reminders).toBe(count)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the lifecycle cron completed {int} bookings',
|
||||
async function (this: AppWorld, count: number) {
|
||||
const result = await this.runLifecycle()
|
||||
expect(result.ended).toBe(count)
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I search the bookings list for {string}',
|
||||
async function (this: AppWorld, text: string) {
|
||||
await this.page
|
||||
.getByPlaceholder(/suchen/i)
|
||||
.first()
|
||||
.fill(text)
|
||||
await this.page.waitForTimeout(400) // debounce
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I trigger booking action {string}',
|
||||
async function (this: AppWorld, actionId: string) {
|
||||
const action = this.page.getByTestId(actionId).first()
|
||||
await action.waitFor({ state: 'visible', timeout: config.responseTimeout })
|
||||
await action.click({ timeout: config.responseTimeout })
|
||||
const confirm = this.page.getByTestId(`${actionId}-confirm`).first()
|
||||
await confirm.waitFor({ state: 'visible', timeout: config.responseTimeout })
|
||||
await confirm.click({ timeout: config.responseTimeout })
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await this.page.waitForTimeout(750)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the audit log tab includes {string}',
|
||||
async function (this: AppWorld, entry: string) {
|
||||
await this.page.getByRole('tab', { name: /protokoll|audit/i }).click()
|
||||
await expect(
|
||||
this.page.getByText(entry, { exact: false }).first(),
|
||||
).toBeVisible({ timeout: config.responseTimeout })
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'the admin approves the booking {string}',
|
||||
async function (this: AppWorld, bookingId: string) {
|
||||
await this.gotoApp(`/admin/bookings/${bookingId}`)
|
||||
await this.page.getByRole('button', { name: /reserv/i }).first().click()
|
||||
await this.page.waitForTimeout(500)
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'the admin records the deposit received for {string}',
|
||||
async function (this: AppWorld, bookingId: string) {
|
||||
await this.gotoApp(`/admin/bookings/${bookingId}`)
|
||||
await this.page.getByRole('button', { name: /anzahlung/i }).first().click()
|
||||
await this.page.waitForTimeout(500)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the booking {string} has status {string}',
|
||||
async function (this: AppWorld, bookingId: string, status: string) {
|
||||
await this.gotoApp(`/admin/bookings/${bookingId}`)
|
||||
await this.expectText(status)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the audit log for {string} includes {string}',
|
||||
async function (this: AppWorld, bookingId: string, entry: string) {
|
||||
await this.gotoApp(`/admin/bookings/${bookingId}`)
|
||||
await this.page.getByRole('tab', { name: /protokoll|audit/i }).click()
|
||||
await expect(
|
||||
this.page.getByText(entry, { exact: false }).first(),
|
||||
).toBeVisible({ timeout: config.responseTimeout })
|
||||
},
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
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
|
||||
128
e2e/tests/support/world.ts
Normal file
128
e2e/tests/support/world.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { World, setWorldConstructor } from '@cucumber/cucumber'
|
||||
import {
|
||||
chromium,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
} from '@playwright/test'
|
||||
import { config } from './types.js'
|
||||
|
||||
/**
|
||||
* AppWorld — generic Playwright world for portal-style apps.
|
||||
*
|
||||
* Exposes the primitives every feature needs:
|
||||
* - openBrowser / closeBrowser — lifecycle
|
||||
* - gotoApp(path) — navigate within the frontend
|
||||
* - login(email, password) — fill + submit /login, wait for redirect
|
||||
* - logout() — visit /logout
|
||||
* - expectText(text) — assert visible text
|
||||
*
|
||||
* Project-specific helpers (e.g. createBooking, assignRoom) belong in
|
||||
* step files, not here. Keep this generic.
|
||||
*/
|
||||
export class AppWorld extends World {
|
||||
browser!: Browser
|
||||
context!: BrowserContext
|
||||
page!: Page
|
||||
|
||||
async openBrowser() {
|
||||
const headed = process.env.HEADED === '1' || process.env.HEADED === 'true'
|
||||
this.browser = await chromium.launch({
|
||||
headless: !headed,
|
||||
slowMo: headed ? 150 : 0,
|
||||
})
|
||||
this.context = await this.browser.newContext({
|
||||
locale: process.env.E2E_LOCALE ?? 'de-DE',
|
||||
})
|
||||
await this.context.addInitScript((apiUrl) => {
|
||||
;(window as typeof window & { __E2E_API_URL?: string }).__E2E_API_URL = apiUrl
|
||||
}, config.apiUrl)
|
||||
this.page = await this.context.newPage()
|
||||
this.page.setDefaultTimeout(config.responseTimeout)
|
||||
}
|
||||
|
||||
async closeBrowser() {
|
||||
await this.context?.close()
|
||||
await this.browser?.close()
|
||||
}
|
||||
|
||||
async gotoApp(path: string) {
|
||||
const url = path.startsWith('http')
|
||||
? path
|
||||
: `${config.appUrl}${path.startsWith('/') ? path : `/${path}`}`
|
||||
await this.page.goto(url)
|
||||
await this.page.waitForFunction(
|
||||
() => document.documentElement.dataset.appHydrated === 'true',
|
||||
undefined,
|
||||
{ timeout: config.responseTimeout },
|
||||
)
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {})
|
||||
await this.page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the login form on /login and waits for navigation away from it.
|
||||
* Assumes the login form has inputs named/typed `email` and a password
|
||||
* input. Override per-project if the form differs.
|
||||
*/
|
||||
async login(email: string, password: string) {
|
||||
await this.gotoApp('/login')
|
||||
await this.page.locator('input[type="email"]').fill(email)
|
||||
await this.page.locator('input[type="password"]').fill(password)
|
||||
await this.page.getByRole('button', { name: /(sign in|login|anmelden)/i }).click()
|
||||
await this.page.waitForURL((url) => !url.pathname.startsWith('/login'), {
|
||||
timeout: config.responseTimeout,
|
||||
})
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.gotoApp('/logout')
|
||||
}
|
||||
|
||||
// Calls an RPC through the browser page so session cookies are included.
|
||||
async callRpc(name: string, data: Record<string, unknown> = {}) {
|
||||
const apiUrl = config.apiUrl
|
||||
const result = await this.page.evaluate(
|
||||
async ({ apiUrl, name, data }) => {
|
||||
const res = await fetch(`${apiUrl}/rpc/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ data }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`RPC ${name} failed (${res.status}): ${body}`)
|
||||
}
|
||||
return res.json()
|
||||
},
|
||||
{ apiUrl, name, data },
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
async runLifecycle() {
|
||||
return this.callRpc('runBookingLifecycleNow', {})
|
||||
}
|
||||
|
||||
async expectText(text: string) {
|
||||
const locator = this.page.getByText(text, { exact: false })
|
||||
const deadline = Date.now() + config.responseTimeout
|
||||
while (Date.now() < deadline) {
|
||||
const count = await locator.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (await locator.nth(i).isVisible().catch(() => false)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
await this.page.waitForTimeout(100)
|
||||
}
|
||||
throw new Error(`Timed out waiting for visible text: ${text}`)
|
||||
}
|
||||
|
||||
async getPageText(): Promise<string> {
|
||||
return this.page.innerText('body')
|
||||
}
|
||||
}
|
||||
|
||||
setWorldConstructor(AppWorld)
|
||||
14
e2e/tsconfig.json
Normal file
14
e2e/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user