Compare commits

...

2 Commits

Author SHA1 Message Date
Marcel
a5856e2f02 test(persons): fix E2E flakiness — replace waitForTimeout with waitForListbox, remove conditional assertions, fix data-hydrated selector
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m59s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m54s
CI / Unit & Component Tests (pull_request) Failing after 3m1s
CI / OCR Service Tests (pull_request) Successful in 29s
CI / Backend Unit Tests (pull_request) Failing after 2m54s
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-26 21:51:47 +02:00
Marcel
afe1acb05d test(persons): add ArrowDown forward-wrap unit test for keyboard navigation
Adds the missing 'ArrowDown from last wraps to first option' test to
close the asymmetric coverage gap noted by Sara (QA) in the review of
PR #350. The ArrowUp backward-wrap test already existed; this test
verifies the % modulo wrap works in the forward direction too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:51:17 +02:00
2 changed files with 69 additions and 74 deletions

View File

@@ -7,17 +7,15 @@
* The tests run at both desktop (1280×720) and tablet (768×1024) viewports
* as required by the acceptance criteria.
*/
import { test, expect } from '@playwright/test';
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: Parameters<typeof test>[1] extends (args: { page: infer P }) => unknown ? P : never
): Promise<string> {
async function getDocumentEditUrl(page: Page): Promise<string> {
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.waitForLoadState('networkidle');
const firstDocLink = page.locator('a[href^="/documents/"]').first();
const href = await firstDocLink.getAttribute('href').catch(() => null);
if (href) {
@@ -26,6 +24,11 @@ async function getDocumentEditUrl(
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 } });
@@ -42,23 +45,20 @@ test.describe('PersonTypeahead — dropdown visibility (desktop)', () => {
await senderInput.click();
await senderInput.fill('a');
// Wait for the dropdown to appear
await page.waitForTimeout(400); // debounce is 300ms
// Wait for the dropdown to appear (handles debounce automatically)
await waitForListbox(page);
// If there are results, verify the first item is visible (not occluded)
const dropdown = page.locator('[role="listbox"]').first();
const hasResults = await dropdown.count().then((n) => n > 0);
await expect(dropdown).toBeVisible();
if (hasResults) {
const firstOption = dropdown.locator('[role="option"]').first();
await expect(firstOption).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);
}
// 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' });
});
@@ -78,18 +78,16 @@ test.describe('PersonTypeahead — dropdown visibility (desktop)', () => {
await senderInput.click();
await senderInput.fill('a');
await page.waitForTimeout(400);
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
const hasDropdown = (await dropdown.count()) > 0;
await expect(dropdown).toBeVisible();
if (hasDropdown) {
const dropdownBox = await dropdown.boundingBox();
expect(dropdownBox).not.toBeNull();
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);
}
// 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' });
});
@@ -108,20 +106,18 @@ test.describe('PersonTypeahead — dropdown visibility (tablet)', () => {
await senderInput.click();
await senderInput.fill('a');
await page.waitForTimeout(400);
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
const hasResults = (await dropdown.count()) > 0;
await expect(dropdown).toBeVisible();
if (hasResults) {
const firstOption = dropdown.locator('[role="option"]').first();
await expect(firstOption).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);
}
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' });
});
@@ -138,19 +134,14 @@ test.describe('PersonTypeahead — keyboard navigation', () => {
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await page.waitForTimeout(400);
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
const hasDropdown = (await dropdown.count()) > 0;
await senderInput.press('ArrowDown');
// First option should now be the active descendant
const activeDescendant = await senderInput.getAttribute('aria-activedescendant');
expect(activeDescendant).toBeTruthy();
if (hasDropdown) {
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' });
}
await page.screenshot({ path: 'test-results/e2e/person-typeahead-keyboard-nav.png' });
});
test('Escape key closes the dropdown', async ({ page }) => {
@@ -161,17 +152,12 @@ test.describe('PersonTypeahead — keyboard navigation', () => {
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await page.waitForTimeout(400);
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
const hasDropdown = (await dropdown.count()) > 0;
if (hasDropdown) {
await expect(dropdown).toBeVisible();
await senderInput.press('Escape');
await page.waitForTimeout(100);
await expect(dropdown).not.toBeVisible();
}
await expect(dropdown).toBeVisible();
await senderInput.press('Escape');
await expect(dropdown).not.toBeVisible();
});
test('aria-expanded is true when dropdown is open', async ({ page }) => {
@@ -187,15 +173,10 @@ test.describe('PersonTypeahead — keyboard navigation', () => {
await senderInput.click();
await senderInput.fill('a');
await page.waitForTimeout(400);
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
const hasDropdown = (await dropdown.count()) > 0;
if (hasDropdown) {
const expanded = await senderInput.getAttribute('aria-expanded');
expect(expanded).toBe('true');
}
const expanded = await senderInput.getAttribute('aria-expanded');
expect(expanded).toBe('true');
});
});
@@ -210,17 +191,12 @@ test.describe('PersonTypeahead — click-outside dismiss (fixed position)', () =
const senderInput = page.locator('#senderId-search');
await senderInput.click();
await senderInput.fill('a');
await page.waitForTimeout(400);
await waitForListbox(page);
const dropdown = page.locator('[role="listbox"]').first();
const hasDropdown = (await dropdown.count()) > 0;
if (hasDropdown) {
await expect(dropdown).toBeVisible();
// Click somewhere else on the page
await page.click('body', { position: { x: 10, y: 10 } });
await page.waitForTimeout(100);
await expect(dropdown).not.toBeVisible();
}
await expect(dropdown).toBeVisible();
// Click somewhere else on the page
await page.click('body', { position: { x: 10, y: 10 } });
await expect(dropdown).not.toBeVisible();
});
});

View File

@@ -397,6 +397,25 @@ describe('PersonTypeahead keyboard navigation', () => {
await expect.element(lastOption).toHaveAttribute('aria-selected', 'true');
});
it('ArrowDown from last wraps to first option', async () => {
mockFetchWithPersons();
render(PersonTypeahead, { name: 'senderId', label: 'Absender' });
const input = page.getByPlaceholder('Namen tippen...');
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();
const firstOption = page.getByRole('option', { name: 'Max Mustermann' });
await expect.element(firstOption).toHaveAttribute('aria-selected', 'true');
});
it('Enter selects the highlighted option', async () => {
mockFetchWithPersons([
{