mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The list hook already returned the canonical total and the page threw it away, so the surface never said how much history it was showing. Render it as a meta line above the rows. The route and the hook already accepted a sport, with nothing to reach it. Add the chip row. Its vocabulary comes from the unfiltered read, which is the same query the list itself runs when no sport is chosen, so the row costs no extra request and choosing a sport can never empty the row it was chosen from. A filtered view falls outside the server prefetch and pays one client fetch; a filter that matches nothing says so instead of borrowing the first-run empty state.
936 lines
26 KiB
TypeScript
936 lines
26 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,
|
|
dayKey: "2026-07-20",
|
|
previousWorkoutId: null,
|
|
};
|
|
|
|
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 },
|
|
}),
|
|
);
|
|
|
|
// The workout detail page's day context reads the day's pulse shape and
|
|
// the night the person woke from into it.
|
|
await page.route("**/api/insights/pulse/intraday*", (route) =>
|
|
fulfilJson(route, {
|
|
dateKey: "2026-07-20",
|
|
bucketMinutes: 10,
|
|
series: [
|
|
{ startMinute: 420, mean: 62, count: 6, min: 58, max: 70 },
|
|
{ startMinute: 430, mean: 118, count: 6, min: 96, max: 141 },
|
|
],
|
|
baseline: 58,
|
|
baselineSource: "resting",
|
|
tension: null,
|
|
resolution: "dense",
|
|
}),
|
|
);
|
|
|
|
await page.route("**/api/sleep/night*", (route) =>
|
|
fulfilJson(route, {
|
|
night: "2026-07-20",
|
|
main: {
|
|
night: "2026-07-20",
|
|
source: "APPLE_HEALTH",
|
|
start: "2026-07-19T22:10:00.000Z",
|
|
end: "2026-07-20T05:45:00.000Z",
|
|
asleepMinutes: 415,
|
|
inBedMinutes: 455,
|
|
awakeMinutes: 40,
|
|
awakenings: 2,
|
|
reconstructed: false,
|
|
stages: {},
|
|
segments: [],
|
|
sourceDiscrepancy: null,
|
|
},
|
|
naps: [],
|
|
}),
|
|
);
|
|
|
|
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(/\/$/);
|
|
});
|
|
});
|