diff --git a/e2e/settings-integration-cards.spec.ts b/e2e/settings-integration-cards.spec.ts new file mode 100644 index 000000000..1663a1668 --- /dev/null +++ b/e2e/settings-integration-cards.spec.ts @@ -0,0 +1,220 @@ +import AxeBuilder from "@axe-core/playwright"; + +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + INTEGRATION_CARD_STATES, + mockIntegrationCards, +} from "./setup/integration-cards-fixture"; + +/** + * Settings → Integrations: the shared integration card, one card per state. + * + * Every provider is mocked into a different `health` (see the fixture), and + * the page is checked at desktop width and at 390 px. Assertions read data + * attributes only: the card's `data-state`, the badge's `data-state`, the one + * primary button's `data-action`. The wording is pinned by the component + * tests against the bundle itself. + * + * Runs in the desktop project and sizes the page itself, so one run covers + * both widths without a second project driving the same account. + */ +const VIEWPORTS = [ + { name: "desktop", width: 1280, height: 900 }, + { name: "390 px", width: 390, height: 844 }, +] as const; + +test.describe("Settings → Integrations: one card per health state", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "sizes the viewport itself", + ); + }); + + for (const viewport of VIEWPORTS) { + test(`renders each state from health alone at ${viewport.name}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await mockIntegrationCards(page); + await page.goto("/settings/integrations"); + + for (const [provider, expected] of Object.entries( + INTEGRATION_CARD_STATES, + )) { + const card = page.locator( + `[data-slot="integration-card"][data-integration="${provider}"]`, + ); + await expect(card, provider).toHaveAttribute( + "data-state", + expected.state, + { timeout: 15_000 }, + ); + + const badge = card.locator('[data-slot="integration-health-badge"]'); + if (expected.state === "not_connected") { + await expect(badge, provider).toHaveCount(0); + } else { + await expect(badge, provider).toHaveAttribute( + "data-state", + expected.state, + ); + // The word is the accessible name, not decoration. + await expect(badge, provider).not.toHaveText(""); + } + + // At most one primary, and exactly the one the state names. + const primary = card.locator( + '[data-slot="integration-primary-action"]', + ); + if (expected.primary === null) { + await expect(primary, provider).toHaveCount(0); + } else { + await expect(primary, provider).toHaveCount(1); + await expect(primary, provider).toHaveAttribute( + "data-action", + expected.primary, + ); + if (viewport.width < 640) { + const box = await primary.boundingBox(); + expect( + box?.height ?? 0, + `${provider} tap target`, + ).toBeGreaterThanOrEqual(44); + } + } + } + + // Nothing on the page states a status the old way. + await expect( + page.locator('[data-testid="integration-status-pill"]'), + ).toHaveCount(0); + + const dims = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + innerWidth: window.innerWidth, + })); + expect( + dims.scrollWidth, + `scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`, + ).toBeLessThanOrEqual(dims.innerWidth + 1); + }); + } + + test("the menu works from the keyboard and keeps its confirmations", async ({ + page, + }) => { + await mockIntegrationCards(page); + await page.goto("/settings/integrations"); + + const card = page.locator( + '[data-slot="integration-card"][data-integration="withings"]', + ); + const trigger = card.locator('[data-slot="integration-menu-trigger"]'); + await expect(trigger).toBeVisible({ timeout: 15_000 }); + await trigger.focus(); + await page.keyboard.press("Enter"); + const menu = page.locator('[data-slot="integration-menu"]'); + await expect(menu).toBeVisible(); + await expect( + menu.locator('[data-slot="integration-menu-item"]'), + ).toHaveCount(7); + + // Disconnect asks first; dismissing the dialog does nothing. + await menu.locator('[data-action="disconnect"]').focus(); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(card).toHaveAttribute("data-state", "limited"); + + // Escape closes the menu and returns focus to its trigger. + await trigger.focus(); + await page.keyboard.press("Enter"); + await expect(menu).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(menu).toHaveCount(0); + await expect(trigger).toBeFocused(); + }); + + test("details open on request and Copy puts exactly the shown text on the clipboard", async ({ + page, + context, + }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]); + await mockIntegrationCards(page); + await page.goto("/settings/integrations"); + + const card = page.locator( + '[data-slot="integration-card"][data-integration="polar"]', + ); + const toggle = card.locator('[data-slot="integration-details-toggle"]'); + const panel = card.locator('[data-slot="integration-details"]'); + await expect(toggle).toHaveAttribute("aria-expanded", "false", { + timeout: 15_000, + }); + await expect(panel).toBeHidden(); + + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(panel).toBeVisible(); + + const shown = await panel + .locator('[data-slot="integration-diagnostics"]') + .innerText(); + expect(shown).toContain("degraded / provider_errors"); + expect(shown).toContain("503"); + + await panel.locator('[data-slot="integration-diagnostics-copy"]').click(); + const status = panel.locator( + '[data-slot="integration-diagnostics-copy-status"]', + ); + await expect(status).toHaveAttribute("data-state", "copied"); + await expect(status).toHaveAttribute("role", "status"); + const copied = await page.evaluate(() => navigator.clipboard.readText()); + expect(copied.trim()).toBe(shown.trim()); + }); + + for (const theme of ["light", "dark"] as const) { + test(`every state reads accessibly in the ${theme} theme`, async ({ + page, + }) => { + await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" }); + await page.addInitScript((explicit: string) => { + window.localStorage.setItem("healthlog-theme", explicit); + }, theme); + await mockIntegrationCards(page); + await page.goto("/settings/integrations"); + const panel = page.locator('[data-slot="connections-panel"]'); + await expect( + panel.locator( + '[data-slot="integration-card"][data-integration="whoop"]', + ), + ).toHaveAttribute("data-state", "action_required", { timeout: 15_000 }); + // Scan with one details panel open, so the diagnostics block and its + // copy button are part of what is checked. + await panel + .locator( + '[data-integration="polar"] [data-slot="integration-details-toggle"]', + ) + .click(); + + const results = await new AxeBuilder({ page }) + .include('[data-slot="connections-panel"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + const serious = results.violations + .filter((v) => v.impact === "serious" || v.impact === "critical") + .map( + (v) => + `${v.id}: ${v.nodes.map((n) => n.target.join(" ")).join(", ")}`, + ); + expect(serious).toEqual([]); + }); + } +}); diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index f827442c8..ffc6d2b8b 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -3,22 +3,16 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; /** - * v1.4.19 phase A5 — `/settings/integrations` on a Pixel-5 viewport - * (393 CSS px) must: + * `/settings/integrations` on a Pixel-5 viewport (393 CSS px) must: * - * 1. Not produce horizontal page overflow. The new - * IntegrationStatusPill is `whitespace-nowrap` so the chip - * itself can grow as wide as its longest possible label - * ("Verbunden · vor 12 min" / "Error — reconnect"); the parent - * header is a `flex-wrap` so the pill drops to its own line if - * the title and pill together exceed the card width — this spec - * verifies neither path leaks into a horizontal scrollbar on the - * element. + * 1. Not produce horizontal page overflow. The badge in each card header is + * `whitespace-nowrap`; the header row wraps so the badge drops under the + * title when both do not fit. Neither path may leak into a horizontal + * scrollbar on the element. * - * 2. Render exactly one pill per integration card. Belt-and-braces - * guard for the card consolidation — the Vitest spec covers this - * on the SSR markup level, but the Pixel-5 layout is what matters - * in practice. + * 2. Render exactly one status badge per connected card, and none on a card + * that is not connected. The Vitest spec covers this on the SSR markup; + * the Pixel-5 layout is what matters in practice. * * Mobile-only spec — desktop project skips it. */ @@ -29,7 +23,7 @@ test.describe("/settings/integrations Pixel-5 layout", () => { test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); }); - test("Pixel-5 viewport: no horizontal page scroll, one pill per card", async ({ + test("Pixel-5 viewport: no horizontal page scroll, one badge per connected card", async ({ page, }) => { // Stub the three endpoints the Integrations section reads — both @@ -66,42 +60,56 @@ test.describe("/settings/integrations Pixel-5 layout", () => { integrations: [ { integration: "withings", - state: "connected", - lastSuccessAt: recent, - lastAttemptAt: recent, lastError: null, - consecutiveFailures: 0, - // v1.12.1 — the cards read connection state from this - // consolidated envelope (the per-provider routes were - // dropped from the section). The Withings pill keys on - // `connected`; a card-less pill keys on `configured`. configured: true, connected: true, - connectedAt: "2026-04-01T12:00:00Z", - legacyLastSyncedAt: recent, - tokenExpiresAt: "2026-12-01T12:00:00Z", - tokenExpired: false, - scope: "user.info,user.metrics,user.activity", - hasActivityScope: true, - syncHealth: { verdict: "fresh", since: null }, + health: { + state: "ok", + reason: "data_arriving", + since: null, + action: null, + unavailable: [], + lastSuccessAt: recent, + lastDataAt: recent, + diagnostics: { + ledgerState: "connected", + failingLegs: [], + failingSince: null, + lastAttemptAt: recent, + verdict: "fresh", + recentCalls: [], + }, + }, metricFreshness: [ { type: "WEIGHT", lastSeenAt: recent, stale: false }, ], }, { - // Configured, card-less, and no longer attempting: the case - // the fallback row exists for. Synthetic since v1.32.33 — - // every shipped provider has a card again, and the row must - // stay proven before the next one is dropped. + // Configured, card-less, and no longer syncing: the case the + // fallback row exists for. Synthetic since v1.32.33 — every + // shipped provider has a card again, and the row must stay + // proven before the next one is dropped. integration: "example-provider", - state: "connected", - lastSuccessAt: recent, - lastAttemptAt: recent, lastError: null, - consecutiveFailures: 0, configured: true, - legacyLastSyncedAt: monthAgo, - syncHealth: { verdict: "stalled", since: monthAgo }, + connected: true, + health: { + state: "degraded", + reason: "sync_stalled", + since: monthAgo, + action: null, + unavailable: [], + lastSuccessAt: monthAgo, + lastDataAt: monthAgo, + diagnostics: { + ledgerState: "connected", + failingLegs: [], + failingSince: null, + lastAttemptAt: monthAgo, + verdict: "stalled", + recentCalls: [], + }, + }, metricFreshness: [], }, // v1.28.x — Strava OAuth card reads off this consolidated @@ -109,30 +117,23 @@ test.describe("/settings/integrations Pixel-5 layout", () => { // never resolves and the integrations page hangs. { integration: "strava", - state: "disconnected", - lastSuccessAt: null, - lastAttemptAt: null, lastError: null, connected: false, configured: false, available: false, hasOwnCredentials: false, - syncHealth: { verdict: "disconnected", since: null }, + health: null, metricFreshness: [], }, // Nightscout reads the envelope too since the card dropped its - // own status round-trip; without an entry its pill never resolves. + // own status round-trip; without an entry its card never settles. { integration: "nightscout", - state: "disconnected", - lastSuccessAt: null, - lastAttemptAt: null, lastError: null, connected: false, configured: false, hasToken: false, - allowPrivateHost: false, - syncHealth: { verdict: "disconnected", since: null }, + health: null, metricFreshness: [], }, ], @@ -142,6 +143,19 @@ test.describe("/settings/integrations Pixel-5 layout", () => { }), ); + // Apple Health has delivered nothing on this account: not connected, so + // no badge. Mocked so a seeded HealthKit row cannot add one. + await page.route("**/api/integrations/healthkit", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { entries: [], lastSyncedAt: null, health: null }, + error: null, + }), + }), + ); + await page.route("**/api/withings/status", (route) => route.fulfill({ status: 200, @@ -164,42 +178,45 @@ test.describe("/settings/integrations Pixel-5 layout", () => { }); await page.waitForLoadState("networkidle"); - // Wait for the pills to mount (queries resolve after first paint). - // One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar, - // Oura, Strava, Nightscout — plus the fallback row for the configured, - // card-less entry. (The Garmin info note is not an OAuth card and - // carries no pill; the Apple Health card's pill keeps its own testid.) - const pills = page.locator('[data-testid="integration-status-pill"]'); - await expect(pills).toHaveCount(9, { timeout: 10_000 }); + // Wait for the cards to settle (queries resolve after first paint). + // Withings and the card-less fallback are connected: one badge each. + // Every provider without an envelope entry stays in its loading state, + // and the not-connected ones carry no badge at all. + const badges = page.locator('[data-slot="integration-health-badge"]'); + await expect(badges).toHaveCount(2, { timeout: 10_000 }); // The acceptance case: a configured integration with no card of its own, - // which has stopped even attempting, is visible on the page. + // which has stopped syncing, is visible on the page, read-only. const fallback = page.locator( '[data-testid="integration-fallback-row"][data-integration="example-provider"]', ); await expect(fallback).toHaveCount(1); await expect( - fallback.locator('[data-testid="integration-status-pill"]'), - ).toHaveAttribute("data-state", "stalled"); + fallback.locator('[data-slot="integration-health-badge"]'), + ).toHaveAttribute("data-state", "degraded"); + await expect( + fallback.locator('[data-slot="integration-menu-trigger"]'), + ).toHaveCount(0); // Card anatomy is uniform because no integration card paints a rule under - // its header any more. The `
` existed only in this folder — nine cards - // carrying a separator no other Settings card has — and the card's own gap - // does the separating now. + // its header any more. await expect( page.locator('[data-testid="integration-card-divider"]'), ).toHaveCount(0); - // Withings is connected in this fixture; WHOOP is a BYO-keys card that - // stays dormant until an operator adds credentials, so assert the one - // connected pill. await expect( page.locator( - '[data-testid="integration-status-pill"][data-state="connected"]', + '[data-slot="integration-card"][data-integration="withings"]', ), - ).toHaveCount(1); + ).toHaveAttribute("data-state", "ok"); + await expect( + page.locator('[data-slot="integration-card"][data-integration="strava"]'), + ).toHaveAttribute("data-state", "not_connected"); - // The redundant v1.4.18 banner must be gone. + // The retired status surfaces stay gone. + await expect( + page.locator('[data-testid="integration-status-pill"]'), + ).toHaveCount(0); await expect( page.locator('[data-testid="integration-status-banner"]'), ).toHaveCount(0); @@ -215,12 +232,11 @@ test.describe("/settings/integrations Pixel-5 layout", () => { `scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`, ).toBeLessThanOrEqual(dims.innerWidth + 1); - // Pill text remains legible at this viewport — assert non-empty - // bounding rect so we know it's painted, not display:none-d by - // some surprising container query. + // Badge text remains legible at this viewport: painted, not hidden by a + // surprising container query. for (let i = 0; i < 2; i++) { - const box = await pills.nth(i).boundingBox(); - expect(box, `pill ${i} has no bounding box`).not.toBeNull(); + const box = await badges.nth(i).boundingBox(); + expect(box, `badge ${i} has no bounding box`).not.toBeNull(); expect(box!.width).toBeGreaterThan(0); expect(box!.height).toBeGreaterThan(0); } @@ -241,7 +257,8 @@ test.describe("Apple Health guidance", () => { data: { entries: [], lastSyncedAt: null, - syncHealth: { verdict: "pending_first_sync", since: null }, + // Nothing delivered yet: no health, so the card is not connected. + health: null, metricFreshness: [], // #778 — nothing accepted yet, so the card must show no // progress section for this pending-first-sync account. @@ -268,10 +285,10 @@ test.describe("Apple Health guidance", () => { // A raw key means the bundle lookup failed, which is the failure this // assertion can actually see from a browser. await expect(description).not.toContainText("settings.appleHealth"); - await expect(page.getByTestId("apple-health-status")).toHaveAttribute( - "data-state", - "pending-setup", - ); + await expect(card).toHaveAttribute("data-state", "not_connected"); + await expect( + card.locator('[data-slot="integration-health-badge"]'), + ).toHaveCount(0); const importLink = page.getByTestId("apple-health-import-link"); await expect(importLink).toHaveAttribute( diff --git a/e2e/setup/integration-cards-fixture.ts b/e2e/setup/integration-cards-fixture.ts new file mode 100644 index 000000000..501861174 --- /dev/null +++ b/e2e/setup/integration-cards-fixture.ts @@ -0,0 +1,248 @@ +import type { Page } from "@playwright/test"; + +/** + * Settings → Integrations with one card per health state, all mocked. + * + * Each provider carries the `health` the server would publish for one + * situation, so a single page shows every state the shared card can be in: + * + * withings limited / plan_limited (no button) + * oura limited / permission_limited (one Reconnect) + * fitbit ok / data_arriving (older history loading) + * polar degraded / provider_errors (no button) + * whoop action_required / paused (one Resume) + * google-health action_required / reauth (one Reconnect) + * strava not connected (Connect) + * nightscout not connected (address form) + * apple-health degraded / no_new_data + * + * Timestamps are relative to now, so the sentences read the same on any day. + */ +const HOUR = 60 * 60 * 1000; + +function ago(hours: number): string { + return new Date(Date.now() - hours * HOUR).toISOString(); +} + +function health(overrides: Record = {}) { + return { + state: "ok", + reason: "data_arriving", + since: null, + action: null, + unavailable: [], + lastSuccessAt: ago(1), + lastDataAt: ago(2), + diagnostics: { + ledgerState: "connected", + failingLegs: [], + failingSince: null, + lastAttemptAt: ago(1), + verdict: "fresh", + recentCalls: [ + { endpoint: "measures", status: "ok", at: ago(1), errorCode: null }, + ], + }, + ...overrides, + }; +} + +function connected(integration: string, h: Record) { + return { + integration, + lastError: null, + connected: true, + configured: true, + available: true, + hasOwnCredentials: true, + health: h, + metricFreshness: [ + { type: "WEIGHT", lastSeenAt: ago(2), stale: false }, + { type: "PULSE", lastSeenAt: ago(80), stale: true }, + ], + }; +} + +function disconnected(integration: string) { + return { + integration, + lastError: null, + connected: false, + configured: false, + available: true, + hasOwnCredentials: false, + health: null, + metricFreshness: [], + }; +} + +export const INTEGRATION_CARD_STATES = { + withings: { state: "limited", primary: null }, + oura: { state: "limited", primary: "reconnect" }, + fitbit: { state: "ok", primary: null }, + polar: { state: "degraded", primary: null }, + whoop: { state: "action_required", primary: "resume" }, + "google-health": { state: "action_required", primary: "reconnect" }, + strava: { state: "not_connected", primary: "connect" }, + nightscout: { state: "not_connected", primary: "connect" }, + "apple-health": { state: "degraded", primary: null }, +} as const; + +export function integrationCardsEnvelope() { + return { + threshold: 3, + metricFreshnessDegraded: false, + integrations: [ + connected( + "withings", + health({ + state: "limited", + reason: "plan_limited", + unavailable: [ + { item: "spo2", cause: "plan" }, + { item: "vascularAge", cause: "plan" }, + { item: "pulseWaveVelocity", cause: "plan" }, + { item: "vo2Max", cause: "plan" }, + { item: "ecg", cause: "plan" }, + ], + }), + ), + { + ...connected("whoop", { + ...health({ + state: "action_required", + reason: "paused_after_errors", + since: ago(50), + action: "resume", + lastDataAt: ago(52), + }), + diagnostics: { + ledgerState: "parked", + failingLegs: ["recovery", "sleep"], + failingSince: ago(50), + lastAttemptAt: ago(3), + verdict: "parked", + recentCalls: [ + { + endpoint: "recovery", + status: "persistent", + at: ago(3), + errorCode: "500", + }, + { + endpoint: "sleep", + status: "persistent", + at: ago(3), + errorCode: "500", + }, + ], + }, + }), + consecutiveFailuresByKind: { + transient: 0, + reauth_required: 0, + persistent: 4, + }, + }, + { + ...connected("fitbit", health()), + backfillCompleted: false, + }, + connected( + "google-health", + health({ + state: "action_required", + reason: "reauth_required", + action: "reconnect", + since: ago(30), + lastDataAt: ago(31), + }), + ), + connected( + "polar", + health({ + state: "degraded", + reason: "provider_errors", + since: ago(9), + lastDataAt: ago(20), + diagnostics: { + ledgerState: "error_transient", + failingLegs: ["sleep"], + failingSince: ago(9), + lastAttemptAt: ago(1), + verdict: "failing", + recentCalls: [ + { + endpoint: "sleep", + status: "transient", + at: ago(1), + errorCode: "503", + }, + { + endpoint: "sleep", + status: "transient", + at: ago(2), + errorCode: "503", + }, + { + endpoint: "activity", + status: "ok", + at: ago(2), + errorCode: null, + }, + ], + }, + }), + ), + connected( + "oura", + health({ + state: "limited", + reason: "permission_limited", + action: "reconnect", + unavailable: [ + { item: "spo2", cause: "permission" }, + { item: "vo2max", cause: "permission" }, + ], + }), + ), + disconnected("strava"), + { ...disconnected("nightscout"), hasToken: false }, + ], + }; +} + +export function appleHealthStatus() { + return { + entries: [], + lastSyncedAt: ago(60), + lastSyncTrigger: "foreground", + lastBackgroundSyncAt: null, + metricFreshness: [], + syncProgress: { recordsAccepted: 48211, oldestMeasuredAt: ago(24 * 900) }, + health: health({ + state: "degraded", + reason: "no_new_data", + since: ago(60), + lastDataAt: ago(60), + }), + }; +} + +/** Route every read the Integrations page makes onto the fixture. */ +export async function mockIntegrationCards(page: Page): Promise { + await page.route("**/api/integrations/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: integrationCardsEnvelope(), error: null }), + }), + ); + await page.route("**/api/integrations/healthkit", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: appleHealthStatus(), error: null }), + }), + ); +} diff --git a/src/components/settings/integrations/integration-card.tsx b/src/components/settings/integrations/integration-card.tsx index 451b563db..3a6cf059f 100644 --- a/src/components/settings/integrations/integration-card.tsx +++ b/src/components/settings/integrations/integration-card.tsx @@ -68,11 +68,16 @@ import { /** The app version the build stamped; empty in a dev build. */ const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || ""; +/** + * The three calm states take the status token on a light wash. "Action + * needed" is the solid destructive badge: red text on a red wash falls below + * AA in both themes, and this is the one state that should stand out anyway. + */ const BADGE_TONE: Record = { ok: "border-success/30 bg-success/15 text-success", limited: "border-info/30 bg-info/15 text-info", degraded: "border-warning/30 bg-warning/15 text-warning", - action_required: "border-destructive/30 bg-destructive/15 text-destructive", + action_required: "", }; /** The status badge: a dot and one word. The word is the accessible name. */ @@ -82,7 +87,7 @@ export function HealthBadge({ state }: { state: ConnectionHealthState }) {