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(); + }); +});