ultra11y 4.4.1 → 4.5.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 +2 -2
- package/package.json +1 -1
- package/scripts/build-standards.mjs +45 -0
- package/scripts/ultra11y.mjs +30 -3
- package/scripts/vendor/wcag-2.2-fr.json +1 -1
- package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
- package/scripts/vendor/wcag-2.2-techniques.json +1 -1
- package/scripts/vendor/wcag-2.2-text.json +1 -1
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +30 -3
- package/skills/ultra11y/SKILL.md +1 -1
- package/skills/ultra11y/references/ci.md +71 -8
- package/skills/ultra11y/references/pages.md +11 -1
- package/skills/ultra11y/scripts/ultra11y.mjs +30 -3
- package/src/cli.ts +12 -0
- package/src/data/wcag-universe.json +1 -1
- package/src/scan-local.ts +42 -1
- package/src/types.ts +16 -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": "4.
|
|
13
|
+
"version": "4.5.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": "4.
|
|
4
|
+
"version": "4.5.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": "4.
|
|
4
|
+
"version": "4.5.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 = "4.
|
|
30
|
+
const ULTRA11Y_PLUGIN_VERSION = "4.5.1";
|
|
31
31
|
|
|
32
32
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
33
|
|
package/README.md
CHANGED
|
@@ -125,7 +125,7 @@ claude mcp add ultra11y -- npx -y ultra11y mcp
|
|
|
125
125
|
**As a GitHub Action** — audits the PR diff and, optionally, the served pages:
|
|
126
126
|
|
|
127
127
|
```yaml
|
|
128
|
-
- uses: maxgfr/ultra11y@
|
|
128
|
+
- uses: maxgfr/ultra11y@v4
|
|
129
129
|
with: { since: auto, standard: rgaa, fail-on: blocking }
|
|
130
130
|
```
|
|
131
131
|
|
|
@@ -383,7 +383,7 @@ a test). See [`CONTRIBUTING.md`](CONTRIBUTING.md) and `skills/ultra11y/reference
|
|
|
383
383
|
block. It re-decides nothing and invents no format, so a page sheet and the compliance report
|
|
384
384
|
cannot disagree; `check` gates it against invented criteria like any other report.
|
|
385
385
|
See `references/pages.md`.
|
|
386
|
-
- **A shipped GitHub Action** — `uses: maxgfr/ultra11y@
|
|
386
|
+
- **A shipped GitHub Action** — `uses: maxgfr/ultra11y@v4` audits the **code** (PR diff) and,
|
|
387
387
|
optionally, the **pages** (it can start your app, wait for it, then scan real URLs or your
|
|
388
388
|
declared sample). The engine ships inside the action, so there is nothing to install and no
|
|
389
389
|
`setup-node`. The gate runs **last**, after SARIF, annotations, the summary, the sticky PR
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.1",
|
|
4
4
|
"description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.33.0",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// node scripts/build-standards.mjs --offline # same (alias; the snapshots are always local)
|
|
14
14
|
// node scripts/build-standards.mjs --refresh <dir> # re-derive the vendored AA snapshot from a w3c/wcag checkout
|
|
15
15
|
// node scripts/build-standards.mjs --refresh-universe # re-fetch (network) the vendored FULL SC universe (all levels + removed 4.1.1)
|
|
16
|
+
// node scripts/build-standards.mjs --refresh-core # re-derive the shipped AA snapshot from that universe (no checkout, no network)
|
|
16
17
|
// node scripts/build-standards.mjs --refresh-fr # re-fetch (network) the vendored French SC/guideline/principle titles
|
|
17
18
|
// node scripts/build-standards.mjs --refresh-text # re-fetch (network) the vendored NORMATIVE SC text + the WCAG glossary
|
|
18
19
|
import { writeFileSync, readFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
|
|
@@ -398,6 +399,49 @@ async function deriveFr() {
|
|
|
398
399
|
return out;
|
|
399
400
|
}
|
|
400
401
|
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// Core mode: the shipped AA snapshot, derived from the vendored UNIVERSE.
|
|
404
|
+
//
|
|
405
|
+
// `deriveSnapshot` above reads a local w3c/wcag checkout, which is why it was the one
|
|
406
|
+
// vendored source the daily refresh could not touch — a new or renamed success criterion
|
|
407
|
+
// would land in the universe and never reach the shipped core.
|
|
408
|
+
//
|
|
409
|
+
// It needs no checkout and no extra network: `--refresh-universe` already fetches the same
|
|
410
|
+
// numbering and titles for EVERY criterion, and records which are `core-AA`. The AA slice
|
|
411
|
+
// is that filter. Byte-identical to what the checkout produces (tests/wcag-core-sync).
|
|
412
|
+
//
|
|
413
|
+
// node scripts/build-standards.mjs --refresh-core
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
function deriveCoreFromUniverse() {
|
|
416
|
+
if (!existsSync(VENDOR_UNIVERSE)) {
|
|
417
|
+
console.error(`build-standards --refresh-core: missing ${VENDOR_UNIVERSE}. Run: node scripts/build-standards.mjs --refresh-universe`);
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
const universe = JSON.parse(readFileSync(VENDOR_UNIVERSE, "utf8"));
|
|
421
|
+
const aa = universe.criteria.filter((c) => c.status === "core-AA").map(({ status, ...rest }) => rest);
|
|
422
|
+
if (!aa.length) {
|
|
423
|
+
console.error(`build-standards --refresh-core: ${VENDOR_UNIVERSE} classifies no criterion as core-AA — refusing to write an empty core.`);
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
const used = new Set(aa.map((c) => c.guideline));
|
|
427
|
+
const snapshot = {
|
|
428
|
+
wcagVersion: universe.wcagVersion,
|
|
429
|
+
source: universe.source,
|
|
430
|
+
criteriaSource: universe.criteriaSource,
|
|
431
|
+
principles: universe.principles.map((p) => ({ number: p.number, title: p.title })),
|
|
432
|
+
guidelines: universe.guidelines.filter((g) => used.has(g.number)).map((g) => ({ number: g.number, title: g.title })),
|
|
433
|
+
criteria: aa,
|
|
434
|
+
};
|
|
435
|
+
return JSON.stringify(snapshot, null, 2) + "\n";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function refreshCore() {
|
|
439
|
+
const text = deriveCoreFromUniverse();
|
|
440
|
+
mkdirSync(dirname(VENDOR), { recursive: true });
|
|
441
|
+
writeFileSync(VENDOR, text);
|
|
442
|
+
console.log(`build-standards --refresh-core: ${JSON.parse(text).criteria.length} A/AA criteria derived from the vendored universe → ${VENDOR}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
401
445
|
// ---------------------------------------------------------------------------
|
|
402
446
|
// Text mode: the NORMATIVE WORDING of every shipped success criterion, plus the terms
|
|
403
447
|
// WCAG itself defines.
|
|
@@ -830,6 +874,7 @@ async function main() {
|
|
|
830
874
|
const refreshIdx = process.argv.indexOf("--refresh");
|
|
831
875
|
if (refreshIdx !== -1) deriveSnapshot(process.argv[refreshIdx + 1]);
|
|
832
876
|
if (process.argv.includes("--refresh-universe")) await deriveUniverse();
|
|
877
|
+
if (process.argv.includes("--refresh-core")) refreshCore();
|
|
833
878
|
if (process.argv.includes("--refresh-fr")) await deriveFr();
|
|
834
879
|
if (process.argv.includes("--refresh-text")) await deriveText();
|
|
835
880
|
build();
|
package/scripts/ultra11y.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
|
|
|
6
6
|
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/types.ts
|
|
9
|
-
var VERSION = "4.
|
|
9
|
+
var VERSION = "4.5.1";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -2289,7 +2289,7 @@ var wcag_universe_default = {
|
|
|
2289
2289
|
wcagVersion: "2.2",
|
|
2290
2290
|
source: "https://www.w3.org/TR/WCAG22/",
|
|
2291
2291
|
criteriaSource: "https://github.com/w3c/wcag",
|
|
2292
|
-
provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-
|
|
2292
|
+
provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-17 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
|
|
2293
2293
|
criteria: [
|
|
2294
2294
|
{
|
|
2295
2295
|
id: "1.1.1",
|
|
@@ -58352,6 +58352,20 @@ var LIVE_REGION_DETAIL = {
|
|
|
58352
58352
|
function clicksAllowed(storageState, interactClicks) {
|
|
58353
58353
|
return interactClicks === true || !storageState;
|
|
58354
58354
|
}
|
|
58355
|
+
function landedOnRequestedPage(requested, landed) {
|
|
58356
|
+
if (!landed || requested === landed) return true;
|
|
58357
|
+
const path = (u) => {
|
|
58358
|
+
try {
|
|
58359
|
+
return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
|
|
58360
|
+
} catch {
|
|
58361
|
+
return void 0;
|
|
58362
|
+
}
|
|
58363
|
+
};
|
|
58364
|
+
const a = path(requested);
|
|
58365
|
+
const b = path(landed);
|
|
58366
|
+
if (a === void 0 || b === void 0) return true;
|
|
58367
|
+
return a === b;
|
|
58368
|
+
}
|
|
58355
58369
|
async function probeLiveRegion(page, lang, allowClicks) {
|
|
58356
58370
|
const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
|
|
58357
58371
|
return await page.evaluate(liveRegionExpr(detail, allowClicks)).catch(() => []);
|
|
@@ -58520,6 +58534,7 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58520
58534
|
const browser = await launchChromium(chromium);
|
|
58521
58535
|
const findings = [];
|
|
58522
58536
|
const snapshots = [];
|
|
58537
|
+
const redirected = [];
|
|
58523
58538
|
try {
|
|
58524
58539
|
for (const page of pages) {
|
|
58525
58540
|
const storageState = page.storageState ?? opts.storageState;
|
|
@@ -58531,6 +58546,10 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58531
58546
|
lang,
|
|
58532
58547
|
snapshot: Boolean(opts.snapshotRoot)
|
|
58533
58548
|
});
|
|
58549
|
+
if (!landedOnRequestedPage(page.url, out2.url)) {
|
|
58550
|
+
redirected.push({ id: page.id, name: page.name, requested: page.url, landed: out2.url });
|
|
58551
|
+
continue;
|
|
58552
|
+
}
|
|
58534
58553
|
findings.push(...tagSampleFindings(toDynamicResult(out2, page.url, lang, LOCAL_ENGINE).findings, page));
|
|
58535
58554
|
const id = opts.snapshotRoot ? writeRunnerSnapshot(opts.snapshotRoot, out2, page.url, page) : void 0;
|
|
58536
58555
|
if (id) snapshots.push(id);
|
|
@@ -58546,7 +58565,8 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58546
58565
|
findings,
|
|
58547
58566
|
sample: sampleScope({ pages }),
|
|
58548
58567
|
testedScs: localTestedScs(interact),
|
|
58549
|
-
...snapshots.length ? { snapshots } : {}
|
|
58568
|
+
...snapshots.length ? { snapshots } : {},
|
|
58569
|
+
...redirected.length ? { redirected } : {}
|
|
58550
58570
|
};
|
|
58551
58571
|
}
|
|
58552
58572
|
async function runCrawlScanLocal(opts) {
|
|
@@ -66645,6 +66665,13 @@ async function cmdScan(p) {
|
|
|
66645
66665
|
console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
|
|
66646
66666
|
return 1;
|
|
66647
66667
|
}
|
|
66668
|
+
if (dynamic.redirected?.length) {
|
|
66669
|
+
for (const r of dynamic.redirected) {
|
|
66670
|
+
console.error(
|
|
66671
|
+
lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} a redirig\xE9 vers ${r.landed}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom du premier.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} redirected to ${r.landed}. Recording it would have described that screen under the first one's name.`
|
|
66672
|
+
);
|
|
66673
|
+
}
|
|
66674
|
+
}
|
|
66648
66675
|
if (useSample && sampleConfig) {
|
|
66649
66676
|
const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
|
|
66650
66677
|
try {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"wcagVersion": "2.2",
|
|
3
3
|
"source": "https://www.w3.org/TR/WCAG22/",
|
|
4
4
|
"criteriaSource": "https://github.com/w3c/wcag",
|
|
5
|
-
"provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-
|
|
5
|
+
"provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-17 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
|
|
6
6
|
"principles": [
|
|
7
7
|
{
|
|
8
8
|
"number": 1,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"source": "https://github.com/w3c/wcag",
|
|
3
3
|
"understanding": "https://www.w3.org/WAI/WCAG22/Understanding/",
|
|
4
4
|
"license": "W3C Document License (code samples: W3C Software License)",
|
|
5
|
-
"fetchedAt": "2026-08-
|
|
5
|
+
"fetchedAt": "2026-08-17",
|
|
6
6
|
"criteria": {
|
|
7
7
|
"1.1.1": {
|
|
8
8
|
"slug": "non-text-content",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": "https://github.com/w3c/wcag",
|
|
3
3
|
"license": "W3C Document License",
|
|
4
|
-
"fetchedAt": "2026-08-
|
|
4
|
+
"fetchedAt": "2026-08-17",
|
|
5
5
|
"criteria": {
|
|
6
6
|
"1.1.1": "All non-text content that is presented to the user has a text alternative that serves the equivalent purpose, except for the situations listed below.\nControls, Input:\nIf non-text content is a control or accepts user input, then it has a name that describes its purpose. (Refer to Success Criterion 4.1.2 for additional requirements for controls and content that accepts user input.)\nTime-Based Media:\nIf non-text content is time-based media, then text alternatives at least provide descriptive identification of the non-text content. (Refer to Guideline 1.2 for additional requirements for media.)\nTest:\nIf non-text content is a test or exercise that would be invalid if presented in text, then text alternatives at least provide descriptive identification of the non-text content.\nSensory:\nIf non-text content is primarily intended to create a specific sensory experience, then text alternatives at least provide descriptive identification of the non-text content.\nCAPTCHA:\nIf the purpose of non-text content is to confirm that content is being accessed by a person rather than a computer, then text alternatives that identify and describe the purpose of the non-text content are provided, and alternative forms of CAPTCHA using output modes for different types of sensory perception are provided to accommodate different disabilities.\nDecoration, Formatting, Invisible:\nIf non-text content is pure decoration, is used only for visual formatting, or is not presented to users, then it is implemented in a way that it can be ignored by assistive technology.",
|
|
7
7
|
"1.2.1": "For prerecorded audio-only and prerecorded video-only media, the following are true, except when the audio or video is a media alternative for text and is clearly labeled as such:\nPrerecorded Audio-only:\nAn alternative for time-based media is provided that presents equivalent information for prerecorded audio-only content.\nPrerecorded Video-only:\nEither an alternative for time-based media or an audio track is provided that presents equivalent information for prerecorded video-only content.",
|
|
@@ -5,7 +5,7 @@ when_to_use: "Invoke WITHOUT being asked whenever changed front-end code should
|
|
|
5
5
|
allowed-tools: Bash(node ${CLAUDE_SKILL_DIR}/scripts/ultra11y.mjs *), Read, Grep, Glob
|
|
6
6
|
license: MIT
|
|
7
7
|
metadata:
|
|
8
|
-
version: 4.
|
|
8
|
+
version: 4.5.1
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# review-a11y — accessibility review of changed code
|
|
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
|
|
|
6
6
|
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/types.ts
|
|
9
|
-
var VERSION = "4.
|
|
9
|
+
var VERSION = "4.5.1";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -2289,7 +2289,7 @@ var wcag_universe_default = {
|
|
|
2289
2289
|
wcagVersion: "2.2",
|
|
2290
2290
|
source: "https://www.w3.org/TR/WCAG22/",
|
|
2291
2291
|
criteriaSource: "https://github.com/w3c/wcag",
|
|
2292
|
-
provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-
|
|
2292
|
+
provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-17 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
|
|
2293
2293
|
criteria: [
|
|
2294
2294
|
{
|
|
2295
2295
|
id: "1.1.1",
|
|
@@ -58352,6 +58352,20 @@ var LIVE_REGION_DETAIL = {
|
|
|
58352
58352
|
function clicksAllowed(storageState, interactClicks) {
|
|
58353
58353
|
return interactClicks === true || !storageState;
|
|
58354
58354
|
}
|
|
58355
|
+
function landedOnRequestedPage(requested, landed) {
|
|
58356
|
+
if (!landed || requested === landed) return true;
|
|
58357
|
+
const path = (u) => {
|
|
58358
|
+
try {
|
|
58359
|
+
return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
|
|
58360
|
+
} catch {
|
|
58361
|
+
return void 0;
|
|
58362
|
+
}
|
|
58363
|
+
};
|
|
58364
|
+
const a = path(requested);
|
|
58365
|
+
const b = path(landed);
|
|
58366
|
+
if (a === void 0 || b === void 0) return true;
|
|
58367
|
+
return a === b;
|
|
58368
|
+
}
|
|
58355
58369
|
async function probeLiveRegion(page, lang, allowClicks) {
|
|
58356
58370
|
const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
|
|
58357
58371
|
return await page.evaluate(liveRegionExpr(detail, allowClicks)).catch(() => []);
|
|
@@ -58520,6 +58534,7 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58520
58534
|
const browser = await launchChromium(chromium);
|
|
58521
58535
|
const findings = [];
|
|
58522
58536
|
const snapshots = [];
|
|
58537
|
+
const redirected = [];
|
|
58523
58538
|
try {
|
|
58524
58539
|
for (const page of pages) {
|
|
58525
58540
|
const storageState = page.storageState ?? opts.storageState;
|
|
@@ -58531,6 +58546,10 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58531
58546
|
lang,
|
|
58532
58547
|
snapshot: Boolean(opts.snapshotRoot)
|
|
58533
58548
|
});
|
|
58549
|
+
if (!landedOnRequestedPage(page.url, out2.url)) {
|
|
58550
|
+
redirected.push({ id: page.id, name: page.name, requested: page.url, landed: out2.url });
|
|
58551
|
+
continue;
|
|
58552
|
+
}
|
|
58534
58553
|
findings.push(...tagSampleFindings(toDynamicResult(out2, page.url, lang, LOCAL_ENGINE).findings, page));
|
|
58535
58554
|
const id = opts.snapshotRoot ? writeRunnerSnapshot(opts.snapshotRoot, out2, page.url, page) : void 0;
|
|
58536
58555
|
if (id) snapshots.push(id);
|
|
@@ -58546,7 +58565,8 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58546
58565
|
findings,
|
|
58547
58566
|
sample: sampleScope({ pages }),
|
|
58548
58567
|
testedScs: localTestedScs(interact),
|
|
58549
|
-
...snapshots.length ? { snapshots } : {}
|
|
58568
|
+
...snapshots.length ? { snapshots } : {},
|
|
58569
|
+
...redirected.length ? { redirected } : {}
|
|
58550
58570
|
};
|
|
58551
58571
|
}
|
|
58552
58572
|
async function runCrawlScanLocal(opts) {
|
|
@@ -66645,6 +66665,13 @@ async function cmdScan(p) {
|
|
|
66645
66665
|
console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
|
|
66646
66666
|
return 1;
|
|
66647
66667
|
}
|
|
66668
|
+
if (dynamic.redirected?.length) {
|
|
66669
|
+
for (const r of dynamic.redirected) {
|
|
66670
|
+
console.error(
|
|
66671
|
+
lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} a redirig\xE9 vers ${r.landed}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom du premier.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} redirected to ${r.landed}. Recording it would have described that screen under the first one's name.`
|
|
66672
|
+
);
|
|
66673
|
+
}
|
|
66674
|
+
}
|
|
66648
66675
|
if (useSample && sampleConfig) {
|
|
66649
66676
|
const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
|
|
66650
66677
|
try {
|
package/skills/ultra11y/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: "Use to AUDIT existing HTML/CSS/JSX against WCAG 2.2 AA accessibili
|
|
|
4
4
|
when_to_use: "Invoke when the user asks for an accessibility AUDIT or a formal deliverable: audit a repo, site or page against WCAG 2.2 AA or a country standard (RGAA, Section 508, EN 301 549); produce a dated conformance report, a criterion grid, a PRD backlog, or tickets (GitHub/GitLab/Jira); author accessible markup from scratch; wire the repo gate (init --hook/--ci) or a rendered-DOM capture pipeline. For a review of the code UNDER CHANGE (diff, branch, PR), use the `review-a11y` skill instead."
|
|
5
5
|
license: MIT
|
|
6
6
|
metadata:
|
|
7
|
-
version: 4.
|
|
7
|
+
version: 4.5.1
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# ultra11y — audit WCAG 2.2 AA and write accessible markup
|
|
@@ -15,7 +15,7 @@ permissions:
|
|
|
15
15
|
steps:
|
|
16
16
|
- uses: actions/checkout@v4
|
|
17
17
|
with: { fetch-depth: 0 } # the diff gate needs the base ref
|
|
18
|
-
- uses: maxgfr/ultra11y@
|
|
18
|
+
- uses: maxgfr/ultra11y@v4 # or pin the exact version, as `init --ci` does
|
|
19
19
|
with:
|
|
20
20
|
since: auto # the PR's base branch
|
|
21
21
|
standard: rgaa
|
|
@@ -27,7 +27,7 @@ steps:
|
|
|
27
27
|
Page by page, with a real browser, in the same step:
|
|
28
28
|
|
|
29
29
|
```yaml
|
|
30
|
-
- uses: maxgfr/ultra11y@
|
|
30
|
+
- uses: maxgfr/ultra11y@v4
|
|
31
31
|
with:
|
|
32
32
|
standard: rgaa
|
|
33
33
|
start: npm run start
|
|
@@ -66,7 +66,7 @@ partial by construction. `adjudicate` closes that — opt-in, in two modes.
|
|
|
66
66
|
env:
|
|
67
67
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # the job's env, never an input
|
|
68
68
|
steps:
|
|
69
|
-
- uses: maxgfr/ultra11y@
|
|
69
|
+
- uses: maxgfr/ultra11y@v4
|
|
70
70
|
with:
|
|
71
71
|
standard: rgaa
|
|
72
72
|
adjudicate: api # or `agent`
|
|
@@ -89,14 +89,43 @@ open the cited files and read around them, which is what *link purpose **in cont
|
|
|
89
89
|
asks for. Expect the API mode to answer `manual` more often; that is it being honest, not
|
|
90
90
|
broken. The metric that separates them is the count of criteria left to assess.
|
|
91
91
|
|
|
92
|
-
**Both modes read
|
|
92
|
+
**Both modes read their credential from the job environment, never from an input** — a
|
|
93
93
|
composite action's steps inherit it, and a secret that never travels through an input is one
|
|
94
|
-
fewer secret to leak into a log. When
|
|
94
|
+
fewer secret to leak into a log. When it is absent the tier **skips itself**: the job stays
|
|
95
95
|
green, the report is still written, and the criteria stay « à évaluer ». That is not an edge
|
|
96
96
|
case — it is precisely what a **fork's pull request** looks like, where secrets are never
|
|
97
97
|
exposed. An adjudication that fails mid-flight (a rate-limited batch refuses the whole fold) is
|
|
98
98
|
absorbed the same way, because losing the verdicts must never cost you the audit.
|
|
99
99
|
|
|
100
|
+
**The two modes do not take the same credential.** `api` runs `judge`, which speaks `x-api-key`
|
|
101
|
+
to `api.anthropic.com`; nothing else stands in for `ANTHROPIC_API_KEY` there. `agent` shells out
|
|
102
|
+
to `claude-code-action`, which accepts **either** an API key **or** the OAuth token a Pro/Max
|
|
103
|
+
subscriber mints with `claude setup-token` — so a repository that already runs Claude Code in CI
|
|
104
|
+
and holds only `CLAUDE_CODE_OAUTH_TOKEN` can adjudicate without buying API credit. Set whichever
|
|
105
|
+
you have; set both and the API key wins.
|
|
106
|
+
|
|
107
|
+
```yaml
|
|
108
|
+
env:
|
|
109
|
+
# `agent` takes either of these. `api` takes only the first.
|
|
110
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
111
|
+
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Asking for `api` with only the OAuth token is a warning, not a failure — the tier skips and says
|
|
115
|
+
which credential it would have needed, rather than failing a request the Messages API was always
|
|
116
|
+
going to reject.
|
|
117
|
+
|
|
118
|
+
**`agent` cannot run on every event.** `claude-code-action` resolves the GitHub event context
|
|
119
|
+
*before* it reads the prompt, and throws `Unsupported event type: <event>` on anything outside
|
|
120
|
+
`pull_request`, `pull_request_target`, `pull_request_review(_comment)`, `issues`,
|
|
121
|
+
`issue_comment`, `workflow_dispatch`, `repository_dispatch`, `schedule` and `workflow_run`.
|
|
122
|
+
**`push` is not in that list** — and `push` is exactly what an accessibility gate runs on. So the
|
|
123
|
+
tier checks the event and degrades to a warning there, the same way it does without a credential:
|
|
124
|
+
an a11y job must never go red for a reason that has nothing to do with accessibility. Put the
|
|
125
|
+
agent tier on the `pull_request` or `schedule` job, or use `adjudicate: api`, which has no such
|
|
126
|
+
constraint. The action also hands `claude-code-action` the job's own `GITHUB_TOKEN`, so this tier
|
|
127
|
+
never obliges you to add `id-token: write` for a step that makes no GitHub API call.
|
|
128
|
+
|
|
100
129
|
**Cost is per run and does not amortise.** Roughly $0.20 for a WCAG run and $0.50 for RGAA at
|
|
101
130
|
Sonnet pricing — the worklist is re-sent whole each time and no batch is large enough to earn
|
|
102
131
|
prompt caching. On a busy repository that is real money per push; this belongs on the default
|
|
@@ -235,7 +264,7 @@ The action audits the **code** and, when you point it at a served app, the **pag
|
|
|
235
264
|
list can come from three places, and none of them has to be written by hand:
|
|
236
265
|
|
|
237
266
|
```yaml
|
|
238
|
-
- uses: maxgfr/ultra11y@
|
|
267
|
+
- uses: maxgfr/ultra11y@v4
|
|
239
268
|
with:
|
|
240
269
|
standard: rgaa
|
|
241
270
|
start: npm run start
|
|
@@ -262,6 +291,40 @@ Two surfaces come out of it:
|
|
|
262
291
|
|
|
263
292
|
A run with no page in scope is not a failure: the report step says so and the job carries on.
|
|
264
293
|
|
|
294
|
+
### Pages behind a login (`storage-state`)
|
|
295
|
+
|
|
296
|
+
The pages worth auditing are rarely the public ones. A form-heavy funnel, a dashboard, a
|
|
297
|
+
back-office — all of them sit behind a sign-in, and a scan that reaches them anonymously does
|
|
298
|
+
not fail: it records **the sign-in screen** under the name of the page you asked for. That is
|
|
299
|
+
the worst failure mode an accessibility report has, because the document looks complete.
|
|
300
|
+
|
|
301
|
+
Produce a Playwright `storageState` in the job — most repositories already have one, written by
|
|
302
|
+
the setup project their E2E suite depends on — and hand the action its path:
|
|
303
|
+
|
|
304
|
+
```yaml
|
|
305
|
+
- run: npx playwright test --project=setup # your existing login, writing the session file
|
|
306
|
+
- uses: maxgfr/ultra11y@v4
|
|
307
|
+
with:
|
|
308
|
+
standard: rgaa
|
|
309
|
+
sample: 'true'
|
|
310
|
+
storage-state: test-results/.auth/user.json
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
The path is forwarded to Playwright as a path and never read by the engine, so a session file
|
|
314
|
+
cannot end up in the job log. A per-page `storageState` in `.ultra11yrc.json` **overrides** this
|
|
315
|
+
one, so a sample can mix public and signed-in pages in a single run.
|
|
316
|
+
|
|
317
|
+
Two things worth knowing about an authenticated scan:
|
|
318
|
+
|
|
319
|
+
- **The click probe is off.** With a session loaded, a click can trigger a real mutation on a
|
|
320
|
+
real record. The live-region probe still fills inputs and re-measures, but it does not click
|
|
321
|
+
buttons unless you set `interact-clicks: 'true'`. (Buttons whose accessible name reads
|
|
322
|
+
destructive are never clicked, session or not.)
|
|
323
|
+
- **A session is not a state machine.** Being signed in gets you past the login; it does not put
|
|
324
|
+
a multi-step funnel on step 4. If a route is guarded by application state, seed that state in
|
|
325
|
+
the job before scanning — otherwise the page redirects and you are back to auditing the wrong
|
|
326
|
+
screen under the right name.
|
|
327
|
+
|
|
265
328
|
## What the artifact looks like when you open it
|
|
266
329
|
|
|
267
330
|
`audits/` used to hold JSON, SARIF and Markdown: everything a machine needs and nothing that
|
|
@@ -395,9 +458,9 @@ one job — the code diff, then the served pages — died on a `409 Conflict`, w
|
|
|
395
458
|
written and never uploaded. Pass `artifact-name` on each invocation:
|
|
396
459
|
|
|
397
460
|
```yaml
|
|
398
|
-
- uses: maxgfr/ultra11y@
|
|
461
|
+
- uses: maxgfr/ultra11y@v4
|
|
399
462
|
with: { since: auto, artifact-name: a11y-code }
|
|
400
|
-
- uses: maxgfr/ultra11y@
|
|
463
|
+
- uses: maxgfr/ultra11y@v4
|
|
401
464
|
with: { crawl: http://localhost:3000, artifact-name: a11y-pages }
|
|
402
465
|
```
|
|
403
466
|
|
|
@@ -220,7 +220,17 @@ Sheets are named `page-<id>.md`: a page whose id is `index` — the ordinary id
|
|
|
220
220
|
gates that mean something here — above all **no invented criterion** — instead of demanding
|
|
221
221
|
the §1–5 structure of a conformance document.
|
|
222
222
|
|
|
223
|
-
###
|
|
223
|
+
### Four honesty rules
|
|
224
|
+
|
|
225
|
+
**0. A page is recorded only if the browser stayed on it.** A sample page's declared `id` and
|
|
226
|
+
`name` are the identity the report speaks, and they are applied to whatever was on screen. So a
|
|
227
|
+
route that redirects — an expired session bouncing to sign-in, a wizard step the application
|
|
228
|
+
state does not open — would be filed under the requested page's name while showing something
|
|
229
|
+
else: a sheet, a screenshot and a rate that describe a different screen, with nothing about the
|
|
230
|
+
document looking wrong. `scan --sample` compares the landed **path** against the requested one
|
|
231
|
+
(a query string or fragment the app appends is the same page) and **drops** a page that moved,
|
|
232
|
+
naming it and where it went. A page reported missing is a bug in the sample or in the state you
|
|
233
|
+
seeded, and both are fixable; a page reported under the wrong name is a false conformance claim.
|
|
224
234
|
|
|
225
235
|
**1. A finding is attributed to a page only when something says so.** In order: the snapshot
|
|
226
236
|
path it was raised on (`.ultra11y/pages/<id>/dom.html` — the id is *in the path*, which is what
|
|
@@ -6,7 +6,7 @@ import { join as join50, relative as relative5, resolve as resolve15, sep as sep
|
|
|
6
6
|
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/types.ts
|
|
9
|
-
var VERSION = "4.
|
|
9
|
+
var VERSION = "4.5.1";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -2289,7 +2289,7 @@ var wcag_universe_default = {
|
|
|
2289
2289
|
wcagVersion: "2.2",
|
|
2290
2290
|
source: "https://www.w3.org/TR/WCAG22/",
|
|
2291
2291
|
criteriaSource: "https://github.com/w3c/wcag",
|
|
2292
|
-
provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-
|
|
2292
|
+
provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-17 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
|
|
2293
2293
|
criteria: [
|
|
2294
2294
|
{
|
|
2295
2295
|
id: "1.1.1",
|
|
@@ -58352,6 +58352,20 @@ var LIVE_REGION_DETAIL = {
|
|
|
58352
58352
|
function clicksAllowed(storageState, interactClicks) {
|
|
58353
58353
|
return interactClicks === true || !storageState;
|
|
58354
58354
|
}
|
|
58355
|
+
function landedOnRequestedPage(requested, landed) {
|
|
58356
|
+
if (!landed || requested === landed) return true;
|
|
58357
|
+
const path = (u) => {
|
|
58358
|
+
try {
|
|
58359
|
+
return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
|
|
58360
|
+
} catch {
|
|
58361
|
+
return void 0;
|
|
58362
|
+
}
|
|
58363
|
+
};
|
|
58364
|
+
const a = path(requested);
|
|
58365
|
+
const b = path(landed);
|
|
58366
|
+
if (a === void 0 || b === void 0) return true;
|
|
58367
|
+
return a === b;
|
|
58368
|
+
}
|
|
58355
58369
|
async function probeLiveRegion(page, lang, allowClicks) {
|
|
58356
58370
|
const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
|
|
58357
58371
|
return await page.evaluate(liveRegionExpr(detail, allowClicks)).catch(() => []);
|
|
@@ -58520,6 +58534,7 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58520
58534
|
const browser = await launchChromium(chromium);
|
|
58521
58535
|
const findings = [];
|
|
58522
58536
|
const snapshots = [];
|
|
58537
|
+
const redirected = [];
|
|
58523
58538
|
try {
|
|
58524
58539
|
for (const page of pages) {
|
|
58525
58540
|
const storageState = page.storageState ?? opts.storageState;
|
|
@@ -58531,6 +58546,10 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58531
58546
|
lang,
|
|
58532
58547
|
snapshot: Boolean(opts.snapshotRoot)
|
|
58533
58548
|
});
|
|
58549
|
+
if (!landedOnRequestedPage(page.url, out2.url)) {
|
|
58550
|
+
redirected.push({ id: page.id, name: page.name, requested: page.url, landed: out2.url });
|
|
58551
|
+
continue;
|
|
58552
|
+
}
|
|
58534
58553
|
findings.push(...tagSampleFindings(toDynamicResult(out2, page.url, lang, LOCAL_ENGINE).findings, page));
|
|
58535
58554
|
const id = opts.snapshotRoot ? writeRunnerSnapshot(opts.snapshotRoot, out2, page.url, page) : void 0;
|
|
58536
58555
|
if (id) snapshots.push(id);
|
|
@@ -58546,7 +58565,8 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58546
58565
|
findings,
|
|
58547
58566
|
sample: sampleScope({ pages }),
|
|
58548
58567
|
testedScs: localTestedScs(interact),
|
|
58549
|
-
...snapshots.length ? { snapshots } : {}
|
|
58568
|
+
...snapshots.length ? { snapshots } : {},
|
|
58569
|
+
...redirected.length ? { redirected } : {}
|
|
58550
58570
|
};
|
|
58551
58571
|
}
|
|
58552
58572
|
async function runCrawlScanLocal(opts) {
|
|
@@ -66645,6 +66665,13 @@ async function cmdScan(p) {
|
|
|
66645
66665
|
console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
|
|
66646
66666
|
return 1;
|
|
66647
66667
|
}
|
|
66668
|
+
if (dynamic.redirected?.length) {
|
|
66669
|
+
for (const r of dynamic.redirected) {
|
|
66670
|
+
console.error(
|
|
66671
|
+
lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} a redirig\xE9 vers ${r.landed}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom du premier.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} redirected to ${r.landed}. Recording it would have described that screen under the first one's name.`
|
|
66672
|
+
);
|
|
66673
|
+
}
|
|
66674
|
+
}
|
|
66648
66675
|
if (useSample && sampleConfig) {
|
|
66649
66676
|
const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
|
|
66650
66677
|
try {
|
package/src/cli.ts
CHANGED
|
@@ -3044,6 +3044,18 @@ async function cmdScan(p: ParsedArgs): Promise<number> {
|
|
|
3044
3044
|
console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
|
|
3045
3045
|
return 1;
|
|
3046
3046
|
}
|
|
3047
|
+
// Pages the scan refused to file, because the browser ended up on another route. Loud, and
|
|
3048
|
+
// on stderr: a silently shorter sample is indistinguishable from a sample that passed, and
|
|
3049
|
+
// the cause is always fixable — an expired session, or state the wizard step needs.
|
|
3050
|
+
if (dynamic.redirected?.length) {
|
|
3051
|
+
for (const r of dynamic.redirected) {
|
|
3052
|
+
console.error(
|
|
3053
|
+
lang === "fr"
|
|
3054
|
+
? `⚠️ ultra11y scan : « ${r.name} » (${r.id}) non enregistrée — ${r.requested} a redirigé vers ${r.landed}. L'enregistrer aurait décrit cet écran sous le nom du premier.`
|
|
3055
|
+
: `⚠️ ultra11y scan: "${r.name}" (${r.id}) not recorded — ${r.requested} redirected to ${r.landed}. Recording it would have described that screen under the first one's name.`,
|
|
3056
|
+
);
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3047
3059
|
// Advisory sample-methodology lint: which normative page KINDS the configured sample
|
|
3048
3060
|
// lacks. Standard-agnostic mechanics + RGAA-specific required kinds; scan has no
|
|
3049
3061
|
// --standard, so we lint against the config's default standard (else RGAA if registered).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"wcagVersion": "2.2",
|
|
3
3
|
"source": "https://www.w3.org/TR/WCAG22/",
|
|
4
4
|
"criteriaSource": "https://github.com/w3c/wcag",
|
|
5
|
-
"provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-
|
|
5
|
+
"provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-17 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
|
|
6
6
|
"criteria": [
|
|
7
7
|
{
|
|
8
8
|
"id": "1.1.1",
|
package/src/scan-local.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { existsSync, statSync } from "node:fs";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
13
|
import { resolve } from "node:path";
|
|
14
|
-
import type { DynamicFinding, DynamicResult, Lang, SamplePage } from "./types.js";
|
|
14
|
+
import type { DynamicFinding, DynamicResult, Lang, SamplePage, ScanRedirect } from "./types.js";
|
|
15
15
|
import { type DiscoverOpts, discoverUrls, type ProbeHit, type RunnerOutput, tagSampleFindings, toDynamicResult, writeRunnerSnapshot } from "./scan.js";
|
|
16
16
|
import { sampleScope } from "./sample.js";
|
|
17
17
|
import { COLLECT_SNAPSHOT, type CollectedPage } from "./snapshot.js";
|
|
@@ -604,6 +604,36 @@ export function clicksAllowed(storageState: string | undefined, interactClicks:
|
|
|
604
604
|
return interactClicks === true || !storageState;
|
|
605
605
|
}
|
|
606
606
|
|
|
607
|
+
/** Did the browser end up on the page we ASKED for?
|
|
608
|
+
*
|
|
609
|
+
* A sample page's declared id/name is the identity the report and the per-page grid speak,
|
|
610
|
+
* and it is applied to whatever the browser had on screen. So a route that redirects — an
|
|
611
|
+
* expired session bouncing to /login, a wizard step the application state does not open —
|
|
612
|
+
* gets recorded under the requested page's name while showing another screen entirely.
|
|
613
|
+
* Nothing about the resulting document looks wrong, which is what makes it the worst
|
|
614
|
+
* failure mode an accessibility report has: a reader sees a page sheet, a screenshot and a
|
|
615
|
+
* conformance rate, and none of it describes the page named at the top.
|
|
616
|
+
*
|
|
617
|
+
* Only the PATH is compared. A query string or fragment the app appends to its own route is
|
|
618
|
+
* the same page; a different path is a different page. Anything unparseable is treated as a
|
|
619
|
+
* match — this guard exists to catch a redirect, not to invent one. Exported for the
|
|
620
|
+
* browser-free policy test. */
|
|
621
|
+
export function landedOnRequestedPage(requested: string, landed: string): boolean {
|
|
622
|
+
if (!landed || requested === landed) return true;
|
|
623
|
+
const path = (u: string): string | undefined => {
|
|
624
|
+
try {
|
|
625
|
+
// `file://` targets and relative paths never redirect; base makes them parseable.
|
|
626
|
+
return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
|
|
627
|
+
} catch {
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
const a = path(requested);
|
|
632
|
+
const b = path(landed);
|
|
633
|
+
if (a === undefined || b === undefined) return true;
|
|
634
|
+
return a === b;
|
|
635
|
+
}
|
|
636
|
+
|
|
607
637
|
async function probeLiveRegion(page: Any, lang: Lang, allowClicks: boolean): Promise<ProbeHit[]> {
|
|
608
638
|
const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
|
|
609
639
|
return (await page.evaluate(liveRegionExpr(detail, allowClicks)).catch(() => [])) as ProbeHit[];
|
|
@@ -880,6 +910,7 @@ export async function runSampleScanLocal(pages: SamplePage[], opts: LocalManyOpt
|
|
|
880
910
|
const browser = await launchChromium(chromium);
|
|
881
911
|
const findings: DynamicFinding[] = [];
|
|
882
912
|
const snapshots: string[] = [];
|
|
913
|
+
const redirected: ScanRedirect[] = [];
|
|
883
914
|
try {
|
|
884
915
|
for (const page of pages) {
|
|
885
916
|
const storageState = page.storageState ?? opts.storageState; // per-page override
|
|
@@ -891,6 +922,15 @@ export async function runSampleScanLocal(pages: SamplePage[], opts: LocalManyOpt
|
|
|
891
922
|
lang,
|
|
892
923
|
snapshot: Boolean(opts.snapshotRoot),
|
|
893
924
|
});
|
|
925
|
+
// The declared identity is applied to whatever the browser had on screen, so a page
|
|
926
|
+
// that bounced elsewhere would be filed under the name of the page nobody looked at.
|
|
927
|
+
// Drop it instead, and say which one and where it went: a page reported missing is a
|
|
928
|
+
// bug in the sample or the seeded state, and both are fixable. A page reported under
|
|
929
|
+
// the wrong name is neither — it is a false conformance claim.
|
|
930
|
+
if (!landedOnRequestedPage(page.url, out.url)) {
|
|
931
|
+
redirected.push({ id: page.id, name: page.name, requested: page.url, landed: out.url });
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
894
934
|
findings.push(...tagSampleFindings(toDynamicResult(out, page.url, lang, LOCAL_ENGINE).findings, page));
|
|
895
935
|
// The sample page's declared id/name/auth/notes win over anything derived from the
|
|
896
936
|
// URL: that identity is what the report and the per-page grid speak.
|
|
@@ -909,6 +949,7 @@ export async function runSampleScanLocal(pages: SamplePage[], opts: LocalManyOpt
|
|
|
909
949
|
sample: sampleScope({ pages }),
|
|
910
950
|
testedScs: localTestedScs(interact),
|
|
911
951
|
...(snapshots.length ? { snapshots } : {}),
|
|
952
|
+
...(redirected.length ? { redirected } : {}),
|
|
912
953
|
};
|
|
913
954
|
}
|
|
914
955
|
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single source of truth for shared types + the embedded version. sync-version.mjs
|
|
2
2
|
// rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
|
|
3
|
-
export const VERSION = "4.
|
|
3
|
+
export const VERSION = "4.5.1";
|
|
4
4
|
export const SCHEMA_VERSION = 2;
|
|
5
5
|
|
|
6
6
|
// Lang = the UI FRAME's language — the `L` localization tables each module (report,
|
|
@@ -47,6 +47,18 @@ export interface SampleScope {
|
|
|
47
47
|
transverse?: string[];
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// A sample page the scan REFUSED to record, because the browser ended up somewhere else —
|
|
51
|
+
// a session that had expired, a wizard step the application state does not open. Recording
|
|
52
|
+
// it would have filed another screen under this page's name, which reads as a complete
|
|
53
|
+
// report and is a false conformance claim. Reported so the sample or the seeded state can
|
|
54
|
+
// be fixed; never silently dropped.
|
|
55
|
+
export interface ScanRedirect {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
requested: string;
|
|
59
|
+
landed: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
50
62
|
// ---- WCAG 2.2 canonical core (src/data/wcag.json, produced by scripts/build-standards.mjs)
|
|
51
63
|
// The engine's canonical key. Success-criterion ids/titles/levels are derived from the
|
|
52
64
|
// official W3C source (https://github.com/w3c/wcag); rule coverage + automatability are
|
|
@@ -560,4 +572,7 @@ export interface DynamicResult {
|
|
|
560
572
|
// static rules never ran against its DOM, so src/pages.ts keeps it `manual` forever).
|
|
561
573
|
// Empty/absent when snapshotting was off or every collection failed. Optional/additive.
|
|
562
574
|
snapshots?: string[];
|
|
575
|
+
// Sample pages dropped because the browser landed on a different path than the one asked
|
|
576
|
+
// for. Absent when every page was reached. Optional/additive — see ScanRedirect.
|
|
577
|
+
redirected?: ScanRedirect[];
|
|
563
578
|
}
|