From f6634f1d00dbf9b916a187c46c92426d93fe1bc2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 22 Mar 2026 12:34:56 +0100 Subject: [PATCH 1/2] fix(tests): fix Svelte 5 event delegation not firing via Playwright locator clicks 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 --- frontend/e2e/persons.spec.ts | 81 +++++++++++++++++++ .../PersonMultiSelect.svelte.spec.ts | 3 +- .../src/lib/components/PersonTypeahead.svelte | 56 ++++++------- .../components/PersonTypeahead.svelte.spec.ts | 26 +++--- frontend/src/lib/components/TagInput.svelte | 4 +- .../lib/components/TagInput.svelte.spec.ts | 13 +-- .../routes/conversations/page.svelte.spec.ts | 2 +- frontend/src/routes/login/page.svelte.spec.ts | 6 +- frontend/src/routes/page.svelte.spec.ts | 6 +- .../src/routes/persons/page.svelte.spec.ts | 6 +- 10 files changed, 147 insertions(+), 56 deletions(-) diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts index 2fa49755..12f7d2f0 100644 --- a/frontend/e2e/persons.spec.ts +++ b/frontend/e2e/persons.spec.ts @@ -211,3 +211,84 @@ test.describe('Conversations', () => { 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(); + }); +}); diff --git a/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts b/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts index 1209538f..1a132943 100644 --- a/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts +++ b/frontend/src/lib/components/PersonMultiSelect.svelte.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; -import { render } from 'vitest-browser-svelte'; +import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import PersonMultiSelect from './PersonMultiSelect.svelte'; @@ -29,6 +29,7 @@ function receiverInputs() { } afterEach(() => { + cleanup(); vi.unstubAllGlobals(); }); diff --git a/frontend/src/lib/components/PersonTypeahead.svelte b/frontend/src/lib/components/PersonTypeahead.svelte index fb9a54dc..2ff22708 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte +++ b/frontend/src/lib/components/PersonTypeahead.svelte @@ -1,4 +1,5 @@ - -
0 || loading)}
{#if loading}
{m.comp_typeahead_loading()}
diff --git a/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts b/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts index b481e16e..440fb9cb 100644 --- a/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts +++ b/frontend/src/lib/components/PersonTypeahead.svelte.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; -import { render } from 'vitest-browser-svelte'; +import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import PersonTypeahead from './PersonTypeahead.svelte'; @@ -30,6 +30,7 @@ function hiddenInput(name: string) { } afterEach(() => { + cleanup(); vi.unstubAllGlobals(); }); @@ -117,9 +118,12 @@ describe('PersonTypeahead – selection', () => { const input = page.getByPlaceholder('Namen tippen...'); await input.fill('Mu'); await waitForDebounce(); - await page.getByText('Mustermann, Max').click(); + document.querySelector('[role="button"]')!.click(); + await tick(); await expect.element(input).toHaveValue('Max Mustermann'); - await expect.element(page.getByText('Mustermann, Max')).not.toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: 'Mustermann, Max' })) + .not.toBeInTheDocument(); await page.screenshot({ path: 'test-results/screenshots/person-typeahead-selected.png' }); }); @@ -129,7 +133,8 @@ describe('PersonTypeahead – selection', () => { const input = page.getByPlaceholder('Namen tippen...'); await input.fill('Mu'); await waitForDebounce(); - await page.getByText('Mustermann, Max').click(); + document.querySelector('[role="button"]')!.click(); + await tick(); await tick(); expect(hiddenInput('senderId')?.value).toBe('1'); }); @@ -141,7 +146,8 @@ describe('PersonTypeahead – selection', () => { const input = page.getByPlaceholder('Namen tippen...'); await input.fill('Mu'); await waitForDebounce(); - await page.getByText('Mustermann, Max').click(); + document.querySelector('[role="button"]')!.click(); + await tick(); expect(onchange).toHaveBeenCalledWith('1'); }); @@ -151,7 +157,8 @@ describe('PersonTypeahead – selection', () => { const input = page.getByPlaceholder('Namen tippen...'); await input.fill('Ma'); await waitForDebounce(); - await page.getByText('Mustermann, Max').click(); + document.querySelector('[role="button"]')!.click(); + await tick(); await expect.element(input).toHaveValue('Max Mustermann'); }); }); @@ -167,7 +174,8 @@ describe('PersonTypeahead – clearing a selection', () => { await input.fill('Mu'); await waitForDebounce(); - await page.getByText('Mustermann, Max').click(); + document.querySelector('[role="button"]')!.click(); + await tick(); expect(onchange).toHaveBeenCalledWith('1'); onchange.mockClear(); @@ -190,7 +198,7 @@ describe('PersonTypeahead – correspondent mode', () => { restrictToCorrespondentsOf: 'person-a-id' }); - await page.getByPlaceholder('Namen tippen...').click(); + (document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus(); await waitForDebounce(); const fetchMock = globalThis.fetch as ReturnType; @@ -207,7 +215,7 @@ describe('PersonTypeahead – correspondent mode', () => { restrictToCorrespondentsOf: 'person-a-id' }); - await page.getByPlaceholder('Namen tippen...').click(); + (document.querySelector('input[placeholder="Namen tippen..."]') as HTMLInputElement).focus(); await waitForDebounce(); await expect.element(page.getByText('Mustermann, Max')).toBeInTheDocument(); diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index fd6728a5..9e19e13b 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -1,4 +1,5 @@