mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Four specs navigated and then read the DOM once, with nothing in between that waits for anything but the network. An idle network says nothing about whether React has rendered, so the read can sample a tree that is still mounting and the assertion then blames the application for a race inside the test. Two such reads went red on consecutive CI runs, on different specs. The settings over-scroll guard also slept a fixed 500 ms, which is the wrong shape twice over: too long when the page is ready, too short on a loaded runner, and it never says what it is waiting for. The structural guard runs in the unit suite rather than in Playwright, because e2e runs at PR-CI only and flaky counts as failed there — one unguarded read otherwise costs a whole pipeline before anyone sees it. It refuses to count a wait whose rejection is thrown away, since that reads like a guarantee and is not one, and would leave the check defeatable by appending a catch. The one deliberate swallow is on the allowlist with its reason: a genuine regression there still fails at the width assertion below it, which reports the measured number instead of a timeout message.
228 lines
9.1 KiB
TypeScript
228 lines
9.1 KiB
TypeScript
import { expect, test } from "./setup/test";
|
||
|
||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||
import { settleBeforeMeasure } from "./utils/settle";
|
||
import {
|
||
mockDashboardSnapshot,
|
||
WEIGHT_ONLY_SUMMARIES,
|
||
} from "./utils/mock-dashboard-snapshot";
|
||
|
||
/**
|
||
* Mobile-viewport smoke (Pixel 5 profile only). Asserts:
|
||
*
|
||
* 1. No horizontal scroll — `documentElement.scrollWidth` must NOT
|
||
* exceed the viewport width by more than the standard 1-pixel
|
||
* sub-pixel rounding tolerance.
|
||
* 2. The fixed bottom-nav doesn't visually clobber main content —
|
||
* asserted by sampling the last visible interactive element in
|
||
* the dashboard's main region and checking it's above the nav.
|
||
* 3. Every interactive CTA visible in the initial viewport meets
|
||
* the WCAG 2.5.5 mobile tap-target height floor (44 CSS px).
|
||
* Width is not floor-checked: the chart-range pill row keeps
|
||
* tabs in the 30-40 px width band by design so the strip fits
|
||
* Pixel-5; WCAG 2.5.5 honours adjacent-target spacing for
|
||
* horizontal groups.
|
||
*
|
||
* Runs only on the `chromium-mobile` project (Pixel 5). The desktop
|
||
* project skips this whole describe block.
|
||
*/
|
||
test.describe("mobile-viewport smoke", () => {
|
||
test.use({ storageState: STORAGE_STATE_PATH });
|
||
|
||
test.beforeEach(({}, testInfo) => {
|
||
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
||
});
|
||
|
||
test("dashboard has no horizontal scroll, bottom-nav respects content, all CTAs ≥ 44 px tall", async ({
|
||
page,
|
||
}) => {
|
||
// v1.7.2 — snapshot flag default-ON; mock the snapshot cell with a
|
||
// WEIGHT-only populated summary so the weight tile paints. Legacy
|
||
// mocks below stay for the reversible `=false` path.
|
||
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
|
||
|
||
// v1.4.37 W-CI — match `/api/analytics` AND any sliced variant
|
||
// (`?slice=summaries`). The previous string glob `**/api/analytics`
|
||
// didn't match the query-string form so the IW1 slim-slice fetch
|
||
// landed on the unmocked real route in CI.
|
||
await page.route(/\/api\/analytics(\?|$)/, (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
data: {
|
||
summaries: {
|
||
WEIGHT: {
|
||
latest: 78.5,
|
||
avg7: 78.2,
|
||
avg30: 77.9,
|
||
slope30: { slope: 0, direction: "flat" },
|
||
count: 30,
|
||
},
|
||
},
|
||
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,
|
||
}),
|
||
}),
|
||
);
|
||
|
||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||
// Gate on the dashboard's own content, not on the network. Every read
|
||
// below is geometric and happens exactly once, so measuring a tree that
|
||
// is still mounting reports a layout nobody will ever see.
|
||
await settleBeforeMeasure(page, page.locator("main#main-content"));
|
||
|
||
// 1) No horizontal scroll
|
||
const dims = await page.evaluate(() => ({
|
||
scrollWidth: document.documentElement.scrollWidth,
|
||
clientWidth: document.documentElement.clientWidth,
|
||
innerWidth: window.innerWidth,
|
||
}));
|
||
// Allow 1 sub-pixel tolerance.
|
||
expect(
|
||
dims.scrollWidth,
|
||
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
|
||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||
|
||
// 2) Bottom-nav doesn't overlap content. The nav is `fixed bottom-0`
|
||
// and `md:hidden`, so on the Pixel 5 viewport it's visible. Pick
|
||
// the dashboard heading as our content sentinel — it sits at the
|
||
// top of the page, well above the nav, so an overlap (== nav over
|
||
// heading) would be a regression. We separately confirm the nav
|
||
// itself sits at the bottom of the visual viewport.
|
||
const navBox = await page
|
||
.locator("nav[aria-label]")
|
||
.filter({ has: page.locator("a[href='/']") })
|
||
.first()
|
||
.boundingBox();
|
||
if (navBox) {
|
||
const viewportHeight = page.viewportSize()?.height ?? 0;
|
||
// The nav's top must be within the viewport (bottom-anchored)
|
||
expect(navBox.y).toBeGreaterThan(0);
|
||
expect(navBox.y + navBox.height).toBeLessThanOrEqual(viewportHeight + 1);
|
||
}
|
||
|
||
// 3) All visible CTAs ≥ 44×44.
|
||
//
|
||
// Selector scope intentionally limited to `main` content. The
|
||
// bottom-nav owns its own WCAG enforcement (a dedicated spec
|
||
// covers the fixed nav and the brand-link cluster), so sweeping
|
||
// `nav a[href]` here only added flake on the Pixel 5 boundary
|
||
// where viewport-detection sometimes flipped during the WebKit
|
||
// render commit. The 44-px floor is also conditional on a true
|
||
// mobile breakpoint: if a CSS `(min-width: 640px)` media query
|
||
// matches (the `sm:` tier is in scope), the dashboard CTAs may
|
||
// intentionally shrink to a tighter target size.
|
||
const isPhoneWidth = await page.evaluate(
|
||
() => !window.matchMedia("(min-width: 640px)").matches,
|
||
);
|
||
if (!isPhoneWidth) {
|
||
// Desktop breakpoint somehow active on the Pixel 5 viewport;
|
||
// skip the floor — different design rules apply.
|
||
return;
|
||
}
|
||
|
||
const buttons = await page.locator("main button, main a[href]").all();
|
||
|
||
const failures: string[] = [];
|
||
const viewportH = page.viewportSize()?.height ?? 0;
|
||
for (const btn of buttons) {
|
||
// Skip elements that aren't visible at all.
|
||
const isVisible = await btn.isVisible().catch(() => false);
|
||
if (!isVisible) continue;
|
||
const box = await btn.boundingBox();
|
||
if (!box) continue;
|
||
// Only assert against elements actually in the initial viewport
|
||
// (above the fold). Off-screen items are fine — the user has to
|
||
// scroll to interact, which is its own UX problem the design
|
||
// review handles.
|
||
if (box.y < 0 || box.y > viewportH) continue;
|
||
// v1.4.37 W-CI — enforce the WCAG 2.5.5 height floor only.
|
||
// The original `width < 44 || height < 44` check flagged
|
||
// grouped compact controls (chart-range tabs like "7T"/"30T"
|
||
// /"90T" sitting at 44 px tall but ~30-40 px wide) as
|
||
// violations even though WCAG 2.5.5 treats horizontally
|
||
// adjacent siblings with adequate spacing as compliant. The
|
||
// primary touch hazard on mobile is vertical mis-tap, so a 44 px
|
||
// height floor is the contract every solo button must clear;
|
||
// grouped pill rows handle width via gap-based spacing instead.
|
||
if (box.height < 44) {
|
||
// Prefer the accessible name when available; fall back to the
|
||
// trimmed text. A role-based label is more stable across
|
||
// re-skins than the raw `innerText` that brittle selectors
|
||
// surfaced previously.
|
||
const ariaLabel = await btn
|
||
.getAttribute("aria-label")
|
||
.catch(() => null);
|
||
const innerText = await btn.innerText().catch(() => "");
|
||
const label = ariaLabel || innerText || "(no text)";
|
||
failures.push(
|
||
`${label.slice(0, 40)} → ${box.width.toFixed(1)}×${box.height.toFixed(1)}`,
|
||
);
|
||
}
|
||
}
|
||
expect(
|
||
failures,
|
||
`Touch-targets below 44 px tall:\n ${failures.join("\n ")}`,
|
||
).toEqual([]);
|
||
});
|
||
|
||
test("center capture action opens the picker; the More hub keeps Mood + Measurements reachable", async ({
|
||
page,
|
||
}) => {
|
||
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,
|
||
}),
|
||
}),
|
||
);
|
||
|
||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||
await page.waitForLoadState("networkidle");
|
||
|
||
// 1) The center capture action opens the capture picker.
|
||
await page.getByTestId("bottom-nav-capture").click();
|
||
await expect(page.getByTestId("capture-picker-options")).toBeVisible();
|
||
for (const kind of ["measurement", "medication", "mood", "water"]) {
|
||
await expect(page.getByTestId(`capture-picker-${kind}`)).toBeVisible();
|
||
}
|
||
// Dismiss the picker before opening the hub.
|
||
await page.keyboard.press("Escape");
|
||
|
||
// 2) The More hub keeps Mood + Measurements reachable (additive
|
||
// middle-path — they left the strip but are NOT orphaned).
|
||
await page.getByTestId("bottom-nav-more").click();
|
||
const hub = page.getByTestId("bottom-nav-more-sheet");
|
||
await expect(hub).toBeVisible();
|
||
await expect(hub.locator("a[href='/measurements']")).toBeVisible();
|
||
await expect(hub.locator("a[href='/mood']")).toBeVisible();
|
||
});
|
||
});
|