mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
test(e2e): drive the integration cards through every health state
One mocked page carries a provider in each state, checked at desktop width and at 390 px by data attributes, with the menu driven from the keyboard, the copied diagnostics compared with the shown text, and an axe pass in both themes. The solid destructive badge replaces the red-on-red wash, which fell below AA.
This commit is contained in:
1 parent
1a5b09a73c
commit
eedd91ec64
4 files changed
+571
-81
No files matched your search
@@ -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([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
* <html> 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 <html> 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 `<hr>` 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(
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<string, unknown>) {
|
||||
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<void> {
|
||||
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 }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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<ConnectionHealthState, string> = {
|
||||
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 }) {
|
||||
<Badge
|
||||
data-slot="integration-health-badge"
|
||||
data-state={state}
|
||||
variant="outline"
|
||||
variant={state === "action_required" ? "destructive" : "outline"}
|
||||
className={cn("gap-1.5", BADGE_TONE[state])}
|
||||
>
|
||||
<span aria-hidden="true" className="size-1.5 rounded-full bg-current" />
|
||||
|
||||
Reference in new issue
Block a user