mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
The post-deploy CI for v1.4.39.2 surfaced eight e2e failures across chromium-desktop and chromium-mobile (chart-overlay-controls, dashboard, charts-mobile, measurement-flow) that all routed through two narrow regressions and one durable test-fixture gap. ### Fixed - Dashboard slim/thick merge no longer blanks the tile strip when the slim slice resolves with an empty summaries record. The v1.4.39.2 inline `slim?.summaries ?? thick?.summaries` short-circuited on JavaScript's truthy-empty-object even when thick carried the full payload. The merge moved to a pure `mergeSlimAndThickAnalytics` helper that uses object emptiness as the discriminator: populated slim still wins on overlapping fields (progressive-paint contract preserved), empty slim falls through to thick, both empty leaves the dashboard's data-floor gates to render the appropriate empty state. Eight new behaviour-level unit tests cover the edge cases. - `MeasurementList` non-grouped rows now preserve decimal precision on both the desktop table and the mobile card grid. The v1.4.37 W7c collapsed-list view passed every value through `fmt.integer`, which kept the per-day step / activity aggregates correct but silently truncated single weight / body-fat / temperature readings — 78.4 kg rendered as "78 kg". Grouped rows (cumulative HK type day aggregates) stay on `fmt.integer` because their readings are integer-only; non-grouped rows now use `fmt.number` which honours up to three fraction digits without forcing a minimum. ### Changed - Playwright route mocks for `/api/analytics` migrate from the literal `**/api/analytics` string glob to the regex form `/\/api\/analytics(\?|$)/` across every authenticated spec. The string glob does not match the sliced URL form `/api/analytics?slice=summaries` the v1.4.39.2 dashboard split fires alongside the thick request, so the slim request fell through to the real route on the seeded test user that returns empty summaries. Ten specs migrate: dashboard, chart-overlay-controls, measurement-flow, charts-mobile, a11y, v1427-responsive-sheet, v1427-coach-launch, v1427-measurements-add-param, v1427-insights-empty-state, onboarding-tour-passthrough. The regex form is the durable alignment with the v1.4.37 W-CI lesson that already swept `onboarding-flicker.spec.ts` and `mobile-viewport.spec.ts`. ### Notes - No schema migration. Runtime + test-fixture-only changes. - `pnpm test --run` green at 4662 / 4663 (1 long-standing skip). - Full Playwright suite green at 116 passing / 36 skipped / 152 total on both `chromium-desktop` and `chromium-mobile` profiles, against the production Next.js build. Closes every failure from CI run 26213226723.
169 lines
5.9 KiB
TypeScript
169 lines
5.9 KiB
TypeScript
import AxeBuilder from "@axe-core/playwright";
|
|
import { expect, test } from "@playwright/test";
|
|
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
|
|
/**
|
|
* Accessibility regression gate — fails the build if any WCAG 2.1 AA
|
|
* violation lands in the `serious` or `critical` bucket. v1.5 phase-5
|
|
* re-enables the `/admin` surface that phase-3 had parked while phase-4b
|
|
* restructured the admin into per-section dynamic routes:
|
|
*
|
|
* - `/auth/login` (public)
|
|
* - `/` (dashboard, authenticated)
|
|
* - `/settings/integrations` (authenticated)
|
|
* - `/admin` (admin overview — `<StatusCardGrid>`)
|
|
* - `/admin/system-status` (representative admin sub-route)
|
|
* - `/admin/users` (admin users management — icon-only buttons)
|
|
*
|
|
* The seeded user (`e2e-tester`) is provisioned with `role: ADMIN` in
|
|
* `e2e/setup/global-setup.ts` so the same fixture covers both regular
|
|
* and admin surfaces without a second user.
|
|
*/
|
|
|
|
function reportBlocking(
|
|
blocking: Awaited<ReturnType<AxeBuilder["analyze"]>>["violations"],
|
|
) {
|
|
if (blocking.length === 0) return;
|
|
// Pretty-print so failures are actionable in CI logs.
|
|
console.log(
|
|
"axe violations:\n" +
|
|
blocking
|
|
.map(
|
|
(v) =>
|
|
` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.length} node(s)\n ${v.helpUrl}`,
|
|
)
|
|
.join("\n"),
|
|
);
|
|
}
|
|
|
|
async function runAxe(page: import("@playwright/test").Page) {
|
|
const results = await new AxeBuilder({ page })
|
|
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
|
.analyze();
|
|
return results.violations.filter(
|
|
(v) => v.impact === "serious" || v.impact === "critical",
|
|
);
|
|
}
|
|
|
|
test.describe("axe-core public surfaces", () => {
|
|
for (const path of ["/auth/login"]) {
|
|
test(`${path} has no serious or critical a11y violations`, async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(path);
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const blocking = await runAxe(page);
|
|
reportBlocking(blocking);
|
|
expect(blocking).toHaveLength(0);
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe("axe-core authenticated surfaces", () => {
|
|
// Reuse the cookie jar captured by `e2e/setup/global-setup.ts`. Login
|
|
// ran once in global-setup so we don't burn auth rate-limit attempts.
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
// Stable mocks so the auth'd pages render deterministically — axe scans
|
|
// need fully-painted DOM, and a flaky API would otherwise stall the
|
|
// analyse() call until the 30s test timeout.
|
|
test.beforeEach(async ({ page }) => {
|
|
// v1.4.39.3 — match `/api/analytics` AND any sliced variant
|
|
// (`?slice=summaries`); the v1.4.39.2 dashboard split fires both
|
|
// in parallel and the literal string glob misses the query-
|
|
// string form.
|
|
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,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
for (const path of [
|
|
"/",
|
|
"/settings/integrations",
|
|
"/admin",
|
|
"/admin/system-status",
|
|
"/admin/users",
|
|
]) {
|
|
test(`${path} has no serious or critical a11y violations`, async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(path, { waitUntil: "domcontentloaded" });
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const blocking = await runAxe(page);
|
|
reportBlocking(blocking);
|
|
expect(blocking).toHaveLength(0);
|
|
});
|
|
}
|
|
|
|
// The keyboard-only "skip to content" link is fixed at the top-left
|
|
// corner of every authenticated page. Marc reported it intercepting
|
|
// logo clicks in v1.4.14 — the fix combines `pointer-events-none`
|
|
// with `-translate-y-full` so the element sits offscreen until
|
|
// focused. Regression guard: with no keyboard focus, the logo's own
|
|
// bounding box must accept the click.
|
|
test("skip-link does not block logo click outside focus", async ({
|
|
page,
|
|
viewport,
|
|
}) => {
|
|
// The desktop sidebar (and its logo) is `hidden md:flex` — the
|
|
// mobile profile (Pixel 5) hides it by design. The skip-link
|
|
// regression Marc originally reported in v1.4.14 was a desktop-
|
|
// only artifact (mobile uses the topbar logo, which is a
|
|
// different DOM node). Skip the mobile project so we don't
|
|
// flake on a contract that doesn't apply there.
|
|
test.skip(
|
|
(viewport?.width ?? 0) < 768,
|
|
"desktop sidebar logo is hidden on mobile by design",
|
|
);
|
|
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
// Find the desktop sidebar logo link — `<Logo>` is rendered inside
|
|
// a `<Link href="/">` and the only logo at the absolute top-left
|
|
// of the layout. We resolve it by role / accessible name to avoid
|
|
// brittle selectors.
|
|
const logoLink = page
|
|
.locator("a[href='/']")
|
|
.filter({ hasText: "HealthLog" })
|
|
.first();
|
|
await expect(logoLink).toBeVisible();
|
|
|
|
const logoBox = await logoLink.boundingBox();
|
|
expect(logoBox).not.toBeNull();
|
|
if (!logoBox) return;
|
|
|
|
// Click the centre of the logo. If the skip-link is intercepting
|
|
// pointer events, Playwright's auto-waiting click would either time
|
|
// out or report the wrong element. The defensive `force: false`
|
|
// (default) ensures we go through the full hit-test pipeline.
|
|
await logoLink.click({
|
|
position: { x: logoBox.width / 2, y: logoBox.height / 2 },
|
|
});
|
|
|
|
// Logo points at "/", so we either stay on "/" or get a no-op
|
|
// reload. Either way the URL must remain on the dashboard.
|
|
await page.waitForURL(/\/$/);
|
|
});
|
|
});
|