Files
HealthLog/e2e/water-capture.spec.ts
T
Marc-André Bombeck 64c2f7410a 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.
2026-08-03 21:47:58 +02:00

149 lines
4.7 KiB
TypeScript

import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { openMenu } from "./open-menu";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
test.describe("water capture", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("opens globally, waits for success, and retains failed input", async ({
page,
}, testInfo) => {
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
let writeCount = 0;
let confirmSuccess: (() => void) | undefined;
const successConfirmed = new Promise<void>((resolve) => {
confirmSuccess = resolve;
});
await page.route("**/api/nutrients/water", async (route) => {
writeCount += 1;
if (writeCount === 1) {
await successConfirmed;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { amountMl: 250 }, error: null }),
});
return;
}
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ data: null, error: "write failed" }),
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
const openWater = async (toastMayOverlap = false) => {
if (testInfo.project.name === "chromium-mobile") {
const capture = page.getByTestId("bottom-nav-capture");
if (toastMayOverlap) {
await capture.dispatchEvent("click");
} else {
await openMenu(page, capture);
}
const option = page.getByTestId("capture-picker-water");
await expect(option).toBeVisible();
const box = await option.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
if (toastMayOverlap) {
await option.dispatchEvent("click");
} else {
await option.click();
}
} else {
await openMenu(
page,
page.locator('[data-tour-id="dashboard-quick-add"]'),
);
await page.getByRole("menuitem", { name: "Log water" }).click();
}
await expect(
page.getByRole("heading", { name: "Add water" }),
).toBeVisible();
if (testInfo.project.name === "chromium-mobile") {
const actions = page
.getByRole("dialog")
.locator(
'[data-slot="water-quick-add-chips"] button, form button[type="submit"]',
);
await expect(actions).toHaveCount(4);
for (const action of await actions.all()) {
await expect(action).toHaveAccessibleName(/.+/);
const actionBox = await action.boundingBox();
expect(actionBox?.height ?? 0).toBeGreaterThanOrEqual(44);
}
}
};
await openWater();
const amount = page.getByRole("spinbutton", {
name: "Custom amount (mL)",
});
await amount.fill("250");
const submit = page
.getByRole("dialog")
.getByRole("button", { name: "Add", exact: true });
await amount.press("Enter");
await expect(submit).toBeDisabled();
await expect(amount).toHaveValue("250");
await expect(page.getByText("250 mL water added.")).toHaveCount(0);
confirmSuccess?.();
await expect(page.getByText("250 mL water added.")).toBeVisible();
await expect(page.getByRole("heading", { name: "Add water" })).toHaveCount(
0,
);
expect(writeCount).toBe(1);
await openWater(true);
const failedAmount = page.getByRole("spinbutton", {
name: "Custom amount (mL)",
});
await expect(failedAmount).toHaveValue("");
await failedAmount.fill("375");
await page
.getByRole("dialog")
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(
page.getByText("Couldn't save that — try again."),
).toBeVisible();
await expect(failedAmount).toHaveValue("375");
await expect(
page.getByRole("heading", { name: "Add water" }),
).toBeVisible();
await expect(page.getByText("375 mL water added.")).toHaveCount(0);
expect(writeCount).toBe(2);
});
});