mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
221 lines
7.7 KiB
TypeScript
221 lines
7.7 KiB
TypeScript
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([]);
|
|
});
|
|
}
|
|
});
|