mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 15:49:23 +02:00
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:
parent
4d46cbb676
commit
3a4c6654b5
3 changed files with 328 additions and 0 deletions
126
apps/mana/apps/web/e2e/events-public-rsvp.spec.ts
Normal file
126
apps/mana/apps/web/e2e/events-public-rsvp.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
127
apps/mana/apps/web/e2e/events.spec.ts
Normal file
127
apps/mana/apps/web/e2e/events.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
75
apps/mana/apps/web/playwright.config.ts
Normal file
75
apps/mana/apps/web/playwright.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue