mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
A spec that arms `page.route(...)` in a `beforeEach` and never drops the handler races its own teardown. The context closes, an async handler is still awaiting, its next call rejects with "Test ended.", and Playwright attributes that to the test that had just passed. With `retries: 2` plus `failOnFlakyTests`, which spec bites is a matter of timing. `e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto fixture that unroutes every page and the context after each test, and clears the page event listeners a spec subscribes to; `expect` rides along so a spec needs one import. Auto means a spec author cannot forget it and does not have to know it exists. Every spec now takes `test` from there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary closed with an allowlist that carries its reason. The per-file `afterEach` on the cycle spec goes away — leaving both would read as not trusting the fixture. Not covered: a page inside a context a spec builds itself. Only `share-documents.spec.ts` does that, and it owns those contexts.
203 lines
8.0 KiB
TypeScript
203 lines
8.0 KiB
TypeScript
import { expect, test } from "./setup/test";
|
|
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
|
|
/**
|
|
* v1.4.19 A6 — Settings consistency snapshot at Pixel-5 (393 CSS px).
|
|
* v1.4.27 — input height floor lifted from 36 px (`h-9`) to 40 px
|
|
* (`h-10`) on `Input`, `Select` trigger, and the native `<select>`
|
|
* primitives per the WCAG 2.5.5 tap-target sweep (MB2). The
|
|
* Dashboard Compare-to trigger followed the same path.
|
|
* v1.4.34.5 — mobile input floor lifted again from 40 px (`h-10`) to
|
|
* 44 px (`h-11`) to clear the WCAG 2.5.5 touch-target minimum on
|
|
* iOS Safari (textarea-zoom sweep). The `sm:h-10` desktop tier is
|
|
* unchanged; this spec runs at the Pixel 5 viewport so the mobile
|
|
* 44 px floor is what we lock in.
|
|
*
|
|
* The fixes that still apply:
|
|
*
|
|
* - 44 px (`h-11` on mobile, `sm:h-10` on >=sm) is the canonical
|
|
* input height across Settings.
|
|
* - Title + action rows use `flex-col` on `<sm` and `flex-row` on
|
|
* `>=sm` so the action button stacks below on mobile.
|
|
* - Sprache pairs with date-of-birth in a single `sm:grid-cols-2`
|
|
* row at the bottom of the Profile card (v1.4.27 R1 audit).
|
|
* - Card-internal spacing standardised on `space-y-4`.
|
|
*
|
|
* This spec captures the post-fix invariants. It is mobile-only — the
|
|
* desktop project skips it.
|
|
*/
|
|
test.describe("Settings mobile consistency (Pixel 5)", () => {
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
test.beforeEach(({}, testInfo) => {
|
|
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
|
});
|
|
|
|
test("/settings/account: every form input renders at 44 px", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/settings/account", { waitUntil: "networkidle" });
|
|
await page.waitForLoadState("networkidle");
|
|
await expect(page.getByLabel(/username/i)).toBeVisible();
|
|
|
|
const formInputs = (
|
|
await page
|
|
.locator(
|
|
'section input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]), section select',
|
|
)
|
|
.evaluateAll((els) =>
|
|
els.map((el) => {
|
|
// DateField / DateTimeField front an sr-only native input with a
|
|
// formatted overlay; the 44px tap target is the wrapper, while the
|
|
// overlay sits inside the wrapper's border (≈42px). Measure the
|
|
// wrapper for any input inside one so the target-size reflects the
|
|
// real affordance, not the inner content box.
|
|
const wrapper = el.closest(
|
|
'[data-slot="date-field"],[data-slot="date-time-field"]',
|
|
);
|
|
const measured = wrapper ?? el;
|
|
const rect = measured.getBoundingClientRect();
|
|
const style = getComputedStyle(el);
|
|
return {
|
|
id: el.id || el.getAttribute("name") || "",
|
|
tag: el.tagName,
|
|
type: el.getAttribute("type") || "",
|
|
height: Math.round(rect.height),
|
|
width: Math.round(rect.width),
|
|
hidden:
|
|
el.classList.contains("sr-only") ||
|
|
style.display === "none" ||
|
|
style.visibility === "hidden",
|
|
};
|
|
}),
|
|
)
|
|
).filter(
|
|
// Some inputs use the accessible visually-hidden pattern: the input
|
|
// itself is sr-only / zero-size and the real 44 px touch target is a
|
|
// sibling visible affordance — the styled label/button for the avatar
|
|
// <input type="file">, and the formatted overlay that fronts the
|
|
// sr-only native <input type="date"|"datetime-local"> inside DateField /
|
|
// DateTimeField. The touch-target sweep must measure those visible
|
|
// affordances, not the hidden input, so exempt any input that is
|
|
// visually hidden or collapsed to zero size.
|
|
(inp) => !(inp.hidden || inp.height === 0 || inp.width === 0),
|
|
);
|
|
|
|
expect(formInputs.length).toBeGreaterThan(0);
|
|
for (const inp of formInputs) {
|
|
expect.soft(inp.height, `${inp.tag}#${inp.id} (${inp.type})`).toBe(44);
|
|
}
|
|
});
|
|
|
|
test("/settings/account: action buttons do not overflow their cards", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/settings/account", { waitUntil: "networkidle" });
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// The account page's action button(s) must live within (or above the
|
|
// bottom of) the parent card. Since v1.18.1 the "restart onboarding"
|
|
// tour button moved to Settings → Advanced, so account carries the
|
|
// change-password button; the tour button is matched here too in case
|
|
// it is present. On mobile the button stacks below the title — it's
|
|
// allowed to extend the card's height, but not push past the right edge.
|
|
const overflowCheck = await page.evaluate(() => {
|
|
const buttons = Array.from(document.querySelectorAll("button"));
|
|
const targets = buttons.filter((b) => {
|
|
const t = (b.textContent || "").trim().toLowerCase();
|
|
return (
|
|
t.includes("change password") ||
|
|
t.includes("passwort ändern") ||
|
|
t.includes("restart onboarding") ||
|
|
t.includes("onboarding-tour")
|
|
);
|
|
});
|
|
return targets.map((b) => {
|
|
const card = b.closest(".bg-card");
|
|
const cardRect = card?.getBoundingClientRect();
|
|
const btnRect = b.getBoundingClientRect();
|
|
return {
|
|
text: (b.textContent || "").trim(),
|
|
btnRight: Math.round(btnRect.right),
|
|
cardRight: cardRect ? Math.round(cardRect.right) : null,
|
|
};
|
|
});
|
|
});
|
|
|
|
expect(
|
|
overflowCheck.length,
|
|
"expected to find the change-password action button on /settings/account",
|
|
).toBeGreaterThanOrEqual(1);
|
|
|
|
for (const t of overflowCheck) {
|
|
expect(t.cardRight, `card for "${t.text}"`).not.toBeNull();
|
|
// 1 px tolerance for sub-pixel rounding.
|
|
expect
|
|
.soft(
|
|
t.btnRight,
|
|
`"${t.text}" right=${t.btnRight} must stay within card right=${t.cardRight}`,
|
|
)
|
|
.toBeLessThanOrEqual((t.cardRight ?? 0) + 1);
|
|
}
|
|
});
|
|
|
|
test("/settings/account: Sprache select shares one grid row with date-of-birth", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/settings/account", { waitUntil: "networkidle" });
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// The v1.4.27 R1 settings audit pairs date-of-birth with language
|
|
// in a single `grid sm:grid-cols-2` row so the profile form keeps
|
|
// a uniform two-column rhythm and the language field no longer
|
|
// sits alone at the bottom with a `sm:max-w-xs` clamp.
|
|
const sharedGrid = await page.evaluate(() => {
|
|
const lang = document.getElementById("language-select");
|
|
const dob = document.getElementById("dob");
|
|
if (!lang || !dob) return { found: false, sharedGrid: false };
|
|
const langGrid = lang.closest('[class*="grid"]');
|
|
const dobGrid = dob.closest('[class*="grid"]');
|
|
return {
|
|
found: true,
|
|
sharedGrid: langGrid !== null && langGrid === dobGrid,
|
|
};
|
|
});
|
|
|
|
expect(sharedGrid.found, "language + dob fields must exist").toBe(true);
|
|
expect(sharedGrid.sharedGrid, "language + dob must share a grid").toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("/settings/dashboard: Compare-to trigger renders at 44 px", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/settings/dashboard", { waitUntil: "networkidle" });
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const trigger = page.locator("#comparison-baseline");
|
|
await expect(trigger).toBeVisible();
|
|
const h = await trigger.evaluate((el) => el.getBoundingClientRect().height);
|
|
expect(Math.round(h)).toBe(44);
|
|
});
|
|
|
|
test("/settings/ai: every native select renders at 44 px", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/settings/ai", { waitUntil: "networkidle" });
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const heights = await page
|
|
.locator("section select")
|
|
.evaluateAll((els) =>
|
|
els.map((el) => Math.round(el.getBoundingClientRect().height)),
|
|
);
|
|
|
|
expect(heights.length).toBeGreaterThan(0);
|
|
for (const h of heights) {
|
|
expect.soft(h).toBe(44);
|
|
}
|
|
});
|
|
});
|