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 `