ultra11y 2.30.0 → 2.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/README.md +59 -7
- package/dist/cypress-plugin.d.ts +17 -0
- package/dist/cypress-plugin.mjs +82 -0
- package/dist/cypress.d.ts +11 -0
- package/dist/cypress.mjs +170 -0
- package/dist/payload-D0VQYlGS.d.ts +44 -0
- package/dist/playwright.d.ts +35 -0
- package/dist/playwright.mjs +250 -0
- package/docker/runner.mjs +12 -1
- package/package.json +25 -3
- package/scripts/build-extension-icons.mjs +137 -0
- package/scripts/check-artifact-recall.mjs +199 -0
- package/scripts/sync-engine.mjs +126 -52
- package/scripts/ultra11y.mjs +1368 -348
- package/scripts/vendor/wcag-2.2-fr.json +1 -1
- package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +1368 -348
- package/skills/ultra11y/SKILL.md +2 -1
- package/skills/ultra11y/references/ci.md +33 -0
- package/skills/ultra11y/references/devtools.md +7 -0
- package/skills/ultra11y/references/dynamic.md +54 -1
- package/skills/ultra11y/references/e2e.md +42 -11
- package/skills/ultra11y/references/extension.md +52 -0
- package/skills/ultra11y/references/judgment.md +38 -0
- package/skills/ultra11y/references/pages.md +66 -3
- package/skills/ultra11y/scripts/ultra11y.mjs +1368 -348
- package/src/annotate.ts +53 -13
- package/src/check.ts +22 -5
- package/src/cli.ts +426 -32
- package/src/collector.ts +125 -0
- package/src/crawl.ts +43 -0
- package/src/data/wcag-universe.json +1 -1
- package/src/dev.ts +136 -5
- package/src/e2e.ts +13 -2
- package/src/init.ts +11 -2
- package/src/integrations/core.ts +75 -0
- package/src/integrations/cypress-plugin.ts +75 -0
- package/src/integrations/cypress.ts +56 -0
- package/src/integrations/payload.ts +141 -0
- package/src/integrations/playwright.ts +95 -0
- package/src/llm.ts +259 -0
- package/src/pages-report.ts +302 -0
- package/src/pages.ts +25 -4
- package/src/report.ts +48 -18
- package/src/sample.ts +57 -0
- package/src/scan-local.ts +57 -5
- package/src/scan.ts +162 -12
- package/src/snapshot.ts +44 -116
- package/src/types.ts +7 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "ultra11y",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
|
|
13
|
-
"version": "2.
|
|
13
|
+
"version": "2.31.0",
|
|
14
14
|
"author": { "name": "maxgfr" },
|
|
15
15
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
16
16
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
3
|
"description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.31.0",
|
|
5
5
|
"author": { "name": "maxgfr" },
|
|
6
6
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
7
7
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
3
|
"description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.31.0",
|
|
5
5
|
"author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
|
|
6
6
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
7
7
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
|
|
|
27
27
|
// Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
|
|
28
28
|
// does not carry it, so a user's own ultra11y.js is never clobbered.
|
|
29
29
|
const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
|
|
30
|
-
const ULTRA11Y_PLUGIN_VERSION = "2.
|
|
30
|
+
const ULTRA11Y_PLUGIN_VERSION = "2.31.0";
|
|
31
31
|
|
|
32
32
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
33
|
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ ultra11y is built around an honest **division of labour**. Automated tools only
|
|
|
32
32
|
- **Needs rendering (scan tier):** computed contrast, visible focus, zoom/reflow, content on hover — decided by `scan` (axe-core in a real browser); until then they stay **residual risks**, never silently "conforming".
|
|
33
33
|
- **Advisory vs non-conformity:** a good practice with no failing normative test (an `advisory` pack rule, a best-practice-only axe violation, an agent recommendation) renders as « Recommandation (non normative) » — it never flips a criterion to `NC` nor enters the conformance rate. An NC always cites a `normativeRef`.
|
|
34
34
|
- **Stateful scan probes:** the local runtime drives the page with bounded, non-navigating interactions (fill inputs then re-measure overflow; a live-region probe for status messages 4.1.3) — `--no-interact` opts out; `--interact-clicks` re-enables button clicks on authenticated scans (destructive-named buttons are never clicked).
|
|
35
|
-
- **Normative page sample (échantillon):** a country-standard audit runs over a declared `sample.pages` set — `sample check` lints its coverage against the standard's required page kinds, `scan --sample` scans it, and an un-scanned `--standard rgaa` report is flagged **partial**.
|
|
35
|
+
- **Normative page sample (échantillon):** a country-standard audit runs over a declared `sample.pages` set — **`pages discover --crawl|--sitemap --write` builds it for you** (names read from each page's `<title>`, existing entries never overwritten), `sample check` lints its coverage against the standard's required page kinds, `scan --sample` scans it, and an un-scanned `--standard rgaa` report is flagged **partial**.
|
|
36
36
|
- **Pack-only detection (declarative):** a standards pack can ship its own `rules` (a bounded, ReDoS-guarded matcher DSL — no code) and normativity/severity `overrides`, projecting onto its criteria without forking the engine.
|
|
37
37
|
|
|
38
38
|
## Measured against a corpus it did not write
|
|
@@ -58,6 +58,14 @@ npx ultra11y --help # no install
|
|
|
58
58
|
npm i -D ultra11y # or as a dev dependency
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
**As a test-runner plugin** — audit a page inside your own E2E run, in the state your test
|
|
62
|
+
built it:
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
import { checkA11y } from "ultra11y/playwright"; // or "ultra11y/cypress" (+ "/plugin")
|
|
66
|
+
await checkA11y(page, { as: "accueil" });
|
|
67
|
+
```
|
|
68
|
+
|
|
61
69
|
**As an agent skill** — the repository ships **two** (pick at the prompt, or pass the name):
|
|
62
70
|
`ultra11y` for full audits, compliance reports, PRD backlogs and standards packs, and
|
|
63
71
|
`review-a11y` for the fast loop that audits **exactly the code under change** (staged files,
|
|
@@ -140,8 +148,11 @@ ultra11y render [<dir>] [--scaffold | --setup | --e2e | --coverage | --storybo
|
|
|
140
148
|
ultra11y snapshot write [--root <dir>] [--fail-on blocking|major|minor] [--json] # payload on stdin → .ultra11y/pages/<id>/ + audit
|
|
141
149
|
ultra11y snapshot list [--root <dir>] [--json]
|
|
142
150
|
ultra11y pages --in <audit.json> [--standard <pack>] [--json] [--lang auto|en|fr] # the per-page criterion grid
|
|
151
|
+
ultra11y pages --in <audit.json> --format report [--split page] [--out <dir>] # the per-page report, with screenshots
|
|
152
|
+
ultra11y pages discover --sitemap <url> | --crawl <url> [--depth <n>] [--max <n>] [--write] # build the page sample
|
|
143
153
|
ultra11y dev [--port <n>] [--standard <pack>] | dev --next # live overlay while you build + dashboard
|
|
144
154
|
ultra11y criteria [<sc>] [--list] [--standard <pack> [--theme <N>]] [--generate] [--json] [--lang auto|en|fr]
|
|
155
|
+
ultra11y judge --in <audit.json> [--standard <pack>] [--max <n>] [--model <id>] [--apply] # adjudicate with a model (ANTHROPIC_API_KEY)
|
|
145
156
|
ultra11y check --report <md> [--standard <pack>] [--quiet] [--json]
|
|
146
157
|
ultra11y verify --report <md> [--standard <pack>] [--semantic] [--apply <verdicts.json>] [--max-verify <n>] [--json]
|
|
147
158
|
ultra11y fix <globs… | -> [--write] [--iterate] [--changed | --since <ref> | --staged] [--safe] [--only <ids>] [--jsx] [--json]
|
|
@@ -260,8 +271,13 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
|
|
|
260
271
|
auto-codemods, fill-in `TODO` placeholders for the agent to complete, and judgment-only
|
|
261
272
|
proposals. `--dry-run` is the default; `--write` applies but only after a re-audit proves
|
|
262
273
|
no new non-conformity, and never on lossy JSX/TSX. See `references/fix.md`.
|
|
263
|
-
- **During your E2E run** — `
|
|
264
|
-
targeted page **inside the test run
|
|
274
|
+
- **During your E2E run** — `import { checkA11y } from "ultra11y/playwright"` (or
|
|
275
|
+
`ultra11y/cypress` + `ultra11y/cypress/plugin`) audits a targeted page **inside the test run
|
|
276
|
+
you already have**; `render --e2e` still writes install-free fixtures for a repo that does
|
|
277
|
+
not depend on ultra11y, and the two cannot drift (the fixtures interpolate the published
|
|
278
|
+
module's tables, and a test gates both over the same findings). Cypress now feeds the pixel
|
|
279
|
+
tier too, via `after:screenshot`. `checkA11y(page, { report: true })` emits the per-page
|
|
280
|
+
report straight out of the run. The audit runs, so the page is checked in the state
|
|
265
281
|
your test built (logged in, form filled, modal open) — state a separate `scan` run does not
|
|
266
282
|
have. Each checked page is persisted as a **snapshot** (`.ultra11y/pages/<id>/`: the whole
|
|
267
283
|
rendered document + computed styles + boxes), which is the durable half: the same page
|
|
@@ -269,6 +285,11 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
|
|
|
269
285
|
a snapshot is a full document, the page-scoped rules finally run — RGAA **8.3** (`lang`),
|
|
270
286
|
**8.5/8.6** (`title`) — and every finding carries both its page and the source file that
|
|
271
287
|
rendered it. See `references/e2e.md` and `references/pages.md`.
|
|
288
|
+
- **In your browser** — a Chrome extension (`extension/`) audits the page you are *looking at*:
|
|
289
|
+
a staging build, a page behind a login you walked through by hand. It is a client of the
|
|
290
|
+
side-car, not a second engine — it fetches the engine's own collector rather than copying it,
|
|
291
|
+
its manifest can reach **loopback and nothing else**, and the popup decides nothing the engine
|
|
292
|
+
did not. See `references/extension.md`.
|
|
272
293
|
- **While you build** — `dev --next` writes a one-line Next overlay component and `dev` starts
|
|
273
294
|
a loopback side-car: a floating panel lists the current page's non-conformities as you
|
|
274
295
|
browse, each linking to its `file:line` in your editor, and `http://127.0.0.1:4111` shows
|
|
@@ -298,6 +319,15 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
|
|
|
298
319
|
rather than guessed, and without signals the rules do not fire, so no pre-existing verdict
|
|
299
320
|
changes. `tests/rgaa-coverage.test.ts` ratchets the result: **48 of 106** RGAA criteria now
|
|
300
321
|
map onto an engine rule, up from 43, and the number can only go up.
|
|
322
|
+
- **Every scanned page is a snapshot** — `scan` no longer keeps only findings: each page it
|
|
323
|
+
visits is persisted to `.ultra11y/pages/<id>/` (`--no-snapshot` opts out). That is what turns
|
|
324
|
+
a URL into a real per-page verdict — a page known only by its URL can never be conforming by
|
|
325
|
+
silence, so a sitemap-driven audit used to produce an almost empty grid. With a snapshot the
|
|
326
|
+
page-scoped rules run (**RGAA 8.3 / 8.5 / 8.6**), the DOM **JavaScript built at runtime** is
|
|
327
|
+
audited like any other markup, and the page re-audits **offline, with no browser**. The
|
|
328
|
+
collection happens on the *pristine* page — before axe injects its source and before any probe
|
|
329
|
+
fills an input or resizes the viewport — otherwise the snapshot would record our own
|
|
330
|
+
instrumentation instead of the site.
|
|
301
331
|
- **Page by page** — RGAA is a per-page norm; the engine's verdict is scope-wide. `pages`
|
|
302
332
|
bridges the two: one row per criterion, one column per page, embedded in `report` and
|
|
303
333
|
rebuilt from a committed `audit.json` alone. The per-page status is not recomputed — a
|
|
@@ -305,7 +335,12 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
|
|
|
305
335
|
and report agree by construction. Two rules hold the line: a finding is attributed to a page
|
|
306
336
|
only when something **says** so (else it is counted as unattributed, never spread across
|
|
307
337
|
pages), and a criterion is conforming *by silence* only on a page whose real rendered DOM
|
|
308
|
-
was audited.
|
|
338
|
+
was audited. And `pages --format report [--split page]` turns that into the **deliverable an
|
|
339
|
+
auditor hands over**: one dossier per page — identity, **screenshot**, rate, *every* criterion
|
|
340
|
+
of the standard with its status on that page, then each non-conformity as the ordinary auditor
|
|
341
|
+
block. It re-decides nothing and invents no format, so a page sheet and the compliance report
|
|
342
|
+
cannot disagree; `check` gates it against invented criteria like any other report.
|
|
343
|
+
See `references/pages.md`.
|
|
309
344
|
- **A shipped GitHub Action** — `uses: maxgfr/ultra11y@main` audits the **code** (PR diff) and,
|
|
310
345
|
optionally, the **pages** (it can start your app, wait for it, then scan real URLs or your
|
|
311
346
|
declared sample). The engine ships inside the action, so there is nothing to install and no
|
|
@@ -325,6 +360,18 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
|
|
|
325
360
|
regression variant (hook + committed baseline / GitHub Actions job) that fails only on **new**
|
|
326
361
|
non-conformities, not the existing backlog. See `references/automation.md`.
|
|
327
362
|
|
|
363
|
+
### Optional model tier (`judge`) — for the runs with no agent in the loop
|
|
364
|
+
|
|
365
|
+
Inside a coding agent the judgment criteria are the agent's to rule on. Outside one — CI, a
|
|
366
|
+
browser extension, an E2E run — nobody does, so they stay « à évaluer ». `judge` adjudicates
|
|
367
|
+
them with a model, and is a **caller, not a second judge**: the worklist, the evidence and the
|
|
368
|
+
prompt are `verify --manual`'s own, and the verdicts pass through the **same fail-closed gate**
|
|
369
|
+
an agent's do. An unjustified `C`, an `NC` citing a line that does not resolve, a verdict for a
|
|
370
|
+
criterion nobody asked about, or an incomplete run are all refused — and a rejected
|
|
371
|
+
adjudication leaves the audit untouched. All-`manual` with reasons is accepted, because that is
|
|
372
|
+
a correct answer. It is the **only** part of the tool that takes an API key; without one the
|
|
373
|
+
command explains itself and exits, and nothing else changes.
|
|
374
|
+
|
|
328
375
|
### Optional dynamic tier (axe-core)
|
|
329
376
|
|
|
330
377
|
`scan` runs **axe-core in a headless browser** to decide the *needs-rendering* criteria the static engine leaves as residual risks — chiefly **computed colour contrast (1.4.3)** plus a 320px **reflow** check (1.4.10) and a render cross-check of the structural rules. Two runtimes (default `--runtime auto`): **`--runtime local`** uses a Playwright that resolves from your project (`--cwd`) — **no Docker** — and additionally probes focus visibility (2.4.7), 200% zoom (1.4.4), text spacing (1.4.12) and content-on-hover (1.4.13), plus **stateful** probes (fill inputs then re-measure overflow, and a live-region probe for status messages 4.1.3 — opt out with `--no-interact`, `--interact-clicks` for authed-scan button clicks), and takes `--storage-state` for authenticated pages; **`--runtime docker`** falls back to a self-contained image built on first use. `--merge` folds the findings into a static `AuditResult`, upgrading `manual` criteria to `C`/`NC`. A country-standard audit scans its declared page sample with `scan --sample` (lint it first with `sample check`):
|
|
@@ -353,14 +400,19 @@ The skill (`skills/ultra11y/SKILL.md` + `references/`) teaches the agent when an
|
|
|
353
400
|
pnpm install
|
|
354
401
|
pnpm test # vitest
|
|
355
402
|
pnpm run typecheck
|
|
356
|
-
pnpm run build # tsup → scripts/ultra11y.mjs
|
|
357
|
-
pnpm run check:build # asserts
|
|
403
|
+
pnpm run build # tsup → scripts/ultra11y.mjs (engine) + dist/ (the e2e plugins)
|
|
404
|
+
pnpm run check:build # asserts every committed artefact is reproducible from its source
|
|
358
405
|
pnpm run build:wcag # re-derive src/data/wcag.json from the vendored W3C source
|
|
359
406
|
pnpm run build:pack:rgaa # re-build the RGAA pack from the vendored DINUM source
|
|
360
407
|
pnpm run build:criteria # regenerate skills/ultra11y/references/criteria.md
|
|
408
|
+
pnpm run build:icons # re-generate the browser extension's icons from their source
|
|
361
409
|
```
|
|
362
410
|
|
|
363
|
-
Releases are cut automatically by semantic-release on push to `main` (GitHub release +
|
|
411
|
+
Releases are cut automatically by **semantic-release** on push to `main` (GitHub release +
|
|
412
|
+
npm publish via OIDC trusted publishing, plus the moving `v2` major tag). The version bump is
|
|
413
|
+
decided by **Conventional Commits**: a commit whose subject carries no `feat:`/`fix:` prefix
|
|
414
|
+
is analysed as *no release*, so a change that must ship needs at least one conventional commit
|
|
415
|
+
in the range — otherwise `@v2` and npm keep serving the previous version while `main` moves on.
|
|
364
416
|
|
|
365
417
|
## Data & licensing
|
|
366
418
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { S as SnapshotPayload, F as FindingLike } from './payload-D0VQYlGS.js';
|
|
2
|
+
|
|
3
|
+
type On = (event: string, handler: any) => void;
|
|
4
|
+
interface TaskPayload extends SnapshotPayload {
|
|
5
|
+
failOn?: string | false;
|
|
6
|
+
/** The name `cy.screenshot()` was called with, when the browser half took one. */
|
|
7
|
+
screenshotName?: string;
|
|
8
|
+
}
|
|
9
|
+
interface TaskResult {
|
|
10
|
+
findings: FindingLike[];
|
|
11
|
+
failing: FindingLike[];
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
/** Register the ultra11y task (and the screenshot capture) on Cypress's node events. */
|
|
15
|
+
declare function register(on: On): void;
|
|
16
|
+
|
|
17
|
+
export { type TaskPayload, type TaskResult, register as default };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// src/integrations/cypress-plugin.ts
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
|
|
4
|
+
// src/integrations/core.ts
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { createRequire } from "module";
|
|
7
|
+
|
|
8
|
+
// src/integrations/payload.ts
|
|
9
|
+
var RANK = { bloquant: 0, majeur: 1, mineur: 2 };
|
|
10
|
+
var THRESHOLD = { blocking: 0, bloquant: 0, major: 1, majeur: 1, minor: 2, mineur: 2 };
|
|
11
|
+
function failingFindings(result, failOn) {
|
|
12
|
+
const max = THRESHOLD[failOn];
|
|
13
|
+
if (max === void 0) throw new Error(`ultra11y: failOn must be blocking|major|minor (got "${failOn}")`);
|
|
14
|
+
return (result.findings ?? []).filter((f) => !f.advisory && (RANK[f.severity] ?? 99) <= max);
|
|
15
|
+
}
|
|
16
|
+
function formatFailure(pageName, failing) {
|
|
17
|
+
const lines = [`ultra11y: ${failing.length} accessibility non-conformity(ies) on "${pageName}":`];
|
|
18
|
+
for (const f of failing.slice(0, 20)) {
|
|
19
|
+
lines.push(` [${f.severity}] ${f.ruleId} (WCAG ${f.criteriaId}) \u2014 ${f.origin?.sourceFile ?? f.file} \u2014 ${f.message}`);
|
|
20
|
+
}
|
|
21
|
+
if (failing.length > 20) lines.push(` \u2026 and ${failing.length - 20} more.`);
|
|
22
|
+
lines.push("Full detail: .ultra11y/pages/ \u2014 re-audit offline with `ultra11y audit`.");
|
|
23
|
+
return lines.join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/integrations/core.ts
|
|
27
|
+
function enginePath() {
|
|
28
|
+
if (process.env.ULTRA11Y) return process.env.ULTRA11Y;
|
|
29
|
+
try {
|
|
30
|
+
return createRequire(import.meta.url).resolve("ultra11y/scripts/ultra11y.mjs");
|
|
31
|
+
} catch {
|
|
32
|
+
return new URL("../scripts/ultra11y.mjs", import.meta.url).pathname;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function auditSnapshot(payload, engine = enginePath()) {
|
|
36
|
+
const res = spawnSync(process.execPath, [engine, "snapshot", "write", "--json"], {
|
|
37
|
+
input: JSON.stringify(payload),
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
maxBuffer: 192 * 1024 * 1024
|
|
40
|
+
});
|
|
41
|
+
if (res.error) throw new Error(`ultra11y: could not run the engine at ${engine} \u2014 ${res.error.message}`);
|
|
42
|
+
if (!res.stdout) throw new Error(`ultra11y: the engine produced no output (exit ${res.status})
|
|
43
|
+
${res.stderr || ""}`);
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(res.stdout);
|
|
46
|
+
} catch {
|
|
47
|
+
throw new Error(`ultra11y: could not parse the engine output
|
|
48
|
+
${res.stdout.slice(0, 500)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/integrations/cypress-plugin.ts
|
|
53
|
+
function register(on) {
|
|
54
|
+
const shots = /* @__PURE__ */ new Map();
|
|
55
|
+
on("after:screenshot", (details) => {
|
|
56
|
+
if (details?.name && details.path) shots.set(details.name, details.path);
|
|
57
|
+
return details;
|
|
58
|
+
});
|
|
59
|
+
on("task", {
|
|
60
|
+
ultra11ySnapshot(payload) {
|
|
61
|
+
const withShot = { ...payload };
|
|
62
|
+
const path = payload.screenshotName ? shots.get(payload.screenshotName) : void 0;
|
|
63
|
+
if (path) {
|
|
64
|
+
try {
|
|
65
|
+
withShot.screenshot = readFileSync(path).toString("base64");
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const result = auditSnapshot(withShot);
|
|
70
|
+
const failOn = payload.failOn === void 0 ? "blocking" : payload.failOn;
|
|
71
|
+
const failing = failOn === false ? [] : failingFindings(result, failOn);
|
|
72
|
+
return {
|
|
73
|
+
findings: result.findings ?? [],
|
|
74
|
+
failing,
|
|
75
|
+
message: failing.length ? formatFailure(String(payload.meta.name), failing) : ""
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export {
|
|
81
|
+
register as default
|
|
82
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { C as CheckOptions } from './payload-D0VQYlGS.js';
|
|
2
|
+
export { s as slugify } from './payload-D0VQYlGS.js';
|
|
3
|
+
|
|
4
|
+
interface CypressCheckOptions extends CheckOptions {
|
|
5
|
+
/** Capture a viewport screenshot so the pixel tier can run. On by default — the plugin
|
|
6
|
+
* reads it back through Cypress's `after:screenshot` event. */
|
|
7
|
+
screenshot?: boolean;
|
|
8
|
+
}
|
|
9
|
+
declare function registerUltra11yCommand(): void;
|
|
10
|
+
|
|
11
|
+
export { type CypressCheckOptions, registerUltra11yCommand };
|
package/dist/cypress.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// src/collector.ts
|
|
2
|
+
var COLLECTED_CSS = [
|
|
3
|
+
"color",
|
|
4
|
+
"backgroundColor",
|
|
5
|
+
"backgroundImage",
|
|
6
|
+
"fontSize",
|
|
7
|
+
"fontWeight",
|
|
8
|
+
"fontStyle",
|
|
9
|
+
"textDecorationLine",
|
|
10
|
+
"textTransform",
|
|
11
|
+
"lineHeight",
|
|
12
|
+
"letterSpacing",
|
|
13
|
+
"wordSpacing",
|
|
14
|
+
"whiteSpace",
|
|
15
|
+
"display",
|
|
16
|
+
"visibility",
|
|
17
|
+
"opacity",
|
|
18
|
+
"position",
|
|
19
|
+
"overflowX",
|
|
20
|
+
"overflowY",
|
|
21
|
+
"outlineStyle",
|
|
22
|
+
"outlineWidth",
|
|
23
|
+
"outlineColor",
|
|
24
|
+
// All four borders + the box shadow: WCAG 1.4.11 asks whether a control's BOUNDARY is
|
|
25
|
+
// perceivable, and a boundary can be drawn by any side, by the fill, by an outline or by a
|
|
26
|
+
// shadow. Collecting only one side would manufacture non-conformities on the other three.
|
|
27
|
+
"borderTopStyle",
|
|
28
|
+
"borderRightStyle",
|
|
29
|
+
"borderBottomStyle",
|
|
30
|
+
"borderLeftStyle",
|
|
31
|
+
"borderTopWidth",
|
|
32
|
+
"borderRightWidth",
|
|
33
|
+
"borderBottomWidth",
|
|
34
|
+
"borderLeftWidth",
|
|
35
|
+
"borderTopColor",
|
|
36
|
+
"borderRightColor",
|
|
37
|
+
"borderBottomColor",
|
|
38
|
+
"borderLeftColor",
|
|
39
|
+
"boxShadow",
|
|
40
|
+
"cursor"
|
|
41
|
+
];
|
|
42
|
+
var COLLECT_MAX_ELEMENTS = 5e3;
|
|
43
|
+
var COLLECT_SNAPSHOT = `(() => {
|
|
44
|
+
const PROPS = ${JSON.stringify(COLLECTED_CSS)};
|
|
45
|
+
const MAX = ${COLLECT_MAX_ELEMENTS};
|
|
46
|
+
const els = document.querySelectorAll('*');
|
|
47
|
+
const styles = [];
|
|
48
|
+
const boxes = [];
|
|
49
|
+
const n = Math.min(els.length, MAX);
|
|
50
|
+
for (let i = 0; i < n; i++) {
|
|
51
|
+
const el = els[i];
|
|
52
|
+
const tag = el.tagName.toLowerCase();
|
|
53
|
+
const cs = getComputedStyle(el);
|
|
54
|
+
const css = {};
|
|
55
|
+
for (const p of PROPS) {
|
|
56
|
+
const v = cs[p];
|
|
57
|
+
if (v !== undefined && v !== null && v !== '') css[p] = String(v);
|
|
58
|
+
}
|
|
59
|
+
styles.push({ i: i, tag: tag, css: css });
|
|
60
|
+
const r = el.getBoundingClientRect();
|
|
61
|
+
boxes.push({ i: i, tag: tag, x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) });
|
|
62
|
+
}
|
|
63
|
+
// The page's own stylesheets. Some criteria are properties of the STYLESHEET rather than of
|
|
64
|
+
// any element's computed style \u2014 focus styling removed with no replacement (2.4.7), a media
|
|
65
|
+
// query that locks the orientation (1.3.4) \u2014 and this is the only place they exist.
|
|
66
|
+
// A cross-origin sheet throws on .cssRules: COUNT those instead of ignoring them, so a rule
|
|
67
|
+
// reading this digest can tell "nothing there" apart from "I could not look".
|
|
68
|
+
const cssRules = [];
|
|
69
|
+
let unreadable = 0;
|
|
70
|
+
const MAXR = 4000;
|
|
71
|
+
const camel = (p) => p.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
72
|
+
const walk = (list, media) => {
|
|
73
|
+
for (const r of list) {
|
|
74
|
+
if (cssRules.length >= MAXR) return;
|
|
75
|
+
if (typeof r.selectorText === 'string' && r.style) {
|
|
76
|
+
const decls = {};
|
|
77
|
+
for (let i = 0; i < r.style.length; i++) {
|
|
78
|
+
const prop = r.style[i];
|
|
79
|
+
decls[camel(prop)] = r.style.getPropertyValue(prop);
|
|
80
|
+
}
|
|
81
|
+
cssRules.push(media ? { selector: r.selectorText, media: media, decls: decls } : { selector: r.selectorText, decls: decls });
|
|
82
|
+
} else if (r.cssRules) {
|
|
83
|
+
const cond = r.conditionText || (r.media && r.media.mediaText) || media;
|
|
84
|
+
walk(r.cssRules, cond);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
for (const sheet of document.styleSheets) {
|
|
89
|
+
try {
|
|
90
|
+
walk(sheet.cssRules, undefined);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
unreadable++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const truncated = els.length > MAX;
|
|
97
|
+
return {
|
|
98
|
+
dom: document.documentElement.outerHTML,
|
|
99
|
+
css: { v: 1, rules: cssRules, unreadable: unreadable, truncated: cssRules.length >= MAXR },
|
|
100
|
+
title: document.title,
|
|
101
|
+
lang: document.documentElement.getAttribute('lang') || '',
|
|
102
|
+
url: location.href,
|
|
103
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
104
|
+
styles: { v: 1, entries: styles, truncated: truncated },
|
|
105
|
+
boxes: { v: 1, entries: boxes, truncated: truncated }
|
|
106
|
+
};
|
|
107
|
+
})()`;
|
|
108
|
+
|
|
109
|
+
// src/integrations/payload.ts
|
|
110
|
+
function slugify(url) {
|
|
111
|
+
let path = url;
|
|
112
|
+
try {
|
|
113
|
+
path = new URL(url).pathname;
|
|
114
|
+
try {
|
|
115
|
+
path = decodeURIComponent(path);
|
|
116
|
+
} catch {
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
}
|
|
120
|
+
const slug = path.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
121
|
+
return slug || (path === "/" || path === "" ? "accueil" : "page");
|
|
122
|
+
}
|
|
123
|
+
function buildPayload(collected, url, runner, opts, screenshot) {
|
|
124
|
+
const id = opts.as || slugify(url);
|
|
125
|
+
return {
|
|
126
|
+
meta: {
|
|
127
|
+
v: 1,
|
|
128
|
+
id,
|
|
129
|
+
name: opts.name || collected.title || id,
|
|
130
|
+
url,
|
|
131
|
+
runner,
|
|
132
|
+
viewport: collected.viewport,
|
|
133
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
134
|
+
...opts.auth !== void 0 ? { auth: opts.auth } : {},
|
|
135
|
+
...opts.sources ? { sources: opts.sources } : {},
|
|
136
|
+
...opts.notes ? { notes: opts.notes } : {}
|
|
137
|
+
},
|
|
138
|
+
dom: collected.dom,
|
|
139
|
+
styles: collected.styles,
|
|
140
|
+
boxes: collected.boxes,
|
|
141
|
+
css: collected.css,
|
|
142
|
+
...screenshot ? { screenshot } : {}
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/integrations/cypress.ts
|
|
147
|
+
function registerUltra11yCommand() {
|
|
148
|
+
Cypress.Commands.add("ultra11y", (opts = {}) => {
|
|
149
|
+
const shotName = opts.screenshot === false ? void 0 : `ultra11y-${opts.as || "page"}-${Date.now()}`;
|
|
150
|
+
if (shotName) cy.screenshot(shotName, { capture: "viewport", log: false });
|
|
151
|
+
return cy.window({ log: false }).then((win) => {
|
|
152
|
+
const collected = win.eval(COLLECT_SNAPSHOT);
|
|
153
|
+
const url = collected.url || win.location.href;
|
|
154
|
+
const payload = {
|
|
155
|
+
...buildPayload(collected, url, "cypress", opts),
|
|
156
|
+
failOn: opts.failOn,
|
|
157
|
+
...shotName ? { screenshotName: shotName } : {}
|
|
158
|
+
};
|
|
159
|
+
return cy.task("ultra11ySnapshot", payload, { log: false }).then((res) => {
|
|
160
|
+
if (res?.failing?.length) throw new Error(res.message);
|
|
161
|
+
return res;
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
registerUltra11yCommand();
|
|
167
|
+
export {
|
|
168
|
+
registerUltra11yCommand,
|
|
169
|
+
slugify
|
|
170
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
interface FindingLike {
|
|
2
|
+
ruleId: string;
|
|
3
|
+
criteriaId: string;
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
severity: string;
|
|
7
|
+
message: string;
|
|
8
|
+
advisory?: boolean;
|
|
9
|
+
origin?: {
|
|
10
|
+
sourceFile?: string;
|
|
11
|
+
sourceLine?: number;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
interface AuditLike {
|
|
15
|
+
findings?: FindingLike[];
|
|
16
|
+
}
|
|
17
|
+
interface SnapshotPayload {
|
|
18
|
+
meta: Record<string, unknown>;
|
|
19
|
+
dom: string;
|
|
20
|
+
styles?: unknown;
|
|
21
|
+
boxes?: unknown;
|
|
22
|
+
css?: unknown;
|
|
23
|
+
screenshot?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Same slug rule as the engine (src/snapshot.ts slugifyPageId): accent-folded URL path. */
|
|
26
|
+
declare function slugify(url: string): string;
|
|
27
|
+
interface CheckOptions {
|
|
28
|
+
/** The page id (directory name). Defaults to the slugified URL path. */
|
|
29
|
+
as?: string;
|
|
30
|
+
/** Human page name for the report. Defaults to the document title. */
|
|
31
|
+
name?: string;
|
|
32
|
+
/** Severity at which the check fails, or `false` to record without ever failing. */
|
|
33
|
+
failOn?: "blocking" | "major" | "minor" | false;
|
|
34
|
+
/** Mark the page as sitting behind authentication. */
|
|
35
|
+
auth?: boolean;
|
|
36
|
+
/** Source files that rendered it — findings are then reported against YOUR code. */
|
|
37
|
+
sources?: string[];
|
|
38
|
+
/** Reproduction notes carried into the auditor ticket. */
|
|
39
|
+
notes?: string;
|
|
40
|
+
/** Capture a viewport screenshot for the pixel tier (Playwright: on by default). */
|
|
41
|
+
screenshot?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { type AuditLike as A, type CheckOptions as C, type FindingLike as F, type SnapshotPayload as S, slugify as s };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { C as CheckOptions, A as AuditLike } from './payload-D0VQYlGS.js';
|
|
2
|
+
|
|
3
|
+
interface PlaywrightPage {
|
|
4
|
+
evaluate(script: string): Promise<unknown>;
|
|
5
|
+
screenshot(opts: {
|
|
6
|
+
fullPage: boolean;
|
|
7
|
+
}): Promise<Buffer>;
|
|
8
|
+
url(): string;
|
|
9
|
+
}
|
|
10
|
+
interface PlaywrightCheckOptions extends CheckOptions {
|
|
11
|
+
/** Also write the per-page report once this page is recorded. Off by default: a report
|
|
12
|
+
* per checked page would be wasteful in a suite; turn it on in a final test. */
|
|
13
|
+
report?: boolean | {
|
|
14
|
+
out?: string;
|
|
15
|
+
standard?: string;
|
|
16
|
+
lang?: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Collect the current page, persist it as a snapshot, audit it, and fail on the threshold. */
|
|
20
|
+
declare function checkA11y(page: PlaywrightPage, opts?: PlaywrightCheckOptions): Promise<AuditLike>;
|
|
21
|
+
/** Optional fixture form. Import `test` from here instead of `@playwright/test`.
|
|
22
|
+
*
|
|
23
|
+
* Playwright belongs to YOUR project, not to this package, so it is resolved from the
|
|
24
|
+
* PROJECT FIRST (cwd) and only then relative to this module — the same order
|
|
25
|
+
* `scan --runtime local` uses (src/scan-local.ts resolveLocalDeps). Module-relative alone
|
|
26
|
+
* works for an ordinary `node_modules/ultra11y` install, where Node walks up into the
|
|
27
|
+
* project's own tree, but silently yields `undefined` whenever the package is linked,
|
|
28
|
+
* pnpm-hoisted elsewhere, or in a monorepo — and a fixture that is `undefined` for
|
|
29
|
+
* layout reasons is a confusing failure.
|
|
30
|
+
*
|
|
31
|
+
* Resolution is lazy and guarded either way, so importing this in a Cypress-only repo
|
|
32
|
+
* gives `undefined` rather than a crash. */
|
|
33
|
+
declare const test: any;
|
|
34
|
+
|
|
35
|
+
export { AuditLike, CheckOptions, type PlaywrightCheckOptions, checkA11y, test };
|