Files
HealthLog/e2e/open-menu.ts
T
Marc-André Bombeck 17a2ec3328 Give the trigger the same patience the click it replaced had
`.click()` carries Playwright's 30 s actionability wait. The visibility check
that replaced it ran on the default expect timeout, so the helper made the wait
shorter than the code it stood in for, and a dashboard that was slow to paint
failed on the trigger rather than on the menu.
2026-08-04 02:11:22 +02:00

67 lines
3.1 KiB
TypeScript

import { expect, type Locator, type Page } from "@playwright/test";
/**
* Click a menu trigger and confirm the menu actually opened.
*
* A bare `trigger.click()` is a coin flip on a server-rendered page. Playwright
* clicks as soon as the element is visible, stable and enabled — all three of
* which are true of markup React has not attached a handler to yet. The click
* lands, nothing happens, and the next line waits thirty seconds for a menu
* item that will never appear, because the one click the test had was spent
* before hydration.
*
* `networkidle` does not close that window either: the trigger is in the first
* HTML, so it is present long before the client bundle finishes. The gate has
* to be the element's own state, not the network's.
*
* This is the failure that took two branches to read: the quick-add trigger on
* the dashboard, clicked, no menu in the failure snapshot, and no defect in the
* component. It surfaces when the machine is loaded — a heavier suite is enough
* to push hydration past the click — so it reads as a branch-specific
* regression when it is a race the spec always had.
*
* The signal is the trigger's own open state. Radix stamps `data-state="open"`;
* a hand-rolled trigger carries `aria-expanded="true"` off React state. Either
* one only appears once a handler ran, which is exactly the thing being waited
* for. Retry the click rather than raising a timeout: the problem is a lost
* event, and waiting longer for a click that was already swallowed does
* nothing.
*/
export async function openMenu(page: Page, trigger: Locator): Promise<void> {
// A bare `.click()` carries Playwright's own 30 s actionability wait, so the
// visibility check that replaces it has to be at least as patient. Left on
// the default expect timeout this helper made the wait SHORTER than the code
// it replaced, and a slow dashboard failed here instead of at the menu.
await expect(trigger).toBeVisible({ timeout: 30_000 });
for (let attempt = 0; attempt < 5; attempt++) {
await trigger.click();
if (await isOpen(trigger)) return;
// Swallowed by a not-yet-hydrated trigger. Give the bundle a moment and
// spend another click.
await page.waitForTimeout(250);
}
// Out of attempts. Fail naming the state the trigger was actually in, rather
// than letting the caller time out on a menu item that was never going to
// appear.
const state = await trigger.getAttribute("data-state");
const expanded = await trigger.getAttribute("aria-expanded");
throw new Error(
`openMenu: the trigger never opened after 5 clicks (data-state=${state}, aria-expanded=${expanded}). ` +
"The page is most likely still hydrating; the trigger is in the server HTML and takes clicks before React attaches.",
);
}
async function isOpen(trigger: Locator): Promise<boolean> {
for (let i = 0; i < 10; i++) {
const [state, expanded] = await Promise.all([
trigger.getAttribute("data-state"),
trigger.getAttribute("aria-expanded"),
]);
if (state === "open" || expanded === "true") return true;
await trigger.page().waitForTimeout(100);
}
return false;
}