// E2E integration — run the audit on a targeted page DURING an existing Cypress/Playwright // run, rather than launching a second browser after the fact (`scan`). // // Two things happen per checked page, and only one of them is new: // 1. the page is collected in the browser (COLLECT_SNAPSHOT — the engine's own collector, // never a copy), and // 2. the payload is piped to `ultra11y snapshot write`, which persists the snapshot AND // audits it, returning the AuditResult. // The fixture therefore holds NO knowledge of the snapshot format, the provenance comment or // the audit — it is a pipe. That is deliberate: `render --setup`'s harvester had to inline // its own escaping (it runs where the engine cannot be spawned) and is kept byte-identical by // a sync test; here we can spawn, so there is nothing to keep in sync and nothing to drift. // // The fixtures are GENERATED FILES written into the target repo (`render --e2e`), not a // published library: zero install, and they work against whatever engine build produced them. import type { Lang } from "./types.js"; import { COLLECT_SNAPSHOT } from "./snapshot.js"; import { RANK, THRESHOLD } from "./integrations/payload.js"; export type E2eRunner = "playwright" | "cypress"; /** Detect the E2E runner(s) from a merged deps map + a "does this config exist" probe — * same shape as detectFrameworks / detectTestRunner (src/render.ts). Playwright first: when * a repo runs both, it is the one that can drive a real Chromium for the pixel tier. */ export function detectE2eRunner(deps: Record, has: (file: string) => boolean): E2eRunner[] { const dep = (n: string): boolean => Object.hasOwn(deps, n); const out: E2eRunner[] = []; if (dep("@playwright/test") || dep("playwright") || ["ts", "js", "mjs", "cjs"].some((e) => has(`playwright.config.${e}`))) out.push("playwright"); if (dep("cypress") || ["ts", "js", "mjs", "cjs"].some((e) => has(`cypress.config.${e}`))) out.push("cypress"); return out; } // The shared Node-side half, inlined into both fixtures. Spawns the engine once per checked // page: stdin carries the collected payload, stdout carries the AuditResult. function runnerCore(enginePath: string): string { return `import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; // Engine resolution, in order: the ULTRA11Y env override (same convention as the git hook), // then the path baked in when \`render --e2e\` generated this file. const ENGINE = process.env.ULTRA11Y || ${JSON.stringify(enginePath)}; /** Persist a collected page as a snapshot and audit it. Returns the AuditResult. */ export function auditSnapshot(payload) { const res = spawnSync(process.execPath, [ENGINE, "snapshot", "write", "--json"], { input: JSON.stringify(payload), encoding: "utf8", maxBuffer: 64 * 1024 * 1024, }); if (res.error) throw new Error("ultra11y: could not run the engine at " + ENGINE + " — " + res.error.message); if (!res.stdout) throw new Error("ultra11y: the engine produced no output (exit " + res.status + ")\\n" + (res.stderr || "")); try { return JSON.parse(res.stdout); } catch { throw new Error("ultra11y: could not parse the engine output\\n" + res.stdout.slice(0, 500)); } } // Interpolated from src/integrations/core.ts, never restated: a fixture whose severity // tables drifted from the published plugin's would gate two projects differently while // claiming to be the same tool. tests/e2e-core-sync.test.ts runs both over the same // findings to prove the glue around them agrees too. const RANK = ${JSON.stringify(RANK)}; const THRESHOLD = ${JSON.stringify(THRESHOLD)}; /** Findings at or above the threshold, ignoring non-normative recommendations. */ export function failingFindings(result, failOn) { const max = THRESHOLD[failOn]; if (max === undefined) throw new Error('ultra11y: failOn must be blocking|major|minor (got "' + failOn + '")'); return (result.findings || []).filter((f) => !f.advisory && RANK[f.severity] <= max); } export function formatFailure(pageName, failing) { const lines = ["ultra11y: " + failing.length + " accessibility non-conformity(ies) on \\"" + pageName + "\\":"]; for (const f of failing.slice(0, 20)) { lines.push(" [" + f.severity + "] " + f.ruleId + " (WCAG " + f.criteriaId + ") — " + (f.origin && f.origin.sourceFile ? f.origin.sourceFile : f.file) + " — " + f.message); } if (failing.length > 20) lines.push(" … and " + (failing.length - 20) + " more."); lines.push("Full detail: .ultra11y/pages/ — re-audit offline with \`ultra11y audit\`."); return lines.join("\\n"); } `; } /** The Playwright fixture written to `.ultra11y/e2e/playwright.mjs`. */ export function playwrightFixture(enginePath: string): string { return `// ultra11y — Playwright integration. Generated by \`ultra11y render --e2e\`. // // import { test, checkA11y } from "../.ultra11y/e2e/playwright.mjs"; // // test("home page is accessible", async ({ page, ultra11y }) => { // await page.goto("/"); // await ultra11y({ as: "accueil" }); // via the fixture // }); // // // …or without the fixture: // await checkA11y(page, { as: "accueil", failOn: "blocking" }); // // Each checked page is persisted to .ultra11y/pages// (DOM + computed styles + boxes), // so the SAME page can be re-audited later, offline, with no browser — that is what lets CI // and the RGAA report speak page by page. Commit the snapshots to gate on them. // // Options: { as?: string (page id), name?: string, failOn?: "blocking"|"major"|"minor"|false, // auth?: boolean, sources?: string[], notes?: string } // \`failOn: false\` records the snapshot without ever failing the test. ${runnerCore(enginePath)} const COLLECT = ${JSON.stringify(COLLECT_SNAPSHOT)}; export async function checkA11y(page, opts = {}) { const collected = await page.evaluate(COLLECT); // 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 where the CSSOM cannot answer (text over an // image or a gradient). \`screenshot: false\` skips it. let shot; if (opts.screenshot !== false) { try { shot = (await page.screenshot({ fullPage: false })).toString("base64"); } catch { // A screenshot failure must never fail the accessibility check itself. } } const url = collected.url || page.url(); const id = opts.as || slugify(url); const payload = { meta: { v: 1, id: id, name: opts.name || collected.title || id, url: url, runner: "playwright", viewport: collected.viewport, capturedAt: new Date().toISOString(), // dom.html is documentElement.outerHTML and does NOT carry the doctype, so it is // recorded here or nowhere - and a criterion that asks about it then has no evidence. ...(collected.doctype !== undefined ? { doctype: collected.doctype } : {}), ...(opts.auth !== undefined ? { auth: opts.auth } : {}), ...(opts.sources ? { sources: opts.sources } : {}), ...(opts.notes ? { notes: opts.notes } : {}), }, dom: collected.dom, styles: collected.styles, boxes: collected.boxes, css: collected.css, ...(shot ? { screenshot: shot } : {}), }; const result = auditSnapshot(payload); const failOn = opts.failOn === undefined ? "blocking" : opts.failOn; if (failOn !== false) { const failing = failingFindings(result, failOn); if (failing.length) throw new Error(formatFailure(payload.meta.name, failing)); } return result; } // Same slug rule as the engine (src/snapshot.ts slugifyPageId): accent-folded URL path. function slugify(url) { let path = url; try { // Percent-decode first: new URL() encodes non-ASCII, so /Accès would otherwise slugify // to acc-c3-a8s — the raw UTF-8 bytes spelled out as a directory name. path = new URL(url).pathname; try { path = decodeURIComponent(path); } catch {} } catch {} const slug = path.normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); return slug || (path === "/" || path === "" ? "accueil" : "page"); } // Optional fixture form. Import \`test\` from here instead of @playwright/test. // Playwright is resolved through createRequire (this file is ESM, so \`require\` does not // exist) and lazily, so the module still loads in a project that has not installed it. export const test = (() => { try { const base = createRequire(import.meta.url)("@playwright/test").test; return base.extend({ ultra11y: async ({ page }, use) => { await use((opts) => checkA11y(page, opts)); }, }); } catch { return undefined; } })(); `; } /** The Cypress Node-side plugin written to `.ultra11y/e2e/cypress-plugin.mjs`. */ export function cypressPlugin(enginePath: string): string { return `// ultra11y — Cypress plugin (Node side). Generated by \`ultra11y render --e2e\`. // // // cypress.config.js // import ultra11y from "./.ultra11y/e2e/cypress-plugin.mjs"; // export default defineConfig({ e2e: { setupNodeEvents(on, config) { ultra11y(on); return config; } } }); // // Cypress test code runs in the BROWSER and cannot write to disk, so the collected page // round-trips through this task. The browser half is cypress-commands.mjs. ${runnerCore(enginePath)} export default function register(on) { on("task", { ultra11ySnapshot(payload) { const result = auditSnapshot(payload); const failOn = payload.failOn === undefined ? "blocking" : payload.failOn; const failing = failOn === false ? [] : failingFindings(result, failOn); // Return rather than throw: the browser half raises the assertion, so the failure is // attributed to the test rather than to the plugin. return { findings: result.findings || [], failing: failing, message: failing.length ? formatFailure(payload.meta.name, failing) : "" }; }, }); } `; } /** The Cypress browser-side command written to `.ultra11y/e2e/cypress-commands.mjs`. */ export function cypressCommands(): string { return `// ultra11y — Cypress command (browser side). Generated by \`ultra11y render --e2e\`. // // // cypress/support/e2e.js (the supportFile) // import "../../.ultra11y/e2e/cypress-commands.mjs"; // // cy.visit("/"); // cy.ultra11y({ as: "accueil" }); // // Options: { as?, name?, failOn?: "blocking"|"major"|"minor"|false, auth?, sources?, notes? } const COLLECT = ${JSON.stringify(COLLECT_SNAPSHOT)}; Cypress.Commands.add("ultra11y", (opts = {}) => { cy.window({ log: false }).then((win) => { // eslint-disable-next-line no-eval const collected = win.eval(COLLECT); const url = collected.url || win.location.href; const id = opts.as || slugify(url); const payload = { meta: { v: 1, id: id, name: opts.name || collected.title || id, url: url, runner: "cypress", viewport: collected.viewport, capturedAt: new Date().toISOString(), ...(collected.doctype !== undefined ? { doctype: collected.doctype } : {}), ...(opts.auth !== undefined ? { auth: opts.auth } : {}), ...(opts.sources ? { sources: opts.sources } : {}), ...(opts.notes ? { notes: opts.notes } : {}), }, dom: collected.dom, styles: collected.styles, boxes: collected.boxes, css: collected.css, failOn: opts.failOn, }; return cy.task("ultra11ySnapshot", payload, { log: false }).then((res) => { if (res && res.failing && res.failing.length) throw new Error(res.message); return res; }); }); }); function slugify(url) { let path = url; try { // Percent-decode first: new URL() encodes non-ASCII, so /Accès would otherwise slugify // to acc-c3-a8s — the raw UTF-8 bytes spelled out as a directory name. path = new URL(url).pathname; try { path = decodeURIComponent(path); } catch (e) {} } catch (e) {} const slug = path.normalize("NFD").replace(/[\\u0300-\\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); return slug || (path === "/" || path === "" ? "accueil" : "page"); } `; } export interface E2ePaths { playwright?: string; cypressPlugin?: string; cypressCommands?: string; } /** Instructions printed after `render --e2e` writes the fixtures. */ export function e2eSetupPlan(runners: E2eRunner[], paths: E2ePaths, lang: Lang = "en"): string { const fr = lang === "fr"; const out: string[] = []; if (!runners.length) { out.push( fr ? "Aucun runner E2E détecté (ni Playwright, ni Cypress). Installez-en un, ou forcez l'écriture avec --runner playwright|cypress." : "No E2E runner detected (neither Playwright nor Cypress). Install one, or force the files with --runner playwright|cypress.", ); return out.join("\n"); } if (paths.playwright) { out.push(fr ? `Fixture Playwright écrite : ${paths.playwright}` : `Playwright fixture written: ${paths.playwright}`); out.push(""); out.push(fr ? "Dans un test :" : "In a test:"); out.push(` import { test, checkA11y } from "${paths.playwright}";`); out.push(` await page.goto("/");`); out.push(` await checkA11y(page, { as: "accueil" });`); out.push(""); } if (paths.cypressPlugin) { out.push(fr ? `Plugin Cypress écrit : ${paths.cypressPlugin}` : `Cypress plugin written: ${paths.cypressPlugin}`); if (paths.cypressCommands) out.push(fr ? `Commande Cypress écrite : ${paths.cypressCommands}` : `Cypress command written: ${paths.cypressCommands}`); out.push(""); out.push(fr ? "Dans cypress.config.js (setupNodeEvents) :" : "In cypress.config.js (setupNodeEvents):"); out.push(` import ultra11y from "./${paths.cypressPlugin}";`); out.push(` setupNodeEvents(on, config) { ultra11y(on); return config; }`); out.push(fr ? "Puis dans le supportFile :" : "Then in the supportFile:"); out.push(` import "${paths.cypressCommands ? `../../${paths.cypressCommands}` : ""}";`); out.push(fr ? "Dans un test : cy.visit('/'); cy.ultra11y({ as: 'accueil' });" : "In a test: cy.visit('/'); cy.ultra11y({ as: 'accueil' });"); out.push(""); } out.push( fr ? "Chaque page vérifiée est enregistrée dans .ultra11y/pages// — committez-la pour auditer la vraie page hors ligne (CI, rapport page par page)." : "Every checked page is recorded in .ultra11y/pages// — commit it to audit the real page offline (CI, per-page report).", ); return out.join("\n"); }