// THE LIVE PROBES — the measurements that only exist while a browser is acting on the page. // // Contrast and colour can be settled from a recorded snapshot; zoom, reflow, text spacing, // hover and focus visibility cannot. They are properties of a page being STRESSED — the root // font-size doubled, the viewport narrowed to 320px, a spacing stylesheet forced on, Tab // pressed, a tooltip hovered — and a digest taken beforehand says nothing about any of them. // // They lived inside `scan-local`, which opens its own browser, and that is precisely why a // real application could never decide them: the screens that matter sit behind authentication // and behind a state machine, so a scanner arriving cold gets a redirect. An E2E suite already // has the page in the right state and had no way to ask for these measurements. Hence this // module: no Playwright import, no engine import, nothing but strings evaluated in a page and // a caller-supplied `page`. Both runtimes use it, so a probe means the same thing in each. /** THE COVERAGE CONTRACT INSIDE `probes.json`, versioned apart from the snapshot itself. * * `SNAPSHOT_VERSION` describes the DIRECTORY — a dom, some digests, a screenshot — and it has * not changed. What changed is the meaning of one field: up to 5.42.0, `probed` was written * for a walk of the tab ring, of the hover triggers or of the page's interactions with NO * completeness check at all, so a ring cut off at the tagging cap made the same claim as a * whole one. A snapshot outlives the engine that wrote it, and believing an older file's claim * for those criteria reinstates the exact defect the check was added to close. * * v2 = `probed` is written only for a pass that finished. Absent = v1 = it means whatever the * producer of the day felt like, and the criteria whose completeness is now tracked are not * credited from it. */ export const PROBES_VERSION = 2; /** The criteria whose `probed` claim depends on a walk having FINISHED — the tab ring (2.4.7, * 2.4.11, 2.1.2), the hover triggers (1.4.13) and the page's interactions (4.1.3). The digest * and one-shot measurements are deliberately absent: a 320px reflow either ran or it did not, * and there is no half of it to have been cut short, so withholding it would cost coverage for * nothing. */ export const WALK_DEPENDENT_SCS: readonly string[] = ["1.4.13", "2.1.2", "2.4.7", "2.4.11", "4.1.3"]; /** One thing a probe observed. Structurally identical to `ProbeHit` in src/scan.ts, restated * here so this module keeps no dependency on the scan runtime. */ export interface ProbeHit { selector: string; html: string; detail: string; } // Playwright's types are not a dependency of this package; the page is structurally typed to // exactly the surface the probes use. type Any = any; // Shared helpers injected at the top of every probe expression. export const PRELUDE = ` const __sel = (e) => { if (!e || !e.tagName) return '—'; const esc = (v) => (typeof CSS !== 'undefined' && CSS.escape) ? CSS.escape(v) : String(v).replace(/[^a-zA-Z0-9_-]/g, '\\\\$&'); const unique = (s) => { try { return document.querySelectorAll(s).length === 1; } catch { return false; } }; const short = (n) => { const t = n.tagName.toLowerCase(); if (n.id) return t + '#' + esc(n.id); const c = typeof n.className === 'string' ? n.className.trim().split(/\\s+/)[0] : ''; return c ? t + '.' + esc(c) : t; }; const first = short(e); if (unique(first)) return first; const parts = []; for (let n = e; n && n.tagName; n = n.parentElement) { let part = short(n); if (n.id && unique(part)) { parts.unshift(part); return parts.join(' > '); } const parent = n.parentElement; if (parent) { const same = Array.from(parent.children).filter((x) => x.tagName === n.tagName); if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(n) + 1) + ')'; } parts.unshift(part); const path = parts.join(' > '); if (unique(path)) return path; } return parts.join(' > ') || first; }; const __vis = (e) => { const r = e.getBoundingClientRect(); if (r.width <= 4 || r.height <= 4) return false; // tiny / 1px sr-only boxes const s = getComputedStyle(e); if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false; // visually-hidden "screen-reader-only" pattern (clip rect / clip-path inset) — present in // the a11y tree but not painted; must not be measured for clipping/target-size. if (s.clip && s.clip !== 'auto' && s.clip !== 'rect(auto, auto, auto, auto)') return false; if (s.clipPath && (s.clipPath.indexOf('inset(100%') >= 0 || s.clipPath.indexOf('inset(50%') >= 0)) return false; return true; }; const __html = (e) => (e.outerHTML || '').slice(0, 160); `; /** The numbers a probe stops at, and the width it narrows to. * * 320px is normative (WCAG 1.4.10) and stays the default; how many focusables are worth * tabbing through, and how many hits are worth recording before the point is made, are * judgements about the pages being audited. A repository that has 400 focusable elements on * one screen should be able to say so instead of being quietly cut off at 120. */ export interface ProbeLimits { reflowWidth: number; maxFocusables: number; maxHits: number; /** Hover triggers opened before the probe stops looking. `maxHits` bounds what is * RECORDED; this bounds what is TRIED, which is the number that costs wall-clock. */ maxTriggers: number; /** Timeout handed to every interaction the probes drive (hover above all). * * Playwright's own default is 0 — *wait forever*, bounded only by the caller's test * timeout. That default is right for a test asserting on its own page and catastrophic * here: a trigger that never becomes actionable then blocks OUR pass until SOMEBODY * ELSE'S test dies. Measured on a real sweep: one such trigger killed its test at 120s * and, through a serial group, took 15 more tests with it. */ actionTimeoutMs: number; /** Wall-clock the whole pass may spend. What it cuts short is recorded in `skipped`, never * silently dropped: a measurement that did not happen must not read as one that found * nothing. */ budgetMs: number; } export const PROBE_DEFAULTS: ProbeLimits = { reflowWidth: 320, maxFocusables: 120, maxHits: 20, maxTriggers: 60, actionTimeoutMs: 1_000, budgetMs: 20_000, }; /** A deadline the probes can consult without every one of them knowing about the clock. * `left()` is what an interaction may still spend; `out()` is the question a loop asks * between iterations. */ export interface ProbeDeadline { out(): boolean; left(): number; } export function probeDeadline(budgetMs: number, now: () => number = Date.now): ProbeDeadline { const end = now() + budgetMs; return { out: () => now() >= end, left: () => Math.max(0, end - now()) }; } /** Never wait longer than the interaction's own timeout OR the budget, whichever is nearer — * and never wait zero, which Playwright reads as "no limit", the exact bug this closes. */ function actionTimeout(limits: ProbeLimits, deadline?: ProbeDeadline): number { const left = deadline ? deadline.left() : limits.actionTimeoutMs; return Math.max(1, Math.min(limits.actionTimeoutMs, left || limits.actionTimeoutMs)); } // The 320px reflow check (same semantics as the Docker RUNNER), mapped to 1.4.10. export const REFLOW_PROBE = `(() => { const el = document.scrollingElement || document.documentElement; return { horizontalScroll: el.scrollWidth > el.clientWidth + 2 }; })()`; // 1.4.4 Resize Text: enlarge text to 200% and detect actual LOSS OF CONTENT — text // clipped in an overflow:hidden / nowrap / ellipsis container. (A mere horizontal // scrollbar at 200% text is NOT a 1.4.4 failure — 2D reflow is 1.4.10 — so we do not // flag page-level horizontal scroll here; only content the user can no longer read.) export const REFLOW_ZOOM_PROBE = `(() => { ${PRELUDE} const root = document.documentElement; const prev = root.style.fontSize; root.style.fontSize = '200%'; const hits = []; for (const e of Array.from(document.querySelectorAll('p,li,h1,h2,h3,h4,h5,h6,td,th,button,a,label,span'))) { if (!__vis(e)) continue; if ((e.textContent || '').trim().length < 8) continue; const s = getComputedStyle(e); const clip = s.overflow === 'hidden' || s.overflowY === 'hidden' || s.overflowX === 'hidden'; const noWrap = s.whiteSpace === 'nowrap' || s.textOverflow === 'ellipsis'; if ((clip || noWrap) && (e.scrollHeight > e.clientHeight + 6 || e.scrollWidth > e.clientWidth + 6)) { hits.push({ selector: __sel(e), html: __html(e), detail: 'Texte tronqué/masqué à 200% (conteneur overflow:hidden / nowrap) — perte de contenu au zoom (1.4.4).' }); } if (hits.length >= 12) break; } root.style.fontSize = prev; return hits; })()`; // 1.4.12 Text Spacing override (line-height 1.5, letter 0.12em, word 0.16em, p 2em). export const TEXT_SPACING_CSS = "* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }"; export const TEXT_SPACING_PROBE = `(() => { ${PRELUDE} const hits = []; for (const e of Array.from(document.querySelectorAll('p,li,span,a,button,h1,h2,h3,h4,h5,h6,td,th,label,div'))) { if (!__vis(e)) continue; if ((e.textContent || '').trim().length < 8) continue; const s = getComputedStyle(e); const clipped = (s.overflowX === 'hidden' || s.overflowY === 'hidden' || s.overflow === 'hidden') && (e.scrollHeight > e.clientHeight + 2 || e.scrollWidth > e.clientWidth + 2); const ellipsis = s.textOverflow === 'ellipsis' && e.scrollWidth > e.clientWidth + 2; if (clipped || ellipsis) { // No criterion id in the text: every rendering already names the criterion this finding // belongs to, and a hard-coded « 1.4.12 » is a WCAG number appearing inside a deliverable // that may be keyed on another standard entirely. hits.push({ selector: __sel(e), html: __html(e), detail: 'Texte tronqué/masqué sous l\\'espacement de texte imposé — perte de contenu.' }); } if (hits.length >= 20) break; } return hits; })()`; // 2.4.7 Focus Visible — pass 1: tag each focusable + snapshot its unfocused style. // `scope` (a CSS selector or "" for the whole document) restricts the pass — used to // re-run it INSIDE an opened dialog whose focusables the pristine pass could not see. // // Custom radios/checkboxes (RGAA 10.7): the DSFR-style pattern hides the native input // (sr-only) and paints the focus ring on its LABEL. Measuring the hidden input would // always report "no visible change" as a false pass — so for a visually-hidden but // focusable radio/checkbox we measure the PROXY (label[for], wrapping label, or the // aria-labelledby target) instead, keyed by the input (which is what Tab focuses). // // AND THE PROXY'S PSEUDO-ELEMENTS, WHICH IS WHERE THE RING ACTUALLY IS. Having gone to // the trouble of finding the label, reading only the label's OWN computed style measures // the one box the design system does not paint: DSFR, GOV.UK, USWDS and Bootstrap all // draw the control — the box, the tick, and the focus ring — in `label::before`. The // label element itself never changes on focus, so every custom checkbox and radio came // back "focus not visible". // // Measured on a real 37-page audit: TWELVE findings, all of them `