From 0cdfb97a451fd14047b83d1e560600e6200a0921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 23:46:43 +0200 Subject: [PATCH] test: cover sharing accessibility states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared-record accessibility suite had four tests for five states: success and empty shared one, and neither of them ever entered an empty state. A browser run reports what it ran and never what it did not, so a state nobody wrote a test for produced exactly the same output as a state that passed. The five states are enumerated in a fixture, each test names its state, and a fast guard reads the spec back against the list — a state that loses its test, its anchor, or one of its proofs now fails in the ordinary gate rather than at the point somebody notices the spinner is silent. Every state runs four proofs: an axe scan, the document reading order, either a keyboard reach with a visible focus indicator or a live-region announcement for the one state with nothing to press, and, where the copy is something a person has to act on, that it renders as content rather than as muted meta and clears the contrast floor. The retry control on the read-failure card gained a stable slot so the keyboard proof targets the affordance rather than its wording. The eight release journeys are enumerated in the same fixture. --- ...v137-sharing-managed-profiles-a11y.spec.ts | 280 +++++++++++++++++- .../sharing-accessibility-states.test.tsx | 230 ++++++++++++++ src/components/ui/query-error-card.tsx | 11 +- tests/fixtures/v137/e2e-journeys.ts | 176 +++++++++++ 4 files changed, 686 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/sharing-accessibility-states.test.tsx create mode 100644 tests/fixtures/v137/e2e-journeys.ts diff --git a/e2e/v137-sharing-managed-profiles-a11y.spec.ts b/e2e/v137-sharing-managed-profiles-a11y.spec.ts index 6396eba29..87c3e3128 100644 --- a/e2e/v137-sharing-managed-profiles-a11y.spec.ts +++ b/e2e/v137-sharing-managed-profiles-a11y.spec.ts @@ -4,13 +4,190 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { SCOPE_A11Y_STORAGE_STATE_PATH } from "./setup/test-helpers"; -async function scan(page: Page) { +/** + * The five states of a shared record, each proved usable rather than merely + * reachable. + * + * Accessibility work concentrates on the state where everything worked, + * because that is the state the person building the screen is looking at. The + * other four are where somebody who cannot see the screen is most stranded: a + * spinner nobody announces, an error card that reads as an empty page, a + * refusal whose only way out is a control no keyboard reaches. This file had + * four tests for five states, success and empty shared one, and neither of + * them entered an empty state at all. + * + * Each test now names its state in brackets, enters it, and runs four proofs: + * an axe scan, the reading order, either a keyboard reach or an announcement, + * and — where the state has copy somebody has to act on — that the copy is + * content rather than muted meta. + * + * `src/__tests__/sharing-accessibility-states.test.tsx` reads this file back + * against `tests/fixtures/v137/e2e-journeys.ts` and fails if a state loses its + * test or one of its proofs. A browser suite reports what it ran, never what + * it did not, so the enumeration lives where a fast test can check it. + */ + +/** WCAG 2.1 AA, as the rest of the suite scans it. */ +async function scanForViolations(page: Page) { const result = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) .analyze(); expect(result.violations).toEqual([]); } +/** + * The named elements appear in this order in the document. + * + * Reading order is what a screen reader follows and what a keyboard walks, and + * CSS can put a control visually above an explanation while the DOM keeps it + * below. Every selector must resolve: an absent element would otherwise make + * an order of one trivially correct, which is the shape of check that passes + * for the whole life of a regression. + */ +async function expectReaderOrder(page: Page, selectors: string[]) { + expect(selectors.length).toBeGreaterThan(1); + const positions = await page.evaluate((list) => { + const all = Array.from(document.querySelectorAll("*")); + return list.map((selector) => { + const element = document.querySelector(selector); + return element ? all.indexOf(element) : -1; + }); + }, selectors); + + const missing = selectors.filter((_, i) => positions[i] === -1); + expect(missing, `not on the page: ${missing.join(", ")}`).toEqual([]); + expect(positions, `reading order is ${selectors.join(" then ")}`).toEqual( + [...positions].sort((a, b) => a - b), + ); +} + +/** + * Tab from the top of the document until focus lands on the target. + * + * Real Tab presses rather than `element.focus()`, for two reasons: a control + * that is present but not in the tab order fails here, which is the defect + * this proves the absence of; and `:focus-visible` only applies to keyboard + * focus, so the ring can be asserted at the same time. The invitation form's + * own radios shipped `sr-only` inputs whose focus ring landed on a zero-size + * box — the whole class of defect is invisible to `element.focus()`. + */ +async function expectKeyboardReach(page: Page, selector: string) { + await expect(page.locator(selector).first()).toBeVisible(); + await page.evaluate(() => { + (document.activeElement as HTMLElement | null)?.blur(); + document.body.focus(); + }); + + const MAX_TABS = 120; + let reached = false; + for (let i = 0; i < MAX_TABS && !reached; i += 1) { + await page.keyboard.press("Tab"); + reached = await page.evaluate( + (sel) => document.activeElement?.matches(sel) ?? false, + selector, + ); + } + expect(reached, `${selector} is not reachable by Tab`).toBe(true); + + // And the person can see where they are. A focusable control with no + // visible indicator is WCAG 2.4.7 and is exactly what an `sr-only` input + // produces. + const outlined = await page.evaluate((sel) => { + const element = document.querySelector(sel); + if (!element) return false; + if (element.matches(":focus-visible")) return true; + // Some rings are painted by an ancestor via `has-[:focus-visible]`. + return Boolean(element.closest(":has(:focus-visible)")); + }, selector); + expect(outlined, `${selector} takes focus without showing it`).toBe(true); +} + +/** + * The state says out loud that it is happening. + * + * For the one state with nothing to press. A spinner is a picture; without a + * live region and a label it is a blank page to a screen reader, and the hold + * before the active record resolves is precisely when somebody must not be + * told nothing. + */ +async function expectAnnounced(page: Page, selector: string) { + const region = page.locator(selector).first(); + await expect(region).toBeVisible(); + await expect(region).toHaveAttribute("role", /status|alert/); + const label = await region.evaluate((node) => + (node.textContent ?? "").trim(), + ); + expect(label.length, `${selector} announces nothing`).toBeGreaterThan(0); +} + +/** + * Copy somebody has to act on is content, not meta. + * + * Two halves, and both are needed. The class check is the design standard — + * UI-STANDARDS §3 reserves `text-muted-foreground` for meta, and consent or + * safety text rendered as meta is text people skip. The ratio check is the + * measurable one: it walks up for an opaque background and asserts the AA + * floor for body text, so a future token change that keeps the class and + * loses the contrast still fails. + */ +async function expectContentContrast(page: Page, selector: string) { + const result = await page.evaluate((sel) => { + const element = document.querySelector(sel); + if (!element) return null; + + const parse = (value: string): [number, number, number, number] | null => { + const nums = value.match(/[\d.]+/g); + if (!nums || nums.length < 3) return null; + return [ + Number(nums[0]), + Number(nums[1]), + Number(nums[2]), + nums.length > 3 ? Number(nums[3]) : 1, + ]; + }; + + const luminance = ([r, g, b]: [number, number, number, number]): number => { + const channel = (c: number) => { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); + }; + + const foreground = parse(getComputedStyle(element).color); + let node: Element | null = element; + let background: [number, number, number, number] | null = null; + while (node && !background) { + const parsed = parse(getComputedStyle(node).backgroundColor); + if (parsed && parsed[3] > 0) background = parsed; + node = node.parentElement; + } + if (!foreground || !background) return null; + + const light = Math.max(luminance(foreground), luminance(background)); + const dark = Math.min(luminance(foreground), luminance(background)); + + // The class list of the element and of any descendant carrying the text: + // the copy is sometimes a wrapper's child. + const classes = [element, ...Array.from(element.querySelectorAll("*"))] + .map((n) => n.className) + .filter((c): c is string => typeof c === "string") + .join(" "); + + return { ratio: (light + 0.05) / (dark + 0.05), classes }; + }, selector); + + expect(result, `${selector} is not on the page`).not.toBeNull(); + expect( + result!.classes, + `${selector} renders material copy in the muted token`, + ).not.toContain("text-muted-foreground"); + expect( + result!.ratio, + `${selector} is below the AA floor for body text`, + ).toBeGreaterThanOrEqual(4.5); +} + async function openProfileRecord(page: Page) { await page.goto("/"); await page.getByRole("button", { name: "User menu" }).first().click(); @@ -34,30 +211,81 @@ test.describe("shared record accessibility states", () => { test.describe.configure({ mode: "serial" }); test.use({ storageState: SCOPE_A11Y_STORAGE_STATE_PATH }); - test("success and empty shared profile states have no WCAG violations", async ({ + test("[success] the shared profile is scannable, ordered and escapable", async ({ page, }) => { await openProfileRecord(page); await expect(page.locator('[data-slot="profile-summary"]')).toBeVisible(); - await scan(page); + + await scanForViolations(page); + // Whose record this is comes before what is in it. A reader who meets the + // record before the banner has already read somebody else's health data + // believing it is their own. + await expectReaderOrder(page, [ + '[data-slot="shared-record-banner"]', + '[data-slot="shared-record-banner-context"]', + '[data-slot="profile-summary"]', + ]); + await expectContentContrast( + page, + '[data-slot="shared-record-banner-context"]', + ); + await expectKeyboardReach(page, '[data-slot="shared-record-banner-exit"]'); + await leaveRecord(page); }); - test("the capability hydration loading state has no WCAG violations", async ({ + test("[empty] nothing shared yet reads as an answer, not a failure", async ({ + page, + }) => { + // The delegate's own shared-access panel. They hold grants and have given + // none, so the "who can open my record" card is genuinely empty — which is + // a state the suite claimed to cover and never entered. + await page.goto("/settings/access"); + await expect( + page.locator('[data-slot="grants-given-card"] [data-slot="empty-state"]'), + ).toBeVisible(); + + await scanForViolations(page); + await expectReaderOrder(page, [ + '[data-slot="grant-invite-card"]', + '[data-slot="grants-given-card"]', + '[data-slot="grants-given-card"] [data-slot="empty-state"]', + ]); + await expectContentContrast( + page, + '[data-slot="grants-given-card"] [data-slot="empty-state"] p', + ); + // The way out of an empty state is the act that fills it. + await expectKeyboardReach(page, '[data-slot="grant-invite-identifier"]'); + }); + + test("[loading] the hold before the active record is known is announced", async ({ page, }) => { await page.route("**/api/auth/me", async (route) => { - await new Promise((resolve) => setTimeout(resolve, 250)); + await new Promise((resolve) => setTimeout(resolve, 1500)); await route.continue(); }); await page.goto("/measurements"); await expect( page.locator('[data-slot="record-scope-hydration-gate"]'), ).toBeVisible(); - await scan(page); + + await scanForViolations(page); + await expectAnnounced(page, '[data-slot="record-scope-hydration-gate"]'); + // The label lives inside the region, so a reader that reaches the region + // reaches the sentence. A label rendered as a sibling would be announced + // separately or not at all. + await expectReaderOrder(page, [ + '[data-slot="record-scope-hydration-gate"]', + '[data-slot="record-scope-hydration-gate"] .sr-only', + ]); + + await page.unroute("**/api/auth/me"); }); - test("the shared profile read error state has no WCAG violations", async ({ + test("[error] a failed read says so and hands the retry to a keyboard", async ({ page, }) => { await page.route("**/api/profile/summary**", (route) => @@ -65,11 +293,23 @@ test.describe("shared record accessibility states", () => { ); await openProfileRecord(page); await expect(page.locator('[data-slot="query-error-card"]')).toBeVisible(); - await scan(page); + + await scanForViolations(page); + await expectReaderOrder(page, [ + '[data-slot="shared-record-banner"]', + '[data-slot="query-error-card"]', + '[data-slot="query-error-retry"]', + ]); + // The sentence that says the read failed. Muted here would put "this did + // not load" in the same register as a timestamp. + await expectContentContrast(page, '[data-slot="query-error-card"] p'); + await expectKeyboardReach(page, '[data-slot="query-error-retry"]'); + + await page.unroute("**/api/profile/summary**"); await leaveRecord(page); }); - test("the shared-record refusal state has no WCAG violations", async ({ + test("[refusal] a surface sharing does not cover keeps a way back", async ({ page, }) => { await openProfileRecord(page); @@ -77,7 +317,27 @@ test.describe("shared record accessibility states", () => { await expect( page.locator('[data-slot="shared-record-unavailable"]'), ).toBeVisible(); - await scan(page); + + await scanForViolations(page); + // The explanation before the button. A reader who meets "leave this + // record" first is being offered an act before being told why. + await expectReaderOrder(page, [ + '[data-slot="shared-record-unavailable"]', + '[data-slot="shared-record-unavailable"] p', + '[data-slot="shared-record-unavailable-leave"]', + ]); + await expectContentContrast( + page, + '[data-slot="shared-record-unavailable"] p', + ); + await expectKeyboardReach( + page, + '[data-slot="shared-record-unavailable-leave"]', + ); + await page.locator('[data-slot="shared-record-unavailable-leave"]').click(); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toHaveCount(0); }); }); diff --git a/src/__tests__/sharing-accessibility-states.test.tsx b/src/__tests__/sharing-accessibility-states.test.tsx new file mode 100644 index 000000000..907bd1a0a --- /dev/null +++ b/src/__tests__/sharing-accessibility-states.test.tsx @@ -0,0 +1,230 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + ACCESSIBILITY_STATES, + SHARING_ACCESSIBILITY_STATES, + V137_E2E_JOURNEYS, +} from "../../tests/fixtures/v137/e2e-journeys"; + +/** + * All five states are claimed by a browser test that really examines them. + * + * ## Why a fast test guards a browser suite + * + * A Playwright run reports what it ran, never what it did not. "The + * accessibility suite is green" and "the accessibility suite covers the error + * state" are different sentences, and only the first one is ever checked — + * a state nobody wrote a test for produces exactly the same output as a state + * that passes. Before this file the suite had four tests for five states, with + * success and empty sharing one and neither of them actually entering an empty + * one. + * + * So the states are enumerated in `tests/fixtures/v137/e2e-journeys.ts` and + * this file reads the spec back against the list. It cannot tell whether the + * assertions inside a test are any good. It can tell that each state has a + * test, that the test enters the state (there is an anchor, and the anchor is + * a slot the component tree really renders), and that the test performs each + * proof the state's own record says it needs — scan, reader order, and either + * a keyboard reach or an announcement. + * + * ## The trap this file is written around + * + * Every assertion here is over a MATCHED set, and an empty match set satisfies + * most of them silently: a spec path that no longer exists reads as a spec + * with no offending tests. Each leg therefore pins the size of what it + * matched — five states, five titles, eight journeys, a non-zero count of + * helper calls — and the parse is proved against a synthetic spec at the + * bottom, so the day the extractor stops seeing `test("…")` is the day this + * file fails rather than the day it goes quiet. + */ +const ROOT = process.cwd(); +const A11Y_SPEC = join(ROOT, "e2e/v137-sharing-managed-profiles-a11y.spec.ts"); + +/** The helper each state's test has to call, by what the state offers. */ +const SCAN = "scanForViolations"; +const READER_ORDER = "expectReaderOrder"; +const KEYBOARD = "expectKeyboardReach"; +const ANNOUNCED = "expectAnnounced"; +const CONTRAST = "expectContentContrast"; + +/** + * Split a spec into `title -> body`. + * + * Bodies are delimited by the next `test(` rather than by brace matching: a + * body containing a string with a brace in it is common, a body containing the + * literal `test(` is not. Exported and exercised against a synthetic spec + * below, because a splitter that returned an empty map would make every leg + * here pass by matching nothing. + */ +export function testBodies(source: string): Map { + const bodies = new Map(); + // `test.only(` and `test.skip(` are tests; `test.describe(` is not, and the + // synthetic spec below is what caught the first version treating it as one. + const opener = /\btest(?:\.(?!describe)\w+)*\(\s*"([^"]+)"/g; + const starts: Array<{ title: string; from: number }> = []; + let match: RegExpExecArray | null; + while ((match = opener.exec(source)) !== null) { + starts.push({ title: match[1], from: match.index }); + } + for (let i = 0; i < starts.length; i += 1) { + const to = i + 1 < starts.length ? starts[i + 1].from : source.length; + bodies.set(starts[i].title, source.slice(starts[i].from, to)); + } + return bodies; +} + +describe("the state parser sees what it looks for", () => { + const SYNTHETIC = ` + import { expect, test } from "./setup/test"; + + test.describe("a group", () => { + test("first — success", async ({ page }) => { + await scanForViolations(page); + }); + + test("second — refusal", async ({ page }) => { + await expectKeyboardReach(page, '[data-slot="x"]'); + }); + }); + `; + + it("splits a spec into its test bodies", () => { + const bodies = testBodies(SYNTHETIC); + expect([...bodies.keys()]).toEqual(["first — success", "second — refusal"]); + expect(bodies.get("first — success")).toContain(SCAN); + // And no body bleeds into the next one, which is what would make a single + // well-instrumented test satisfy the checks for every state. + expect(bodies.get("first — success")).not.toContain(KEYBOARD); + }); + + it("does not treat a describe block as a test", () => { + expect(testBodies(SYNTHETIC).has("a group")).toBe(false); + }); +}); + +describe("the five states are enumerated once each", () => { + it("lists exactly the five, with no state named twice", () => { + expect(ACCESSIBILITY_STATES).toHaveLength(5); + expect(SHARING_ACCESSIBILITY_STATES).toHaveLength(5); + expect(SHARING_ACCESSIBILITY_STATES.map((c) => c.state).sort()).toEqual( + [...ACCESSIBILITY_STATES].sort(), + ); + }); + + it("anchors every state on a slot the tree really renders", () => { + // A state can otherwise be added to the list with an anchor nothing emits, + // and the browser test would then wait for an element that cannot appear — + // which fails, but fails as a timeout that reads like flake rather than + // like a missing surface. + for (const c of SHARING_ACCESSIBILITY_STATES) { + const source = readFileSync(join(ROOT, c.rendersIn), "utf8"); + // The last `[data-slot="…"]` in a compound selector is the one the + // module has to emit; the prefix is context from an ancestor. + const slots = [...c.anchor.matchAll(/data-slot="([^"]+)"/g)].map( + (m) => m[1], + ); + expect(slots.length, c.anchor).toBeGreaterThan(0); + expect(source, `${c.rendersIn} does not emit ${c.anchor}`).toContain( + `data-slot="${slots[slots.length - 1]}"`, + ); + } + }); + + it("gives every state a way forward or says it has none", () => { + // Exactly one state may be without a control, and it is the one nobody can + // act in. A second `null` here would mean a dead end shipped as a state. + const dead = SHARING_ACCESSIBILITY_STATES.filter( + (c) => c.keyboardTarget === null, + ); + expect(dead.map((c) => c.state)).toEqual(["loading"]); + }); +}); + +describe("every state is claimed by a browser test that examines it", () => { + const spec = readFileSync(A11Y_SPEC, "utf8"); + const bodies = testBodies(spec); + + it("parses a non-zero number of tests out of the spec", () => { + // The pin the legs below rest on: an empty map satisfies "no state is + // missing a helper call" perfectly. + expect(bodies.size).toBeGreaterThanOrEqual( + SHARING_ACCESSIBILITY_STATES.length, + ); + }); + + for (const c of SHARING_ACCESSIBILITY_STATES) { + describe(c.state, () => { + const body = [...bodies.entries()].find(([title]) => + title.includes(`[${c.state}]`), + )?.[1]; + + it("has a test that names the state", () => { + expect( + body, + `no test title carries [${c.state}] in ${A11Y_SPEC}`, + ).toBeDefined(); + }); + + it("enters the state before it asserts anything about it", () => { + expect(body ?? "", c.anchor).toContain(c.anchor); + }); + + it("scans it and proves the reading order", () => { + expect(body ?? "").toContain(SCAN); + expect(body ?? "").toContain(READER_ORDER); + }); + + it( + c.keyboardTarget === null + ? "announces itself, having nothing to press" + : "puts its way forward within reach of a keyboard", + () => { + if (c.keyboardTarget === null) { + expect(body ?? "").toContain(ANNOUNCED); + expect(body ?? "").not.toContain(KEYBOARD); + } else { + expect(body ?? "").toContain(KEYBOARD); + expect(body ?? "").toContain(c.keyboardTarget); + } + }, + ); + + it( + c.contrastTarget === null + ? "has no visible copy to check" + : "renders its material copy as content, not as muted meta", + () => { + if (c.contrastTarget === null) { + expect(body ?? "").not.toContain(CONTRAST); + } else { + expect(body ?? "").toContain(CONTRAST); + expect(body ?? "").toContain(c.contrastTarget); + } + }, + ); + }); + } + + it("defines every helper it demands", () => { + // Otherwise the checks above are satisfied by a call to a function that + // does not exist, which typechecks nowhere this guard can see. + for (const helper of [SCAN, READER_ORDER, KEYBOARD, ANNOUNCED, CONTRAST]) { + expect(spec, helper).toMatch( + new RegExp(`(async\\s+function|const)\\s+${helper}\\b`), + ); + } + }); +}); + +describe("the eight release journeys are enumerated", () => { + it("names exactly eight, with distinct ids", () => { + expect(V137_E2E_JOURNEYS).toHaveLength(8); + expect(new Set(V137_E2E_JOURNEYS.map((j) => j.id)).size).toBe(8); + for (const journey of V137_E2E_JOURNEYS) { + expect(journey.claim.length, journey.id).toBeGreaterThan(40); + } + }); +}); diff --git a/src/components/ui/query-error-card.tsx b/src/components/ui/query-error-card.tsx index facb42628..0104b260b 100644 --- a/src/components/ui/query-error-card.tsx +++ b/src/components/ui/query-error-card.tsx @@ -48,7 +48,16 @@ export function QueryErrorCard({

{description}

) : null} {onRetry ? ( - diff --git a/tests/fixtures/v137/e2e-journeys.ts b/tests/fixtures/v137/e2e-journeys.ts new file mode 100644 index 000000000..37f4c118a --- /dev/null +++ b/tests/fixtures/v137/e2e-journeys.ts @@ -0,0 +1,176 @@ +/** + * What a browser has to prove about v1.37 sharing, enumerated. + * + * Two lists, and both exist for the same reason: a browser suite reports what + * it ran, never what it did not. A state nobody wrote a test for and a journey + * somebody quietly dropped both look like a green run. Naming them in one + * place that a fast test reads back turns "we cover the error state" from a + * claim in a summary into something that fails. + * + * Nothing here drives a browser. The enumeration is the contract; the spec is + * the implementation, and `src/__tests__/sharing-accessibility-states.test.ts` + * is what holds the two together. + */ + +/* -------------------------------------------------------------------------- */ +/* The five states */ +/* -------------------------------------------------------------------------- */ + +/** + * Every state a shared-record surface can be in, and all five have to be + * usable — not merely reachable. + * + * Accessibility work concentrates on the state where everything worked, + * because that is the state somebody building the screen is looking at. The + * other four are where a person who cannot see the screen is most stranded: a + * spinner nobody announces, an error card that reads as an empty page, a + * refusal whose only way out is a button no keyboard reaches. + */ +export const ACCESSIBILITY_STATES = [ + "success", + "empty", + "loading", + "error", + "refusal", +] as const; + +export type AccessibilityState = (typeof ACCESSIBILITY_STATES)[number]; + +export interface AccessibilityStateCase { + readonly state: AccessibilityState; + /** + * The `data-slot` that proves the state is actually on screen. + * + * A scan of a page that never entered the state passes trivially, so every + * case names the element that says it arrived, and the fast guard checks the + * component tree really renders that slot. + */ + readonly anchor: string; + /** The module that renders {@link anchor}, so a state cannot be invented. */ + readonly rendersIn: string; + /** + * The control keyboard focus must reach, or `null` where the state offers + * none at all. A loading state has nothing to press; every other state here + * has a way forward, and a way forward a keyboard cannot reach is not one. + */ + readonly keyboardTarget: string | null; + /** + * Copy the person has to read and act on, which must render as content + * rather than as muted meta (UI-STANDARDS §3). `null` where the state's only + * text is a screen-reader label, which has no visual contrast to check. + */ + readonly contrastTarget: string | null; + /** Why this state is on the list, in one line. */ + readonly claim: string; +} + +export const SHARING_ACCESSIBILITY_STATES = [ + { + state: "success", + anchor: '[data-slot="profile-summary"]', + rendersIn: "src/components/records/profile-summary.tsx", + keyboardTarget: '[data-slot="shared-record-banner-exit"]', + contrastTarget: '[data-slot="shared-record-banner-context"]', + claim: + "inside somebody else's record, the line saying whose it is and the way out are both usable", + }, + { + state: "empty", + anchor: '[data-slot="grants-given-card"] [data-slot="empty-state"]', + rendersIn: "src/components/ui/empty-state.tsx", + keyboardTarget: '[data-slot="grant-invite-identifier"]', + contrastTarget: + '[data-slot="grants-given-card"] [data-slot="empty-state"] p', + claim: + "nothing shared yet reads as an answer rather than as a page that failed to load", + }, + { + state: "loading", + anchor: '[data-slot="record-scope-hydration-gate"]', + rendersIn: "src/components/layout/record-scope-hydration-gate.tsx", + keyboardTarget: null, + contrastTarget: null, + claim: + "the hold before the active record is known is announced, not a silent blank", + }, + { + state: "error", + anchor: '[data-slot="query-error-card"]', + rendersIn: "src/components/ui/query-error-card.tsx", + keyboardTarget: '[data-slot="query-error-retry"]', + contrastTarget: '[data-slot="query-error-card"] p', + claim: "a failed read says so and offers the retry to a keyboard", + }, + { + state: "refusal", + anchor: '[data-slot="shared-record-unavailable"]', + rendersIn: "src/components/layout/shared-record-unavailable.tsx", + keyboardTarget: '[data-slot="shared-record-unavailable-leave"]', + contrastTarget: '[data-slot="shared-record-unavailable"] p', + claim: + "a surface sharing does not cover explains itself and keeps a way back to one's own record", + }, +] as const satisfies readonly AccessibilityStateCase[]; + +/* -------------------------------------------------------------------------- */ +/* The eight journeys */ +/* -------------------------------------------------------------------------- */ + +export interface JourneyClaim { + /** Stable id, quoted in the spec's test title so the two can be matched. */ + readonly id: string; + /** What the journey proves, in the release's own words. */ + readonly claim: string; +} + +/** + * The eight end-to-end proofs the release is required to run, transcribed from + * the phase context rather than restated: a paraphrase is where a requirement + * quietly narrows. + * + * The list is here rather than in a spec file because more than one spec runs + * parts of it, and because the integration plan reconciles the whole set at + * the end. An id that no test title mentions is a journey nobody ran. + */ +export const V137_E2E_JOURNEYS = [ + { + id: "J1-adult-levels-lifecycle", + claim: + "Adult full READ, scoped READ, WRITE and MANAGE through invite, accept, switch, expiry, revoke and direct URL.", + }, + { + id: "J2-manage-edits-without-settings", + claim: + "Adult MANAGE edits and deletes with specific activity, and zero Guardian-only settings or integration reach.", + }, + { + id: "J3-managed-profile-lifecycle", + claim: + "Truthful managed-profile creation, two Guardians, concurrent last-Guardian protection, record settings and deletion.", + }, + { + id: "J4-guardian-notification-matrix", + claim: + "Two Guardians by two profiles through APNs, Web Push, Telegram and ntfy with recipient locale and preferences, non-interactive payloads and subject/recipient attempts.", + }, + { + id: "J5-revoked-idempotent-replay", + claim: + "Revoked MANAGE idempotency replay returns 403, no cached DTO and no second mutation.", + }, + { + id: "J6-no-delegated-provider-egress", + claim: + "An invited MANAGE mutation with a configured provider produces no provider job or call; the owner positive control enqueues.", + }, + { + id: "J7-durable-import-actor", + claim: + "Asynchronous import completion retains record, actor and job identity.", + }, + { + id: "J8-old-client-compatibility", + claim: + "Old-client compatibility preserves legacy decode, scoped fail-closed behaviour and MANAGE decode until the canonical client is ready.", + }, +] as const satisfies readonly JourneyClaim[];