Wait for content the page owns before scanning it

Several accessibility scans ran against a page that had not finished
arriving, so axe reported violations the app does not have. The worst of
them was the medications case, which waited for a level-one heading and
then checked that a level-one heading exists. A page that really lost its
heading would have waited out the timeout under a misleading name instead
of failing the rule, which makes it a check that cannot fail rather than a
flaky one.

The filter rail was the other bad sentinel. It renders beside the loading
silhouettes rather than after them, so it was on screen while the rows were
still grey rectangles. Under an 8x CPU throttle the rail and the loading
marker resolved in the same 40ms poll and the rows a poll later.

Every case now gates on a marker the real component carries in its
resolved, non-empty branch, and on a component other than the one the scan
asserts. Both the desktop table and the mobile card list carry the same
marker so the wait holds at any viewport. No sentinel names a heading, the
app frame, a busy marker or the filter rail any more.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 18:50:31 +02:00
1 parent 210380f46d
commit 47e50ece39
10 files changed
+153 -31

No files matched your search

+83 -20
View File
@@ -11,6 +11,30 @@ type AxeViolation = Awaited<
type Theme = "light" | "dark";
/**
* One scan target: where to go, and what has to be on screen before axe is
* allowed to look.
*
* The `painted` locator is the whole contract of this file. A scan that runs
* against a page whose content has not arrived reports violations the app does
* not have — "page must have a level-one heading" against a page that has one,
* a moment later — and that reads as a product defect until somebody re-runs
* it. Two rules keep the sentinel honest, and both were learned from a case
* that broke them:
*
* 1. It must name something the PAGE renders once its data is there, never
* something the shell or a skeleton also carries. `#main-content` is the
* app frame and exists before the route paints anything; `[role="status"]`
* is on every busy placeholder; a filter rail renders next to the loading
* silhouettes it filters. Each of those is a coin flip, not a wait.
* 2. It must not be the thing the scan asserts. Waiting for `<h1>` and then
* letting axe check that an `<h1>` exists is a check that cannot fail —
* strictly worse than the flake, because a page that really lost its
* heading would wait out the timeout under a misleading name instead of
* failing the rule. Every case here gates on content that lives in a
* different component from the heading, so dropping the heading still
* fails the scan.
*/
type RouteCase = {
name: string;
path: string;
@@ -670,9 +694,7 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
// one), so that selector matched the loading tree the server sends before
// the page client has mounted anything. On a loaded runner the scan then
// landed on a document whose only heading had not rendered yet and blamed
// the app for a missing `<h1>` it does have. Same rule as the overview
// case above: a sentinel must prove the content, not the placeholder
// standing in for it.
// the app for a missing `<h1>` it does have.
name: "/insights/workouts list",
path: "/insights/workouts",
painted: (page) =>
@@ -700,29 +722,41 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
const RECORD_ROUTES: readonly RouteCase[] = [
{
// `filter-bar` is a sibling of the loading silhouettes, not their
// successor — the rail renders above the list in every branch, so it was
// on screen while the rows were still `measurement-list-loading`
// rectangles. The rows wrapper exists only in the resolved, non-empty
// branch, and both the desktop table and the mobile card list carry it.
name: "/measurements",
path: "/measurements",
painted: (page) => page.locator('[data-slot="filter-bar"]'),
painted: (page) => page.locator('[data-slot="measurement-rows"]:visible'),
},
{
// Same shape as /measurements, and the one this was measured on: under
// an 8× CPU throttle `filter-bar` and `mood-list-loading` resolved in the
// same 40 ms poll, the rows a poll later.
name: "/mood",
path: "/mood",
painted: (page) => page.locator('[data-slot="filter-bar"]'),
painted: (page) => page.locator('[data-slot="mood-rows"]:visible'),
},
{
// Was `getByRole("heading", { level: 1 })` — the axe rule's own query
// used as its own precondition. The card carries the mocked medication's
// id; `MedicationCardSkeleton` carries no id at all.
name: "/medications",
path: "/medications",
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
painted: (page) => page.locator("[data-medication-id]").first(),
},
{
name: "/labs",
path: "/labs",
painted: (page) => page.getByText("A11y LDL").first(),
painted: (page) => page.locator('[data-slot="lab-list"]'),
},
{
name: "/documents compact panel",
path: "/documents?view=compact",
painted: (page) => page.getByText("A11y blood panel").first(),
painted: (page) =>
page.locator(`[data-document-id="${A11Y_DOCUMENT_ID}"]`).first(),
},
];
@@ -730,40 +764,55 @@ const ADMIN_AND_BASELINE_ROUTES: readonly RouteCase[] = [
{
name: "/admin/app-logs",
path: "/admin/app-logs",
painted: (page) => page.getByText("a11y.paint").first(),
painted: (page) => page.locator('[data-slot="app-log-rows"] tr').first(),
},
{
name: "/admin/backups",
path: "/admin/backups",
painted: (page) =>
page.getByText("a11y-backup-user", { exact: true }).filter({
visible: true,
}),
painted: (page) => page.locator('[data-slot="backup-rows"]:visible'),
},
{
// This one is why the whole file was swept. `#main-content` is the
// authenticated shell's `<main>`: it is in the first HTML on every route,
// it wraps whatever the segment happens to be streaming, and on `/` that
// is `app/loading.tsx` — a silhouette with no heading in it. The scan was
// therefore free to run against a document that legitimately had no `<h1>`
// yet and report the app as missing one. Measured under an 8× CPU
// throttle: `#main-content` at 759 ms, the tile strip at 4896 ms. The
// strip is the dashboard's own content and has a separate
// `dashboard-tile-strip-skeleton` marker for its loading phase, so the two
// states cannot be confused for one another.
name: "/ dashboard",
path: "/",
painted: (page) => page.locator("#main-content"),
painted: (page) => page.locator('[data-slot="dashboard-tile-strip"]'),
},
{
// The settings heading belongs to `<SettingsShell>`, i.e. the chrome —
// waiting on it says nothing about the section body below. The
// connections panel is that body. This route has no data-gated content of
// its own: every provider card renders immediately and fills in its
// status pill later, so the panel's presence is the honest ceiling here.
name: "/settings/integrations",
path: "/settings/integrations",
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
painted: (page) => page.locator('[data-slot="connections-panel"]'),
},
{
// The heading comes from `<AdminShell>` and is on screen while every card
// under it is still a spinner. The audit list is the last card on the
// overview and only exists once its query has answered.
name: "/admin overview",
path: "/admin",
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
painted: (page) => page.locator('[data-slot="admin-audit-rows"]'),
},
{
name: "/admin/system-status",
path: "/admin/system-status",
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
painted: (page) => page.locator('[data-slot="system-status-grid"]'),
},
{
name: "/admin/users",
path: "/admin/users",
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
painted: (page) => page.locator('[data-slot="admin-user-rows"]:visible'),
},
];
@@ -776,7 +825,11 @@ test.describe("axe-core public surfaces", () => {
page,
theme,
"/auth/login",
page.getByRole("main"),
// Not `getByRole("main")`: the public shell renders that landmark
// around whatever the segment is streaming, so it is satisfied
// before the login card itself exists. The action block is the
// page's own.
page.locator('[data-slot="login-actions"]'),
);
expectNoViolations(theme, "/auth/login", blocking);
});
@@ -836,7 +889,7 @@ test.describe("axe-core authenticated route and state matrix", () => {
await test.step(`${theme} /labs open OCR picker dialog`, async () => {
await page.goto("/labs", { waitUntil: "domcontentloaded" });
await expect(page.getByText("A11y LDL").first()).toBeVisible({
await expect(page.locator('[data-slot="lab-list"]')).toBeVisible({
timeout: 15_000,
});
await page
@@ -879,6 +932,11 @@ test.describe("axe-core authenticated route and state matrix", () => {
await test.step(`${theme} /medications open representative wizard dialog`, async () => {
await page.goto("/medications", { waitUntil: "domcontentloaded" });
// axe scans the whole document, dialog and page behind it alike, so
// the list has to have arrived before the dialog opens on top of it.
await expect(page.locator("[data-medication-id]").first()).toBeVisible({
timeout: 15_000,
});
await page
.locator('#main-content [data-slot="dropdown-menu-trigger"]')
.first()
@@ -899,6 +957,11 @@ test.describe("axe-core authenticated route and state matrix", () => {
await page.goto(`/documents?view=compact&doc=${A11Y_DOCUMENT_ID}`, {
waitUntil: "domcontentloaded",
});
// Same reason as the medication dialog: the vault behind the sheet is
// in the scan, so it has to be past `documents-loading` first.
await expect(
page.locator(`[data-document-id="${A11Y_DOCUMENT_ID}"]`).first(),
).toBeVisible({ timeout: 15_000 });
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible({ timeout: 15_000 });
await sheet.getByRole("button", { name: /summari/i }).click();
+8 -1
View File
@@ -363,7 +363,14 @@ export default function LoginPage() {
/>
</div>
) : (
<div className="mt-8 space-y-4">
<div
// The login card's own action block. `<main>` around it belongs
// to the shell and is on screen before this page renders at all,
// so it is this marker, not the landmark, that says the form has
// arrived.
data-slot="login-actions"
className="mt-8 space-y-4"
>
{oidcStatus?.enabled && (
<Button
onClick={handleOidcLogin}
@@ -229,7 +229,13 @@ export function AppLogPreviewSection() {
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{/* `app-log-rows` names the read events. The branch above it
is a spinner and the one below an empty state, so the
marker exists only once the buffer has actually answered. */}
<tbody
data-slot="app-log-rows"
className="divide-border divide-y"
>
{events.map((event, i) => {
const actionLabel =
event.action?.name ??
+7 -1
View File
@@ -658,7 +658,12 @@ export function BackupsSection() {
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{/* `backup-rows` names the read rows. The three branches above
are a spinner, a load error and an empty state, so the
marker cannot stand for a page that has not answered yet.
The narrow-layout list below carries it too, so a wait on
it holds at any viewport. */}
<tbody data-slot="backup-rows" className="divide-border divide-y">
{rows.map((row, i) => (
<tr key={row.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
<td className="px-3 py-2 font-medium">{row.username}</td>
@@ -713,6 +718,7 @@ export function BackupsSection() {
stay reachable on a phone instead of scrolling off-screen. */}
<ul
className="space-y-2 md:hidden"
data-slot="backup-rows"
data-testid="admin-backups-mobile-list"
>
{rows.map((row) => (
@@ -99,7 +99,10 @@ export function RecentAuditPreview() {
title={t("admin.overview.auditEmpty")}
/>
) : (
<ul className="divide-border divide-y">
// `admin-audit-rows` names the read entries. The branches above
// are a spinner, a load error and an empty state, so the marker
// stands for a card that has actually answered.
<ul data-slot="admin-audit-rows" className="divide-border divide-y">
{entries.map((entry) => {
const isFailed = entry.action === "auth.login.failed";
// F-31 (v1.4.19): each row links into the full
@@ -141,7 +141,13 @@ export function SystemStatusSection() {
description={t("admin.systemStatusDescription")}
/>
{status ? (
<div className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4">
<div
// `system-status-grid` names the answered snapshot. The two
// branches below it are a load error and a spinner, so the
// marker cannot stand for a section still waiting on the read.
data-slot="system-status-grid"
className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4"
>
<StatusItem
icon={Database}
label={t("admin.database")}
@@ -354,7 +354,15 @@ export function UserManagementSection() {
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{/* `admin-user-rows` names the read accounts. The section
renders an empty state instead when the list is empty and
nothing at all while the query is in flight, so the marker
stands for answered data. The mobile card list below
carries it too, so a wait on it holds at any viewport. */}
<tbody
data-slot="admin-user-rows"
className="divide-border divide-y"
>
{filteredUsers.map((u, i) => (
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
<td className="px-3 py-2 font-medium">{u.username}</td>
@@ -392,6 +400,7 @@ export function UserManagementSection() {
is hidden behind a horizontal scroll. */}
<ul
className="mt-4 space-y-2 md:hidden"
data-slot="admin-user-rows"
data-testid="admin-users-mobile-list"
>
{filteredUsers.map((u) => (
+5 -1
View File
@@ -184,7 +184,10 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
// (#40); the card/tile view is the alternative the settings toggle selects.
if (prefs.view === "list") {
return (
<div className="space-y-3">
// `lab-list` marks the read results in both view shapes. The loading
// branch above carries `lab-list-loading` instead, so the marker
// cannot stand for a silhouette.
<div data-slot="lab-list" className="space-y-3">
{truncated ? (
<p className="text-muted-foreground text-xs">
{t("labs.showingLatestOf", { shown, total })}
@@ -259,6 +262,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
// control on the tile; the trash icon lives only on the value detail view.
return (
<ul
data-slot="lab-list"
className={cn(
"grid list-none gap-4 p-0",
// A lone lab spans the full row rather than orphaning half of it;
@@ -964,7 +964,16 @@ export function MeasurementList({
<>
{/* Desktop table — rendered only when not mobile (v1.28.42 H3). */}
{!isMobile && (
<div className="bg-card border-border hidden overflow-hidden rounded-lg border md:block">
<div
// `measurement-rows` marks the READ rows, not the list frame:
// the filter rail above renders next to the loading
// silhouettes as well, so it proves nothing about the data
// having arrived. This wrapper exists only in the resolved,
// non-empty branch, and the mobile list below carries the
// same marker so a wait on it holds at any viewport.
data-slot="measurement-rows"
className="bg-card border-border hidden overflow-hidden rounded-lg border md:block"
>
<Table>
<TableHeader>
<TableRow>
@@ -1216,7 +1225,7 @@ export function MeasurementList({
{/* Mobile list — rendered only when mobile (v1.28.42 H3). */}
{isMobile && (
<div className="space-y-2 md:hidden">
<div data-slot="measurement-rows" className="space-y-2 md:hidden">
{data.measurements.map((m) => {
const Icon = TYPE_ICONS[m.type];
const isGrouped =
+11 -2
View File
@@ -629,7 +629,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
) : (
<>
{/* Desktop table */}
<div className="bg-card border-border hidden overflow-hidden rounded-lg border md:block">
<div
// `mood-rows` marks the READ entries, not the list frame: the
// filter rail above renders next to the loading silhouettes
// too, so it proves nothing about the data having arrived.
// This wrapper exists only in the resolved, non-empty branch,
// and the mobile list below carries the same marker so a wait
// on it holds at any viewport.
data-slot="mood-rows"
className="bg-card border-border hidden overflow-hidden rounded-lg border md:block"
>
<Table>
<TableHeader>
<TableRow>
@@ -760,7 +769,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
`data-testid="mood-row"` hook is what the Playwright Pixel-5
guard at `e2e/mood-card-mobile.spec.ts` queries to assert
"exactly one occurrence of the score per row". */}
<div className="space-y-2 md:hidden">
<div data-slot="mood-rows" className="space-y-2 md:hidden">
{data.entries.map((entry) => {
const isSelected = selectedIds.has(entry.id);
return (