mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
A spec that arms `page.route(...)` in a `beforeEach` and never drops the handler races its own teardown. The context closes, an async handler is still awaiting, its next call rejects with "Test ended.", and Playwright attributes that to the test that had just passed. With `retries: 2` plus `failOnFlakyTests`, which spec bites is a matter of timing. `e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto fixture that unroutes every page and the context after each test, and clears the page event listeners a spec subscribes to; `expect` rides along so a spec needs one import. Auto means a spec author cannot forget it and does not have to know it exists. Every spec now takes `test` from there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary closed with an allowlist that carries its reason. The per-file `afterEach` on the cycle spec goes away — leaving both would read as not trusting the fixture. Not covered: a page inside a context a spec builds itself. Only `share-documents.spec.ts` does that, and it owns those contexts.
896 lines
25 KiB
TypeScript
896 lines
25 KiB
TypeScript
import AxeBuilder from "@axe-core/playwright";
|
|
import type { Locator, Page, Route } from "@playwright/test";
|
|
import { expect, test } from "./setup/test";
|
|
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot";
|
|
|
|
type AxeViolation = Awaited<
|
|
ReturnType<AxeBuilder["analyze"]>
|
|
>["violations"][number];
|
|
|
|
type Theme = "light" | "dark";
|
|
|
|
type RouteCase = {
|
|
name: string;
|
|
path: string;
|
|
painted: (page: Page) => Locator;
|
|
};
|
|
|
|
const THEMES: readonly Theme[] = ["light", "dark"];
|
|
const WCAG_TAGS: Record<string, true> = {
|
|
wcag2a: true,
|
|
wcag2aa: true,
|
|
wcag21a: true,
|
|
wcag21aa: true,
|
|
};
|
|
const SEMANTIC_RULES: Record<string, true> = {
|
|
"heading-order": true,
|
|
"page-has-heading-one": true,
|
|
"landmark-one-main": true,
|
|
"landmark-unique": true,
|
|
region: true,
|
|
};
|
|
const A11Y_WORKOUT_ID = "a11y-workout";
|
|
const A11Y_CONVERSATION_ID = "a11y-conversation";
|
|
const A11Y_DOCUMENT_ID = "a11y-document";
|
|
|
|
const fixedMeasurementRows = Array.from({ length: 10 }, (_, index) => ({
|
|
id: `a11y-measurement-${index}`,
|
|
type: "WEIGHT",
|
|
value: 78.5 + (index % 3) - 1,
|
|
measuredAt: `2026-07-${String(20 - index).padStart(2, "0")}T08:00:00.000Z`,
|
|
source: "MANUAL",
|
|
notes: null,
|
|
}));
|
|
|
|
const workoutListEntry = {
|
|
id: A11Y_WORKOUT_ID,
|
|
sportType: "running",
|
|
startedAt: "2026-07-20T07:00:00.000Z",
|
|
endedAt: "2026-07-20T07:30:00.000Z",
|
|
durationSec: 1800,
|
|
distanceM: 5200,
|
|
activeEnergyKcal: 320,
|
|
avgHr: 145,
|
|
maxHr: 170,
|
|
source: "APPLE_HEALTH",
|
|
externalId: "a11y-workout-external",
|
|
hasRoute: true,
|
|
hasHrSeries: true,
|
|
};
|
|
|
|
const workoutDetail = {
|
|
...workoutListEntry,
|
|
minHr: 110,
|
|
stepCount: 5800,
|
|
elevationM: 12.5,
|
|
pauseDurationSec: null,
|
|
metadata: null,
|
|
route: {
|
|
geometry: {
|
|
type: "LineString",
|
|
coordinates: Array.from({ length: 20 }, (_, index) => [
|
|
11 + index * 0.0004,
|
|
50 + index * 0.0003,
|
|
]),
|
|
},
|
|
sampleTimestamps: null,
|
|
},
|
|
samples: { sampleCount: 20, samples: null },
|
|
hrSeries: {
|
|
source: "pulse_window",
|
|
bucketSec: 8,
|
|
points: [
|
|
{ tSec: 0, mean: 130, min: 125, max: 135 },
|
|
{ tSec: 8, mean: 140, min: 135, max: 145 },
|
|
{ tSec: 16, mean: 150, min: 145, max: 155 },
|
|
],
|
|
envelope: false,
|
|
},
|
|
zones: {
|
|
model: "tanaka",
|
|
hrMax: 180,
|
|
zones: [
|
|
{ zone: 1, lowBpm: 90, highBpm: 108, seconds: 300 },
|
|
{ zone: 2, lowBpm: 109, highBpm: 126, seconds: 420 },
|
|
{ zone: 3, lowBpm: 127, highBpm: 144, seconds: 540 },
|
|
{ zone: 4, lowBpm: 145, highBpm: 162, seconds: 360 },
|
|
{ zone: 5, lowBpm: 163, highBpm: null, seconds: 180 },
|
|
],
|
|
},
|
|
splits: [
|
|
{ km: 1, durationSec: 340, paceSecPerKm: 340 },
|
|
{ km: 2, durationSec: 345, paceSecPerKm: 345 },
|
|
],
|
|
sportContext: {
|
|
count: 8,
|
|
avgDurationSec: 2040,
|
|
avgDistanceM: 5800,
|
|
avgAvgHr: 148,
|
|
},
|
|
aiInsight: null,
|
|
canonicalId: A11Y_WORKOUT_ID,
|
|
};
|
|
|
|
const medication = {
|
|
id: "a11y-medication",
|
|
name: "A11y medication",
|
|
dose: "10 mg",
|
|
category: "OTHER",
|
|
active: true,
|
|
notificationsEnabled: true,
|
|
pausedAt: null,
|
|
lastTakenAt: null,
|
|
startsOn: null,
|
|
endsOn: null,
|
|
oneShot: false,
|
|
schedules: [
|
|
{
|
|
id: "a11y-medication-schedule",
|
|
windowStart: "08:00",
|
|
windowEnd: "09:00",
|
|
label: null,
|
|
dose: null,
|
|
daysOfWeek: null,
|
|
timesOfDay: ["08:00"],
|
|
rrule: "FREQ=DAILY",
|
|
rollingIntervalDays: null,
|
|
reminderGraceMinutes: null,
|
|
},
|
|
],
|
|
};
|
|
|
|
const inboundDocument = {
|
|
id: A11Y_DOCUMENT_ID,
|
|
kind: "LAB_RESULT",
|
|
title: "A11y blood panel",
|
|
filename: "a11y-blood-panel.txt",
|
|
mimeType: "text/plain",
|
|
byteSize: 2048,
|
|
status: "CONFIRMED",
|
|
providerType: "mock",
|
|
reportDate: "2026-07-19",
|
|
documentDate: "2026-07-19",
|
|
errorReason: null,
|
|
factCount: 0,
|
|
pendingCount: 0,
|
|
conditionLinks: [],
|
|
servingClass: "attachment",
|
|
hasContentIndex: true,
|
|
contentIndexSource: "vision",
|
|
hasThumbnail: false,
|
|
createdAt: "2026-07-19T12:00:00.000Z",
|
|
updatedAt: "2026-07-19T12:00:00.000Z",
|
|
};
|
|
|
|
function fulfilJson(route: Route, data: unknown) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ data, error: null }),
|
|
});
|
|
}
|
|
|
|
function reportBlocking(label: string, blocking: AxeViolation[]) {
|
|
if (blocking.length === 0) return;
|
|
console.log(
|
|
`axe violations for ${label}:\n${blocking
|
|
.map(
|
|
(violation) =>
|
|
` - [${violation.impact}] ${violation.id}: ${violation.help}\n` +
|
|
` ${violation.helpUrl}\n` +
|
|
violation.nodes
|
|
.map(
|
|
(node, index) =>
|
|
` node ${index + 1}: ${node.target.join(" ")}\n` +
|
|
` ${node.failureSummary ?? "No failure summary supplied."}\n` +
|
|
` html: ${node.html}`,
|
|
)
|
|
.join("\n"),
|
|
)
|
|
.join("\n")}`,
|
|
);
|
|
}
|
|
|
|
async function runAxe(page: Page): Promise<AxeViolation[]> {
|
|
const results = await new AxeBuilder({ page })
|
|
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "best-practice"])
|
|
.analyze();
|
|
|
|
return results.violations.filter((violation) => {
|
|
if (SEMANTIC_RULES[violation.id]) {
|
|
return (
|
|
violation.impact === "moderate" ||
|
|
violation.impact === "serious" ||
|
|
violation.impact === "critical"
|
|
);
|
|
}
|
|
const isWcagRule = violation.tags.some((tag) => WCAG_TAGS[tag]);
|
|
return (
|
|
isWcagRule &&
|
|
(violation.impact === "serious" || violation.impact === "critical")
|
|
);
|
|
});
|
|
}
|
|
|
|
async function useExplicitTheme(page: Page, theme: Theme) {
|
|
await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
|
|
await page.addInitScript((explicitTheme: Theme) => {
|
|
window.localStorage.setItem("healthlog-theme", explicitTheme);
|
|
}, theme);
|
|
}
|
|
|
|
async function expectTheme(page: Page, theme: Theme) {
|
|
await expect(page.locator("html")).toHaveClass(
|
|
new RegExp(`(^|\\s)${theme}(\\s|$)`),
|
|
);
|
|
await expect
|
|
.poll(() => page.evaluate(() => localStorage.getItem("healthlog-theme")))
|
|
.toBe(theme);
|
|
}
|
|
|
|
async function scanPaintedState(
|
|
page: Page,
|
|
theme: Theme,
|
|
label: string,
|
|
painted: Locator,
|
|
): Promise<AxeViolation[]> {
|
|
await expect(painted).toBeVisible({ timeout: 15_000 });
|
|
await page.evaluate(async () => {
|
|
const finiteAnimations = document
|
|
.getAnimations()
|
|
.filter(
|
|
(animation) => animation.effect?.getTiming().iterations !== Infinity,
|
|
);
|
|
const { promise: animationTimeout, resolve } =
|
|
Promise.withResolvers<void>();
|
|
window.setTimeout(resolve, 500);
|
|
await Promise.race([
|
|
Promise.allSettled(
|
|
finiteAnimations.map((animation) => animation.finished),
|
|
),
|
|
animationTimeout,
|
|
]);
|
|
});
|
|
await expectTheme(page, theme);
|
|
const blocking = await runAxe(page);
|
|
reportBlocking(`${theme} ${label}`, blocking);
|
|
return blocking;
|
|
}
|
|
|
|
function expectNoViolations(
|
|
theme: Theme,
|
|
label: string,
|
|
blocking: AxeViolation[],
|
|
) {
|
|
expect(blocking, `${theme} ${label} accessibility violations`).toHaveLength(
|
|
0,
|
|
);
|
|
}
|
|
|
|
async function visitAndScan(
|
|
page: Page,
|
|
theme: Theme,
|
|
routeCase: RouteCase,
|
|
): Promise<AxeViolation[]> {
|
|
return test.step(`${theme} ${routeCase.name}`, async () => {
|
|
await page.goto(routeCase.path, { waitUntil: "domcontentloaded" });
|
|
return scanPaintedState(
|
|
page,
|
|
theme,
|
|
routeCase.name,
|
|
routeCase.painted(page),
|
|
);
|
|
});
|
|
}
|
|
|
|
async function installA11yMocks(page: Page) {
|
|
await page.route(/\/api\/analytics(\?|$)/, (route) =>
|
|
fulfilJson(route, {
|
|
summaries: POPULATED_SUMMARIES,
|
|
bpInTargetPct: 80,
|
|
glucoseByContext: {},
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/mood/analytics", (route) =>
|
|
fulfilJson(route, {
|
|
entries: [{ date: "2026-07-20", score: 4 }],
|
|
summary: { count: 1 },
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/mood/insights", (route) =>
|
|
fulfilJson(route, {
|
|
summary: { count: 1 },
|
|
tagBreakdown: [],
|
|
trends: [],
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/mood/tags*", (route) =>
|
|
fulfilJson(route, { groups: [], tags: [] }),
|
|
);
|
|
|
|
await page.route("**/api/mood-entries*", (route) =>
|
|
fulfilJson(route, {
|
|
entries: [
|
|
{
|
|
id: "a11y-mood",
|
|
date: "2026-07-20",
|
|
mood: "GUT",
|
|
score: 4,
|
|
tags: [],
|
|
tagKeys: [],
|
|
source: "MANUAL",
|
|
moodLoggedAt: "2026-07-20T18:30:00.000Z",
|
|
note: null,
|
|
},
|
|
],
|
|
meta: { total: 1 },
|
|
}),
|
|
);
|
|
|
|
await page.route(/\/api\/measurements\/series-batch(\?|$)/, (route) => {
|
|
const url = new URL(route.request().url());
|
|
const types = (url.searchParams.get("types") ?? "WEIGHT").split(",");
|
|
const series = Object.fromEntries(
|
|
types.map((type) => [
|
|
type,
|
|
fixedMeasurementRows.map((row) => ({
|
|
type,
|
|
value: row.value,
|
|
measuredAt: row.measuredAt,
|
|
count: 1,
|
|
})),
|
|
]),
|
|
);
|
|
return fulfilJson(route, { series });
|
|
});
|
|
|
|
await page.route("**/api/measurements*", (route) =>
|
|
fulfilJson(route, {
|
|
measurements: fixedMeasurementRows,
|
|
meta: { total: fixedMeasurementRows.length },
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/insights/comprehensive", (route) =>
|
|
fulfilJson(route, {
|
|
totalMeasurements: fixedMeasurementRows.length,
|
|
moodSummary: { count: 1 },
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/workouts**", (route) => {
|
|
const url = new URL(route.request().url());
|
|
if (url.pathname === `/api/workouts/${A11Y_WORKOUT_ID}`) {
|
|
return fulfilJson(route, workoutDetail);
|
|
}
|
|
return fulfilJson(route, {
|
|
workouts: [workoutListEntry],
|
|
meta: {
|
|
total: 1,
|
|
limit: Number(url.searchParams.get("limit") ?? 100),
|
|
offset: Number(url.searchParams.get("offset") ?? 0),
|
|
droppedDuplicates: 0,
|
|
},
|
|
});
|
|
});
|
|
|
|
await page.route(/\/api\/insights\/chat(?:\/[^/?]+)?(?:\?.*)?$/, (route) => {
|
|
const url = new URL(route.request().url());
|
|
if (url.pathname === `/api/insights/chat/${A11Y_CONVERSATION_ID}`) {
|
|
return fulfilJson(route, {
|
|
id: A11Y_CONVERSATION_ID,
|
|
title: "A11y evidence conversation",
|
|
createdAt: "2026-07-20T09:00:00.000Z",
|
|
updatedAt: "2026-07-20T09:01:00.000Z",
|
|
messageCount: 2,
|
|
fenced: false,
|
|
attachments: [],
|
|
documentTitle: null,
|
|
attachmentCount: 0,
|
|
summary: null,
|
|
messages: [
|
|
{
|
|
id: "a11y-coach-user-message",
|
|
role: "user",
|
|
content: "How is my blood pressure trending?",
|
|
createdAt: "2026-07-20T09:00:00.000Z",
|
|
metricSource: null,
|
|
providerType: null,
|
|
promptVersion: null,
|
|
tokensUsed: null,
|
|
model: null,
|
|
},
|
|
{
|
|
id: "a11y-coach-assistant-message",
|
|
role: "assistant",
|
|
content: "Your recent readings are steady.",
|
|
createdAt: "2026-07-20T09:01:00.000Z",
|
|
metricSource: {
|
|
windows: ["last30days"],
|
|
metrics: ["bp", "pulse"],
|
|
counts: { bp: 12, pulse: 14 },
|
|
keyValues: [
|
|
{
|
|
label: "Average blood pressure",
|
|
value: "124/80",
|
|
unit: "mmHg",
|
|
window: "last 30 days",
|
|
},
|
|
],
|
|
},
|
|
providerType: "mock",
|
|
promptVersion: "a11y",
|
|
tokensUsed: 42,
|
|
model: "mock",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
return fulfilJson(route, {
|
|
conversations: [
|
|
{
|
|
id: A11Y_CONVERSATION_ID,
|
|
title: "A11y evidence conversation",
|
|
createdAt: "2026-07-20T09:00:00.000Z",
|
|
updatedAt: "2026-07-20T09:01:00.000Z",
|
|
messageCount: 2,
|
|
fenced: false,
|
|
attachments: [],
|
|
documentTitle: null,
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
});
|
|
});
|
|
|
|
await page.route("**/api/insights/coach/nudge-status*", (route) =>
|
|
fulfilJson(route, { nudgedAt: null, unread: false }),
|
|
);
|
|
await page.route("**/api/coach/about-me/questions*", (route) =>
|
|
fulfilJson(route, { questions: [] }),
|
|
);
|
|
await page.route("**/api/auth/me/coach-prefs", (route) =>
|
|
fulfilJson(route, {
|
|
tone: "warm",
|
|
verbosity: "default",
|
|
excludeMetrics: [],
|
|
showEvidenceByDefault: false,
|
|
defaultWindow: "allTime",
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/medications**", (route) => {
|
|
const url = new URL(route.request().url());
|
|
if (url.pathname === "/api/medications/compliance") {
|
|
return fulfilJson(route, []);
|
|
}
|
|
if (url.pathname === "/api/medications/layout") {
|
|
return fulfilJson(route, { version: 1, view: "cards", order: [] });
|
|
}
|
|
if (url.pathname === "/api/medications") {
|
|
return fulfilJson(route, [medication]);
|
|
}
|
|
return fulfilJson(route, {});
|
|
});
|
|
await page.route("**/api/settings/reminder-thresholds", (route) =>
|
|
fulfilJson(route, {
|
|
lowStockThreshold: null,
|
|
lowStockRunwayDays: null,
|
|
reorderLeadDays: null,
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/labs/ocr/capability", (route) =>
|
|
fulfilJson(route, {
|
|
available: true,
|
|
mode: "vision",
|
|
reason: null,
|
|
pdfSupported: true,
|
|
}),
|
|
);
|
|
await page.route("**/api/labs?*", (route) =>
|
|
fulfilJson(route, {
|
|
results: [
|
|
{
|
|
id: "a11y-lab",
|
|
biomarkerId: null,
|
|
panel: "A11y panel",
|
|
analyte: "A11y LDL",
|
|
value: 2.4,
|
|
valueText: null,
|
|
unit: "mmol/L",
|
|
referenceLow: 0,
|
|
referenceHigh: 3,
|
|
takenAt: "2026-07-19T12:00:00.000Z",
|
|
source: "MANUAL",
|
|
hasNote: false,
|
|
rangeStatus: "inRange",
|
|
createdAt: "2026-07-19T12:00:00.000Z",
|
|
updatedAt: "2026-07-19T12:00:00.000Z",
|
|
},
|
|
],
|
|
meta: { total: 1, limit: 500, offset: 0 },
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/documents/inbound**", (route) => {
|
|
const url = new URL(route.request().url());
|
|
if (url.pathname === "/api/documents/inbound/usage") {
|
|
return fulfilJson(route, {
|
|
usedBytes: inboundDocument.byteSize,
|
|
quotaBytes: 1_073_741_824,
|
|
maxFileBytes: 26_214_400,
|
|
acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"],
|
|
linkedEpisodes: [],
|
|
assistAvailable: true,
|
|
contentIndex: { enabled: true, indexedCount: 1, totalCount: 1 },
|
|
});
|
|
}
|
|
if (url.pathname === "/api/documents/inbound/capability") {
|
|
return fulfilJson(route, {
|
|
available: true,
|
|
mode: "vision",
|
|
reason: null,
|
|
pdfSupported: true,
|
|
egress: "local",
|
|
});
|
|
}
|
|
if (url.pathname === `/api/documents/inbound/${A11Y_DOCUMENT_ID}/summary`) {
|
|
return fulfilJson(route, {
|
|
summary: "A deterministic summary of the A11y blood panel.",
|
|
});
|
|
}
|
|
if (url.pathname === `/api/documents/inbound/${A11Y_DOCUMENT_ID}`) {
|
|
return fulfilJson(route, {
|
|
...inboundDocument,
|
|
facts: [],
|
|
summary: "A stored summary of the A11y blood panel.",
|
|
summaryGeneratedAt: "2026-07-19T12:05:00.000Z",
|
|
summaryState: "READY",
|
|
});
|
|
}
|
|
if (url.pathname === "/api/documents/inbound") {
|
|
return fulfilJson(route, {
|
|
documents: [inboundDocument],
|
|
nextCursor: null,
|
|
});
|
|
}
|
|
return fulfilJson(route, {});
|
|
});
|
|
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
|
|
fulfilJson(route, { documentsAutoAiRead: false }),
|
|
);
|
|
|
|
await page.route("**/api/admin/app-logs*", (route) =>
|
|
fulfilJson(route, {
|
|
events: [
|
|
{
|
|
timestamp: "2026-07-20T10:00:00.000Z",
|
|
duration_ms: 24,
|
|
request_id: "a11y-request",
|
|
trace_id: "a11y-trace",
|
|
level: "info",
|
|
kind: "http",
|
|
service: "web",
|
|
environment: "test",
|
|
http: {
|
|
method: "GET",
|
|
path: "/api/version",
|
|
route: "/api/version",
|
|
status: 200,
|
|
},
|
|
action: { name: "a11y.paint" },
|
|
},
|
|
],
|
|
meta: { total: 1, bufferMax: 500 },
|
|
}),
|
|
);
|
|
await page.route("**/api/admin/backups", (route) =>
|
|
fulfilJson(route, {
|
|
rows: [
|
|
{
|
|
id: "a11y-backup",
|
|
userId: "a11y-user",
|
|
username: "a11y-backup-user",
|
|
type: "WEEKLY_AUTO",
|
|
sizeBytes: 4096,
|
|
createdAt: "2026-07-20T03:00:00.000Z",
|
|
},
|
|
],
|
|
retentionDays: 30,
|
|
}),
|
|
);
|
|
}
|
|
|
|
const INSIGHTS_ROUTES: readonly RouteCase[] = [
|
|
{
|
|
name: "/insights overview",
|
|
path: "/insights",
|
|
painted: (page) =>
|
|
page.locator('[data-slot="wellness-scores"]').filter({
|
|
has: page.locator(
|
|
'[data-slot="wellness-scores-grid"]:not([aria-busy="true"])',
|
|
),
|
|
}),
|
|
},
|
|
{
|
|
name: "/insights/weight metric subpage",
|
|
path: "/insights/weight",
|
|
painted: (page) => page.locator(".recharts-wrapper").first(),
|
|
},
|
|
{
|
|
name: "/insights/workouts list",
|
|
path: "/insights/workouts",
|
|
painted: (page) =>
|
|
page
|
|
.locator(
|
|
'#main-content [data-slot="workout-list"], #main-content [role="status"]',
|
|
)
|
|
.first(),
|
|
},
|
|
{
|
|
name: "/insights/workouts/:id detail",
|
|
path: `/insights/workouts/${A11Y_WORKOUT_ID}`,
|
|
painted: (page) => page.locator('[data-slot="workout-detail-header"]'),
|
|
},
|
|
{
|
|
name: "/coach",
|
|
path: "/coach",
|
|
painted: (page) => page.locator('[data-slot="coach-page"]').first(),
|
|
},
|
|
];
|
|
|
|
const RECORD_ROUTES: readonly RouteCase[] = [
|
|
{
|
|
name: "/measurements",
|
|
path: "/measurements",
|
|
painted: (page) => page.locator('[data-slot="filter-bar"]'),
|
|
},
|
|
{
|
|
name: "/mood",
|
|
path: "/mood",
|
|
painted: (page) => page.locator('[data-slot="filter-bar"]'),
|
|
},
|
|
{
|
|
name: "/medications",
|
|
path: "/medications",
|
|
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
|
|
},
|
|
{
|
|
name: "/labs",
|
|
path: "/labs",
|
|
painted: (page) => page.getByText("A11y LDL").first(),
|
|
},
|
|
{
|
|
name: "/documents compact panel",
|
|
path: "/documents?view=compact",
|
|
painted: (page) => page.getByText("A11y blood panel").first(),
|
|
},
|
|
];
|
|
|
|
const ADMIN_AND_BASELINE_ROUTES: readonly RouteCase[] = [
|
|
{
|
|
name: "/admin/app-logs",
|
|
path: "/admin/app-logs",
|
|
painted: (page) => page.getByText("a11y.paint").first(),
|
|
},
|
|
{
|
|
name: "/admin/backups",
|
|
path: "/admin/backups",
|
|
painted: (page) =>
|
|
page.getByText("a11y-backup-user", { exact: true }).filter({
|
|
visible: true,
|
|
}),
|
|
},
|
|
{
|
|
name: "/ dashboard",
|
|
path: "/",
|
|
painted: (page) => page.locator("#main-content"),
|
|
},
|
|
{
|
|
name: "/settings/integrations",
|
|
path: "/settings/integrations",
|
|
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
|
|
},
|
|
{
|
|
name: "/admin overview",
|
|
path: "/admin",
|
|
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
|
|
},
|
|
{
|
|
name: "/admin/system-status",
|
|
path: "/admin/system-status",
|
|
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
|
|
},
|
|
{
|
|
name: "/admin/users",
|
|
path: "/admin/users",
|
|
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
|
|
},
|
|
];
|
|
|
|
test.describe("axe-core public surfaces", () => {
|
|
for (const theme of THEMES) {
|
|
test(`theme=${theme} /auth/login`, async ({ page }) => {
|
|
await useExplicitTheme(page, theme);
|
|
await page.goto("/auth/login", { waitUntil: "domcontentloaded" });
|
|
const blocking = await scanPaintedState(
|
|
page,
|
|
theme,
|
|
"/auth/login",
|
|
page.getByRole("main"),
|
|
);
|
|
expectNoViolations(theme, "/auth/login", blocking);
|
|
});
|
|
}
|
|
});
|
|
|
|
test.describe("axe-core authenticated route and state matrix", () => {
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
for (const theme of THEMES) {
|
|
test(`theme=${theme} routes: /insights, metric, workouts list/detail, /coach`, async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
await useExplicitTheme(page, theme);
|
|
await installA11yMocks(page);
|
|
const blocking: AxeViolation[] = [];
|
|
for (const routeCase of INSIGHTS_ROUTES) {
|
|
blocking.push(...(await visitAndScan(page, theme, routeCase)));
|
|
}
|
|
expectNoViolations(theme, "insights route matrix", blocking);
|
|
});
|
|
|
|
test(`theme=${theme} routes: /measurements, /mood, /medications, /labs, /documents`, async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
await useExplicitTheme(page, theme);
|
|
await installA11yMocks(page);
|
|
const blocking: AxeViolation[] = [];
|
|
for (const routeCase of RECORD_ROUTES) {
|
|
blocking.push(...(await visitAndScan(page, theme, routeCase)));
|
|
}
|
|
expectNoViolations(theme, "record route matrix", blocking);
|
|
});
|
|
|
|
test(`theme=${theme} routes: retained baseline + /admin/app-logs and /admin/backups`, async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(150_000);
|
|
await useExplicitTheme(page, theme);
|
|
await installA11yMocks(page);
|
|
const blocking: AxeViolation[] = [];
|
|
for (const routeCase of ADMIN_AND_BASELINE_ROUTES) {
|
|
blocking.push(...(await visitAndScan(page, theme, routeCase)));
|
|
}
|
|
expectNoViolations(theme, "admin and retained route matrix", blocking);
|
|
});
|
|
|
|
test(`theme=${theme} open states: OCR picker, Coach evidence chips, medication dialog, document detail sheet`, async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(120_000);
|
|
await useExplicitTheme(page, theme);
|
|
await installA11yMocks(page);
|
|
const blocking: AxeViolation[] = [];
|
|
|
|
await test.step(`${theme} /labs open OCR picker dialog`, async () => {
|
|
await page.goto("/labs", { waitUntil: "domcontentloaded" });
|
|
await expect(page.getByText("A11y LDL").first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
await page
|
|
.locator('#main-content [data-slot="dropdown-menu-trigger"]')
|
|
.first()
|
|
.click();
|
|
await page.getByRole("menuitem").first().click();
|
|
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
|
|
await expect(sheet.locator('input[type="file"]')).toHaveCount(1);
|
|
blocking.push(
|
|
...(await scanPaintedState(
|
|
page,
|
|
theme,
|
|
"/labs open OCR picker",
|
|
sheet,
|
|
)),
|
|
);
|
|
});
|
|
|
|
await test.step(`${theme} /coach open evidence disclosure and source chips`, async () => {
|
|
await page.goto(`/coach?c=${A11Y_CONVERSATION_ID}`, {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
const evidence = page.locator('[data-slot="coach-evidence"]').first();
|
|
await expect(evidence).toBeVisible({ timeout: 15_000 });
|
|
await evidence.locator("summary").click();
|
|
await expect(evidence).toHaveAttribute("open", "");
|
|
await expect(
|
|
evidence.locator('[data-slot="coach-source-chips"]'),
|
|
).toBeVisible();
|
|
blocking.push(
|
|
...(await scanPaintedState(
|
|
page,
|
|
theme,
|
|
"/coach open evidence disclosure and source chips",
|
|
evidence,
|
|
)),
|
|
);
|
|
});
|
|
|
|
await test.step(`${theme} /medications open representative wizard dialog`, async () => {
|
|
await page.goto("/medications", { waitUntil: "domcontentloaded" });
|
|
await page
|
|
.locator('#main-content [data-slot="dropdown-menu-trigger"]')
|
|
.first()
|
|
.click();
|
|
await page.getByRole("menuitem").last().click();
|
|
const dialog = page.locator('[data-slot="medication-wizard-dialog"]');
|
|
blocking.push(
|
|
...(await scanPaintedState(
|
|
page,
|
|
theme,
|
|
"/medications open wizard dialog",
|
|
dialog,
|
|
)),
|
|
);
|
|
});
|
|
|
|
await test.step(`${theme} /documents compact detail sheet and summary panel`, async () => {
|
|
await page.goto(`/documents?view=compact&doc=${A11Y_DOCUMENT_ID}`, {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
|
|
await expect(sheet).toBeVisible({ timeout: 15_000 });
|
|
await sheet.getByRole("button", { name: /summari/i }).click();
|
|
const summary = sheet.locator('[data-slot="document-summary-panel"]');
|
|
await expect(summary).toContainText("deterministic summary", {
|
|
timeout: 15_000,
|
|
});
|
|
blocking.push(
|
|
...(await scanPaintedState(
|
|
page,
|
|
theme,
|
|
"/documents compact detail sheet and summary panel",
|
|
sheet,
|
|
)),
|
|
);
|
|
});
|
|
expectNoViolations(theme, "open state matrix", blocking);
|
|
});
|
|
}
|
|
|
|
test("skip-link does not block logo click outside focus", async ({
|
|
page,
|
|
viewport,
|
|
}) => {
|
|
test.skip(
|
|
(viewport?.width ?? 0) < 768,
|
|
"desktop sidebar logo is hidden on mobile by design",
|
|
);
|
|
|
|
await installA11yMocks(page);
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
const logoLink = page
|
|
.locator("a[href='/']")
|
|
.filter({ hasText: "HealthLog" })
|
|
.first();
|
|
await expect(logoLink).toBeVisible();
|
|
|
|
const logoBox = await logoLink.boundingBox();
|
|
expect(logoBox).not.toBeNull();
|
|
if (!logoBox) return;
|
|
|
|
await logoLink.click({
|
|
position: { x: logoBox.width / 2, y: logoBox.height / 2 },
|
|
});
|
|
await page.waitForURL(/\/$/);
|
|
});
|
|
});
|