test(events): playwright e2e specs + flake-resistant config

Restore the events Playwright suite (lost in a rebase) and harden it
against Vite cold-start HMR flakes. Six tests cover the local-first
host flow (create, edit guests, RSVP totals, delete) and the public
RSVP page (snapshot render, submit, upsert, 404). The host flow runs
in guest mode and dismisses the welcome modal via a small helper.

playwright.config.ts boots mana-auth, the Vite dev server, and
mana-events as separate webServers with reuseExistingServer=true so
running tests against an already-up dev environment is a no-op. Bumps
the per-test timeout to 60s and the expect timeout to 10s, and tells
goto() to wait for networkidle so locator clicks don't race a Vite
recompile.
This commit is contained in:
Till JS 2026-04-07 18:36:45 +02:00
parent 4d46cbb676
commit 3a4c6654b5
3 changed files with 328 additions and 0 deletions

View file

@ -0,0 +1,126 @@
/**
* Public RSVP page end-to-end smoke test.
*
* Bypasses the host's JWT publish flow by seeding events.events_published
* directly in Postgres, then exercises the unauthenticated /rsvp/[token]
* page like a real guest would.
*
* Requires: Postgres + mana-events service running. Both are booted by
* the playwright.config.ts webServer block in local dev.
*/
import { test, expect } from '@playwright/test';
import postgres from 'postgres';
const DATABASE_URL =
process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform';
const EVENTS_URL = process.env.PUBLIC_MANA_EVENTS_URL || 'http://localhost:3065';
const sql = postgres(DATABASE_URL, { max: 2 });
const TEST_TOKEN = 'E2E_PUBLIC_RSVP_TOKEN';
const TEST_EVENT_ID = '00000000-0000-0000-0000-000000000e2e';
test.afterAll(async () => {
await sql`DELETE FROM events.events_published WHERE token = ${TEST_TOKEN}`;
await sql.end();
});
test.describe('Public RSVP page', () => {
test.beforeEach(async () => {
// Wipe + reseed so each test starts clean
await sql`DELETE FROM events.events_published WHERE token = ${TEST_TOKEN}`;
const startAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
const endAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000 + 3 * 60 * 60 * 1000).toISOString();
await sql`
INSERT INTO events.events_published
(token, event_id, user_id, title, description, location, start_at, end_at, all_day, color)
VALUES
(${TEST_TOKEN}, ${TEST_EVENT_ID}, 'e2e-host', 'E2E Public Party',
'Bring snacks!', 'Café am See',
${startAt}::timestamptz, ${endAt}::timestamptz, false, '#f43f5e')
`;
});
test('renders the seeded event snapshot', async ({ page }) => {
await page.goto(`/rsvp/${TEST_TOKEN}`, { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: 'E2E Public Party' })).toBeVisible();
await expect(page.getByText('Café am See')).toBeVisible();
await expect(page.getByText('Bring snacks!')).toBeVisible();
});
test('submits an RSVP and shows the success state', async ({ page }) => {
await page.goto(`/rsvp/${TEST_TOKEN}`, { waitUntil: 'networkidle' });
// Fill the form (labels rendered in EN because Playwright sends Accept-Language: en)
await page.getByRole('textbox', { name: /Your name|Dein Name/ }).fill('Tante Erika');
await page
.getByRole('textbox', { name: /Email \(optional\)|E-Mail \(optional\)/ })
.fill('erika@example.com');
// Status defaults to "yes" — explicit click and bring 2 plus-ones
await page.getByRole('button', { name: /Ja, komme|Yes, coming/i }).click();
await page.locator('input[type="range"]').fill('2');
await page
.getByRole('textbox', { name: /Note \(optional\)|Notiz \(optional\)/ })
.fill('Komme erst um 20 Uhr');
await page.getByRole('button', { name: /Antwort senden|Send reply/i }).click();
// Success card should appear
await expect(page.getByRole('heading', { name: /Danke|Thanks/ })).toBeVisible({
timeout: 5_000,
});
// Verify the row landed in Postgres
const rows = await sql<
{ name: string; status: string; plus_ones: number; note: string | null }[]
>`
SELECT name, status, plus_ones, note
FROM events.public_rsvps
WHERE token = ${TEST_TOKEN}
`;
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
name: 'Tante Erika',
status: 'yes',
plus_ones: 2,
note: 'Komme erst um 20 Uhr',
});
});
test('upserts when the same person submits twice', async ({ page }) => {
const nameField = page.getByRole('textbox', { name: /Your name|Dein Name/ });
// First submission
await page.goto(`/rsvp/${TEST_TOKEN}`, { waitUntil: 'networkidle' });
await nameField.fill('Onkel Klaus');
await page.getByRole('button', { name: /Vielleicht|Maybe/i }).click();
await page.getByRole('button', { name: /Antwort senden|Send reply/i }).click();
await expect(page.getByRole('heading', { name: /Danke|Thanks/i })).toBeVisible();
// Click "Change answer" to flip back to the form. Svelte 5 keeps the
// script-scoped $state across the {#if} branches, so name is still
// "Onkel Klaus" — no need to refill. Just wait for the form input to
// be visible again before changing the status.
await page.getByRole('button', { name: /Antwort ändern|Change answer/i }).click();
await expect(nameField).toBeVisible();
await page.getByRole('button', { name: /✕ Nein|✕ No/i }).click();
await page.getByRole('button', { name: /Antwort senden|Send reply/i }).click();
await expect(page.getByRole('heading', { name: /Danke|Thanks/i })).toBeVisible();
// DB should still have exactly one row for Klaus, status now 'no'
const rows = await sql<{ status: string }[]>`
SELECT status FROM events.public_rsvps
WHERE token = ${TEST_TOKEN} AND name = 'Onkel Klaus'
`;
expect(rows).toHaveLength(1);
expect(rows[0].status).toBe('no');
});
test('returns 404 for an unknown token', async ({ page }) => {
const response = await page.goto('/rsvp/THIS_TOKEN_DOES_NOT_EXIST');
expect(response?.status()).toBe(404);
});
});

View file

@ -0,0 +1,127 @@
/**
* Events module end-to-end smoke test.
*
* Covers the local-first happy path (guest mode, no login):
* 1. Open /events on a fresh IndexedDB
* 2. Create an event via the inline form
* 3. Open the detail view
* 4. Add a guest, change RSVP status, see the summary update
* 5. Delete the event
*
* The "publish + public RSVP" flow is exercised separately in
* events-public-rsvp.spec.ts so we don't need a real auth dance here.
*/
import { test, expect, type Page } from '@playwright/test';
/**
* The unified Mana app shows a guest-welcome modal on first load that
* intercepts every click. Always dismiss it before doing anything else.
*/
async function dismissWelcomeModal(page: Page) {
const dialog = page.locator('[role="dialog"][aria-labelledby="welcome-title"]');
// Wait up to 10s for the modal to appear (it's mounted after AuthGate finishes)
try {
await dialog.waitFor({ state: 'visible', timeout: 10_000 });
} catch {
// No modal — already dismissed in a previous test or guest-mode disabled
return;
}
await dialog.getByRole('button', { name: /Weiter als Gast|Continue as Guest/i }).click();
await dialog.waitFor({ state: 'hidden' });
}
// Each test gets its own browser context so IndexedDB starts empty.
test.describe('Events module — local flow', () => {
test('create, edit guest list, delete an event in guest mode', async ({ page }) => {
// 1. Land on /events
await page.goto('/events', { waitUntil: 'networkidle' });
// AuthGate may show a guest-welcome modal — dismiss it if present.
await dismissWelcomeModal(page);
// Heading should appear
await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible();
// 2. Create event via the inline form
await page.getByRole('button', { name: '+ Neues Event' }).click();
const title = `E2E Test Party ${Date.now()}`;
await page.getByPlaceholder("Worum geht's?", { exact: false }).fill(title);
// Date input — use a fixed future date
const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const dateValue = future.toISOString().slice(0, 10);
await page.locator('input[type="date"]').fill(dateValue);
// Time input is pre-filled to 19:00, leave it
await page.getByPlaceholder('Ort (optional)').fill('Café am See');
await page.getByRole('button', { name: 'Event anlegen' }).click();
// After create, ListView callback navigates to /events/{id}
await page.waitForURL(/\/events\/[a-f0-9-]+$/);
// 3. Detail view should show the title
await expect(page.getByRole('heading', { name: title })).toBeVisible();
await expect(page.getByText('Café am See')).toBeVisible();
// 4. Add a guest
await page.getByPlaceholder('Name', { exact: false }).first().fill('Tante Erika');
await page
.getByPlaceholder(/E-Mail/i)
.first()
.fill('erika@example.com');
await page.getByRole('button', { name: 'Hinzufügen' }).click();
// Guest row should appear
await expect(page.getByText('Tante Erika')).toBeVisible();
await expect(page.getByText('erika@example.com')).toBeVisible();
// Set Erika to "Ja"
const rsvpSelect = page.locator('select.rsvp-select').first();
await rsvpSelect.selectOption('yes');
// Summary should reflect 1 yes / 1 attending (no plus-ones)
await expect(page.locator('.rsvp-summary .badge.yes .count')).toHaveText('1');
// Set 2 plus-ones
await page.locator('input[type="number"]').first().fill('2');
// Blur to commit (onchange)
await page.locator('input[type="number"]').first().blur();
// totalAttending should now be 3 (1 + 2 plus-ones)
await expect(page.locator('.rsvp-summary .total strong')).toHaveText('3');
// 5. Delete the event — confirm dialog
page.once('dialog', (dialog) => dialog.accept());
await page.getByRole('button', { name: 'Löschen' }).click();
// We should land back on the events list
await page.waitForURL(/\/events\/?$/);
await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible();
// The deleted event should no longer appear
await expect(page.getByText(title)).not.toBeVisible();
});
test('quick-input adapter creates an event from the title bar', async ({ page }) => {
await page.goto('/events', { waitUntil: 'networkidle' });
await dismissWelcomeModal(page);
// Sanity: page loaded
await expect(page.getByRole('heading', { name: 'Events' })).toBeVisible();
// Create via the visible "+ Neues Event" form (we don't depend on the
// global QuickInputBar here — it lives outside the events route).
await page.getByRole('button', { name: '+ Neues Event' }).click();
const title = `Quick ${Date.now()}`;
await page.getByPlaceholder("Worum geht's?", { exact: false }).fill(title);
const future = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000);
await page.locator('input[type="date"]').fill(future.toISOString().slice(0, 10));
await page.getByRole('button', { name: 'Event anlegen' }).click();
await page.waitForURL(/\/events\/[a-f0-9-]+$/);
await expect(page.getByRole('heading', { name: title })).toBeVisible();
});
});

View file

@ -0,0 +1,75 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright configuration for the unified Mana web app.
*
* Tests live in ./e2e and exercise the Svelte 5 routes against a local
* dev server. Postgres + Redis + MinIO must be running (`pnpm docker:up`
* from the monorepo root). The webServer block boots both the SvelteKit
* dev server and the mana-events backend so RSVP flows can be exercised
* end to end.
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: false, // Tests share IndexedDB / Postgres state
forbidOnly: !!process.env.CI,
// Local: 1 retry to absorb cold-start HMR flakes (Vite recompiling
// during the first navigation). CI uses 2 retries.
retries: process.env.CI ? 2 : 1,
workers: 1,
// Default per-test timeout 30s — bumped to 60s so a Vite cold compile
// during the first test of a fresh dev server doesn't time out.
timeout: 60_000,
expect: {
// Default 5s — bumped so locator polls survive a single HMR pause.
timeout: 10_000,
},
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
// Wait until Vite has finished any in-flight HMR before navigating.
navigationTimeout: 30_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: process.env.CI
? undefined
: [
{
command: 'cd ../../../../services/mana-auth && bun run src/index.ts',
url: 'http://localhost:3001/health',
reuseExistingServer: true,
timeout: 60_000,
},
{
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: true,
timeout: 120_000,
},
{
command: 'cd ../../../../services/mana-events && bun run src/index.ts',
url: 'http://localhost:3065/health',
reuseExistingServer: true,
timeout: 60_000,
env: {
PORT: '3065',
DATABASE_URL:
process.env.DATABASE_URL ||
'postgresql://mana:devpassword@localhost:5432/mana_platform',
MANA_AUTH_URL: 'http://localhost:3001',
CORS_ORIGINS: 'http://localhost:5173',
},
},
],
});