From 957c2f279e06625b891cb16c161fa355630deee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 08:19:01 +0200 Subject: [PATCH] test(e2e): walk the Topics preview and sweep its pages at 390 px and with axe A journey on its own account, seeded with 40 days of heart and sleep, weight, blood pressure, a short run of steps and mood entries, and lab markers with and without a trend: switched off there is no More tile and no sidebar entry, the pages leave for Insights and the routes answer 404; switched on in Settings the More sheet leads with the Topics tile and the sidebar carries the entry below Insights; every offered topic renders, with the pair that has 14 paired days stated and the pairs short of it saying so. The phone sweep opens every topic page with the preview on, and the axe matrix scans the overview and two topics in both themes. The Settings sweep gains the Preview features page. --- e2e/a11y.spec.ts | 48 +++++- e2e/mobile-route-overflow.spec.ts | 4 + e2e/settings-admin-one-piece.spec.ts | 1 + e2e/setup/global-setup.ts | 66 +++++++- e2e/setup/topics-fixture.ts | 138 +++++++++++++++++ e2e/topics-preview.spec.ts | 219 +++++++++++++++++++++++++++ 6 files changed, 471 insertions(+), 5 deletions(-) create mode 100644 e2e/setup/topics-fixture.ts create mode 100644 e2e/topics-preview.spec.ts diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index e6ac14953..913bbbf6a 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -2,7 +2,10 @@ import AxeBuilder from "@axe-core/playwright"; import type { Locator, Page, Route } from "@playwright/test"; import { expect, test } from "./setup/test"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + MOBILE_ROUTES_STORAGE_STATE_PATH, + STORAGE_STATE_PATH, +} from "./setup/global-setup"; import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { openMenu } from "./open-menu"; import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot"; @@ -979,6 +982,49 @@ const ADMIN_AND_BASELINE_ROUTES: readonly RouteCase[] = [ }, ]; +/** + * The topic pages (preview), on the phone-sweep account: its preview is on + * and never switched, and its record holds a little of every kind, so the + * overview draws tiles and the pages draw both their data and their + * below-threshold states. Gated on the grid and the page body, which live in + * other components than the `

` the scan checks. + */ +const TOPIC_ROUTES: readonly RouteCase[] = [ + { + name: "/topics overview", + path: "/topics", + painted: (page) => page.locator('[data-slot="topics-grid"]:visible'), + }, + { + name: "/topics/heart", + path: "/topics/heart", + painted: (page) => page.locator('[data-slot="topic-detail-body"]:visible'), + }, + { + name: "/topics/metabolism", + path: "/topics/metabolism", + painted: (page) => page.locator('[data-slot="topic-detail-body"]:visible'), + }, +]; + +test.describe("axe-core topic pages (preview)", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + for (const theme of THEMES) { + test(`theme=${theme} routes: /topics, /topics/heart, /topics/metabolism`, async ({ + page, + }) => { + test.setTimeout(120_000); + await useExplicitTheme(page, theme); + const blocking: AxeViolation[] = []; + for (const routeCase of TOPIC_ROUTES) { + blocking.push(...(await visitAndScan(page, theme, routeCase))); + } + expectNoViolations(theme, "topic route matrix", blocking); + }); + } +}); + test.describe("axe-core public surfaces", () => { for (const theme of THEMES) { test(`theme=${theme} /auth/login`, async ({ page }) => { diff --git a/e2e/mobile-route-overflow.spec.ts b/e2e/mobile-route-overflow.spec.ts index 127ad3a3c..66e8249c7 100644 --- a/e2e/mobile-route-overflow.spec.ts +++ b/e2e/mobile-route-overflow.spec.ts @@ -6,6 +6,7 @@ import type { APIRequestContext } from "@playwright/test"; import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs"; import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids"; import { SETTINGS_SECTION_SLUGS } from "@/lib/settings/section-slugs"; +import { TOPIC_KEYS } from "@/lib/topics/registry"; import { expect, test } from "./setup/test"; import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; @@ -172,6 +173,9 @@ const DYNAMIC_ROUTES: Record = { )}`, ]; }, + // Every topic (preview). The sweep account has the preview on; a topic its + // modules do not offer still renders its frame and the refusal card. + "/topics/[topic]": async () => TOPIC_KEYS.map((topic) => `/topics/${topic}`), // An ECG strip only arrives through a device sync; no route writes one. The // detail page is swept in its not-found state, which is still the frame, // header and back link a phone renders. diff --git a/e2e/settings-admin-one-piece.spec.ts b/e2e/settings-admin-one-piece.spec.ts index 2b8e70726..3f10f6540 100644 --- a/e2e/settings-admin-one-piece.spec.ts +++ b/e2e/settings-admin-one-piece.spec.ts @@ -58,6 +58,7 @@ const SETTINGS_ROUTES = [ "/settings/api", "/settings/mcp", "/settings/advanced", + "/settings/preview", "/settings/about", ] as const; diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index faf10ceb1..fd6ba2966 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -35,6 +35,7 @@ import pg from "pg"; import { seedDayDoorsRecord } from "./day-doors-fixture"; import { seedMobileRoutesRecord } from "./mobile-routes-fixture"; +import { seedTopicsRecord } from "./topics-fixture"; // Resolve relative to the project root rather than __dirname/import.meta.url — // Playwright's TS loader runs files as CJS without `"type":"module"`, and @@ -289,6 +290,27 @@ export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateScoreHistory.json", ); +/** + * The topic pages' own account (`topics-preview.spec.ts`). + * + * The journey switches the Topics preview on and off, which adds and removes + * a navigation entry for the whole account; on the shared account a parallel + * spec would see its More sheet change underneath it. The preview starts off, + * the way every account starts, and the record is written after the login + * capture (`topics-fixture.ts`). + */ +export const E2E_TOPICS = { + email: "e2e-topics@healthlog.test", + username: "e2e-topics", + password: "Tp8!Hq4nVw7zKc3M", + role: "USER", +} as const; + +export const TOPICS_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateTopics.json", +); + export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateAiOptional.json", @@ -908,15 +930,17 @@ export default async function globalSetup(config: FullConfig): Promise { // The phone-width route sweep's account. Every opt-in module is on, so // the sweep reaches the glucose, documents, wellbeing, nutrients and - // environment surfaces; its record is written after the login capture. + // environment surfaces, and the Topics preview is on, so the topic pages + // render rather than leave for Insights; its record is written after the + // login capture. await pool.query( `INSERT INTO users (id, username, email, password_hash, role, created_at, updated_at, height_cm, date_of_birth, gender, onboarding_completed_at, onboarding_tour_completed, - module_preferences_json) + module_preferences_json, preview_features_json) VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true, - $8::jsonb) + $8::jsonb, '{"topics": true}'::jsonb) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, @@ -924,7 +948,8 @@ export default async function globalSetup(config: FullConfig): Promise { updated_at = EXCLUDED.updated_at, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, - module_preferences_json = EXCLUDED.module_preferences_json`, + module_preferences_json = EXCLUDED.module_preferences_json, + preview_features_json = EXCLUDED.preview_features_json`, [ cuid(), E2E_MOBILE_ROUTES.username, @@ -990,6 +1015,31 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The topic pages' account. Default modules, the preview off; its record + // is written after the login capture. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json, preview_features_json) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL, NULL) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = NULL, + preview_features_json = NULL`, + [ + cuid(), + E2E_TOPICS.username, + E2E_TOPICS.email, + await hashPassword(E2E_TOPICS.password), + now, + ], + ); + // The day-doors journey's account. Every module the journey reaches is // on; its record is written after the login capture. await pool.query( @@ -1650,6 +1700,14 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH); await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH); + // The topic pages' jar, then its record — see `E2E_TOPICS`. + await capture(E2E_TOPICS, TOPICS_STORAGE_STATE_PATH); + await seedTopicsRecord( + baseURL, + TOPICS_STORAGE_STATE_PATH, + E2E_TOPICS.username, + ); + // The score-history journey's jar — see `E2E_SCORE_HISTORY`. await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH); diff --git a/e2e/setup/topics-fixture.ts b/e2e/setup/topics-fixture.ts new file mode 100644 index 000000000..927f844c9 --- /dev/null +++ b/e2e/setup/topics-fixture.ts @@ -0,0 +1,138 @@ +/** + * The record `topics-preview.spec.ts` reads its topics from. + * + * Enough of each kind that every topic the account is offered has something + * to show, and some of it deliberately short of the thresholds, so the page + * states what is missing rather than a figure: + * + * - 40 days of resting heart rate, heart-rate variability and sleep, with + * every fourth night under seven hours and the morning after it higher: + * the heart pairs reach 14 paired days and say something; + * - 30 days of weight, 25 of blood pressure; + * - 12 days of steps: the steps pairs stay below 14 and say so; + * - 10 mood entries: the mind pairs stay below 14 and say so; + * - LDL three times over two years (a trend line) and vitamin D once + * (below its range, no trend yet). + * + * The readings go straight into Postgres: the topics read the live table, and + * a few hundred route calls would only slow `globalSetup` down. Mood entries + * and lab results go through the app's routes, which own their encryption and + * the marker catalogue. An account that already holds the LDL marker is taken + * as seeded, so a local re-run against a reused database does not double it. + */ +import { randomUUID } from "node:crypto"; + +import { request as playwrightRequest } from "@playwright/test"; +import pg from "pg"; + +export const TOPICS_LAB_MARKER = "LDL"; + +const DAY_MS = 86_400_000; + +type Row = [type: string, unit: string, value: number, measuredAt: Date]; + +/** The readings, one per local morning, ending yesterday. */ +function readings(now: number): Row[] { + const at = (daysAgo: number, hourUtc: number) => { + const d = new Date(now - daysAgo * DAY_MS); + d.setUTCHours(hourUtc, 30, 0, 0); + return d; + }; + const rows: Row[] = []; + for (let i = 1; i <= 40; i++) { + const shortNight = i % 4 === 0; + rows.push(["SLEEP_DURATION", "min", shortNight ? 360 : 480, at(i, 5)]); + rows.push([ + "RESTING_HEART_RATE", + "bpm", + 55 + (i % 10) + (shortNight ? 5 : 0), + at(i, 6), + ]); + rows.push(["HEART_RATE_VARIABILITY", "ms", 80 - (i % 10) * 3, at(i, 6)]); + } + for (let i = 1; i <= 30; i++) { + rows.push(["WEIGHT", "kg", 82 - (30 - i) * 0.03, at(i, 6)]); + } + for (let i = 1; i <= 25; i++) { + rows.push(["BLOOD_PRESSURE_SYS", "mmHg", 118 + (i % 5), at(i, 7)]); + rows.push(["BLOOD_PRESSURE_DIA", "mmHg", 76 + (i % 3), at(i, 7)]); + rows.push(["PULSE", "bpm", 64 + (i % 4), at(i, 7)]); + } + for (let i = 1; i <= 12; i++) { + rows.push(["ACTIVITY_STEPS", "steps", 7000 + (i % 5) * 900, at(i, 18)]); + } + return rows; +} + +export async function seedTopicsRecord( + baseURL: string, + storageStatePath: string, + username: string, +): Promise { + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: storageStatePath, + }); + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[topics-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + const labs = await ctx.get("/api/labs"); + const body = (await labs.json()) as { data: unknown }; + if (JSON.stringify(body.data ?? null).includes(`"${TOPICS_LAB_MARKER}"`)) { + return; + } + + const user = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [username], + ); + const userId = user.rows[0]?.id; + if (!userId) throw new Error("[topics-fixture] topics account not seeded"); + + const now = Date.now(); + for (const [type, unit, value, measuredAt] of readings(now)) { + await pool.query( + `INSERT INTO measurements + (id, user_id, type, value, unit, source, measured_at, updated_at) + VALUES ($1, $2, $3::"measurement_type", $4, $5, 'MANUAL', $6, now())`, + [randomUUID(), userId, type, value, unit, measuredAt], + ); + } + + const post = async (path: string, data: unknown): Promise => { + const res = await ctx.post(path, { data }); + if (res.status() === 409 || res.ok()) return; + throw new Error( + `[topics-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`, + ); + }; + for (let i = 1; i <= 10; i++) { + const at = new Date(now - i * DAY_MS); + at.setUTCHours(19, 0, 0, 0); + await post("/api/mood-entries", { + mood: i % 3 === 0 ? "OKAY" : "GUT", + moodLoggedAt: at.toISOString(), + }); + } + for (const [analyte, unit, value, daysAgo, low, high] of [ + [TOPICS_LAB_MARKER, "mg/dL", 140, 760, 0, 130], + [TOPICS_LAB_MARKER, "mg/dL", 121, 390, 0, 130], + [TOPICS_LAB_MARKER, "mg/dL", 98, 20, 0, 130], + ["Vitamin D", "ng/mL", 24, 25, 30, 100], + ] as const) { + await post("/api/labs", { + analyte, + panel: "Topics", + value, + unit, + referenceLow: low, + referenceHigh: high, + takenAt: new Date(now - daysAgo * DAY_MS).toISOString(), + }); + } + } finally { + await pool.end(); + await ctx.dispose(); + } +} diff --git a/e2e/topics-preview.spec.ts b/e2e/topics-preview.spec.ts new file mode 100644 index 000000000..a7fb35f8f --- /dev/null +++ b/e2e/topics-preview.spec.ts @@ -0,0 +1,219 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { TOPICS_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expectNoHorizontalOverflow } from "./utils/horizontal-overflow"; + +/** + * The Topics preview end to end, on its own account (`E2E_TOPICS`, seeded by + * `topics-fixture.ts`): + * + * - switched off (every account's start): no More tile, no sidebar entry, + * the pages leave for Insights and the routes answer 404; + * - switched on in Settings > System > Preview features: the More sheet + * leads with a Topics tile carrying the Beta badge, the desktop sidebar + * has the entry directly below Insights; + * - every offered topic renders from the seeded record, with the relationship + * that has fourteen paired days stated and the ones short of it saying how + * far they are, the lab trend drawn for the marker with three readings over + * a year and named as missing for the one with a single reading. + * + * Phone project only, in one serial run: the switch is account-wide, so two + * projects toggling it at once would read each other's state. + */ +test.describe.configure({ mode: "serial" }); + +async function setPreview(page: Page, topics: boolean) { + const res = await page.request.patch("/api/settings/preview-features", { + data: { topics }, + }); + expect(res.ok(), `preview PATCH answered ${res.status()}`).toBe(true); +} + +async function openMoreSheet(page: Page) { + await page.locator('[data-testid="bottom-nav-more"]').click(); + const sheet = page.locator('[data-testid="bottom-nav-more-sheet"]'); + await expect(sheet).toBeVisible(); + return sheet; +} + +test.describe("Topics (preview)", () => { + test.use({ + storageState: TOPICS_STORAGE_STATE_PATH, + viewport: { width: 390, height: 844 }, + }); + + test.beforeEach(async ({}, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "phone journey"); + }); + + test.afterAll(async ({ browser }, testInfo) => { + if (testInfo.project.name !== "chromium-mobile") return; + const context = await browser.newContext({ + storageState: TOPICS_STORAGE_STATE_PATH, + baseURL: testInfo.project.use.baseURL, + }); + // Leave the account as every account starts: the preview off. + await context.request.patch("/api/settings/preview-features", { + data: { topics: false }, + }); + await context.close(); + }); + + test("switched off: no tile, no entry, the pages leave and the routes refuse", async ({ + page, + }) => { + await setPreview(page, false); + await page.goto("/"); + const sheet = await openMoreSheet(page); + await expect(sheet.locator('a[href="/topics"]')).toHaveCount(0); + await page.keyboard.press("Escape"); + + await page.goto("/topics"); + await page.waitForURL(/\/insights$/); + await page.goto("/topics/heart"); + await page.waitForURL(/\/insights$/); + + const api = await page.request.get("/api/topics"); + expect(api.status()).toBe(404); + expect( + ((await api.json()) as { meta?: { errorCode?: string } }).meta, + ).toMatchObject({ errorCode: "topics.preview_off" }); + }); + + test("switched on in Settings: the More sheet leads with the Topics tile", async ({ + page, + }) => { + await setPreview(page, false); + await page.goto("/settings/preview"); + const toggle = page.locator('[data-slot="settings-preview-topics-switch"]'); + await expect(toggle).toHaveAttribute("aria-checked", "false"); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-checked", "true"); + await expect( + page.locator('[data-slot="settings-preview-topics-open"]'), + ).toBeVisible(); + + const sheet = await openMoreSheet(page); + const tiles = sheet.locator("a"); + await expect(tiles.first()).toHaveAttribute("href", "/topics"); + await expect( + tiles.first().locator('[data-slot="preview-badge"]'), + ).toBeVisible(); + await tiles.first().click(); + await page.waitForURL(/\/topics$/); + await expect(page.locator('[data-slot="topics-grid"]')).toBeVisible(); + }); + + test("every offered topic renders from the record, below-threshold states included", async ({ + page, + }) => { + await setPreview(page, true); + await page.goto("/topics"); + const grid = page.locator('[data-slot="topics-grid"]'); + await expect(grid).toBeVisible(); + for (const key of [ + "heart", + "body", + "blood-pressure", + "sleep", + "activity", + "metabolism", + "mind", + ]) { + await expect( + grid.locator(`[data-slot="topic-tile"][data-topic="${key}"]`), + ).toBeVisible(); + } + // No cycle module for this account: named once, never a tile. + await expect( + grid.locator('[data-slot="topic-tile"][data-topic="cycle"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="topics-hidden-list"]'), + ).toBeVisible(); + await expectNoHorizontalOverflow(page, "/topics"); + + // The heart: the pair with 40 paired days speaks, the steps pair cannot. + await grid + .locator('[data-topic="heart"] [data-slot="topic-tile-link"]') + .click(); + await page.waitForURL(/\/topics\/heart$/); + const body = page.locator('[data-slot="topic-detail-body"]'); + await expect(body).toBeVisible(); + await expect( + body.locator( + '[data-slot="topic-relationship"][data-relationship="hrv-resting-hr"]', + ), + ).toHaveAttribute("data-status", "linked"); + await expect( + body.locator( + '[data-slot="topic-signal"][data-signal="RESTING_HEART_RATE"]', + ), + ).toBeVisible(); + await expect( + body.locator('[data-slot="topic-chart-row"]').first(), + ).toBeVisible(); + await expect(body.locator('[data-slot="topic-missing"]')).toBeVisible(); + await expectNoHorizontalOverflow(page, "/topics/heart"); + + // The range switch re-reads the window. + await page + .locator('[data-slot="topic-range"]') + .getByRole("radio", { name: /90/ }) + .click(); + await expect(body).toBeVisible(); + + await page.goto("/topics/activity"); + await expect( + page.locator( + '[data-slot="topic-relationship"][data-relationship="steps-sleep"]', + ), + ).toHaveAttribute("data-status", "insufficient"); + + await page.goto("/topics/mind"); + await expect( + page.locator( + '[data-slot="topic-relationship"][data-relationship="short-nights-mood"]', + ), + ).toHaveAttribute("data-status", "insufficient"); + + await page.goto("/topics/metabolism"); + const labs = page.locator('[data-slot="topic-labs"]'); + await expect(labs).toBeVisible(); + await expect(labs.locator('[data-slot="topic-lab-trend"]')).toHaveCount(1); + await expect( + labs.locator('[data-slot="topic-lab-trend-short"]'), + ).toBeVisible(); + await expect( + labs.locator('[data-slot="topic-lab-marker"][data-status="below"]'), + ).toHaveCount(1); + await expectNoHorizontalOverflow(page, "/topics/metabolism"); + + for (const key of ["body", "blood-pressure", "sleep"]) { + await page.goto(`/topics/${key}`); + await expect( + page.locator('[data-slot="topic-detail-body"]'), + ).toBeVisible(); + await expectNoHorizontalOverflow(page, `/topics/${key}`); + } + }); + + test("the desktop sidebar carries the entry directly below Insights", async ({ + page, + }) => { + await setPreview(page, true); + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto("/topics"); + const nav = page.locator('[data-slot="sidebar-column"] nav'); + const links = nav.locator("a"); + await expect(nav.locator('a[href="/topics"]')).toBeVisible(); + const hrefs = await links.evaluateAll((els) => + els.map((el) => el.getAttribute("href")), + ); + expect(hrefs[hrefs.indexOf("/insights") + 1]).toBe("/topics"); + await expect( + nav.locator('a[href="/topics"] [data-slot="preview-badge"]'), + ).toBeVisible(); + }); +});