From 6ab7abb9df3c2dde1daf0f8d423314d97f20eaa2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 11:27:24 +0200 Subject: [PATCH] fix(tests): fix 3 pre-existing vitest-browser spec failures Three distinct root causes: 1. hilfe/transkription: Wikipedia link test was checking .textContent but the accessible text had moved to aria-label in a prior commit. 2. documents/[id]/edit: vi.spyOn on a Svelte 5 compiled .svelte.ts service object does not reliably track calls in vitest-browser mode; replaced with a plain closure-based mock. 3. GeschichteEditor: TipTap's onMount steals focus and its ProseMirror view interferes with Playwright CDP event dispatch. Three workarounds: - blur: dispatchEvent(new FocusEvent('blur')) bypasses focus-state check - save buttons: dispatchEvent(new MouseEvent('click')) from in-browser JS context reliably triggers Svelte 5 onclick vs. Playwright CDP click - trailing-space fill: input.value + dispatchEvent('input') works where userEvent.fill('value ') silently fails to update bind:value Co-Authored-By: Claude Sonnet 4.6 --- .../GeschichteEditor.svelte.spec.ts | 36 ++++++++++++++++--- .../documents/[id]/edit/page.svelte.spec.ts | 36 +++++++++++++++---- .../hilfe/transkription/page.svelte.spec.ts | 4 +-- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts index 9207deab..3593af8e 100644 --- a/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts @@ -53,7 +53,12 @@ describe('GeschichteEditor — title-required guard', () => { render(GeschichteEditor, { onSubmit }); await userEvent.click(page.getByPlaceholder('Titel der Geschichte')); - await userEvent.tab(); // blur + // userEvent.tab() / keyboard('{Tab}') do not reliably fire the blur event on + // inputs inside Playwright's test iframe. .blur() is a no-op when the element + // has lost focus to TipTap's onMount initialisation. Dispatching the FocusEvent + // directly fires Svelte's onblur listener regardless of the current focus owner. + const input = await page.getByPlaceholder('Titel der Geschichte').element(); + input.dispatchEvent(new FocusEvent('blur')); await expect.element(page.getByText('Bitte gib einen Titel ein.')).toBeInTheDocument(); }); }); @@ -111,8 +116,23 @@ describe('GeschichteEditor — onSubmit payload', () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render(GeschichteEditor, { onSubmit }); - await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), ' My title '); - await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' })); + // userEvent.fill() with trailing whitespace does not fire the input event chain + // that Svelte's bind:value requires (CDP limitation). Setting .value directly + // and dispatching an input event works around this while preserving the trailing + // space needed to verify the trim() contract. + const input = (await page + .getByPlaceholder('Titel der Geschichte') + .element()) as HTMLInputElement; + input.value = 'My title '; + input.dispatchEvent(new Event('input', { bubbles: true })); + + // userEvent.click() via Playwright CDP does not reliably trigger Svelte 5 onclick + // handlers when a TipTap editor is mounted in the same component. Dispatching + // the MouseEvent directly from the browser JS context bypasses this issue. + const btn = (await page + .getByRole('button', { name: 'Entwurf speichern' }) + .element()) as HTMLButtonElement; + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(onSubmit).toHaveBeenCalledTimes(1); const payload = onSubmit.mock.calls[0][0]; @@ -125,7 +145,10 @@ describe('GeschichteEditor — onSubmit payload', () => { render(GeschichteEditor, { onSubmit }); await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story'); - await userEvent.click(page.getByRole('button', { name: 'Veröffentlichen' })); + const btn = (await page + .getByRole('button', { name: 'Veröffentlichen' }) + .element()) as HTMLButtonElement; + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED'); @@ -140,7 +163,10 @@ describe('GeschichteEditor — onSubmit payload', () => { }); await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story'); - await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' })); + const btn = (await page + .getByRole('button', { name: 'Entwurf speichern' }) + .element()) as HTMLButtonElement; + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); expect(onSubmit).toHaveBeenCalledTimes(1); const payload = onSubmit.mock.calls[0][0]; diff --git a/frontend/src/routes/documents/[id]/edit/page.svelte.spec.ts b/frontend/src/routes/documents/[id]/edit/page.svelte.spec.ts index 573b037e..0172d09c 100644 --- a/frontend/src/routes/documents/[id]/edit/page.svelte.spec.ts +++ b/frontend/src/routes/documents/[id]/edit/page.svelte.spec.ts @@ -1,7 +1,12 @@ import { vi, describe, it, expect, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; +import { + createConfirmService, + CONFIRM_KEY, + type ConfirmOptions, + type ConfirmService +} from '$lib/shared/services/confirm.svelte.js'; import EditPage from './+page.svelte'; afterEach(cleanup); @@ -32,17 +37,34 @@ describe('Edit page — delete button', () => { }); it('opens a confirm dialog when the delete button is clicked', async () => { - const service = createConfirmService(); + // vi.spyOn on a Svelte 5 compiled service object does not reliably track calls in + // vitest-browser mode (signal scoping), so we use a plain mock object instead. + let capturedOptions: ConfirmOptions | null = null; + let settleRef: ((value: boolean) => void) | null = null; + const mockService: ConfirmService = { + confirm(opts) { + capturedOptions = opts; + return new Promise((resolve) => { + settleRef = resolve; + }); + }, + get options(): ConfirmOptions | null { + return null; + }, + settle(value) { + settleRef?.(value); + } + }; + render(EditPage, { props: { data: { document: makeDocument() }, form: null }, - context: new Map([[CONFIRM_KEY, service]]) + context: new Map([[CONFIRM_KEY, mockService]]) }); await page.getByRole('button', { name: /löschen/i }).click(); - // The confirm service should have received an options object (dialog is open) - expect(service.options).not.toBeNull(); - expect(service.options?.destructive).toBe(true); - service.settle(false); + await vi.waitFor(() => expect(capturedOptions).not.toBeNull()); + expect(capturedOptions).toMatchObject({ destructive: true }); + settleRef?.(false); }); it('submits the delete form when the user confirms', async () => { diff --git a/frontend/src/routes/hilfe/transkription/page.svelte.spec.ts b/frontend/src/routes/hilfe/transkription/page.svelte.spec.ts index 259b9f5c..ff2cfca9 100644 --- a/frontend/src/routes/hilfe/transkription/page.svelte.spec.ts +++ b/frontend/src/routes/hilfe/transkription/page.svelte.spec.ts @@ -25,9 +25,9 @@ describe('Richtlinien page — structure', () => { await expect.element(wikiLink).toHaveAttribute('target', '_blank'); await expect.element(wikiLink).toHaveAttribute('rel', 'noopener noreferrer'); await expect.element(wikiLink).toHaveAttribute('referrerpolicy', 'no-referrer'); - // visible annotation (not sr-only) + // icon communicates "opens new tab" visually; aria-label carries the text for a11y const link = document.querySelector('a[href*="wikipedia"]') as HTMLAnchorElement; - expect(link.textContent).toContain('öffnet in neuem Tab'); + expect(link.getAttribute('aria-label')).toContain('öffnet in neuem Tab'); }); it('renders Regeln h2 section', async () => {