Adds a Playwright flow that picks two persons through the typeahead, asserts both ?personId= params end up in the URL with two chips on screen, then removes the first chip and verifies only the second person id remains. Also extends .prettierignore so a stale root-owned test-results directory left over from running tests inside Docker doesn't break the pre-commit lint hook.
124 lines
5.1 KiB
TypeScript
124 lines
5.1 KiB
TypeScript
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 persons to filter by. Open the picker and pick one whose name
|
|
// the dev seed reliably contains. Then open the picker again and pick a
|
|
// second one. Picking is via the typeahead — we type, wait for the listbox,
|
|
// click the first option.
|
|
async function pickPerson(query: string) {
|
|
await page.getByRole('button', { name: /Person wählen/ }).click();
|
|
const input = page.getByRole('combobox', { name: /Person wählen/ });
|
|
await input.fill(query);
|
|
// Wait for at least one option in the listbox, then click it
|
|
const firstOption = page.getByRole('option').first();
|
|
await expect(firstOption).toBeVisible();
|
|
await firstOption.click();
|
|
}
|
|
|
|
await pickPerson('a');
|
|
await page.waitForURL(/personId=/);
|
|
const firstUrl = new URL(page.url());
|
|
const firstIds = firstUrl.searchParams.getAll('personId');
|
|
expect(firstIds).toHaveLength(1);
|
|
|
|
await pickPerson('b');
|
|
await page.waitForURL((url) => url.searchParams.getAll('personId').length === 2);
|
|
const secondUrl = new URL(page.url());
|
|
const secondIds = secondUrl.searchParams.getAll('personId');
|
|
expect(secondIds).toHaveLength(2);
|
|
expect(secondIds[0]).toBe(firstIds[0]); // first one persists
|
|
expect(secondIds[1]).not.toBe(firstIds[0]); // second is different
|
|
|
|
// 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
|
|
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([secondIds[1]]);
|
|
});
|
|
|
|
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([]);
|
|
});
|
|
});
|