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>
This commit is contained in:
Marcel
2026-04-26 21:51:47 +02:00
parent afe1acb05d
commit a5856e2f02

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();
});
});