Files
familienarchiv/frontend/e2e/person-typeahead.spec.ts
Marcel fe13df574a
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Has started running
test(persons): fix E2E flakiness — replace waitForTimeout with waitForListbox, remove conditional assertions, fix data-hydrated selector
Addresses three blockers raised in PR #350 review (Felix, Sara, Tobias):

1. Replace all waitForTimeout(400) calls with waitForListbox() which uses
   waitForSelector('[role="listbox"]', { state: 'visible' }) — auto-waits
   for the debounce to resolve, faster on fast machines and reliable under CI.

2. Remove all conditional if (hasResults) / if (hasDropdown) wrappers.
   Tests now use unconditional expect(dropdown).toBeVisible() assertions so
   a missing-data condition causes an explicit failure instead of a silent
   green run.

3. Replace waitForSelector('[data-hydrated]') with waitForLoadState('networkidle')
   in getDocumentEditUrl — the data-hydrated attribute does not exist in the
   app markup and would cause a 30s timeout on every test.

4. Extract page: Page type import from @playwright/test and introduce
   waitForListbox(page: Page) helper to avoid repeating the selector pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 09:01:44 +02:00

203 lines
6.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* E2E regression tests for PersonTypeahead dropdown visibility.
*
* These tests verify that the dropdown list is never clipped by a parent
* container's stacking context — the root cause of issue #343.
*
* The tests run at both desktop (1280×720) and tablet (768×1024) viewports
* as required by the acceptance criteria.
*/
import { test, expect, type Page } from '@playwright/test';
/**
* Find a document edit URL to use as the test page.
* Falls back to /documents/new if no existing document is found.
*/
async function getDocumentEditUrl(page: Page): Promise<string> {
await page.goto('/');
await page.waitForLoadState('networkidle');
const firstDocLink = page.locator('a[href^="/documents/"]').first();
const href = await firstDocLink.getAttribute('href').catch(() => null);
if (href) {
return `${href}/edit`;
}
return '/documents/new';
}
/** Wait for the listbox to become visible after triggering a search. */
async function waitForListbox(page: Page): Promise<void> {
await page.waitForSelector('[role="listbox"]', { state: 'visible', timeout: 2000 });
}
test.describe('PersonTypeahead — dropdown visibility (desktop)', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('sender dropdown items are visible and not clipped in document edit', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
// Find the sender typeahead input (the visible text input, not the hidden one)
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
// Type to trigger the dropdown
await senderInput.click();
await senderInput.fill('a');
// Wait for the dropdown to appear (handles debounce automatically)
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const firstOption = dropdown.locator('[role="option"]').first();
await expect(firstOption).toBeVisible();
// Verify the bounding box is within the viewport (not clipped)
const box = await firstOption.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
expect(box!.y + box!.height).toBeLessThan(720);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-desktop.png' });
});
test('dropdown is positioned below the input field (not hidden behind parent)', async ({
page
}) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
const inputBox = await senderInput.boundingBox();
expect(inputBox).not.toBeNull();
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const dropdownBox = await dropdown.boundingBox();
expect(dropdownBox).not.toBeNull();
// Dropdown must appear below the input, not on top or clipped behind it
expect(dropdownBox!.y).toBeGreaterThanOrEqual(inputBox!.y + inputBox!.height - 5);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-position.png' });
});
});
test.describe('PersonTypeahead — dropdown visibility (tablet)', () => {
test.use({ viewport: { width: 768, height: 1024 } });
test('sender dropdown items are visible and not clipped on tablet viewport', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await expect(senderInput).toBeVisible();
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
const firstOption = dropdown.locator('[role="option"]').first();
await expect(firstOption).toBeVisible();
const box = await firstOption.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
expect(box!.y + box!.height).toBeLessThan(1024);
await page.screenshot({ path: 'test-results/e2e/person-typeahead-dropdown-tablet.png' });
});
});
test.describe('PersonTypeahead — keyboard navigation', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('ArrowDown moves focus to the first option', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
await senderInput.press('ArrowDown');
// First option should now be the active descendant
const activeDescendant = await senderInput.getAttribute('aria-activedescendant');
expect(activeDescendant).toBeTruthy();
await page.screenshot({ path: 'test-results/e2e/person-typeahead-keyboard-nav.png' });
});
test('Escape key closes the dropdown', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
await senderInput.press('Escape');
await expect(dropdown).not.toBeVisible();
});
test('aria-expanded is true when dropdown is open', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
// Initially closed
const initialExpanded = await senderInput.getAttribute('aria-expanded');
expect(initialExpanded).toBe('false');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const expanded = await senderInput.getAttribute('aria-expanded');
expect(expanded).toBe('true');
});
});
test.describe('PersonTypeahead — click-outside dismiss (fixed position)', () => {
test.use({ viewport: { width: 1280, height: 720 } });
test('clicking outside a fixed-position dropdown closes it', async ({ page }) => {
const editUrl = await getDocumentEditUrl(page);
await page.goto(editUrl);
await page.waitForLoadState('networkidle');
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
await expect(dropdown).toBeVisible();
// Click somewhere else on the page
await page.click('body', { position: { x: 10, y: 10 } });
await expect(dropdown).not.toBeVisible();
});
});