mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Wait for the menu to open, not for the network to go quiet
The water-capture journey clicked the dashboard quick-add trigger after
`waitForLoadState("networkidle")` and then waited thirty seconds for a menu
item that never arrived. The failure snapshot shows the page rendered, the
trigger present, and no menu anywhere: the click landed on server HTML that
React had not attached a handler to yet, and the one click the test had was
spent before hydration.
`networkidle` cannot close that window, because the trigger is in the first
HTML and exists long before the client bundle finishes. The gate has to be the
element's own state. `openMenu` clicks and confirms the trigger reports itself
open — Radix stamps `data-state`, the hand-rolled mobile capture button carries
`aria-expanded` off React state — and spends another click if it does not,
because the problem is a lost event and waiting longer for a swallowed click
achieves nothing. Out of attempts it fails 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.
This is a race the spec always had; it surfaces when the machine is loaded
enough to push hydration past the click, which is why it reads as a
branch-specific regression when it is not one. The tour passthrough spec keeps
its single un-retried click on the same trigger: proving that exactly one click
reaches the button through the overlay is that test's entire point.
This commit is contained in:
1 parent
7eed26eb01
commit
64c2f7410a
2 files changed
+68
-2
No files matched your search
@@ -0,0 +1,62 @@
|
||||
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> {
|
||||
await expect(trigger).toBeVisible();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { openMenu } from "./open-menu";
|
||||
import {
|
||||
mockDashboardSnapshot,
|
||||
WEIGHT_ONLY_SUMMARIES,
|
||||
@@ -66,7 +67,7 @@ test.describe("water capture", () => {
|
||||
if (toastMayOverlap) {
|
||||
await capture.dispatchEvent("click");
|
||||
} else {
|
||||
await capture.click();
|
||||
await openMenu(page, capture);
|
||||
}
|
||||
const option = page.getByTestId("capture-picker-water");
|
||||
await expect(option).toBeVisible();
|
||||
@@ -78,7 +79,10 @@ test.describe("water capture", () => {
|
||||
await option.click();
|
||||
}
|
||||
} else {
|
||||
await page.locator('[data-tour-id="dashboard-quick-add"]').click();
|
||||
await openMenu(
|
||||
page,
|
||||
page.locator('[data-tour-id="dashboard-quick-add"]'),
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Log water" }).click();
|
||||
}
|
||||
await expect(
|
||||
|
||||
Reference in new issue
Block a user