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>
203 lines
6.8 KiB
TypeScript
203 lines
6.8 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|