import AxeBuilder from '@axe-core/playwright'; import { expect, test } from '@playwright/test'; /** * Minimal Geschichten coverage. The deeper a11y / visual-regression suite is * tracked separately; this file proves the core writer + reader journey works * end-to-end against the real stack. * * Pre-requisite: V59 has granted BLOG_WRITE to the Administrators group, so * the seeded admin user can author. The auth.setup project handles login. */ const stamp = () => new Date().toISOString().replace(/[^0-9]/g, ''); test.describe('Geschichten — writer + reader journey', () => { test('admin can create a draft, publish it, and see it on the index', async ({ page }) => { const title = `E2E story ${stamp()}`; // Land on the index — empty state or pre-existing demo data is fine await page.goto('/geschichten'); await page.waitForSelector('[data-hydrated]'); await expect(page.getByRole('heading', { name: 'Geschichten', level: 1 })).toBeVisible(); // Click "Neue Geschichte" — visible because admin has BLOG_WRITE await page.getByRole('link', { name: 'Neue Geschichte' }).click(); await page.waitForURL('/geschichten/new'); // Fill in title — the body editor is Tiptap and harder to script reliably await page.getByPlaceholder('Titel der Geschichte').fill(title); // Save as draft and verify we land on the detail page await page.getByRole('button', { name: 'Entwurf speichern' }).click(); await page.waitForURL(/\/geschichten\/[^/]+$/); // Capture the new id from the URL const detailUrl = page.url(); const id = detailUrl.split('/').pop(); expect(id).toBeTruthy(); // Publish from the edit page await page.getByRole('link', { name: 'Bearbeiten' }).click(); await page.waitForURL(/\/edit$/); await page.getByRole('button', { name: 'Veröffentlichen' }).click(); await page.waitForURL(detailUrl); // Index now shows the published story await page.goto('/geschichten'); await expect(page.getByRole('link', { name: title })).toBeVisible(); }); test('reader is taken to a story detail when clicking a card', async ({ page }) => { await page.goto('/geschichten'); await page.waitForSelector('[data-hydrated]'); // Use the first story link in the list (demo data exists; if not, the // previous test seeded one). The link wraps the whole card. const firstStory = page.locator('a[href^="/geschichten/"]').filter({ hasText: /.+/ }).first(); await expect(firstStory).toBeVisible(); await firstStory.click(); await page.waitForURL(/\/geschichten\/[^/]+$/); await expect(page.locator('article')).toBeVisible(); }); test('multi-person filter: chips, URL params, and AND removal work end-to-end', async ({ page }) => { await page.goto('/geschichten'); await page.waitForSelector('[data-hydrated]'); // We need two distinct persons to filter by, but we don't want to couple this // test to specific seed names. Strategy: type a single broadly-occurring vowel // ("e" is present in the vast majority of German names), open the listbox, // and pick whichever option matches the predicate. // // option DOM ids encode the person id as `${listboxId}-option-${personId}`, // so we can identify the *first different* option without knowing the seed. const PROBE = 'e'; async function openPicker() { await page.getByRole('button', { name: /Person wählen/ }).click(); const input = page.getByRole('combobox', { name: /Person wählen/ }); await input.fill(PROBE); // Wait for the listbox to be populated. await expect(page.getByRole('option').first()).toBeVisible(); } async function pickFirstOption(): Promise { const opt = page.getByRole('option').first(); const optId = (await opt.getAttribute('id')) ?? ''; const personId = optId.split('-option-')[1] ?? ''; expect(personId).not.toEqual(''); await opt.click(); return personId; } async function pickFirstOptionDifferentFrom(excludeId: string): Promise { // Iterate through visible options and return the first whose person id != excludeId. const optionCount = await page.getByRole('option').count(); for (let i = 0; i < optionCount; i++) { const candidate = page.getByRole('option').nth(i); const optId = (await candidate.getAttribute('id')) ?? ''; const personId = optId.split('-option-')[1] ?? ''; if (personId && personId !== excludeId) { await candidate.click(); return personId; } } throw new Error( `Expected at least two distinct persons matching "${PROBE}" in the seed, found only one.` ); } await openPicker(); const firstId = await pickFirstOption(); await page.waitForURL(/personId=/); const firstIds = new URL(page.url()).searchParams.getAll('personId'); expect(firstIds).toEqual([firstId]); await openPicker(); const secondId = await pickFirstOptionDifferentFrom(firstId); await page.waitForURL((url) => url.searchParams.getAll('personId').length === 2); const secondIds = new URL(page.url()).searchParams.getAll('personId'); expect(secondIds).toEqual([firstId, secondId]); expect(secondId).not.toEqual(firstId); // Two chips visible — find them by their remove-aria-label pattern const chipButtons = page.getByRole('button', { name: /aus Filter entfernen/ }); await expect(chipButtons).toHaveCount(2); // Remove the first chip — URL drops to one param, only the second id remains await chipButtons.first().click(); await page.waitForURL((url) => url.searchParams.getAll('personId').length === 1); const finalIds = new URL(page.url()).searchParams.getAll('personId'); expect(finalIds).toEqual([secondId]); }); test('AxeBuilder finds no critical violations on the index', async ({ page }) => { await page.goto('/geschichten'); await page.waitForSelector('[data-hydrated]'); const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); // Filter to non-deferred severity. We don't gate the whole PR on a clean // AxeBuilder run yet — Sara's review tracks the broader a11y backlog — // but any "serious" or "critical" finding from this scan would block merge. const blocking = results.violations.filter( (v) => v.impact === 'serious' || v.impact === 'critical' ); expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]); }); });