Files
Marc-André Bombeck 87e908decd Ask the menu whether it opened, not the trigger that just disappeared
My own bug, and it produced a failure that pointed at the opposite of the
cause. The helper clicked the trigger and then re-read the trigger's
`data-state` to decide whether the menu had opened. Radix marks the rest of the
page `aria-hidden` while a menu is open, and Playwright's role locators skip
anything hidden from the accessibility tree — so a trigger found through
`getByRole` stops matching the instant the click succeeds. Reading its
attributes then does not report "still closed"; it hangs until the test budget
runs out, and the log blames the trigger for not being there.

The check is on the menu now. Both journeys pass locally, and the reason is in
the file so the next person does not have to lose an evening to it.
2026-08-04 11:21:51 +02:00

63 lines
2.6 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.
*
* The check is on the MENU, not on the trigger, and that detail is the whole
* reason this file has a comment. Radix marks the rest of the page
* `aria-hidden` while a menu is open, and Playwright's role locators ignore
* anything hidden from the accessibility tree — so a trigger found through
* `getByRole` stops matching the moment the click succeeds. Re-reading its
* attributes afterwards does not report "not open", it hangs until the test
* budget runs out, which is a failure that points at the trigger and means the
* opposite.
*
* 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.
await expect(trigger).toBeVisible({ timeout: 30_000 });
const menu = page.locator(
'[role="menu"], [role="dialog"][data-state="open"], [data-slot="capture-picker"]',
);
for (let attempt = 0; attempt < 5; attempt++) {
await trigger.click();
if (
await menu
.first()
.isVisible()
.catch(() => false)
)
return;
try {
await expect(menu.first()).toBeVisible({ timeout: 1_500 });
return;
} catch {
// Swallowed by a not-yet-hydrated trigger. Give the bundle a moment and
// spend another click. The trigger is re-clicked by locator, so a Radix
// portal that moved focus does not matter.
await page.waitForTimeout(250);
}
}
await expect(
menu.first(),
"the menu never opened after 5 clicks — the page is most likely still hydrating, and the trigger takes clicks before React attaches",
).toBeVisible({ timeout: 5_000 });
}