mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
`.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.
67 lines
3.1 KiB
TypeScript
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;
|
|
}
|