Files
HealthLog/e2e/mobile-horizontal-overflow.spec.ts
T

256 lines
11 KiB
TypeScript

import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
import {
expectNoHorizontalOverflow,
measureHorizontalOverflow,
PHONE_WIDTHS,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* The content-heavy routes must not scroll sideways at any phone width.
*
* This replaces a per-surface habit of asserting
* `documentElement.scrollWidth <= innerWidth` on whatever happened to render.
* That assertion had two holes, and a real report walked through both:
*
* 1. It measured the DOCUMENT. `AuthShell` scrolls inside
* `<main id="main-content">`, whose `overflow-y:auto` makes `overflow-x`
* compute to `auto` — so an over-wide child grows `main.scrollWidth`, the
* user pans the page sideways, and the document never widens by a pixel.
* 2. It ran against an EMPTY page. A surface with no content fits any
* viewport, so the guard proved nothing about the surface people use.
*
* `expectNoHorizontalOverflow` closes (1) by asserting both scroll areas and
* naming the widest offending element on failure. The fixtures below close (2)
* by giving each route a body before it is measured.
*
* Widths cover real iPhone (390 / 393 / 402 / 430) and common Android (360)
* CSS widths — a single width hides anything whose overflow is width-dependent.
* Runs on `chromium-mobile` only; the desktop project skips the whole file.
*/
interface RouteCase {
path: string;
/** Something that must be on-screen before measuring — proves it is populated. */
ready: string;
setup?: (page: import("@playwright/test").Page) => Promise<void>;
}
const ROUTE_CASES: RouteCase[] = [
{
path: "/insights",
// The "signals of the day" section is the one whose heading carries the
// long provenance sentence, so it is also the readiness signal.
ready: '[data-slot="coincident-deviation-section"]',
setup: mockPopulatedInsights,
},
{
path: "/",
ready: "main#main-content",
setup: (page) => mockDashboardSnapshot(page),
},
{ path: "/measurements", ready: "main#main-content" },
{ path: "/medications", ready: "main#main-content" },
];
test.describe("content-heavy routes have no horizontal page scroll at phone widths", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
for (const routeCase of ROUTE_CASES) {
test(`${routeCase.path} does not scroll sideways on any phone width`, async ({
page,
}) => {
await routeCase.setup?.(page);
for (const width of PHONE_WIDTHS) {
await page.setViewportSize({ width, height: 851 });
await page.goto(routeCase.path, { waitUntil: "domcontentloaded" });
await expect(page.locator(routeCase.ready).first()).toBeVisible();
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, `${routeCase.path} @${width}px`);
}
});
}
/**
* The fixture itself has to stay honest. If `mockPopulatedInsights` ever
* stops populating the overview — a renamed route, a changed envelope — the
* width assertions above would keep passing against an empty page and the
* guard would quietly go back to proving nothing. Assert the page is
* actually carrying content, so fixture rot fails loudly instead.
*/
test("the /insights fixture really populates the overview", async ({
page,
}) => {
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
const signals = page.locator('[data-slot="coincident-deviation-section"]');
await expect(signals).toBeVisible();
// The prose in the heading's trailing action slot — the shape that widened
// the page. It must be present AND long enough to be able to.
const method = signals.locator('[data-slot="provenance-explainer-method"]');
await expect(method).toBeVisible();
const text = (await method.textContent()) ?? "";
expect(
text.length,
`provenance caption too short to exercise the overflow class: "${text}"`,
).toBeGreaterThan(60);
// And the surface as a whole is not the empty state.
await expect(
page.locator('[data-slot="section-heading"]').first(),
).toBeVisible();
const headingCount = await page
.locator('[data-slot="section-heading"]')
.count();
expect(headingCount, "overview rendered no sections").toBeGreaterThan(1);
});
/**
* A long trailing caption must WRAP inside the row, not extend it. Asserting
* the wrap directly (rather than only the page-level width) pins the fix at
* the element that broke: a `shrink-0` action slot sized itself to the
* caption's max-content width, ~1.2-1.5k px depending on locale.
*/
test("a long heading action caption wraps instead of widening the row", async ({
page,
}) => {
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
// Wait on the CAPTION, not the heading: the card first paints a skeleton
// whose heading carries no action slot, then swaps to the resolved shell.
// Both render `[data-slot="section-heading"]`, so gating on the heading
// alone can measure the skeleton — or measure nothing at all, because the
// element detaches during the swap.
const method = page
.locator(
'[data-slot="coincident-deviation-section"] [data-slot="provenance-explainer-method"]',
)
.first();
await expect(method).toBeVisible();
// Read both rects in ONE evaluate so the swap cannot land between them.
const box = await method.evaluate((el) => {
const headingEl = el.closest('[data-slot="section-heading"]');
if (!headingEl) return null;
const caption = el.getBoundingClientRect();
const heading = headingEl.getBoundingClientRect();
return {
captionWidth: caption.width,
headingWidth: heading.width,
viewportWidth: window.innerWidth,
};
});
expect(box, "caption is not inside a section heading").not.toBeNull();
expect(
Math.round(box!.captionWidth),
`caption width ${box!.captionWidth} exceeds its heading row ${box!.headingWidth} — the action slot is sizing to max-content instead of wrapping`,
).toBeLessThanOrEqual(Math.round(box!.headingWidth) + 1);
expect(
Math.round(box!.headingWidth),
"heading row is wider than the viewport",
).toBeLessThanOrEqual(box!.viewportWidth);
});
/**
* The helper must be able to SEE overflow through the `<main>` scroll
* container — the exact blind spot that let the previous guard pass. Inject
* a deliberately over-wide, un-contained block and assert the measurement
* reports it. Without this, a helper that silently measured the wrong
* element would make every assertion above vacuous.
*/
test("the overflow measurement detects an over-wide block inside <main>", async ({
page,
}) => {
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
// Wait for the same readiness signal and settle step every route case
// above uses. This was the one test in the file that measured and injected
// straight after `domcontentloaded`, against a tree that was still
// mounting, and it is the one that went flaky on CI: `main.scrollWidth`
// came back at exactly `clientWidth` with a 1500px child supposedly in it.
//
// Two candidate mechanisms were checked against a local run rather than
// assumed. A render swapping `main`'s children and taking the canary with
// it does NOT happen: the node survives the skeleton-to-content swap
// (12/12 runs, canary attached, scrollWidth 1500). That leaves `main` not
// yet BEING the scroll container at injection time, which is what settling
// rules out and what the assertions below now report on if it recurs.
await expect(
page.locator('[data-slot="coincident-deviation-section"]').first(),
).toBeVisible();
await settleForOverflowMeasurement(page);
const clean = await measureHorizontalOverflow(page);
expect(clean.mainScrollWidth).toBeLessThanOrEqual(
(clean.mainClientWidth ?? 0) + 1,
);
await page.evaluate(() => {
const probe = document.createElement("div");
probe.id = "overflow-canary";
probe.style.width = "1500px";
probe.style.height = "10px";
document.querySelector("main#main-content")!.appendChild(probe);
});
// Separate the fixture's own failure from the helper's. If the canary is
// not inside the CURRENT `main`, the measurement below proves nothing
// either way, and a failure that says so is a diagnosis rather than an
// accusation.
await expect(
page.locator("main#main-content #overflow-canary"),
"the canary is not inside the current <main> — the fixture failed, not the helper",
).toBeAttached();
// Captured so that a recurrence names its own cause instead of costing
// another round of guessing. `overflowX: clip` or a `main` that is not a
// scroll container both produce the observed "scrollWidth === clientWidth
// with a 1500px child" and are indistinguishable without this.
const state = await page.evaluate(() => {
const main = document.querySelector<HTMLElement>("main#main-content")!;
const canary = document.getElementById("overflow-canary");
const style = getComputedStyle(main);
return {
overflowX: style.overflowX,
overflowY: style.overflowY,
canaryWidth: canary?.getBoundingClientRect().width ?? null,
canaryInMain: canary ? main.contains(canary) : false,
};
});
const dirty = await measureHorizontalOverflow(page);
expect(
dirty.mainScrollWidth,
`an intentionally 1500px-wide block did not widen main's scroll area — main overflow-x:${state.overflowX} overflow-y:${state.overflowY}, canary width ${state.canaryWidth}, inside main: ${state.canaryInMain}`,
).toBeGreaterThan((dirty.mainClientWidth ?? 0) + 1);
expect(
dirty.offenders.some((o) => o.selector.includes("overflow-canary")),
`the canary was not reported as an offender; got: ${dirty.offenders
.map((o) => o.selector)
.join(" | ")}`,
).toBe(true);
// The document stayed exactly as wide as the viewport while `<main>` grew
// by 1500px — this is the blind spot, asserted rather than described.
expect(
dirty.docScrollWidth,
"document width changed, so the document-only assertion would have caught this after all",
).toBeLessThanOrEqual(dirty.innerWidth + 1);
});
});