ultra11y 2.30.0 → 2.31.1
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 +65 -8
- package/dist/cypress-plugin.d.ts +23 -0
- package/dist/cypress-plugin.mjs +95 -0
- package/dist/cypress.d.ts +22 -0
- package/dist/cypress.mjs +171 -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 +1445 -350
- 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 +1445 -350
- package/skills/ultra11y/SKILL.md +25 -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 +45 -11
- package/skills/ultra11y/references/extension.md +52 -0
- package/skills/ultra11y/references/judgment.md +43 -0
- package/skills/ultra11y/references/pages.md +66 -3
- package/skills/ultra11y/scripts/ultra11y.mjs +1445 -350
- package/src/annotate.ts +53 -13
- package/src/check.ts +22 -5
- package/src/cli.ts +439 -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 +142 -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 +78 -0
- package/src/integrations/cypress.ts +64 -0
- package/src/integrations/payload.ts +141 -0
- package/src/integrations/playwright.ts +95 -0
- package/src/llm.ts +259 -0
- package/src/mcp/handlers.ts +60 -2
- package/src/mcp/tools.ts +26 -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.1",
|
|
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.1",
|
|
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.1",
|
|
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.1";
|
|
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]
|
|
@@ -184,7 +195,7 @@ the one output an accessibility tool must never produce.
|
|
|
184
195
|
|
|
185
196
|
### Tools
|
|
186
197
|
|
|
187
|
-
|
|
198
|
+
Eleven read tools. `ultra11y_audit` is the entry point:
|
|
188
199
|
|
|
189
200
|
| Tool | What it does |
|
|
190
201
|
|------|--------------|
|
|
@@ -197,6 +208,7 @@ Ten read tools. `ultra11y_audit` is the entry point:
|
|
|
197
208
|
| `ultra11y_verify` | Claim↔evidence worklist |
|
|
198
209
|
| `ultra11y_pack_check` | Validate a country standards pack against what it really ships |
|
|
199
210
|
| `ultra11y_sample_check` | Lint the normative page sample |
|
|
211
|
+
| `ultra11y_pages` | The per-page view: the criterion × page grid, or one dossier per page |
|
|
200
212
|
| `ultra11y_read` | A file, or a line range, from the project |
|
|
201
213
|
|
|
202
214
|
`--allow-write` additionally exposes `ultra11y_fix` (safe codemods, dry-run by
|
|
@@ -205,6 +217,10 @@ project. `ultra11y_scan` is declared but declines over MCP: it drives a headless
|
|
|
205
217
|
browser and may pull a Docker image, which is not a subprocess lifecycle a
|
|
206
218
|
long-lived server should own. It tells you to run it from the CLI and re-audit.
|
|
207
219
|
|
|
220
|
+
Every tool audits exactly what the CLI would: the rendered captures and the page
|
|
221
|
+
snapshots under `.ultra11y/` are ingested here too, so the server never reports a
|
|
222
|
+
smaller audit than the same command run by hand.
|
|
223
|
+
|
|
208
224
|
Pass `--cwd <dir>` at startup to dedicate the server to one project — `cwd` then
|
|
209
225
|
becomes optional on every tool.
|
|
210
226
|
|
|
@@ -260,8 +276,13 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
|
|
|
260
276
|
auto-codemods, fill-in `TODO` placeholders for the agent to complete, and judgment-only
|
|
261
277
|
proposals. `--dry-run` is the default; `--write` applies but only after a re-audit proves
|
|
262
278
|
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
|
|
279
|
+
- **During your E2E run** — `import { checkA11y } from "ultra11y/playwright"` (or
|
|
280
|
+
`ultra11y/cypress` + `ultra11y/cypress/plugin`) audits a targeted page **inside the test run
|
|
281
|
+
you already have**; `render --e2e` still writes install-free fixtures for a repo that does
|
|
282
|
+
not depend on ultra11y, and the two cannot drift (the fixtures interpolate the published
|
|
283
|
+
module's tables, and a test gates both over the same findings). Cypress now feeds the pixel
|
|
284
|
+
tier too, via `after:screenshot`. `checkA11y(page, { report: true })` emits the per-page
|
|
285
|
+
report straight out of the run. The audit runs, so the page is checked in the state
|
|
265
286
|
your test built (logged in, form filled, modal open) — state a separate `scan` run does not
|
|
266
287
|
have. Each checked page is persisted as a **snapshot** (`.ultra11y/pages/<id>/`: the whole
|
|
267
288
|
rendered document + computed styles + boxes), which is the durable half: the same page
|
|
@@ -269,6 +290,11 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
|
|
|
269
290
|
a snapshot is a full document, the page-scoped rules finally run — RGAA **8.3** (`lang`),
|
|
270
291
|
**8.5/8.6** (`title`) — and every finding carries both its page and the source file that
|
|
271
292
|
rendered it. See `references/e2e.md` and `references/pages.md`.
|
|
293
|
+
- **In your browser** — a Chrome extension (`extension/`) audits the page you are *looking at*:
|
|
294
|
+
a staging build, a page behind a login you walked through by hand. It is a client of the
|
|
295
|
+
side-car, not a second engine — it fetches the engine's own collector rather than copying it,
|
|
296
|
+
its manifest can reach **loopback and nothing else**, and the popup decides nothing the engine
|
|
297
|
+
did not. See `references/extension.md`.
|
|
272
298
|
- **While you build** — `dev --next` writes a one-line Next overlay component and `dev` starts
|
|
273
299
|
a loopback side-car: a floating panel lists the current page's non-conformities as you
|
|
274
300
|
browse, each linking to its `file:line` in your editor, and `http://127.0.0.1:4111` shows
|
|
@@ -298,6 +324,15 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
|
|
|
298
324
|
rather than guessed, and without signals the rules do not fire, so no pre-existing verdict
|
|
299
325
|
changes. `tests/rgaa-coverage.test.ts` ratchets the result: **48 of 106** RGAA criteria now
|
|
300
326
|
map onto an engine rule, up from 43, and the number can only go up.
|
|
327
|
+
- **Every scanned page is a snapshot** — `scan` no longer keeps only findings: each page it
|
|
328
|
+
visits is persisted to `.ultra11y/pages/<id>/` (`--no-snapshot` opts out). That is what turns
|
|
329
|
+
a URL into a real per-page verdict — a page known only by its URL can never be conforming by
|
|
330
|
+
silence, so a sitemap-driven audit used to produce an almost empty grid. With a snapshot the
|
|
331
|
+
page-scoped rules run (**RGAA 8.3 / 8.5 / 8.6**), the DOM **JavaScript built at runtime** is
|
|
332
|
+
audited like any other markup, and the page re-audits **offline, with no browser**. The
|
|
333
|
+
collection happens on the *pristine* page — before axe injects its source and before any probe
|
|
334
|
+
fills an input or resizes the viewport — otherwise the snapshot would record our own
|
|
335
|
+
instrumentation instead of the site.
|
|
301
336
|
- **Page by page** — RGAA is a per-page norm; the engine's verdict is scope-wide. `pages`
|
|
302
337
|
bridges the two: one row per criterion, one column per page, embedded in `report` and
|
|
303
338
|
rebuilt from a committed `audit.json` alone. The per-page status is not recomputed — a
|
|
@@ -305,7 +340,12 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
|
|
|
305
340
|
and report agree by construction. Two rules hold the line: a finding is attributed to a page
|
|
306
341
|
only when something **says** so (else it is counted as unattributed, never spread across
|
|
307
342
|
pages), and a criterion is conforming *by silence* only on a page whose real rendered DOM
|
|
308
|
-
was audited.
|
|
343
|
+
was audited. And `pages --format report [--split page]` turns that into the **deliverable an
|
|
344
|
+
auditor hands over**: one dossier per page — identity, **screenshot**, rate, *every* criterion
|
|
345
|
+
of the standard with its status on that page, then each non-conformity as the ordinary auditor
|
|
346
|
+
block. It re-decides nothing and invents no format, so a page sheet and the compliance report
|
|
347
|
+
cannot disagree; `check` gates it against invented criteria like any other report.
|
|
348
|
+
See `references/pages.md`.
|
|
309
349
|
- **A shipped GitHub Action** — `uses: maxgfr/ultra11y@main` audits the **code** (PR diff) and,
|
|
310
350
|
optionally, the **pages** (it can start your app, wait for it, then scan real URLs or your
|
|
311
351
|
declared sample). The engine ships inside the action, so there is nothing to install and no
|
|
@@ -325,6 +365,18 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
|
|
|
325
365
|
regression variant (hook + committed baseline / GitHub Actions job) that fails only on **new**
|
|
326
366
|
non-conformities, not the existing backlog. See `references/automation.md`.
|
|
327
367
|
|
|
368
|
+
### Optional model tier (`judge`) — for the runs with no agent in the loop
|
|
369
|
+
|
|
370
|
+
Inside a coding agent the judgment criteria are the agent's to rule on. Outside one — CI, a
|
|
371
|
+
browser extension, an E2E run — nobody does, so they stay « à évaluer ». `judge` adjudicates
|
|
372
|
+
them with a model, and is a **caller, not a second judge**: the worklist, the evidence and the
|
|
373
|
+
prompt are `verify --manual`'s own, and the verdicts pass through the **same fail-closed gate**
|
|
374
|
+
an agent's do. An unjustified `C`, an `NC` citing a line that does not resolve, a verdict for a
|
|
375
|
+
criterion nobody asked about, or an incomplete run are all refused — and a rejected
|
|
376
|
+
adjudication leaves the audit untouched. All-`manual` with reasons is accepted, because that is
|
|
377
|
+
a correct answer. It is the **only** part of the tool that takes an API key; without one the
|
|
378
|
+
command explains itself and exits, and nothing else changes.
|
|
379
|
+
|
|
328
380
|
### Optional dynamic tier (axe-core)
|
|
329
381
|
|
|
330
382
|
`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 +405,19 @@ The skill (`skills/ultra11y/SKILL.md` + `references/`) teaches the agent when an
|
|
|
353
405
|
pnpm install
|
|
354
406
|
pnpm test # vitest
|
|
355
407
|
pnpm run typecheck
|
|
356
|
-
pnpm run build # tsup → scripts/ultra11y.mjs
|
|
357
|
-
pnpm run check:build # asserts
|
|
408
|
+
pnpm run build # tsup → scripts/ultra11y.mjs (engine) + dist/ (the e2e plugins)
|
|
409
|
+
pnpm run check:build # asserts every committed artefact is reproducible from its source
|
|
358
410
|
pnpm run build:wcag # re-derive src/data/wcag.json from the vendored W3C source
|
|
359
411
|
pnpm run build:pack:rgaa # re-build the RGAA pack from the vendored DINUM source
|
|
360
412
|
pnpm run build:criteria # regenerate skills/ultra11y/references/criteria.md
|
|
413
|
+
pnpm run build:icons # re-generate the browser extension's icons from their source
|
|
361
414
|
```
|
|
362
415
|
|
|
363
|
-
Releases are cut automatically by semantic-release on push to `main` (GitHub release +
|
|
416
|
+
Releases are cut automatically by **semantic-release** on push to `main` (GitHub release +
|
|
417
|
+
npm publish via OIDC trusted publishing, plus the moving `v2` major tag). The version bump is
|
|
418
|
+
decided by **Conventional Commits**: a commit whose subject carries no `feat:`/`fix:` prefix
|
|
419
|
+
is analysed as *no release*, so a change that must ship needs at least one conventional commit
|
|
420
|
+
in the range — otherwise `@v2` and npm keep serving the previous version while `main` moves on.
|
|
364
421
|
|
|
365
422
|
## Data & licensing
|
|
366
423
|
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
/** Forwarded from `cy.ultra11y({ report })` — the browser cannot write files, this side can. */
|
|
9
|
+
report?: boolean | {
|
|
10
|
+
out?: string;
|
|
11
|
+
standard?: string;
|
|
12
|
+
lang?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
interface TaskResult {
|
|
16
|
+
findings: FindingLike[];
|
|
17
|
+
failing: FindingLike[];
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
/** Register the ultra11y task (and the screenshot capture) on Cypress's node events. */
|
|
21
|
+
declare function register(on: On): void;
|
|
22
|
+
|
|
23
|
+
export { type TaskPayload, type TaskResult, register as default };
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
function writePagesReport(opts = {}, engine = enginePath()) {
|
|
52
|
+
const args = [engine, "pages", "--in", "-", "--format", "report"];
|
|
53
|
+
if (opts.split !== false) args.push("--split", "page");
|
|
54
|
+
args.push("--out", opts.out ?? "audits/pages");
|
|
55
|
+
if (opts.standard) args.push("--standard", opts.standard);
|
|
56
|
+
if (opts.lang) args.push("--lang", opts.lang);
|
|
57
|
+
const audit = spawnSync(process.execPath, [engine, "audit", ".ultra11y/pages", "--json"], { encoding: "utf8", maxBuffer: 192 * 1024 * 1024 });
|
|
58
|
+
if (audit.error || !audit.stdout) return void 0;
|
|
59
|
+
const res = spawnSync(process.execPath, args, { input: audit.stdout, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
60
|
+
if (res.error || res.status !== 0) return void 0;
|
|
61
|
+
return res.stdout.trim() || void 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/integrations/cypress-plugin.ts
|
|
65
|
+
function register(on) {
|
|
66
|
+
const shots = /* @__PURE__ */ new Map();
|
|
67
|
+
on("after:screenshot", (details) => {
|
|
68
|
+
if (details?.name && details.path) shots.set(details.name, details.path);
|
|
69
|
+
return details;
|
|
70
|
+
});
|
|
71
|
+
on("task", {
|
|
72
|
+
ultra11ySnapshot(payload) {
|
|
73
|
+
const withShot = { ...payload };
|
|
74
|
+
const path = payload.screenshotName ? shots.get(payload.screenshotName) : void 0;
|
|
75
|
+
if (path) {
|
|
76
|
+
try {
|
|
77
|
+
withShot.screenshot = readFileSync(path).toString("base64");
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const result = auditSnapshot(withShot);
|
|
82
|
+
if (payload.report) writePagesReport(typeof payload.report === "object" ? payload.report : {});
|
|
83
|
+
const failOn = payload.failOn === void 0 ? "blocking" : payload.failOn;
|
|
84
|
+
const failing = failOn === false ? [] : failingFindings(result, failOn);
|
|
85
|
+
return {
|
|
86
|
+
findings: result.findings ?? [],
|
|
87
|
+
failing,
|
|
88
|
+
message: failing.length ? formatFailure(String(payload.meta.name), failing) : ""
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export {
|
|
94
|
+
register as default
|
|
95
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
/** Also write the per-page report once this page is recorded. Off by default: a report per
|
|
9
|
+
* checked page would be wasteful in a suite, so turn it on in a final test.
|
|
10
|
+
*
|
|
11
|
+
* Cypress test code runs in the browser and cannot write files, so the option is only
|
|
12
|
+
* DECLARED here and forwarded — the Node half does the work. Same option, same behaviour
|
|
13
|
+
* as Playwright's; a runner that silently ignored it would be worse than one that lacked it. */
|
|
14
|
+
report?: boolean | {
|
|
15
|
+
out?: string;
|
|
16
|
+
standard?: string;
|
|
17
|
+
lang?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
declare function registerUltra11yCommand(): void;
|
|
21
|
+
|
|
22
|
+
export { type CypressCheckOptions, registerUltra11yCommand };
|
package/dist/cypress.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
...opts.report ? { report: opts.report } : {},
|
|
158
|
+
...shotName ? { screenshotName: shotName } : {}
|
|
159
|
+
};
|
|
160
|
+
return cy.task("ultra11ySnapshot", payload, { log: false }).then((res) => {
|
|
161
|
+
if (res?.failing?.length) throw new Error(res.message);
|
|
162
|
+
return res;
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
registerUltra11yCommand();
|
|
168
|
+
export {
|
|
169
|
+
registerUltra11yCommand,
|
|
170
|
+
slugify
|
|
171
|
+
};
|
|
@@ -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 };
|