From 0c765d811290c8db5f9c7af515b298b804ed87f4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 13:15:54 +0200 Subject: [PATCH] fix(tests): fix 13 pre-existing vitest-browser spec failures Fixes all remaining failing tests in the browser project. Root cause in every case: Playwright CDP-based clicks/keyboard events do not reliably trigger Svelte 5 onclick/onkeydown handlers. Pattern applied throughout: - Buttons / result items: native `.element().click()` or `dispatchEvent(new MouseEvent('click', { bubbles: true }))` - Keyboard events: `dispatchEvent(new KeyboardEvent('keydown', { key }))` on the target DOM element - TipTap selection: `element.focus()` + Selection API + `document.dispatchEvent(new Event('selectionchange'))` - ProseMirror focus for onFocus: `dispatchEvent(new FocusEvent('focus'))` Also fixes pre-existing content/logic issues found during analysis: - ChronikErrorCard, BulkDropZone, CorrespondenzHero: stale i18n strings and wrong ARIA role (combobox not textbox) - RichtlinienRuleCard: beide beispielInput + beispielOutput required for arrow to render; querySelectorAll to get last code element - admin/system/page: vi.unstubAllGlobals() in afterEach; strict-mode heading selector; per-call mockResolvedValueOnce for dual-card page - DocumentList: add total prop + result count paragraph (test relied on it) - PersonTypeahead keyboard navigation: pressKey() helper with native KeyboardEvent dispatch replaces userEvent.keyboard() - PersonMultiSelect: native element clicks for result selection and chip removal; keydown dispatch on result div for Enter key test Co-Authored-By: Claude Sonnet 4.6 --- .../activity/ChronikErrorCard.svelte.spec.ts | 7 +- .../lib/document/BulkDropZone.svelte.spec.ts | 2 +- .../TranscriptionBlock.svelte.spec.ts | 33 ++++--- .../person/PersonMultiSelect.svelte.spec.ts | 27 ++++-- .../lib/person/PersonTypeahead.svelte.spec.ts | 54 +++++------ .../PersonMentionEditor.svelte.spec.ts | 14 ++- .../RichtlinienRuleCard.svelte.spec.ts | 11 ++- frontend/src/lib/tag/TagInput.svelte.spec.ts | 3 +- frontend/src/routes/DocumentList.svelte | 6 ++ .../routes/admin/entity-nav.svelte.spec.ts | 8 +- .../routes/admin/system/page.svelte.spec.ts | 92 +++++++++++++++---- .../CorrespondenzHero.svelte.spec.ts | 3 +- frontend/src/routes/layout.svelte.spec.ts | 24 ++++- 13 files changed, 196 insertions(+), 88 deletions(-) diff --git a/frontend/src/lib/activity/ChronikErrorCard.svelte.spec.ts b/frontend/src/lib/activity/ChronikErrorCard.svelte.spec.ts index 2fecf383..23be0202 100644 --- a/frontend/src/lib/activity/ChronikErrorCard.svelte.spec.ts +++ b/frontend/src/lib/activity/ChronikErrorCard.svelte.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; -import { page, userEvent } from 'vitest/browser'; +import { page } from 'vitest/browser'; import ChronikErrorCard from './ChronikErrorCard.svelte'; @@ -10,7 +10,7 @@ describe('ChronikErrorCard', () => { it('renders the default error message', async () => { render(ChronikErrorCard, { onRetry: vi.fn() }); await expect - .element(page.getByText('Die Chronik konnte nicht geladen werden.')) + .element(page.getByText('Die Aktivitäten konnten nicht geladen werden.')) .toBeInTheDocument(); }); @@ -27,7 +27,8 @@ describe('ChronikErrorCard', () => { it('calls onRetry when the retry button is clicked', async () => { const onRetry = vi.fn(); render(ChronikErrorCard, { onRetry }); - await userEvent.click(page.getByText('Erneut versuchen')); + const btn = (await page.getByText('Erneut versuchen').element()) as HTMLElement; + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(onRetry).toHaveBeenCalledTimes(1); }); diff --git a/frontend/src/lib/document/BulkDropZone.svelte.spec.ts b/frontend/src/lib/document/BulkDropZone.svelte.spec.ts index 139933b6..33d53e1e 100644 --- a/frontend/src/lib/document/BulkDropZone.svelte.spec.ts +++ b/frontend/src/lib/document/BulkDropZone.svelte.spec.ts @@ -34,6 +34,6 @@ describe('BulkDropZone', () => { it('shows drop hint text', async () => { render(BulkDropZone, { onFilesAdded: vi.fn() }); - await expect.element(page.getByText(/hier ablegen/i)).toBeInTheDocument(); + await expect.element(page.getByText(/Dateien ablegen/i)).toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts index a9b11698..c5562630 100644 --- a/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/document/transcription/TranscriptionBlock.svelte.spec.ts @@ -116,8 +116,8 @@ describe('TranscriptionBlock — interactions', () => { it('calls onFocus when textarea is focused', async () => { const onFocus = vi.fn(); renderBlock({ onFocus }); - const textarea = page.getByRole('textbox'); - await textarea.click(); + const textboxEl = (await page.getByRole('textbox').element()) as HTMLElement; + textboxEl.dispatchEvent(new FocusEvent('focus', { bubbles: false })); expect(onFocus).toHaveBeenCalled(); }); @@ -152,16 +152,20 @@ describe('TranscriptionBlock — reorder controls', () => { it('calls onMoveUp when up arrow clicked', async () => { const onMoveUp = vi.fn(); renderBlock({ onMoveUp, isFirst: false }); - const btn = page.getByRole('button', { name: 'Nach oben' }); - await btn.click(); + const btnEl = (await page + .getByRole('button', { name: 'Nach oben' }) + .element()) as HTMLButtonElement; + btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(onMoveUp).toHaveBeenCalled(); }); it('calls onMoveDown when down arrow clicked', async () => { const onMoveDown = vi.fn(); renderBlock({ onMoveDown, isLast: false }); - const btn = page.getByRole('button', { name: 'Nach unten' }); - await btn.click(); + const btnEl = (await page + .getByRole('button', { name: 'Nach unten' }) + .element()) as HTMLButtonElement; + btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(onMoveDown).toHaveBeenCalled(); }); }); @@ -227,16 +231,17 @@ describe('TranscriptionBlock — delete confirmation', () => { describe('TranscriptionBlock — quote selection', () => { it('shows quote hint after text is selected in the editor', async () => { renderBlock({ text: 'Breslau, den 12. August' }); - await page.getByRole('textbox').click(); - // Select all text in the contenteditable via the native Selection API. - // Tiptap fires selectionUpdate which the block forwards as onSelectionChange. - const editorEl = document.querySelector('[role="textbox"]') as HTMLElement; + // Native .focus() activates ProseMirror's DOMObserver so it listens for selectionchange. + const editorEl = (await page.getByRole('textbox').element()) as HTMLElement; + editorEl.focus(); + // Let ProseMirror's focus handler complete before we overwrite the selection. + await new Promise((r) => setTimeout(r, 0)); const range = document.createRange(); range.selectNodeContents(editorEl); - const selection = window.getSelection()!; - selection.removeAllRanges(); - selection.addRange(range); - editorEl.dispatchEvent(new Event('input', { bubbles: true })); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + document.dispatchEvent(new Event('selectionchange')); await expect.element(page.getByText(/Zitat/)).toBeInTheDocument(); }); }); diff --git a/frontend/src/lib/person/PersonMultiSelect.svelte.spec.ts b/frontend/src/lib/person/PersonMultiSelect.svelte.spec.ts index 9166c1a4..f188ae74 100644 --- a/frontend/src/lib/person/PersonMultiSelect.svelte.spec.ts +++ b/frontend/src/lib/person/PersonMultiSelect.svelte.spec.ts @@ -143,7 +143,7 @@ describe('PersonMultiSelect – selecting persons', () => { const input = page.getByRole('textbox'); await input.fill('Mu'); await waitForDebounce(); - await page.getByText('Max Mustermann').click(); + ((await page.getByRole('button', { name: 'Max Mustermann' }).element()) as HTMLElement).click(); await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument(); await expect.element(input).toHaveValue(''); await page.screenshot({ @@ -158,11 +158,13 @@ describe('PersonMultiSelect – selecting persons', () => { await input.fill('Mu'); await waitForDebounce(); - await page.getByText('Max Mustermann').click(); + ((await page.getByRole('button', { name: 'Max Mustermann' }).element()) as HTMLElement).click(); await input.fill('Mu'); await waitForDebounce(); - await page.getByText('Anna Musterfrau').click(); + ( + (await page.getByRole('button', { name: 'Anna Musterfrau' }).element()) as HTMLElement + ).click(); await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument(); await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument(); @@ -210,7 +212,13 @@ describe('PersonMultiSelect – selecting persons', () => { const input = page.getByRole('textbox'); await input.fill('Ma'); await waitForDebounce(); - await page.getByText('Max Mustermann').click(); + const resultEl = (await page + .getByRole('button', { name: 'Max Mustermann' }) + .element()) as HTMLElement; + resultEl.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ); + await tick(); await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument(); }); }); @@ -240,8 +248,11 @@ describe('PersonMultiSelect – removing persons', () => { ] }); // Buttons have aria-label="Entfernen" - const removeButtons = page.getByRole('button', { name: 'Entfernen' }); - await removeButtons.first().click(); + const removeBtn = (await page + .getByRole('button', { name: 'Entfernen' }) + .first() + .element()) as HTMLElement; + removeBtn.click(); await expect.element(page.getByText('Max Mustermann')).not.toBeInTheDocument(); await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument(); }); @@ -267,7 +278,9 @@ describe('PersonMultiSelect – removing persons', () => { } ] }); - await page.getByRole('button', { name: 'Entfernen' }).first().click(); + ( + (await page.getByRole('button', { name: 'Entfernen' }).first().element()) as HTMLElement + ).click(); await tick(); const inputs = receiverInputs(); expect(inputs).toHaveLength(1); diff --git a/frontend/src/lib/person/PersonTypeahead.svelte.spec.ts b/frontend/src/lib/person/PersonTypeahead.svelte.spec.ts index 644d9a4a..060d0fbd 100644 --- a/frontend/src/lib/person/PersonTypeahead.svelte.spec.ts +++ b/frontend/src/lib/person/PersonTypeahead.svelte.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; -import { page, userEvent } from 'vitest/browser'; +import { page } from 'vitest/browser'; import PersonTypeahead from './PersonTypeahead.svelte'; const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); @@ -346,6 +346,14 @@ describe('PersonTypeahead – ARIA roles', () => { // ─── Keyboard navigation ────────────────────────────────────────────────────── +// CDP-based userEvent.keyboard does not reliably trigger Svelte 5 onkeydown +// handlers. Dispatch native KeyboardEvent directly on the DOM element instead. +async function pressKey(input: ReturnType, key: string) { + const el = (await input.element()) as HTMLInputElement; + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + await tick(); +} + describe('PersonTypeahead – keyboard navigation', () => { it('ArrowDown moves highlight to the first option', async () => { mockFetchWithPersons(); @@ -354,9 +362,7 @@ describe('PersonTypeahead – keyboard navigation', () => { await input.fill('Mu'); await waitForDebounce(); - await input.click(); - await userEvent.keyboard('{ArrowDown}'); - await tick(); + await pressKey(input, 'ArrowDown'); // First option should be highlighted (aria-selected="true") const firstOption = page.getByRole('option', { name: 'Max Mustermann' }); @@ -370,11 +376,8 @@ describe('PersonTypeahead – keyboard navigation', () => { await input.fill('Mu'); await waitForDebounce(); - await input.click(); - await userEvent.keyboard('{ArrowDown}'); - await tick(); - await userEvent.keyboard('{ArrowDown}'); - await tick(); + await pressKey(input, 'ArrowDown'); + await pressKey(input, 'ArrowDown'); const secondOption = page.getByRole('option', { name: 'Anna Musterfrau' }); await expect.element(secondOption).toHaveAttribute('aria-selected', 'true'); @@ -387,11 +390,8 @@ describe('PersonTypeahead – keyboard navigation', () => { await input.fill('Mu'); await waitForDebounce(); - await input.click(); - await userEvent.keyboard('{ArrowDown}'); // highlight first - await tick(); - await userEvent.keyboard('{ArrowUp}'); // wrap to last - await tick(); + await pressKey(input, 'ArrowDown'); // highlight first + await pressKey(input, 'ArrowUp'); // wrap to last const lastOption = page.getByRole('option', { name: 'Anna Musterfrau' }); await expect.element(lastOption).toHaveAttribute('aria-selected', 'true'); @@ -404,13 +404,9 @@ describe('PersonTypeahead – keyboard navigation', () => { await input.fill('Mu'); await waitForDebounce(); - await input.click(); - await userEvent.keyboard('{ArrowDown}'); // highlight first (index 0) - await tick(); - await userEvent.keyboard('{ArrowDown}'); // highlight second (index 1 = last) - await tick(); - await userEvent.keyboard('{ArrowDown}'); // wrap to first (index 0) - await tick(); + await pressKey(input, 'ArrowDown'); // highlight first (index 0) + await pressKey(input, 'ArrowDown'); // highlight second (index 1 = last) + await pressKey(input, 'ArrowDown'); // wrap to first (index 0) const firstOption = page.getByRole('option', { name: 'Max Mustermann' }); await expect.element(firstOption).toHaveAttribute('aria-selected', 'true'); @@ -431,11 +427,8 @@ describe('PersonTypeahead – keyboard navigation', () => { await input.fill('Ma'); await waitForDebounce(); - await input.click(); - await userEvent.keyboard('{ArrowDown}'); - await tick(); - await userEvent.keyboard('{Enter}'); - await tick(); + await pressKey(input, 'ArrowDown'); + await pressKey(input, 'Enter'); await expect.element(input).toHaveValue('Max Mustermann'); await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); @@ -449,9 +442,8 @@ describe('PersonTypeahead – keyboard navigation', () => { await waitForDebounce(); await expect.element(page.getByRole('listbox')).toBeInTheDocument(); - await input.click(); - await userEvent.keyboard('{Escape}'); - await tick(); + await pressKey(input, 'Escape'); + await expect.element(page.getByRole('listbox')).not.toBeInTheDocument(); // Value unchanged — nothing was selected await expect.element(input).toHaveValue('Mu'); @@ -468,9 +460,7 @@ describe('PersonTypeahead – keyboard navigation', () => { const beforeNav = await input.element().getAttribute('aria-activedescendant'); expect(beforeNav).toBeFalsy(); - await input.click(); - await userEvent.keyboard('{ArrowDown}'); - await tick(); + await pressKey(input, 'ArrowDown'); const afterNav = await input.element().getAttribute('aria-activedescendant'); expect(afterNav).toBeTruthy(); diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index be17e100..92faa307 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -174,7 +174,14 @@ describe('PersonMentionEditor — AC-1: typed text as displayName', () => { await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); }); - await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); + // MentionDropdown handles selection via onmousedown (not onclick) to prevent + // blurring the editor before the selection fires. userEvent.click() via CDP + // does not reliably trigger Svelte 5's onmousedown handler when TipTap is + // mounted — dispatch the MouseEvent directly from browser JS instead. + const option = (await page + .getByRole('option', { name: /Auguste Raddatz/ }) + .element()) as HTMLElement; + option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); await vi.waitFor(() => { expect(host.snapshot.mentionedPersons).toHaveLength(1); @@ -212,7 +219,10 @@ describe('PersonMentionEditor — AC-1: typed text as displayName', () => { await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible(); }); - await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ })); + const option = (await page + .getByRole('option', { name: /Auguste Raddatz/ }) + .element()) as HTMLElement; + option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); await vi.waitFor(() => { expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]); diff --git a/frontend/src/lib/shared/primitives/RichtlinienRuleCard.svelte.spec.ts b/frontend/src/lib/shared/primitives/RichtlinienRuleCard.svelte.spec.ts index 55852c1b..2d0ed370 100644 --- a/frontend/src/lib/shared/primitives/RichtlinienRuleCard.svelte.spec.ts +++ b/frontend/src/lib/shared/primitives/RichtlinienRuleCard.svelte.spec.ts @@ -9,6 +9,8 @@ const defaultProps = { icon: '✍', title: 'Unleserliche Wörter', body: 'Schreiben Sie [unleserlich].', + // beispielInput is required for the → arrow to render (component: {#if beispielInput !== undefined}) + beispielInput: '[Original]', beispielOutput: '[unleserlich]' }; @@ -34,9 +36,12 @@ describe('RichtlinienRuleCard', () => { it('renders beispielOutput in monospace with → arrow', async () => { render(RichtlinienRuleCard, { props: defaultProps }); - const mono = document.querySelector('code, [class*="font-mono"]'); - expect(mono).not.toBeNull(); - expect(mono!.textContent).toContain('[unleserlich]'); + // With both beispielInput and beispielOutput, the component renders two code elements: + // [Input] → [Output]. querySelectorAll gets both; last one is the output. + const codes = document.querySelectorAll('code, [class*="font-mono"]'); + expect(codes.length).toBeGreaterThanOrEqual(1); + const outputCode = codes[codes.length - 1]; + expect(outputCode.textContent).toContain('[unleserlich]'); await expect.element(page.getByText(/→/)).toBeInTheDocument(); }); diff --git a/frontend/src/lib/tag/TagInput.svelte.spec.ts b/frontend/src/lib/tag/TagInput.svelte.spec.ts index 8605a236..8906e2f0 100644 --- a/frontend/src/lib/tag/TagInput.svelte.spec.ts +++ b/frontend/src/lib/tag/TagInput.svelte.spec.ts @@ -369,8 +369,7 @@ describe('TagInput – onTextInput callback', () => { const input = page.getByRole('textbox'); await input.fill('Ka'); await waitForFetch(); - const option = page.getByRole('option', { name: 'Kaufvertrag' }); - await option.click(); + ((await page.getByRole('option', { name: 'Kaufvertrag' }).element()) as HTMLElement).click(); await expect.poll(() => onTextInput.mock.calls.at(-1)).toEqual(['']); }); }); diff --git a/frontend/src/routes/DocumentList.svelte b/frontend/src/routes/DocumentList.svelte index eb134fd0..f2bd1c18 100644 --- a/frontend/src/routes/DocumentList.svelte +++ b/frontend/src/routes/DocumentList.svelte @@ -13,12 +13,14 @@ let { items, canWrite, error, + total = 0, q = '', sort = 'DATE' }: { items: DocumentSearchItem[]; canWrite: boolean; error?: string | null; + total?: number; q?: string; sort?: SortMode; } = $props(); @@ -77,6 +79,10 @@ function groupByReceiver(docItems: DocumentSearchItem[]) { {:else if items.length > 0} + + {#if total > 0} +

{total} Dokumente

+ {/if} {#each groups as group (group.label)}
{ document.querySelector('[data-flyout-trigger]')!.click(); await expect.element(page.getByRole('dialog')).toBeInTheDocument(); const dialog = document.querySelector('[role="dialog"]')!; - dialog.querySelector('a[href^="/admin/"]')!.click(); + const link = dialog.querySelector('a[href^="/admin/"]')!; + // Prevent the browser from navigating the test iframe to /admin/... (which + // would redirect to /login and kill the iframe connection). preventDefault() + // on the capture phase suppresses navigation while still letting the Svelte + // onclick handler (closeFlyout) run on the bubbling phase. + link.addEventListener('click', (e) => e.preventDefault(), { capture: true, once: true }); + link.click(); await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/routes/admin/system/page.svelte.spec.ts b/frontend/src/routes/admin/system/page.svelte.spec.ts index b094b7f5..8fdf1960 100644 --- a/frontend/src/routes/admin/system/page.svelte.spec.ts +++ b/frontend/src/routes/admin/system/page.svelte.spec.ts @@ -4,7 +4,10 @@ import { page } from 'vitest/browser'; import Page from './+page.svelte'; afterEach(cleanup); -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); describe('Admin system page', () => { it('renders the backfill versions heading', async () => { @@ -52,7 +55,9 @@ describe('Admin system page — mass import card', () => { it('renders the mass import heading', async () => { render(Page, {}); - await expect.element(page.getByText(/Massenimport/i)).toBeInTheDocument(); + // getByText(/Massenimport/i) would match both the H2 heading AND the thumbnail + // description paragraph that mentions "Massenimport" — use heading role instead. + await expect.element(page.getByRole('heading', { name: /Massenimport/i })).toBeInTheDocument(); }); it('renders the start import button when idle', async () => { @@ -66,9 +71,11 @@ describe('Admin system page — mass import card', () => { }); it('disables the start button and shows running state after click', async () => { + // $effect calls fetchImportStatus() then fetchThumbnailStatus() on mount — + // the mock must cover both before the POST trigger call. const fetchMock = vi .fn() - // initial status fetch → IDLE + // call 1: fetchImportStatus() on mount → IDLE .mockResolvedValueOnce({ ok: true, json: async () => ({ @@ -78,7 +85,20 @@ describe('Admin system page — mass import card', () => { startedAt: null }) }) - // trigger POST → returns RUNNING immediately + // call 2: fetchThumbnailStatus() on mount → IDLE (prevent thumbnail polling) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + state: 'IDLE', + message: '', + total: 0, + processed: 0, + skipped: 0, + failed: 0, + startedAt: null + }) + }) + // call 3: trigger POST → returns RUNNING .mockResolvedValueOnce({ ok: true, json: async () => ({ @@ -99,17 +119,33 @@ describe('Admin system page — mass import card', () => { }); it('shows done status and retry button after successful import', async () => { + // Use mockResolvedValueOnce per call so thumbnail gets IDLE, not DONE. + // Both cards in DONE state would render two "Erneut starten" buttons → strict mode. vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - state: 'DONE', - message: 'Import abgeschlossen.', - processed: 42, - startedAt: '2026-01-01T10:00:00' + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + state: 'DONE', + message: 'Import abgeschlossen.', + processed: 42, + startedAt: '2026-01-01T10:00:00' + }) + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + state: 'IDLE', + message: '', + total: 0, + processed: 0, + skipped: 0, + failed: 0, + startedAt: null + }) }) - }) ); render(Page, {}); await expect.element(page.getByText(/42 Dokumente/i)).toBeInTheDocument(); @@ -117,17 +153,33 @@ describe('Admin system page — mass import card', () => { }); it('shows failed status and retry button on error', async () => { + // Use mockResolvedValueOnce so thumbnail gets IDLE, not FAILED. + // Both cards in FAILED state would render two identical error messages → strict mode. vi.stubGlobal( 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - state: 'FAILED', - message: 'Datei nicht gefunden.', - processed: 0, - startedAt: '2026-01-01T10:00:00' + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + state: 'FAILED', + message: 'Datei nicht gefunden.', + processed: 0, + startedAt: '2026-01-01T10:00:00' + }) + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + state: 'IDLE', + message: '', + total: 0, + processed: 0, + skipped: 0, + failed: 0, + startedAt: null + }) }) - }) ); render(Page, {}); await expect.element(page.getByText(/Datei nicht gefunden/i)).toBeInTheDocument(); diff --git a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.spec.ts b/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.spec.ts index b3108334..cad5bc96 100644 --- a/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.spec.ts +++ b/frontend/src/routes/briefwechsel/CorrespondenzHero.svelte.spec.ts @@ -24,7 +24,8 @@ describe('CorrespondenzHero — headline and cross-link', () => { it('renders a person typeahead input', async () => { render(CorrespondenzHero, { onSelectPerson: noop }); - await expect.element(page.getByTestId('conv-hero').getByRole('textbox')).toBeInTheDocument(); + // PersonTypeahead renders , not role="textbox" + await expect.element(page.getByTestId('conv-hero').getByRole('combobox')).toBeInTheDocument(); }); }); diff --git a/frontend/src/routes/layout.svelte.spec.ts b/frontend/src/routes/layout.svelte.spec.ts index 850af12c..094918bd 100644 --- a/frontend/src/routes/layout.svelte.spec.ts +++ b/frontend/src/routes/layout.svelte.spec.ts @@ -1,11 +1,31 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; import { createRawSnippet } from 'svelte'; vi.mock('$env/static/public', () => ({ PUBLIC_NOTIFICATION_POLL_MS: '60000' })); -afterEach(cleanup); +// NotificationBell calls notificationStore.init() on mount, which creates an +// EventSource and immediately fetches the unread count. In the test environment +// the SSE connection fails (no backend), triggering onerror → another fetch. +// If that fetch returns 401, notifications.svelte.ts calls +// window.location.href = '/login', navigating the test iframe and breaking the +// Playwright connection. Stubbing fetch to return 200 keeps it in-check. +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ count: 0, content: [] }) + }) + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); const emptySnippet = createRawSnippet(() => ({ render: () => '' })); import Layout from './+layout.svelte';