diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 913bbbf6a..e6ac14953 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -2,10 +2,7 @@ import AxeBuilder from "@axe-core/playwright"; import type { Locator, Page, Route } from "@playwright/test"; import { expect, test } from "./setup/test"; -import { - MOBILE_ROUTES_STORAGE_STATE_PATH, - STORAGE_STATE_PATH, -} from "./setup/global-setup"; +import { 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"; @@ -982,49 +979,6 @@ 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 66e8249c7..127ad3a3c 100644 --- a/e2e/mobile-route-overflow.spec.ts +++ b/e2e/mobile-route-overflow.spec.ts @@ -6,7 +6,6 @@ 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"; @@ -173,9 +172,6 @@ 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 3f10f6540..2b8e70726 100644 --- a/e2e/settings-admin-one-piece.spec.ts +++ b/e2e/settings-admin-one-piece.spec.ts @@ -58,7 +58,6 @@ 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 fd6ba2966..faf10ceb1 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -35,7 +35,6 @@ 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 @@ -290,27 +289,6 @@ 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", @@ -930,17 +908,15 @@ 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, and the Topics preview is on, so the topic pages - // render rather than leave for Insights; its record is written after the - // login capture. + // environment surfaces; 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, preview_features_json) + module_preferences_json) VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true, - $8::jsonb, '{"topics": true}'::jsonb) + $8::jsonb) ON CONFLICT (username) DO UPDATE SET email = EXCLUDED.email, password_hash = EXCLUDED.password_hash, @@ -948,8 +924,7 @@ 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, - preview_features_json = EXCLUDED.preview_features_json`, + module_preferences_json = EXCLUDED.module_preferences_json`, [ cuid(), E2E_MOBILE_ROUTES.username, @@ -1015,31 +990,6 @@ 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( @@ -1700,14 +1650,6 @@ 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 deleted file mode 100644 index 927f844c9..000000000 --- a/e2e/setup/topics-fixture.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * 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 deleted file mode 100644 index a7fb35f8f..000000000 --- a/e2e/topics-preview.spec.ts +++ /dev/null @@ -1,219 +0,0 @@ -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(); - }); -});