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[];