// `ultra11y/playwright` — audit a page DURING your Playwright run, in the state your test // built it (logged in, form filled, modal open). `scan` cannot see that state: it starts a // second browser after the fact. // // import { test, checkA11y } from "ultra11y/playwright"; // // test("home page is accessible", async ({ page, ultra11y }) => { // await page.goto("/"); // await ultra11y({ as: "accueil" }); // }); // // // …or on any page object, with no fixture: // await checkA11y(page, { as: "contact", failOn: "major" }); // // The durable half is not the assertion — it is the SNAPSHOT. Each checked page is written // to `.ultra11y/pages//` (DOM + computed styles + boxes + stylesheets + screenshot), so // the same page re-audits offline with no browser: that is how CI decides the rendering // criteria without booting the app, and how the report speaks page by page. Commit it. import { readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { join } from "node:path"; import { COLLECT_SNAPSHOT } from "../collector.js"; import { runLiveProbes } from "../probes.js"; import { type AuditLike, type CheckOptions, type ProbeTuning, auditSnapshot, buildPayload, gate, stayedOnPage, writePagesReport } from "./core.js"; // Playwright's own types are not a dependency of this package (it is a peer of YOUR repo), // so the page object is structurally typed to exactly what is used. interface PlaywrightPage { evaluate(script: string): Promise; screenshot(opts: { fullPage: boolean }): Promise; url(): string; // Used only by the live probes (`probes: true`), so they stay optional: a page object that // cannot resize or press a key still records a snapshot exactly as before. viewportSize?(): { width: number; height: number } | null; setViewportSize?(size: { width: number; height: number }): Promise; addStyleTag?(opts: { content: string }): Promise; hover?(selector: string): Promise; waitForTimeout?(ms: number): Promise; keyboard?: { press(key: string): Promise }; mouse?: { move(x: number, y: number): Promise }; } export interface PlaywrightCheckOptions extends CheckOptions { /** Also write the per-page report once this page is recorded. Off by default: a report * per checked page would be wasteful in a suite; turn it on in a final test. */ report?: boolean | { out?: string; standard?: string; lang?: string }; } /** Read the three shapes `probes` accepts — `true`, a list of criteria, or an options object — * into the one shape `runLiveProbes` takes. */ function probeOptions(p: PlaywrightCheckOptions["probes"]): { only?: string[]; limits?: Omit } { // `probes: true` means "use what the repository declared". The bounds are a judgement about // the pages being audited, so they belong in `.ultra11yrc.json` next to the sample and the // standard — not repeated at every call site, and not compiled into the tool. // // Read directly rather than through `loadConfig`: this module is a lean bundle a consumer // imports into their test run, and pulling the standards registry in for three numbers // would be a poor trade. if (p === true || p === undefined) return fromConfig(); if (p === false) return {}; if (Array.isArray(p)) return { only: p, ...fromConfig() }; const { only, ...limits } = p; return { ...(only ? { only } : {}), limits }; } function fromConfig(): { only?: string[]; limits?: Omit } { try { const cfg = JSON.parse(readFileSync(join(process.cwd(), ".ultra11yrc.json"), "utf8")) as { probes?: ProbeTuning }; if (!cfg.probes) return {}; const { only, ...limits } = cfg.probes; return { ...(only ? { only } : {}), limits }; } catch { // No config, or one this cannot read — the engine defaults apply, which is the same thing // every repository that never opens the question gets. return {}; } } /** THE AXE PASS, RUN INSIDE THE CALLER'S OWN TEST. * * `@axe-core/playwright` belongs to the AUDITED project, never to this package: resolved at * runtime from the caller's cwd first (the same order `enginePath` and the Playwright fixture * use), so a repo that has it gets the pass and a repo that does not is untouched. * * Returns `undefined` when it did not run, and `{ ran: true, violations }` when it did. The * difference matters downstream: `ran` is what lets an empty violation list COUNT — silence * from a rule engine that ran is a measurement, silence from one that never started is not. */ async function runAxe(page: PlaywrightPage, mode: boolean | "auto" | undefined): Promise<{ ran: true; violations: unknown[] } | undefined> { if (mode === false) return undefined; const demanded = mode === true; // `any`: the builder's shape is axe's, which is not a dependency here. let AxeBuilder: any; // PROJECT FIRST, then relative to this module — the same order the Playwright fixture above // and `scan --runtime local` use, and for the same reason: module-relative alone works for an // ordinary `node_modules/ultra11y` install but yields nothing whenever the package is linked, // pnpm-hoisted elsewhere, or in a monorepo. Anchoring on the cwd ALONE is just as wrong in the // other direction — a suite that changes directory to write its snapshot then resolves nothing // at all, and the pass evaporates silently because the default is `auto`. let lastError: unknown; for (const from of [join(process.cwd(), "package.json"), import.meta.url]) { try { const mod = createRequire(from)("@axe-core/playwright"); AxeBuilder = mod.default ?? mod; break; } catch (e) { lastError = e; } } if (!AxeBuilder) { if (demanded) { console.warn( `ultra11y: axe was requested but @axe-core/playwright could not be resolved from ${process.cwd()} nor from this package — ${ lastError instanceof Error ? lastError.message : String(lastError) }. Install it, or set \`axe: false\`. The criteria axe decides stay to assess.`, ); } return undefined; } try { const res = (await new AxeBuilder({ page }).analyze()) as { violations?: unknown[] }; return { ran: true, violations: res.violations ?? [] }; } catch (e) { // Loud, for the same reason a failed probe is: a swallowed failure is indistinguishable // from a clean page, and the criteria simply stay to assess with nobody able to tell why. console.warn( `ultra11y: the axe pass failed on this page — ${e instanceof Error ? e.message : String(e)}. The snapshot was still recorded; the criteria axe decides stay to assess.`, ); return undefined; } } /** Collect the current page, persist it as a snapshot, audit it, and fail on the threshold. */ export async function checkA11y(page: PlaywrightPage, opts: PlaywrightCheckOptions = {}): Promise { const collected = (await page.evaluate(COLLECT_SNAPSHOT)) as Parameters[0]; // A VIEWPORT screenshot, deliberately: the boxes come from getBoundingClientRect, which is // viewport-relative, so a full-page capture would put the two coordinate systems out of // step. It feeds the pixel tier — contrast over an image or a gradient, where the CSSOM // has no answer. A screenshot failure must never fail the accessibility check itself. let shot: string | undefined; if (opts.screenshot !== false) { try { shot = (await page.screenshot({ fullPage: false })).toString("base64"); } catch { /* pixel tier skipped for this page; every other rule still runs */ } } const url = collected.url || page.url(); // Refuse a page the browser did not stay on, when the caller said where it went. The // snapshot's identity is `as`/`name`, applied to whatever is on screen — so a guarded route // that redirected would be filed under the requested page's name, and the resulting sheet, // screenshot and rate would all describe another screen. Skipping loudly beats that. if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) { throw new Error( `ultra11y: ${opts.expectPath} landed on ${url} — not recording it as "${opts.as ?? opts.name ?? "this page"}". ` + `The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`, ); } // The probes run AFTER the snapshot is collected, deliberately: they stress the page — // double the root font size, narrow the viewport to 320px, force a spacing stylesheet, press // Tab, hover a tooltip — and what gets recorded must be the page as the test built it, not // the page mid-measurement. Everything they touch is restored before this returns. // // Guarded as a whole: a probe run that throws costs the measurements, never the snapshot and // never the caller's test. let probes: unknown; // `opts.liveRegion` OPENS THIS DOOR TOO. It rode inside a guard that only asked about // `probes`, so `{ liveRegion: true }` on its own — the exact call the option's own // documentation describes — did nothing at all, silently. An option that has to be paired // with another one to exist is not an option, it is a trap. // // `probes: false, liveRegion: true` is a coherent ask and now works: `probeOptions(false)` // returns no tuning, `runLiveProbes` runs only what `only` allows, and 4.1.3 is measured // without pressing Tab or narrowing the viewport. if (opts.probes || opts.liveRegion) { try { probes = await runLiveProbes(page, { ...probeOptions(opts.probes), ...(opts.probes ? {} : { only: ["4.1.3"] }), ...(opts.liveRegion ? { liveRegion: opts.liveRegion } : {}), }); } catch (e) { // LOUD, not silent. A swallowed probe failure is indistinguishable from a page with // nothing wrong: the criteria it would have decided simply stay « to assess », run after // run, with nobody able to tell whether they were measured and clean or never measured // at all. That confusion is the exact failure mode this tool exists to prevent, so it // must not be the one it ships. The page is still recorded either way. console.warn( `ultra11y: the live probes failed on this page — ${e instanceof Error ? e.message : String(e)}. The snapshot was still recorded; the criteria they decide stay to assess.`, ); } } // After the probes, and after the snapshot: axe reads the page, it does not stress it, so // its position only has to be somewhere the page is settled. Running it last keeps the // recorded DOM the one the test built. const axe = await runAxe(page, opts.axe ?? "auto"); const payload = buildPayload(collected, url, "playwright", opts, shot, probes, axe); const result = auditSnapshot(payload); if (opts.report) writePagesReport(typeof opts.report === "object" ? opts.report : {}); gate(result, String(payload.meta.name), opts.failOn); return result; } /** Optional fixture form. Import `test` from here instead of `@playwright/test`. * * Playwright belongs to YOUR project, not to this package, so it is resolved from the * PROJECT FIRST (cwd) and only then relative to this module — the same order * `scan --runtime local` uses (src/scan-local.ts resolveLocalDeps). Module-relative alone * works for an ordinary `node_modules/ultra11y` install, where Node walks up into the * project's own tree, but silently yields `undefined` whenever the package is linked, * pnpm-hoisted elsewhere, or in a monorepo — and a fixture that is `undefined` for * layout reasons is a confusing failure. * * Resolution is lazy and guarded either way, so importing this in a Cypress-only repo * gives `undefined` rather than a crash. */ // `any`: the shape is Playwright's, which is not a dependency here. export const test: any = (() => { // `any` for the same reason. const load = (from: string): any => createRequire(from)("@playwright/test"); for (const from of [`${process.cwd()}/package.json`, import.meta.url]) { try { const base = load(from).test; if (!base?.extend) continue; return base.extend({ // `any` for the same reason. ultra11y: async ({ page }: any, use: any) => { await use((opts: PlaywrightCheckOptions) => checkA11y(page, opts)); }, }); } catch { /* try the next resolution root */ } } return undefined; })(); /** Options for {@link sweepSample}. */ export interface SweepOptions { /** Where `.ultra11yrc.json` lives. Defaults to the process cwd. */ cwd?: string; /** Which declared pages this sweep owns. Return false for a page the repo snapshots itself * — a wizard step that needs application state seeded first, say. Skipped pages are simply * not declared here; they are still in the sample, so the report still expects them. */ only?: (page: SamplePageLike) => boolean; /** Awaited after navigation, before collecting. This is where a framework's readiness goes: * a design system that boots asynchronously will otherwise be serialized half-mounted, and * the audit reports non-conformities about markup no user ever meets. * * Typed loosely on purpose. The caller writes this against Playwright's real `Page` — with * `waitForLoadState`, `waitForFunction`, locators — and Playwright is not a dependency of * this package, so the narrow structural shape the collector needs would reject the very * function this option exists to take. Same reasoning as `test` below. */ // `any`: the shape is Playwright's, which is not a dependency here. settle?: (page: any) => Promise | void; /** Passed to every `checkA11y`. `failOn: false` (the default here) records without * asserting: the durable output of a sweep is the snapshot, not a red test. */ check?: Omit; } export interface SamplePageLike { id: string; name: string; url: string; auth?: boolean; notes?: string; sources?: string[]; } /** Declare one Playwright test per page of the `.ultra11yrc.json` sample. * * The sample already says what every page is — id, name, URL, whether it sits behind a login, * the sources that render it. A repo that then hand-writes a route table to drive its sweep is * keeping a second copy of that, and the two drift: a page renamed in one place keeps its old * identity in the report, which is the failure this whole tier exists to prevent. * * So the sample drives the sweep: * * ```ts * import { test } from "@playwright/test"; * import { sweepSample } from "ultra11y/playwright"; * * sweepSample({ settle: async (page) => { await page.waitForLoadState("networkidle"); } }); * ``` * * Each page is navigated, checked against `expectPath` and its HTTP status, and recorded. A * page the browser did not stay on — or that answered an error at the requested address — is * `test.skip`ped with the reason, never filed under the requested name. * * Pages your own specs drive (a funnel step that needs state seeded) are excluded with * `only`. */ export function samplePagesFor(opts: Pick = {}): SamplePageLike[] { const cwd = opts.cwd ?? process.cwd(); let declared: SamplePageLike[]; try { const raw = readFileSync(join(cwd, ".ultra11yrc.json"), "utf8"); declared = (JSON.parse(raw) as { sample?: { pages?: SamplePageLike[] } }).sample?.pages ?? []; } catch { // Not "declare zero tests and pass" — that is indistinguishable from a sweep that ran. throw new Error(`ultra11y: no readable .ultra11yrc.json in ${cwd} — sweepSample reads the page sample from it.`); } return declared.filter((p) => opts.only?.(p) ?? true); } /** The path a sample URL points at. The sample stores absolute URLs (it is also read by * `scan`, which needs a real address); a Playwright test wants the path, so its `baseURL` * applies. Anything unparseable is already a path. */ export function sweepTarget(url: string): string { try { return new URL(url).pathname || url; } catch { return url; } } /** The defaults every swept page is checked with, and the one seam a test can reach without * a Playwright runtime in the room. * * `probes: true` is the default HERE and opt-in on `checkA11y`, and the asymmetry is the * point. `checkA11y` is called from a test that owns its page and may be asserting on focus * or hover a line later; a sweep exists for nothing but recording the sample, and the probes * are the only thing that can decide zoom, reflow, text spacing and content-on-hover on * screens a cold scanner never reaches. Measured on egapro: 15 of 20 pages came from a sweep * that never probed, and because conformity is an AND across every page in scope, those four * criteria stayed « à évaluer » for the whole audit — including on the five pages that HAD * been probed. Opt out with `check: { probes: false }`. */ export function sweepCheckOptions(check?: SweepOptions["check"]): SweepOptions["check"] & { failOn: false | undefined } { // `liveRegion` does NOT join it, and the asymmetry with `probes` is the point a second time. // // It was on here for one release-in-progress, on the argument that a sweep asserts nothing // afterwards. Two things make that the wrong default. It is the only probe that changes the // page rather than stressing it — it types into fields and toggles controls, and a framework // that reacted has reacted: an autosave, a validation, a request, in the worst case a route // change that leaves the axe pass measuring a page the snapshot is not of. And it buys less // than it looked: with clicks off (which they must be on an authenticated sweep, where a // server mutation is invisible to any check this side of the network) the pass never presses // a button, so it now correctly WITHHOLDS 4.1.3 on any page that has one. // // So it stays a deliberate opt-in — `check: { liveRegion: true }`, or `{ liveRegion: { clicks: // true } }` on a disposable environment — where the caller can weigh both against their own // application. return { failOn: false, probes: true, ...check } as SweepOptions["check"] & { failOn: false | undefined }; } export function sweepSample(opts: SweepOptions = {}): void { const pages = samplePagesFor(opts); const t = test; if (!t) throw new Error("ultra11y: @playwright/test could not be resolved — sweepSample needs it."); if (!pages.length) return; // Serial by default is NOT imposed here: pages are independent unless the repo's own state // makes them otherwise, and that is the repo's call (`workers` in its config). for (const p of pages) { const target = sweepTarget(p.url); t(`a11y — ${p.name}`, async ({ page }: { page: PlaywrightPage & { goto(u: string): Promise<{ status(): number } | null> } }) => { const response = await page.goto(target); if (opts.settle) await opts.settle(page); const landed = page.url(); t.skip(!stayedOnPage(target, landed), `${target} landed on ${landed} — the current state does not open this screen; nothing to record as "${p.name}"`); const status = response?.status(); t.skip( status !== undefined && status >= 400, `${target} answered HTTP ${status} — an error page at the requested address; nothing to record as "${p.name}"`, ); await checkA11y(page, { ...sweepCheckOptions(opts.check), as: p.id, name: p.name, ...(p.auth !== undefined ? { auth: p.auth } : {}), ...(p.notes ? { notes: p.notes } : {}), ...(p.sources ? { sources: p.sources } : {}), expectPath: target, }); }); } } export type { AuditLike, CheckOptions };