mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment lines in 1,308 files lose the work-item identifier at their head. Release versions and issue references stay; nothing outside comment text changes, which the script checks per file against a comment-free printout. healthlog/no-planning-tags is enabled at error level with this commit, so the tree it lands on is clean.
231 lines
9.4 KiB
TypeScript
231 lines
9.4 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 — 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 — 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"]) {
|
||
await expect(page.getByTestId(`capture-picker-${kind}`)).toBeVisible();
|
||
}
|
||
// Dismiss the picker before opening the hub, and wait until it is gone:
|
||
// while its close animation runs, the overlay still covers the bottom
|
||
// nav and swallows the tap on "More", so the hub never opens.
|
||
await page.keyboard.press("Escape");
|
||
await expect(page.getByTestId("capture-picker-options")).toBeHidden();
|
||
|
||
// 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();
|
||
});
|
||
});
|