Replace Playwright locator .click() calls with native DOM element.click() for all tests that trigger Svelte 5 delegated onclick handlers ($.delegated). Playwright's CDP-based synthetic events don't propagate through Svelte 5's document-level handle_event_propagation delegation mechanism, while native DOM .click() does. Also replace locator.click() with element.focus() for onfocus handler tests, and add cleanup() to afterEach in all spec files missing it to prevent test pollution between runs. Fix TagInput.svelte to use untrack() when reading bindable state after an await to avoid track_reactivity_loss errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
||
|
||
test.describe('Person list', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto('/persons');
|
||
});
|
||
|
||
test('renders the persons list page', async ({ page }) => {
|
||
await expect(page.getByRole('heading', { name: /Personen/i })).toBeVisible();
|
||
await expect(page.getByRole('link', { name: /Neue Person/i })).toBeVisible();
|
||
await page.screenshot({ path: 'test-results/e2e/persons-list.png' });
|
||
});
|
||
|
||
test('search filters the persons list', async ({ page }) => {
|
||
// Navigate directly with the query param — tests that search results are filtered
|
||
// correctly without depending on the debounced oninput → goto chain in CI.
|
||
await page.goto('/persons?q=zzz_unlikely_match');
|
||
await expect(page.getByText(/Keine Personen gefunden/i)).toBeVisible();
|
||
await page.screenshot({ path: 'test-results/e2e/persons-search-empty.png' });
|
||
});
|
||
|
||
test('clicking a person opens the detail page', async ({ page }) => {
|
||
const firstPerson = page.locator('a[href^="/persons/"]').first();
|
||
await firstPerson.click();
|
||
await expect(page).toHaveURL(/\/persons\/.+/);
|
||
await page.screenshot({ path: 'test-results/e2e/person-detail.png' });
|
||
});
|
||
});
|
||
|
||
test.describe('Person detail', () => {
|
||
test('shows the person name and their documents', async ({ page }) => {
|
||
await page.goto('/persons');
|
||
const firstPerson = page.locator('a[href^="/persons/"]').first();
|
||
await firstPerson.click();
|
||
// The detail page shows the person's name as the top-level heading
|
||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||
await page.screenshot({ path: 'test-results/e2e/person-detail-documents.png' });
|
||
});
|
||
|
||
test('can enter and cancel edit mode', async ({ page }) => {
|
||
await page.goto('/persons');
|
||
const firstPerson = page.locator('a[href^="/persons/"]').first();
|
||
await firstPerson.click();
|
||
// Click the edit button
|
||
const editBtn = page.getByRole('button', { name: /Bearbeiten/i });
|
||
if (await editBtn.isVisible()) {
|
||
await editBtn.click();
|
||
await expect(page.getByLabel('Vorname')).toBeVisible();
|
||
await page.screenshot({ path: 'test-results/e2e/person-edit-form.png' });
|
||
// Cancel
|
||
await page.getByRole('button', { name: /Abbrechen/i }).click();
|
||
await expect(page.getByLabel('Vorname')).not.toBeVisible();
|
||
}
|
||
});
|
||
|
||
test('birth and death year fields appear in edit mode and save correctly', async ({ page }) => {
|
||
await page.goto('/persons');
|
||
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||
await firstPerson.click();
|
||
await page.waitForSelector('[data-hydrated]');
|
||
|
||
const editBtn = page.getByRole('button', { name: /Bearbeiten/i });
|
||
await editBtn.click();
|
||
|
||
await expect(page.getByLabel(/Geburtsjahr/i)).toBeVisible();
|
||
await expect(page.getByLabel(/Todesjahr/i)).toBeVisible();
|
||
|
||
await page.getByLabel(/Geburtsjahr/i).fill('1890');
|
||
await page.getByLabel(/Todesjahr/i).fill('1965');
|
||
|
||
await page.getByRole('button', { name: /Speichern/i }).click();
|
||
|
||
// After saving, the years should be shown in view mode
|
||
await expect(page.getByText('* 1890')).toBeVisible();
|
||
await expect(page.getByText('† 1965')).toBeVisible();
|
||
await page.screenshot({ path: 'test-results/e2e/person-birth-death-years.png' });
|
||
});
|
||
});
|
||
|
||
test.describe('New person', () => {
|
||
test('renders the new person form', async ({ page }) => {
|
||
await page.goto('/persons/new');
|
||
await expect(page.getByLabel('Vorname')).toBeVisible();
|
||
await expect(page.getByLabel('Nachname')).toBeVisible();
|
||
await expect(page.getByRole('button', { name: /Erstellen/i })).toBeVisible();
|
||
await page.screenshot({ path: 'test-results/e2e/person-new.png' });
|
||
});
|
||
|
||
test('shows a validation error when submitting with empty fields', async ({ page }) => {
|
||
await page.goto('/persons/new');
|
||
// HTML required attribute prevents submission without filling required fields
|
||
await page.getByRole('button', { name: /Erstellen/i }).click();
|
||
// The form should not have navigated away
|
||
await expect(page).toHaveURL('/persons/new');
|
||
});
|
||
});
|
||
|
||
test.describe('Person detail — sort toggle', () => {
|
||
test('each section has its own sort toggle that works independently', async ({ page }) => {
|
||
await page.goto('/persons');
|
||
const firstPerson = page.locator('a[href^="/persons/"]').first();
|
||
await firstPerson.click();
|
||
await page.waitForSelector('[data-hydrated]');
|
||
|
||
// Find sort buttons — there may be 0, 1 or 2 depending on whether sections have >1 doc
|
||
const sortBtns = page.getByRole('button', { name: /Neueste zuerst|Älteste zuerst/i });
|
||
const btnCount = await sortBtns.count();
|
||
|
||
if (btnCount >= 1) {
|
||
const firstBtn = sortBtns.first();
|
||
await expect(firstBtn).toContainText('Neueste zuerst');
|
||
await firstBtn.click();
|
||
await expect(firstBtn).toContainText('Älteste zuerst');
|
||
await firstBtn.click();
|
||
await expect(firstBtn).toContainText('Neueste zuerst');
|
||
}
|
||
|
||
if (btnCount >= 2) {
|
||
// Second sort button toggles independently
|
||
const secondBtn = sortBtns.nth(1);
|
||
await expect(secondBtn).toContainText('Neueste zuerst');
|
||
await secondBtn.click();
|
||
await expect(secondBtn).toContainText('Älteste zuerst');
|
||
// First button should be unaffected
|
||
await expect(sortBtns.first()).toContainText('Neueste zuerst');
|
||
}
|
||
|
||
await page.screenshot({ path: 'test-results/e2e/person-sort-toggle.png' });
|
||
});
|
||
});
|
||
|
||
test.describe('Person detail — sent and received documents', () => {
|
||
test('shows both sent and received document sections', async ({ page }) => {
|
||
await page.goto('/persons');
|
||
const firstPerson = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||
await firstPerson.click();
|
||
await page.waitForSelector('[data-hydrated]');
|
||
|
||
await expect(page.getByRole('heading', { name: /Gesendete Dokumente/i })).toBeVisible();
|
||
await expect(page.getByRole('heading', { name: /Empfangene Dokumente/i })).toBeVisible();
|
||
await page.screenshot({ path: 'test-results/e2e/person-sent-received.png' });
|
||
});
|
||
|
||
test('shows year range next to document count when documents have dates', async ({ page }) => {
|
||
// Navigate to the first person who has documents with dates
|
||
await page.goto('/persons');
|
||
const personLinks = page.locator('a[href^="/persons/"]:not([href="/persons/new"])');
|
||
const count = await personLinks.count();
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
await page.goto('/persons');
|
||
await personLinks.nth(i).click();
|
||
await page.waitForSelector('[data-hydrated]');
|
||
|
||
// Check if either section heading has a year range (4 digits)
|
||
const sentHeading = page.getByRole('heading', { name: /Gesendete Dokumente/i }).locator('..');
|
||
const hasYearRange = await sentHeading.locator('span').filter({ hasText: /\d{4}/ }).count();
|
||
if (hasYearRange > 0) {
|
||
await expect(
|
||
sentHeading.locator('span').filter({ hasText: /\d{4}/ }).first()
|
||
).toBeVisible();
|
||
await page.screenshot({ path: 'test-results/e2e/person-year-range.png' });
|
||
return;
|
||
}
|
||
}
|
||
// If no person has dated documents, the test is a no-op (year range is optional)
|
||
});
|
||
});
|
||
|
||
test.describe('Person detail — conversations link', () => {
|
||
test('co-correspondent chips link to conversations pre-filled with both persons', async ({
|
||
page
|
||
}) => {
|
||
await page.goto('/persons');
|
||
const firstLink = page.locator('a[href^="/persons/"]:not([href="/persons/new"])').first();
|
||
const href = await firstLink.getAttribute('href');
|
||
const personId = href!.split('/persons/')[1];
|
||
await firstLink.click();
|
||
await page.waitForSelector('[data-hydrated]');
|
||
|
||
// Co-correspondent chips link to /conversations?senderId=X&receiverId=Y
|
||
const chip = page.locator(`a[href^="/conversations?senderId=${personId}&receiverId="]`).first();
|
||
if ((await chip.count()) > 0) {
|
||
const chipHref = await chip.getAttribute('href');
|
||
expect(chipHref).toMatch(/\/conversations\?senderId=.+&receiverId=.+/);
|
||
}
|
||
});
|
||
});
|
||
|
||
test.describe('Conversations', () => {
|
||
test('shows the empty state when no persons are selected', async ({ page }) => {
|
||
await page.goto('/conversations');
|
||
await expect(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeVisible();
|
||
await page.screenshot({ path: 'test-results/e2e/conversations-empty.png' });
|
||
});
|
||
|
||
test('nav link is active on the conversations page', async ({ page }) => {
|
||
await page.goto('/conversations');
|
||
const navLink = page.getByRole('link', { name: 'Konversationen' });
|
||
await expect(navLink).toHaveClass(/text-brand-navy/);
|
||
});
|
||
|
||
test('sort toggle changes the button label', async ({ page }) => {
|
||
await page.goto('/conversations');
|
||
await page.waitForSelector('[data-hydrated]');
|
||
const btn = page.getByRole('button', { name: /Sortierung/i });
|
||
await expect(btn).toContainText('Neueste zuerst');
|
||
await btn.click();
|
||
await expect(page).toHaveURL(/dir=ASC/);
|
||
await expect(btn).toContainText('Älteste zuerst');
|
||
await page.screenshot({ path: 'test-results/e2e/conversations-sort.png' });
|
||
});
|
||
});
|
||
|
||
test.describe('Conversations — enhancements', () => {
|
||
// Hans→Anna (1923) and Anna→Hans (1965) are seeded in DataInitializer
|
||
// Navigate directly by URL so the test doesn't rely on typeahead interaction
|
||
async function loadHansAnnaConversation(page: import('@playwright/test').Page) {
|
||
// Resolve person IDs from the persons list
|
||
await page.goto('/persons');
|
||
const hansLink = page.getByRole('link', { name: /Hans Müller/ }).first();
|
||
const hansHref = await hansLink.getAttribute('href');
|
||
const hansId = hansHref!.split('/').pop()!;
|
||
|
||
const annaLink = page.getByRole('link', { name: /Anna Schmidt/ }).first();
|
||
const annaHref = await annaLink.getAttribute('href');
|
||
const annaId = annaHref!.split('/').pop()!;
|
||
|
||
await page.goto(`/conversations?senderId=${hansId}&receiverId=${annaId}`);
|
||
await page.waitForURL(/senderId=/);
|
||
}
|
||
|
||
test('shows document count and year range summary when both persons are selected', async ({
|
||
page
|
||
}) => {
|
||
await loadHansAnnaConversation(page);
|
||
// Hans→Anna (1923) + Anna→Hans (1965) = 2 documents, range 1923–1965
|
||
await expect(page.getByTestId('conv-summary')).toContainText('2');
|
||
await expect(page.getByTestId('conv-summary')).toContainText('1923');
|
||
await expect(page.getByTestId('conv-summary')).toContainText('1965');
|
||
await page.screenshot({ path: 'test-results/e2e/conversations-summary.png' });
|
||
});
|
||
|
||
test('shows year dividers between documents from different years', async ({ page }) => {
|
||
await loadHansAnnaConversation(page);
|
||
// Expect at least two year dividers (1923 and 1965)
|
||
await expect(page.getByTestId('year-divider').first()).toBeVisible();
|
||
const dividers = page.getByTestId('year-divider');
|
||
const texts = await dividers.allTextContents();
|
||
expect(texts.some((t) => t.includes('1923'))).toBe(true);
|
||
expect(texts.some((t) => t.includes('1965'))).toBe(true);
|
||
await page.screenshot({ path: 'test-results/e2e/conversations-year-dividers.png' });
|
||
});
|
||
|
||
test('swap button switches sender and receiver and reloads', async ({ page }) => {
|
||
await loadHansAnnaConversation(page);
|
||
const url = new URL(page.url());
|
||
const originalSenderId = url.searchParams.get('senderId')!;
|
||
const originalReceiverId = url.searchParams.get('receiverId')!;
|
||
|
||
await page.getByTestId('conv-swap-btn').click();
|
||
await page.waitForURL(/senderId=/);
|
||
|
||
const swappedUrl = new URL(page.url());
|
||
expect(swappedUrl.searchParams.get('senderId')).toBe(originalReceiverId);
|
||
expect(swappedUrl.searchParams.get('receiverId')).toBe(originalSenderId);
|
||
await page.screenshot({ path: 'test-results/e2e/conversations-swap.png' });
|
||
});
|
||
|
||
test('shows "new document" link pre-filled with both persons when conversation is loaded', async ({
|
||
page
|
||
}) => {
|
||
await loadHansAnnaConversation(page);
|
||
const url = new URL(page.url());
|
||
const senderId = url.searchParams.get('senderId')!;
|
||
const receiverId = url.searchParams.get('receiverId')!;
|
||
|
||
const link = page.getByTestId('conv-new-doc-link');
|
||
await expect(link).toBeVisible();
|
||
const href = await link.getAttribute('href');
|
||
expect(href).toContain(`senderId=${senderId}`);
|
||
expect(href).toContain(`receiverId=${receiverId}`);
|
||
await page.screenshot({ path: 'test-results/e2e/conversations-new-doc-link.png' });
|
||
});
|
||
|
||
test('does not show swap button or new document link when only one person is selected', async ({
|
||
page
|
||
}) => {
|
||
await page.goto('/conversations');
|
||
await page.waitForURL('/conversations');
|
||
await expect(page.getByTestId('conv-swap-btn')).not.toBeVisible();
|
||
await expect(page.getByTestId('conv-new-doc-link')).not.toBeVisible();
|
||
});
|
||
});
|