ultra11y 4.5.2 → 5.0.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/package.json +1 -1
- package/scripts/ultra11y.mjs +62 -14
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +62 -14
- package/skills/ultra11y/SKILL.md +1 -1
- package/skills/ultra11y/references/ci.md +9 -0
- package/skills/ultra11y/references/pages.md +25 -9
- package/skills/ultra11y/scripts/ultra11y.mjs +62 -14
- package/src/cli.ts +26 -3
- package/src/pages.ts +32 -1
- package/src/report.ts +5 -1
- package/src/scan-local.ts +76 -19
- package/src/scan.ts +21 -0
- package/src/types.ts +13 -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": "
|
|
13
|
+
"version": "5.0.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": "
|
|
4
|
+
"version": "5.0.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": "
|
|
4
|
+
"version": "5.0.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 = "
|
|
30
|
+
const ULTRA11Y_PLUGIN_VERSION = "5.0.0";
|
|
31
31
|
|
|
32
32
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
33
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
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",
|
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 = "
|
|
9
|
+
var VERSION = "5.0.0";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -53203,6 +53203,20 @@ function pageGridModel(result, derived, standard, lang) {
|
|
|
53203
53203
|
for (const p of derived) for (const pc of derivePackResults(pageView(result, p), standard)) put(pc.id, p.id, pc.status);
|
|
53204
53204
|
return { rows, status };
|
|
53205
53205
|
}
|
|
53206
|
+
function renderRedirected(redirected, lang = "en") {
|
|
53207
|
+
const fr = lang === "fr";
|
|
53208
|
+
const out2 = [
|
|
53209
|
+
fr ? `> \u26A0\uFE0F **${redirected.length} page(s) de l'\xE9chantillon n'ont pas \xE9t\xE9 enregistr\xE9es** \u2014 le navigateur n'est pas rest\xE9 sur l'adresse demand\xE9e. Les enregistrer aurait d\xE9crit un autre \xE9cran sous le nom demand\xE9. Elles ne comptent donc ni comme conformes ni comme non conformes : elles manquent.` : `> \u26A0\uFE0F **${redirected.length} sample page(s) were not recorded** \u2014 the browser did not stay on the address asked for. Recording them would have described another screen under the requested name. They count as neither conforming nor non-conforming: they are missing.`,
|
|
53210
|
+
"",
|
|
53211
|
+
fr ? "| Page | Demand\xE9 | Atteint | Motif |" : "| Page | Requested | Landed | Reason |",
|
|
53212
|
+
"| --- | --- | --- | --- |"
|
|
53213
|
+
];
|
|
53214
|
+
for (const r of redirected) {
|
|
53215
|
+
const why = r.reason === "http-status" ? fr ? `HTTP ${r.status ?? "\u2265 400"} \u2014 page d'erreur rendue \xE0 la m\xEAme adresse` : `HTTP ${r.status ?? "\u2265 400"} \u2014 error page served at the same address` : fr ? "redirection" : "redirect";
|
|
53216
|
+
out2.push(`| ${r.name} (\`${r.id}\`) | \`${r.requested}\` | \`${r.landed}\` | ${why} |`);
|
|
53217
|
+
}
|
|
53218
|
+
return out2;
|
|
53219
|
+
}
|
|
53206
53220
|
function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
|
|
53207
53221
|
const s = L3[lang];
|
|
53208
53222
|
const out2 = [];
|
|
@@ -53445,6 +53459,7 @@ function render(r, lang, opts) {
|
|
|
53445
53459
|
const derived = derivePages(r, pageScope);
|
|
53446
53460
|
out2.push(`## \u{1F4C4} ${s.perPageTitle}`, "", `> ${s.perPageNote}`, "");
|
|
53447
53461
|
if (r.scope.sample?.transverse?.length) out2.push(`> ${s.transverseNote(r.scope.sample.transverse.join(", "))}`, "");
|
|
53462
|
+
if (r.scope.redirected?.length) out2.push(...renderRedirected(r.scope.redirected, lang), "");
|
|
53448
53463
|
const pack = isCore(opts.standard) ? void 0 : loadPack(opts.standard);
|
|
53449
53464
|
for (const pg of derived) {
|
|
53450
53465
|
const nc = pg.findings.filter((f) => !f.advisory);
|
|
@@ -57792,6 +57807,14 @@ function resolveHostAnchor(file, snippet2) {
|
|
|
57792
57807
|
function mergeDynamic(audit2, dynamic, lang = "en") {
|
|
57793
57808
|
const merged = JSON.parse(JSON.stringify(audit2));
|
|
57794
57809
|
if (dynamic.sample) merged.scope.sample = dynamic.sample;
|
|
57810
|
+
if (dynamic.redirected?.length) {
|
|
57811
|
+
const byId3 = new Map((merged.scope.redirected ?? []).map((r) => [r.id, r]));
|
|
57812
|
+
for (const r of dynamic.redirected) byId3.set(r.id, r);
|
|
57813
|
+
const scannedIds = new Set((merged.scope.sample?.pages ?? []).map((p) => p.id));
|
|
57814
|
+
const still = [...byId3.values()].filter((r) => !scannedIds.has(r.id));
|
|
57815
|
+
if (still.length) merged.scope.redirected = still;
|
|
57816
|
+
else delete merged.scope.redirected;
|
|
57817
|
+
}
|
|
57795
57818
|
if (dynamic.testedScs?.length) {
|
|
57796
57819
|
const tested = /* @__PURE__ */ new Set([...merged.scope.scan?.testedScs ?? [], ...dynamic.testedScs]);
|
|
57797
57820
|
merged.scope.scan = { testedScs: [...tested].sort() };
|
|
@@ -58354,17 +58377,23 @@ function clicksAllowed(storageState, interactClicks) {
|
|
|
58354
58377
|
}
|
|
58355
58378
|
function landedOnRequestedPage(requested, landed) {
|
|
58356
58379
|
if (!landed || requested === landed) return true;
|
|
58357
|
-
const
|
|
58380
|
+
const parse2 = (u) => {
|
|
58358
58381
|
try {
|
|
58359
|
-
return new URL(u, "http://x.invalid")
|
|
58382
|
+
return new URL(u, "http://x.invalid");
|
|
58360
58383
|
} catch {
|
|
58361
58384
|
return void 0;
|
|
58362
58385
|
}
|
|
58363
58386
|
};
|
|
58364
|
-
const a =
|
|
58365
|
-
const b =
|
|
58366
|
-
if (a
|
|
58367
|
-
|
|
58387
|
+
const a = parse2(requested);
|
|
58388
|
+
const b = parse2(landed);
|
|
58389
|
+
if (!a || !b) return true;
|
|
58390
|
+
const path = (u) => u.pathname.replace(/\/+$/, "");
|
|
58391
|
+
if (path(a) !== path(b)) return false;
|
|
58392
|
+
const host = (u) => u.hostname.replace(/^www\./, "");
|
|
58393
|
+
if (host(a) !== host(b)) return false;
|
|
58394
|
+
if (a.hash && a.hash !== b.hash) return false;
|
|
58395
|
+
if (a.search && a.search !== b.search) return false;
|
|
58396
|
+
return true;
|
|
58368
58397
|
}
|
|
58369
58398
|
async function probeLiveRegion(page, lang, allowClicks) {
|
|
58370
58399
|
const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
|
|
@@ -58405,10 +58434,12 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
|
|
|
58405
58434
|
const empty = [];
|
|
58406
58435
|
try {
|
|
58407
58436
|
const url = isFile ? "file://" + resolve11(target) : target;
|
|
58408
|
-
await page.goto(url, { waitUntil: "load", timeout: 45e3 });
|
|
58437
|
+
const response = await page.goto(url, { waitUntil: "load", timeout: 45e3 });
|
|
58409
58438
|
await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
58410
58439
|
});
|
|
58411
58440
|
await page.waitForTimeout(1200);
|
|
58441
|
+
const landedUrl = page.url() || url;
|
|
58442
|
+
const httpStatus = typeof response?.status === "function" ? response.status() : void 0;
|
|
58412
58443
|
let snapshot;
|
|
58413
58444
|
let screenshot;
|
|
58414
58445
|
if (opts.snapshot !== false) {
|
|
@@ -58454,6 +58485,8 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
|
|
|
58454
58485
|
const liveRegion = opts.interact ? await probeLiveRegion(page, l, opts.allowClicks).catch(() => empty) : [];
|
|
58455
58486
|
return {
|
|
58456
58487
|
url: page.url() || target,
|
|
58488
|
+
landedUrl,
|
|
58489
|
+
...httpStatus !== void 0 ? { httpStatus } : {},
|
|
58457
58490
|
violations,
|
|
58458
58491
|
reflow,
|
|
58459
58492
|
focusVisible: dialogFocus.length ? [...focusVisible, ...dialogFocus] : focusVisible,
|
|
@@ -58546,8 +58579,14 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58546
58579
|
lang,
|
|
58547
58580
|
snapshot: Boolean(opts.snapshotRoot)
|
|
58548
58581
|
});
|
|
58549
|
-
|
|
58550
|
-
|
|
58582
|
+
const requestedUrl = isFile ? "file://" + resolve11(page.url) : page.url;
|
|
58583
|
+
const landedUrl = out2.landedUrl ?? out2.url;
|
|
58584
|
+
if (!landedOnRequestedPage(requestedUrl, landedUrl)) {
|
|
58585
|
+
redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "redirect" });
|
|
58586
|
+
continue;
|
|
58587
|
+
}
|
|
58588
|
+
if (out2.httpStatus !== void 0 && out2.httpStatus >= 400) {
|
|
58589
|
+
redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "http-status", status: out2.httpStatus });
|
|
58551
58590
|
continue;
|
|
58552
58591
|
}
|
|
58553
58592
|
findings.push(...tagSampleFindings(toDynamicResult(out2, page.url, lang, LOCAL_ENGINE).findings, page));
|
|
@@ -58557,13 +58596,15 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58557
58596
|
} finally {
|
|
58558
58597
|
await browser.close();
|
|
58559
58598
|
}
|
|
58599
|
+
const dropped = new Set(redirected.map((r) => r.id));
|
|
58600
|
+
const scanned = pages.filter((p) => !dropped.has(p.id));
|
|
58560
58601
|
return {
|
|
58561
58602
|
tool: "ultra11y",
|
|
58562
58603
|
engine: LOCAL_ENGINE,
|
|
58563
|
-
target: `${pages.length} page(s) (\xE9chantillon)`,
|
|
58604
|
+
target: `${scanned.length}/${pages.length} page(s) (\xE9chantillon)`,
|
|
58564
58605
|
date: today(),
|
|
58565
58606
|
findings,
|
|
58566
|
-
sample: sampleScope({ pages }),
|
|
58607
|
+
sample: sampleScope({ pages: scanned }),
|
|
58567
58608
|
testedScs: localTestedScs(interact),
|
|
58568
58609
|
...snapshots.length ? { snapshots } : {},
|
|
58569
58610
|
...redirected.length ? { redirected } : {}
|
|
@@ -66635,7 +66676,7 @@ async function cmdScan(p) {
|
|
|
66635
66676
|
}
|
|
66636
66677
|
sampleConfig = v.sample;
|
|
66637
66678
|
if (!useLocal && sampleConfig.pages.some((pg) => pg.storageState)) {
|
|
66638
|
-
const forced =
|
|
66679
|
+
const forced = runtimeFlag === "docker";
|
|
66639
66680
|
console.error(
|
|
66640
66681
|
lang === "fr" ? `ultra11y scan : l'\xE9chantillon comporte des pages authentifi\xE9es (storageState), non prises en charge par le runtime Docker.${forced ? " Retirez --runtime docker." : " Le runtime local n'a pas pu \xEAtre r\xE9solu : installez @playwright/test et @axe-core/playwright dans le projet audit\xE9 (ou pointez --cwd dessus). Via l'Action, ce sont des d\xE9pendances du d\xE9p\xF4t."}` : `ultra11y scan: the sample has authenticated pages (storageState), unsupported by the Docker runtime.${forced ? " Drop --runtime docker." : " The local runtime could not be resolved: install @playwright/test and @axe-core/playwright in the audited project (or point --cwd at it). Through the Action, those are dependencies of the repository."}`
|
|
66641
66682
|
);
|
|
@@ -66668,10 +66709,17 @@ async function cmdScan(p) {
|
|
|
66668
66709
|
}
|
|
66669
66710
|
if (dynamic.redirected?.length) {
|
|
66670
66711
|
for (const r of dynamic.redirected) {
|
|
66712
|
+
const why = r.reason === "http-status" ? lang === "fr" ? `a r\xE9pondu HTTP ${r.status} \xE0 la m\xEAme adresse` : `answered HTTP ${r.status} at the same address` : lang === "fr" ? `a redirig\xE9 vers ${r.landed}` : `redirected to ${r.landed}`;
|
|
66671
66713
|
console.error(
|
|
66672
|
-
lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested}
|
|
66714
|
+
lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} ${why}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom demand\xE9.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} ${why}. Recording it would have described that screen under the requested name.`
|
|
66673
66715
|
);
|
|
66674
66716
|
}
|
|
66717
|
+
if (useSample && sampleConfig && dynamic.redirected.length === sampleConfig.pages.length) {
|
|
66718
|
+
console.error(
|
|
66719
|
+
lang === "fr" ? `ultra11y scan : aucune des ${sampleConfig.pages.length} pages de l'\xE9chantillon n'a pu \xEAtre enregistr\xE9e \u2014 rien n'a \xE9t\xE9 mesur\xE9. V\xE9rifiez la session, l'URL de base et l'\xE9tat applicatif attendu par ces pages.` : `ultra11y scan: none of the ${sampleConfig.pages.length} sample pages could be recorded \u2014 nothing was measured. Check the session, the base URL, and the application state those pages expect.`
|
|
66720
|
+
);
|
|
66721
|
+
return 1;
|
|
66722
|
+
}
|
|
66675
66723
|
}
|
|
66676
66724
|
if (useSample && sampleConfig) {
|
|
66677
66725
|
const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
|
|
@@ -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:
|
|
8
|
+
version: 5.0.0
|
|
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 = "
|
|
9
|
+
var VERSION = "5.0.0";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -53203,6 +53203,20 @@ function pageGridModel(result, derived, standard, lang) {
|
|
|
53203
53203
|
for (const p of derived) for (const pc of derivePackResults(pageView(result, p), standard)) put(pc.id, p.id, pc.status);
|
|
53204
53204
|
return { rows, status };
|
|
53205
53205
|
}
|
|
53206
|
+
function renderRedirected(redirected, lang = "en") {
|
|
53207
|
+
const fr = lang === "fr";
|
|
53208
|
+
const out2 = [
|
|
53209
|
+
fr ? `> \u26A0\uFE0F **${redirected.length} page(s) de l'\xE9chantillon n'ont pas \xE9t\xE9 enregistr\xE9es** \u2014 le navigateur n'est pas rest\xE9 sur l'adresse demand\xE9e. Les enregistrer aurait d\xE9crit un autre \xE9cran sous le nom demand\xE9. Elles ne comptent donc ni comme conformes ni comme non conformes : elles manquent.` : `> \u26A0\uFE0F **${redirected.length} sample page(s) were not recorded** \u2014 the browser did not stay on the address asked for. Recording them would have described another screen under the requested name. They count as neither conforming nor non-conforming: they are missing.`,
|
|
53210
|
+
"",
|
|
53211
|
+
fr ? "| Page | Demand\xE9 | Atteint | Motif |" : "| Page | Requested | Landed | Reason |",
|
|
53212
|
+
"| --- | --- | --- | --- |"
|
|
53213
|
+
];
|
|
53214
|
+
for (const r of redirected) {
|
|
53215
|
+
const why = r.reason === "http-status" ? fr ? `HTTP ${r.status ?? "\u2265 400"} \u2014 page d'erreur rendue \xE0 la m\xEAme adresse` : `HTTP ${r.status ?? "\u2265 400"} \u2014 error page served at the same address` : fr ? "redirection" : "redirect";
|
|
53216
|
+
out2.push(`| ${r.name} (\`${r.id}\`) | \`${r.requested}\` | \`${r.landed}\` | ${why} |`);
|
|
53217
|
+
}
|
|
53218
|
+
return out2;
|
|
53219
|
+
}
|
|
53206
53220
|
function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
|
|
53207
53221
|
const s = L3[lang];
|
|
53208
53222
|
const out2 = [];
|
|
@@ -53445,6 +53459,7 @@ function render(r, lang, opts) {
|
|
|
53445
53459
|
const derived = derivePages(r, pageScope);
|
|
53446
53460
|
out2.push(`## \u{1F4C4} ${s.perPageTitle}`, "", `> ${s.perPageNote}`, "");
|
|
53447
53461
|
if (r.scope.sample?.transverse?.length) out2.push(`> ${s.transverseNote(r.scope.sample.transverse.join(", "))}`, "");
|
|
53462
|
+
if (r.scope.redirected?.length) out2.push(...renderRedirected(r.scope.redirected, lang), "");
|
|
53448
53463
|
const pack = isCore(opts.standard) ? void 0 : loadPack(opts.standard);
|
|
53449
53464
|
for (const pg of derived) {
|
|
53450
53465
|
const nc = pg.findings.filter((f) => !f.advisory);
|
|
@@ -57792,6 +57807,14 @@ function resolveHostAnchor(file, snippet2) {
|
|
|
57792
57807
|
function mergeDynamic(audit2, dynamic, lang = "en") {
|
|
57793
57808
|
const merged = JSON.parse(JSON.stringify(audit2));
|
|
57794
57809
|
if (dynamic.sample) merged.scope.sample = dynamic.sample;
|
|
57810
|
+
if (dynamic.redirected?.length) {
|
|
57811
|
+
const byId3 = new Map((merged.scope.redirected ?? []).map((r) => [r.id, r]));
|
|
57812
|
+
for (const r of dynamic.redirected) byId3.set(r.id, r);
|
|
57813
|
+
const scannedIds = new Set((merged.scope.sample?.pages ?? []).map((p) => p.id));
|
|
57814
|
+
const still = [...byId3.values()].filter((r) => !scannedIds.has(r.id));
|
|
57815
|
+
if (still.length) merged.scope.redirected = still;
|
|
57816
|
+
else delete merged.scope.redirected;
|
|
57817
|
+
}
|
|
57795
57818
|
if (dynamic.testedScs?.length) {
|
|
57796
57819
|
const tested = /* @__PURE__ */ new Set([...merged.scope.scan?.testedScs ?? [], ...dynamic.testedScs]);
|
|
57797
57820
|
merged.scope.scan = { testedScs: [...tested].sort() };
|
|
@@ -58354,17 +58377,23 @@ function clicksAllowed(storageState, interactClicks) {
|
|
|
58354
58377
|
}
|
|
58355
58378
|
function landedOnRequestedPage(requested, landed) {
|
|
58356
58379
|
if (!landed || requested === landed) return true;
|
|
58357
|
-
const
|
|
58380
|
+
const parse2 = (u) => {
|
|
58358
58381
|
try {
|
|
58359
|
-
return new URL(u, "http://x.invalid")
|
|
58382
|
+
return new URL(u, "http://x.invalid");
|
|
58360
58383
|
} catch {
|
|
58361
58384
|
return void 0;
|
|
58362
58385
|
}
|
|
58363
58386
|
};
|
|
58364
|
-
const a =
|
|
58365
|
-
const b =
|
|
58366
|
-
if (a
|
|
58367
|
-
|
|
58387
|
+
const a = parse2(requested);
|
|
58388
|
+
const b = parse2(landed);
|
|
58389
|
+
if (!a || !b) return true;
|
|
58390
|
+
const path = (u) => u.pathname.replace(/\/+$/, "");
|
|
58391
|
+
if (path(a) !== path(b)) return false;
|
|
58392
|
+
const host = (u) => u.hostname.replace(/^www\./, "");
|
|
58393
|
+
if (host(a) !== host(b)) return false;
|
|
58394
|
+
if (a.hash && a.hash !== b.hash) return false;
|
|
58395
|
+
if (a.search && a.search !== b.search) return false;
|
|
58396
|
+
return true;
|
|
58368
58397
|
}
|
|
58369
58398
|
async function probeLiveRegion(page, lang, allowClicks) {
|
|
58370
58399
|
const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
|
|
@@ -58405,10 +58434,12 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
|
|
|
58405
58434
|
const empty = [];
|
|
58406
58435
|
try {
|
|
58407
58436
|
const url = isFile ? "file://" + resolve11(target) : target;
|
|
58408
|
-
await page.goto(url, { waitUntil: "load", timeout: 45e3 });
|
|
58437
|
+
const response = await page.goto(url, { waitUntil: "load", timeout: 45e3 });
|
|
58409
58438
|
await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
58410
58439
|
});
|
|
58411
58440
|
await page.waitForTimeout(1200);
|
|
58441
|
+
const landedUrl = page.url() || url;
|
|
58442
|
+
const httpStatus = typeof response?.status === "function" ? response.status() : void 0;
|
|
58412
58443
|
let snapshot;
|
|
58413
58444
|
let screenshot;
|
|
58414
58445
|
if (opts.snapshot !== false) {
|
|
@@ -58454,6 +58485,8 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
|
|
|
58454
58485
|
const liveRegion = opts.interact ? await probeLiveRegion(page, l, opts.allowClicks).catch(() => empty) : [];
|
|
58455
58486
|
return {
|
|
58456
58487
|
url: page.url() || target,
|
|
58488
|
+
landedUrl,
|
|
58489
|
+
...httpStatus !== void 0 ? { httpStatus } : {},
|
|
58457
58490
|
violations,
|
|
58458
58491
|
reflow,
|
|
58459
58492
|
focusVisible: dialogFocus.length ? [...focusVisible, ...dialogFocus] : focusVisible,
|
|
@@ -58546,8 +58579,14 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58546
58579
|
lang,
|
|
58547
58580
|
snapshot: Boolean(opts.snapshotRoot)
|
|
58548
58581
|
});
|
|
58549
|
-
|
|
58550
|
-
|
|
58582
|
+
const requestedUrl = isFile ? "file://" + resolve11(page.url) : page.url;
|
|
58583
|
+
const landedUrl = out2.landedUrl ?? out2.url;
|
|
58584
|
+
if (!landedOnRequestedPage(requestedUrl, landedUrl)) {
|
|
58585
|
+
redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "redirect" });
|
|
58586
|
+
continue;
|
|
58587
|
+
}
|
|
58588
|
+
if (out2.httpStatus !== void 0 && out2.httpStatus >= 400) {
|
|
58589
|
+
redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "http-status", status: out2.httpStatus });
|
|
58551
58590
|
continue;
|
|
58552
58591
|
}
|
|
58553
58592
|
findings.push(...tagSampleFindings(toDynamicResult(out2, page.url, lang, LOCAL_ENGINE).findings, page));
|
|
@@ -58557,13 +58596,15 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58557
58596
|
} finally {
|
|
58558
58597
|
await browser.close();
|
|
58559
58598
|
}
|
|
58599
|
+
const dropped = new Set(redirected.map((r) => r.id));
|
|
58600
|
+
const scanned = pages.filter((p) => !dropped.has(p.id));
|
|
58560
58601
|
return {
|
|
58561
58602
|
tool: "ultra11y",
|
|
58562
58603
|
engine: LOCAL_ENGINE,
|
|
58563
|
-
target: `${pages.length} page(s) (\xE9chantillon)`,
|
|
58604
|
+
target: `${scanned.length}/${pages.length} page(s) (\xE9chantillon)`,
|
|
58564
58605
|
date: today(),
|
|
58565
58606
|
findings,
|
|
58566
|
-
sample: sampleScope({ pages }),
|
|
58607
|
+
sample: sampleScope({ pages: scanned }),
|
|
58567
58608
|
testedScs: localTestedScs(interact),
|
|
58568
58609
|
...snapshots.length ? { snapshots } : {},
|
|
58569
58610
|
...redirected.length ? { redirected } : {}
|
|
@@ -66635,7 +66676,7 @@ async function cmdScan(p) {
|
|
|
66635
66676
|
}
|
|
66636
66677
|
sampleConfig = v.sample;
|
|
66637
66678
|
if (!useLocal && sampleConfig.pages.some((pg) => pg.storageState)) {
|
|
66638
|
-
const forced =
|
|
66679
|
+
const forced = runtimeFlag === "docker";
|
|
66639
66680
|
console.error(
|
|
66640
66681
|
lang === "fr" ? `ultra11y scan : l'\xE9chantillon comporte des pages authentifi\xE9es (storageState), non prises en charge par le runtime Docker.${forced ? " Retirez --runtime docker." : " Le runtime local n'a pas pu \xEAtre r\xE9solu : installez @playwright/test et @axe-core/playwright dans le projet audit\xE9 (ou pointez --cwd dessus). Via l'Action, ce sont des d\xE9pendances du d\xE9p\xF4t."}` : `ultra11y scan: the sample has authenticated pages (storageState), unsupported by the Docker runtime.${forced ? " Drop --runtime docker." : " The local runtime could not be resolved: install @playwright/test and @axe-core/playwright in the audited project (or point --cwd at it). Through the Action, those are dependencies of the repository."}`
|
|
66641
66682
|
);
|
|
@@ -66668,10 +66709,17 @@ async function cmdScan(p) {
|
|
|
66668
66709
|
}
|
|
66669
66710
|
if (dynamic.redirected?.length) {
|
|
66670
66711
|
for (const r of dynamic.redirected) {
|
|
66712
|
+
const why = r.reason === "http-status" ? lang === "fr" ? `a r\xE9pondu HTTP ${r.status} \xE0 la m\xEAme adresse` : `answered HTTP ${r.status} at the same address` : lang === "fr" ? `a redirig\xE9 vers ${r.landed}` : `redirected to ${r.landed}`;
|
|
66671
66713
|
console.error(
|
|
66672
|
-
lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested}
|
|
66714
|
+
lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} ${why}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom demand\xE9.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} ${why}. Recording it would have described that screen under the requested name.`
|
|
66673
66715
|
);
|
|
66674
66716
|
}
|
|
66717
|
+
if (useSample && sampleConfig && dynamic.redirected.length === sampleConfig.pages.length) {
|
|
66718
|
+
console.error(
|
|
66719
|
+
lang === "fr" ? `ultra11y scan : aucune des ${sampleConfig.pages.length} pages de l'\xE9chantillon n'a pu \xEAtre enregistr\xE9e \u2014 rien n'a \xE9t\xE9 mesur\xE9. V\xE9rifiez la session, l'URL de base et l'\xE9tat applicatif attendu par ces pages.` : `ultra11y scan: none of the ${sampleConfig.pages.length} sample pages could be recorded \u2014 nothing was measured. Check the session, the base URL, and the application state those pages expect.`
|
|
66720
|
+
);
|
|
66721
|
+
return 1;
|
|
66722
|
+
}
|
|
66675
66723
|
}
|
|
66676
66724
|
if (useSample && sampleConfig) {
|
|
66677
66725
|
const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
|
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:
|
|
7
|
+
version: 5.0.0
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# ultra11y — audit WCAG 2.2 AA and write accessible markup
|
|
@@ -76,8 +76,17 @@ steps:
|
|
|
76
76
|
|---|---|---|
|
|
77
77
|
| `adjudicate` | `none` | `api` · `agent` · `none` |
|
|
78
78
|
| `adjudicate-model` | *(empty)* | model id for `api`; else `$ULTRA11Y_LLM_MODEL`, else the engine default |
|
|
79
|
+
| `adjudicate-max-turns` | *(empty)* | turn budget for `agent`; empty derives it from the worklist |
|
|
79
80
|
| `gate-adjudicated` | `false` | let a model-ruled NC fail the job |
|
|
80
81
|
|
|
82
|
+
**The turn budget is derived, and that matters more than it sounds.** The runbook is sequential
|
|
83
|
+
and the whole point of agent mode is that it opens the files a criterion cites, so the cost is
|
|
84
|
+
per item — under RGAA the worklist runs to ~80. A budget too small does not truncate the result,
|
|
85
|
+
it **loses** it: the fold is fail-closed on a single unfilled verdict, so a run cut short throws
|
|
86
|
+
away every verdict it had already produced, and you pay for all of them. The action therefore
|
|
87
|
+
counts the worklist and budgets from it. `adjudicate-max-turns` overrides that when you know
|
|
88
|
+
better.
|
|
89
|
+
|
|
81
90
|
**`api`** sends the worklist to the Messages API in batches of 8 and folds the verdicts back.
|
|
82
91
|
**`agent`** emits the worklist plus `orchestrate --eco`'s runbook and hands them to a
|
|
83
92
|
`claude-code-action` run, then folds the same way. Both end in the **same fail-closed gate** an
|
|
@@ -222,15 +222,31 @@ the §1–5 structure of a conformance document.
|
|
|
222
222
|
|
|
223
223
|
### Four honesty rules
|
|
224
224
|
|
|
225
|
-
**0. A page is recorded only if the browser stayed on it.** A sample page's
|
|
226
|
-
`name` are the identity the report speaks, and they are applied to whatever was
|
|
227
|
-
route that redirects — an expired session bouncing to sign-in, a wizard step the
|
|
228
|
-
state does not open — would be filed under the requested page's name while showing
|
|
229
|
-
else: a sheet, a screenshot and a rate that describe a different screen, with nothing
|
|
230
|
-
document looking wrong.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
225
|
+
**0. A page is recorded only if the browser stayed on it, and answered.** A sample page's
|
|
226
|
+
declared `id` and `name` are the identity the report speaks, and they are applied to whatever was
|
|
227
|
+
on screen. So a route that redirects — an expired session bouncing to sign-in, a wizard step the
|
|
228
|
+
application state does not open — would be filed under the requested page's name while showing
|
|
229
|
+
something else: a sheet, a screenshot and a rate that describe a different screen, with nothing
|
|
230
|
+
about the document looking wrong.
|
|
231
|
+
|
|
232
|
+
`scan --sample` refuses two ways:
|
|
233
|
+
|
|
234
|
+
- **It moved.** The address is compared as the app routes: the **path** always; the **fragment**
|
|
235
|
+
and the **query** when the request carried one, because a hash-router app puts the route there
|
|
236
|
+
and `#/admin` → `#/login` is exactly the bounce this rule exists for; the **host**, since a
|
|
237
|
+
same-path landing on an IdP is the same misattribution (a scheme, a port or a `www.` differ
|
|
238
|
+
freely — that is canonicalisation). The comparison uses the URL captured right after the
|
|
239
|
+
navigation settles, before any probe could click something that routes.
|
|
240
|
+
- **It answered an error.** A framework's own not-found page is a complete, valid document
|
|
241
|
+
served at the address you asked for — `notFound()` in Next is exactly that — so no address
|
|
242
|
+
comparison can see it. The HTTP status can: anything ≥ 400 is refused.
|
|
243
|
+
|
|
244
|
+
A refused page is **named in the report**, with what was asked, what was reached and why, and it
|
|
245
|
+
is removed from `scope.sample` — leaving it there would put it back in the grid with the same
|
|
246
|
+
basis as a page really visited, so the deliverable would claim we looked at it. A run where
|
|
247
|
+
*every* page was refused exits non-zero: that is not a partial result, it is a scan that measured
|
|
248
|
+
nothing. A page reported missing is a bug in the sample or in the state you seeded, and both are
|
|
249
|
+
fixable; a page reported under the wrong name is a false conformance claim.
|
|
234
250
|
|
|
235
251
|
**1. A finding is attributed to a page only when something says so.** In order: the snapshot
|
|
236
252
|
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 = "
|
|
9
|
+
var VERSION = "5.0.0";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -53203,6 +53203,20 @@ function pageGridModel(result, derived, standard, lang) {
|
|
|
53203
53203
|
for (const p of derived) for (const pc of derivePackResults(pageView(result, p), standard)) put(pc.id, p.id, pc.status);
|
|
53204
53204
|
return { rows, status };
|
|
53205
53205
|
}
|
|
53206
|
+
function renderRedirected(redirected, lang = "en") {
|
|
53207
|
+
const fr = lang === "fr";
|
|
53208
|
+
const out2 = [
|
|
53209
|
+
fr ? `> \u26A0\uFE0F **${redirected.length} page(s) de l'\xE9chantillon n'ont pas \xE9t\xE9 enregistr\xE9es** \u2014 le navigateur n'est pas rest\xE9 sur l'adresse demand\xE9e. Les enregistrer aurait d\xE9crit un autre \xE9cran sous le nom demand\xE9. Elles ne comptent donc ni comme conformes ni comme non conformes : elles manquent.` : `> \u26A0\uFE0F **${redirected.length} sample page(s) were not recorded** \u2014 the browser did not stay on the address asked for. Recording them would have described another screen under the requested name. They count as neither conforming nor non-conforming: they are missing.`,
|
|
53210
|
+
"",
|
|
53211
|
+
fr ? "| Page | Demand\xE9 | Atteint | Motif |" : "| Page | Requested | Landed | Reason |",
|
|
53212
|
+
"| --- | --- | --- | --- |"
|
|
53213
|
+
];
|
|
53214
|
+
for (const r of redirected) {
|
|
53215
|
+
const why = r.reason === "http-status" ? fr ? `HTTP ${r.status ?? "\u2265 400"} \u2014 page d'erreur rendue \xE0 la m\xEAme adresse` : `HTTP ${r.status ?? "\u2265 400"} \u2014 error page served at the same address` : fr ? "redirection" : "redirect";
|
|
53216
|
+
out2.push(`| ${r.name} (\`${r.id}\`) | \`${r.requested}\` | \`${r.landed}\` | ${why} |`);
|
|
53217
|
+
}
|
|
53218
|
+
return out2;
|
|
53219
|
+
}
|
|
53206
53220
|
function renderPageGrid(result, pages, standard = CORE2, lang = "en") {
|
|
53207
53221
|
const s = L3[lang];
|
|
53208
53222
|
const out2 = [];
|
|
@@ -53445,6 +53459,7 @@ function render(r, lang, opts) {
|
|
|
53445
53459
|
const derived = derivePages(r, pageScope);
|
|
53446
53460
|
out2.push(`## \u{1F4C4} ${s.perPageTitle}`, "", `> ${s.perPageNote}`, "");
|
|
53447
53461
|
if (r.scope.sample?.transverse?.length) out2.push(`> ${s.transverseNote(r.scope.sample.transverse.join(", "))}`, "");
|
|
53462
|
+
if (r.scope.redirected?.length) out2.push(...renderRedirected(r.scope.redirected, lang), "");
|
|
53448
53463
|
const pack = isCore(opts.standard) ? void 0 : loadPack(opts.standard);
|
|
53449
53464
|
for (const pg of derived) {
|
|
53450
53465
|
const nc = pg.findings.filter((f) => !f.advisory);
|
|
@@ -57792,6 +57807,14 @@ function resolveHostAnchor(file, snippet2) {
|
|
|
57792
57807
|
function mergeDynamic(audit2, dynamic, lang = "en") {
|
|
57793
57808
|
const merged = JSON.parse(JSON.stringify(audit2));
|
|
57794
57809
|
if (dynamic.sample) merged.scope.sample = dynamic.sample;
|
|
57810
|
+
if (dynamic.redirected?.length) {
|
|
57811
|
+
const byId3 = new Map((merged.scope.redirected ?? []).map((r) => [r.id, r]));
|
|
57812
|
+
for (const r of dynamic.redirected) byId3.set(r.id, r);
|
|
57813
|
+
const scannedIds = new Set((merged.scope.sample?.pages ?? []).map((p) => p.id));
|
|
57814
|
+
const still = [...byId3.values()].filter((r) => !scannedIds.has(r.id));
|
|
57815
|
+
if (still.length) merged.scope.redirected = still;
|
|
57816
|
+
else delete merged.scope.redirected;
|
|
57817
|
+
}
|
|
57795
57818
|
if (dynamic.testedScs?.length) {
|
|
57796
57819
|
const tested = /* @__PURE__ */ new Set([...merged.scope.scan?.testedScs ?? [], ...dynamic.testedScs]);
|
|
57797
57820
|
merged.scope.scan = { testedScs: [...tested].sort() };
|
|
@@ -58354,17 +58377,23 @@ function clicksAllowed(storageState, interactClicks) {
|
|
|
58354
58377
|
}
|
|
58355
58378
|
function landedOnRequestedPage(requested, landed) {
|
|
58356
58379
|
if (!landed || requested === landed) return true;
|
|
58357
|
-
const
|
|
58380
|
+
const parse2 = (u) => {
|
|
58358
58381
|
try {
|
|
58359
|
-
return new URL(u, "http://x.invalid")
|
|
58382
|
+
return new URL(u, "http://x.invalid");
|
|
58360
58383
|
} catch {
|
|
58361
58384
|
return void 0;
|
|
58362
58385
|
}
|
|
58363
58386
|
};
|
|
58364
|
-
const a =
|
|
58365
|
-
const b =
|
|
58366
|
-
if (a
|
|
58367
|
-
|
|
58387
|
+
const a = parse2(requested);
|
|
58388
|
+
const b = parse2(landed);
|
|
58389
|
+
if (!a || !b) return true;
|
|
58390
|
+
const path = (u) => u.pathname.replace(/\/+$/, "");
|
|
58391
|
+
if (path(a) !== path(b)) return false;
|
|
58392
|
+
const host = (u) => u.hostname.replace(/^www\./, "");
|
|
58393
|
+
if (host(a) !== host(b)) return false;
|
|
58394
|
+
if (a.hash && a.hash !== b.hash) return false;
|
|
58395
|
+
if (a.search && a.search !== b.search) return false;
|
|
58396
|
+
return true;
|
|
58368
58397
|
}
|
|
58369
58398
|
async function probeLiveRegion(page, lang, allowClicks) {
|
|
58370
58399
|
const detail = LIVE_REGION_DETAIL[lang] ?? LIVE_REGION_DETAIL.en;
|
|
@@ -58405,10 +58434,12 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
|
|
|
58405
58434
|
const empty = [];
|
|
58406
58435
|
try {
|
|
58407
58436
|
const url = isFile ? "file://" + resolve11(target) : target;
|
|
58408
|
-
await page.goto(url, { waitUntil: "load", timeout: 45e3 });
|
|
58437
|
+
const response = await page.goto(url, { waitUntil: "load", timeout: 45e3 });
|
|
58409
58438
|
await page.waitForLoadState("networkidle", { timeout: 8e3 }).catch(() => {
|
|
58410
58439
|
});
|
|
58411
58440
|
await page.waitForTimeout(1200);
|
|
58441
|
+
const landedUrl = page.url() || url;
|
|
58442
|
+
const httpStatus = typeof response?.status === "function" ? response.status() : void 0;
|
|
58412
58443
|
let snapshot;
|
|
58413
58444
|
let screenshot;
|
|
58414
58445
|
if (opts.snapshot !== false) {
|
|
@@ -58454,6 +58485,8 @@ async function runOnPage(browser, AxeBuilder, target, isFile, opts) {
|
|
|
58454
58485
|
const liveRegion = opts.interact ? await probeLiveRegion(page, l, opts.allowClicks).catch(() => empty) : [];
|
|
58455
58486
|
return {
|
|
58456
58487
|
url: page.url() || target,
|
|
58488
|
+
landedUrl,
|
|
58489
|
+
...httpStatus !== void 0 ? { httpStatus } : {},
|
|
58457
58490
|
violations,
|
|
58458
58491
|
reflow,
|
|
58459
58492
|
focusVisible: dialogFocus.length ? [...focusVisible, ...dialogFocus] : focusVisible,
|
|
@@ -58546,8 +58579,14 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58546
58579
|
lang,
|
|
58547
58580
|
snapshot: Boolean(opts.snapshotRoot)
|
|
58548
58581
|
});
|
|
58549
|
-
|
|
58550
|
-
|
|
58582
|
+
const requestedUrl = isFile ? "file://" + resolve11(page.url) : page.url;
|
|
58583
|
+
const landedUrl = out2.landedUrl ?? out2.url;
|
|
58584
|
+
if (!landedOnRequestedPage(requestedUrl, landedUrl)) {
|
|
58585
|
+
redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "redirect" });
|
|
58586
|
+
continue;
|
|
58587
|
+
}
|
|
58588
|
+
if (out2.httpStatus !== void 0 && out2.httpStatus >= 400) {
|
|
58589
|
+
redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "http-status", status: out2.httpStatus });
|
|
58551
58590
|
continue;
|
|
58552
58591
|
}
|
|
58553
58592
|
findings.push(...tagSampleFindings(toDynamicResult(out2, page.url, lang, LOCAL_ENGINE).findings, page));
|
|
@@ -58557,13 +58596,15 @@ async function runSampleScanLocal(pages, opts) {
|
|
|
58557
58596
|
} finally {
|
|
58558
58597
|
await browser.close();
|
|
58559
58598
|
}
|
|
58599
|
+
const dropped = new Set(redirected.map((r) => r.id));
|
|
58600
|
+
const scanned = pages.filter((p) => !dropped.has(p.id));
|
|
58560
58601
|
return {
|
|
58561
58602
|
tool: "ultra11y",
|
|
58562
58603
|
engine: LOCAL_ENGINE,
|
|
58563
|
-
target: `${pages.length} page(s) (\xE9chantillon)`,
|
|
58604
|
+
target: `${scanned.length}/${pages.length} page(s) (\xE9chantillon)`,
|
|
58564
58605
|
date: today(),
|
|
58565
58606
|
findings,
|
|
58566
|
-
sample: sampleScope({ pages }),
|
|
58607
|
+
sample: sampleScope({ pages: scanned }),
|
|
58567
58608
|
testedScs: localTestedScs(interact),
|
|
58568
58609
|
...snapshots.length ? { snapshots } : {},
|
|
58569
58610
|
...redirected.length ? { redirected } : {}
|
|
@@ -66635,7 +66676,7 @@ async function cmdScan(p) {
|
|
|
66635
66676
|
}
|
|
66636
66677
|
sampleConfig = v.sample;
|
|
66637
66678
|
if (!useLocal && sampleConfig.pages.some((pg) => pg.storageState)) {
|
|
66638
|
-
const forced =
|
|
66679
|
+
const forced = runtimeFlag === "docker";
|
|
66639
66680
|
console.error(
|
|
66640
66681
|
lang === "fr" ? `ultra11y scan : l'\xE9chantillon comporte des pages authentifi\xE9es (storageState), non prises en charge par le runtime Docker.${forced ? " Retirez --runtime docker." : " Le runtime local n'a pas pu \xEAtre r\xE9solu : installez @playwright/test et @axe-core/playwright dans le projet audit\xE9 (ou pointez --cwd dessus). Via l'Action, ce sont des d\xE9pendances du d\xE9p\xF4t."}` : `ultra11y scan: the sample has authenticated pages (storageState), unsupported by the Docker runtime.${forced ? " Drop --runtime docker." : " The local runtime could not be resolved: install @playwright/test and @axe-core/playwright in the audited project (or point --cwd at it). Through the Action, those are dependencies of the repository."}`
|
|
66641
66682
|
);
|
|
@@ -66668,10 +66709,17 @@ async function cmdScan(p) {
|
|
|
66668
66709
|
}
|
|
66669
66710
|
if (dynamic.redirected?.length) {
|
|
66670
66711
|
for (const r of dynamic.redirected) {
|
|
66712
|
+
const why = r.reason === "http-status" ? lang === "fr" ? `a r\xE9pondu HTTP ${r.status} \xE0 la m\xEAme adresse` : `answered HTTP ${r.status} at the same address` : lang === "fr" ? `a redirig\xE9 vers ${r.landed}` : `redirected to ${r.landed}`;
|
|
66671
66713
|
console.error(
|
|
66672
|
-
lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested}
|
|
66714
|
+
lang === "fr" ? `\u26A0\uFE0F ultra11y scan : \xAB ${r.name} \xBB (${r.id}) non enregistr\xE9e \u2014 ${r.requested} ${why}. L'enregistrer aurait d\xE9crit cet \xE9cran sous le nom demand\xE9.` : `\u26A0\uFE0F ultra11y scan: "${r.name}" (${r.id}) not recorded \u2014 ${r.requested} ${why}. Recording it would have described that screen under the requested name.`
|
|
66673
66715
|
);
|
|
66674
66716
|
}
|
|
66717
|
+
if (useSample && sampleConfig && dynamic.redirected.length === sampleConfig.pages.length) {
|
|
66718
|
+
console.error(
|
|
66719
|
+
lang === "fr" ? `ultra11y scan : aucune des ${sampleConfig.pages.length} pages de l'\xE9chantillon n'a pu \xEAtre enregistr\xE9e \u2014 rien n'a \xE9t\xE9 mesur\xE9. V\xE9rifiez la session, l'URL de base et l'\xE9tat applicatif attendu par ces pages.` : `ultra11y scan: none of the ${sampleConfig.pages.length} sample pages could be recorded \u2014 nothing was measured. Check the session, the base URL, and the application state those pages expect.`
|
|
66720
|
+
);
|
|
66721
|
+
return 1;
|
|
66722
|
+
}
|
|
66675
66723
|
}
|
|
66676
66724
|
if (useSample && sampleConfig) {
|
|
66677
66725
|
const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
|
package/src/cli.ts
CHANGED
|
@@ -3010,7 +3010,10 @@ async function cmdScan(p: ParsedArgs): Promise<number> {
|
|
|
3010
3010
|
// of this message — "use --runtime local" alone sends an Action user to a flag they
|
|
3011
3011
|
// cannot reach, and telling them to force a runtime that still cannot load would
|
|
3012
3012
|
// exchange this error for a worse one.
|
|
3013
|
-
|
|
3013
|
+
// `runtimeFlag`, not `p.flags.runtime`: `--docker` is a documented alias, and reading the
|
|
3014
|
+
// raw flag would hand a caller who typed it the OTHER half of this message — "install
|
|
3015
|
+
// these dependencies" when the fix is to drop the flag they just passed.
|
|
3016
|
+
const forced = runtimeFlag === "docker";
|
|
3014
3017
|
console.error(
|
|
3015
3018
|
lang === "fr"
|
|
3016
3019
|
? `ultra11y scan : l'échantillon comporte des pages authentifiées (storageState), non prises en charge par le runtime Docker.${
|
|
@@ -3064,12 +3067,32 @@ async function cmdScan(p: ParsedArgs): Promise<number> {
|
|
|
3064
3067
|
// the cause is always fixable — an expired session, or state the wizard step needs.
|
|
3065
3068
|
if (dynamic.redirected?.length) {
|
|
3066
3069
|
for (const r of dynamic.redirected) {
|
|
3070
|
+
const why =
|
|
3071
|
+
r.reason === "http-status"
|
|
3072
|
+
? lang === "fr"
|
|
3073
|
+
? `a répondu HTTP ${r.status} à la même adresse`
|
|
3074
|
+
: `answered HTTP ${r.status} at the same address`
|
|
3075
|
+
: lang === "fr"
|
|
3076
|
+
? `a redirigé vers ${r.landed}`
|
|
3077
|
+
: `redirected to ${r.landed}`;
|
|
3067
3078
|
console.error(
|
|
3068
3079
|
lang === "fr"
|
|
3069
|
-
? `⚠️ ultra11y scan : « ${r.name} » (${r.id}) non enregistrée — ${r.requested}
|
|
3070
|
-
: `⚠️ ultra11y scan: "${r.name}" (${r.id}) not recorded — ${r.requested}
|
|
3080
|
+
? `⚠️ ultra11y scan : « ${r.name} » (${r.id}) non enregistrée — ${r.requested} ${why}. L'enregistrer aurait décrit cet écran sous le nom demandé.`
|
|
3081
|
+
: `⚠️ ultra11y scan: "${r.name}" (${r.id}) not recorded — ${r.requested} ${why}. Recording it would have described that screen under the requested name.`,
|
|
3071
3082
|
);
|
|
3072
3083
|
}
|
|
3084
|
+
// Every page refused is not a partial result, it is a scan that measured nothing. Exiting
|
|
3085
|
+
// 0 there would hand CI a green step and an empty report — the exact shape of a run that
|
|
3086
|
+
// passed. The usual cause is one fixable thing (an expired session, a wrong base URL),
|
|
3087
|
+
// so failing here is what makes it visible on the first run instead of the tenth.
|
|
3088
|
+
if (useSample && sampleConfig && dynamic.redirected.length === sampleConfig.pages.length) {
|
|
3089
|
+
console.error(
|
|
3090
|
+
lang === "fr"
|
|
3091
|
+
? `ultra11y scan : aucune des ${sampleConfig.pages.length} pages de l'échantillon n'a pu être enregistrée — rien n'a été mesuré. Vérifiez la session, l'URL de base et l'état applicatif attendu par ces pages.`
|
|
3092
|
+
: `ultra11y scan: none of the ${sampleConfig.pages.length} sample pages could be recorded — nothing was measured. Check the session, the base URL, and the application state those pages expect.`,
|
|
3093
|
+
);
|
|
3094
|
+
return 1;
|
|
3095
|
+
}
|
|
3073
3096
|
}
|
|
3074
3097
|
// Advisory sample-methodology lint: which normative page KINDS the configured sample
|
|
3075
3098
|
// lacks. Standard-agnostic mechanics + RGAA-specific required kinds; scan has no
|
package/src/pages.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// scored 100% on "does each image have a relevant alternative?". See `pageStatus`.
|
|
24
24
|
import { snapshotPageId } from "./snapshot.js";
|
|
25
25
|
import { CORE, type StandardId, derivePackResults, isCore, loadPack, themeName } from "./standards/index.js";
|
|
26
|
-
import type { AuditResult, CriterionResult, Finding, Lang, PageResult, PageScope, Status } from "./types.js";
|
|
26
|
+
import type { AuditResult, CriterionResult, Finding, Lang, PageResult, PageScope, Status, ScanRedirect } from "./types.js";
|
|
27
27
|
import { isUrlPath } from "./util.js";
|
|
28
28
|
import { automatability, compareSC, scTitle } from "./wcag.js";
|
|
29
29
|
|
|
@@ -375,6 +375,37 @@ export function pageGridModel(result: AuditResult, derived: PageResult[], standa
|
|
|
375
375
|
return { rows, status };
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
/** The pages a scan REFUSED to record, named in the deliverable.
|
|
379
|
+
*
|
|
380
|
+
* Honesty rule 0 (see the header) removes these from `scope.sample`, because a page kept
|
|
381
|
+
* there is re-added to the grid with the same basis as one really visited. Removing it is
|
|
382
|
+
* correct and insufficient: the report would then just be shorter than the sample the
|
|
383
|
+
* project declares, and a silently shorter report reads as a complete one. So the drop is
|
|
384
|
+
* stated — which page, where it went, and why — because that is what makes it fixable. */
|
|
385
|
+
export function renderRedirected(redirected: ScanRedirect[], lang: Lang = "en"): string[] {
|
|
386
|
+
const fr = lang === "fr";
|
|
387
|
+
const out: string[] = [
|
|
388
|
+
fr
|
|
389
|
+
? `> ⚠️ **${redirected.length} page(s) de l'échantillon n'ont pas été enregistrées** — le navigateur n'est pas resté sur l'adresse demandée. Les enregistrer aurait décrit un autre écran sous le nom demandé. Elles ne comptent donc ni comme conformes ni comme non conformes : elles manquent.`
|
|
390
|
+
: `> ⚠️ **${redirected.length} sample page(s) were not recorded** — the browser did not stay on the address asked for. Recording them would have described another screen under the requested name. They count as neither conforming nor non-conforming: they are missing.`,
|
|
391
|
+
"",
|
|
392
|
+
fr ? "| Page | Demandé | Atteint | Motif |" : "| Page | Requested | Landed | Reason |",
|
|
393
|
+
"| --- | --- | --- | --- |",
|
|
394
|
+
];
|
|
395
|
+
for (const r of redirected) {
|
|
396
|
+
const why =
|
|
397
|
+
r.reason === "http-status"
|
|
398
|
+
? fr
|
|
399
|
+
? `HTTP ${r.status ?? "≥ 400"} — page d'erreur rendue à la même adresse`
|
|
400
|
+
: `HTTP ${r.status ?? "≥ 400"} — error page served at the same address`
|
|
401
|
+
: fr
|
|
402
|
+
? "redirection"
|
|
403
|
+
: "redirect";
|
|
404
|
+
out.push(`| ${r.name} (\`${r.id}\`) | \`${r.requested}\` | \`${r.landed}\` | ${why} |`);
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
|
|
378
409
|
/** The Markdown grid: one row per criterion, one column per page. */
|
|
379
410
|
export function renderPageGrid(result: AuditResult, pages: PageScope[], standard: StandardId = CORE, lang: Lang = "en"): string {
|
|
380
411
|
const s = L[lang];
|
package/src/report.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { guidelineTitle, scTitle } from "./wcag.js";
|
|
|
11
11
|
import { prdUnits, partitionUnits } from "./prd.js";
|
|
12
12
|
import { renderAuditorUnit, type AuditorCropLookup } from "./auditor.js";
|
|
13
13
|
import { resolveMessage } from "./messages.js";
|
|
14
|
-
import { attributePages, derivePages, pagesOf, renderPageGrid } from "./pages.js";
|
|
14
|
+
import { attributePages, derivePages, pagesOf, renderPageGrid, renderRedirected } from "./pages.js";
|
|
15
15
|
import { PAGES_DIR } from "./snapshot.js";
|
|
16
16
|
import {
|
|
17
17
|
type StandardId,
|
|
@@ -376,6 +376,10 @@ function render(
|
|
|
376
376
|
const derived = derivePages(r, pageScope);
|
|
377
377
|
out.push(`## 📄 ${s.perPageTitle}`, "", `> ${s.perPageNote}`, "");
|
|
378
378
|
if (r.scope.sample?.transverse?.length) out.push(`> ${s.transverseNote(r.scope.sample.transverse.join(", "))}`, "");
|
|
379
|
+
// Pages the scan refused to record. Named here, in the section a reader uses to judge
|
|
380
|
+
// coverage, because the alternative is a report that is simply shorter than the sample
|
|
381
|
+
// the project declares — and a silently shorter deliverable reads as a complete one.
|
|
382
|
+
if (r.scope.redirected?.length) out.push(...renderRedirected(r.scope.redirected, lang), "");
|
|
379
383
|
// Standard-aware per-finding label: a pack report (RGAA, …) speaks its own criteria
|
|
380
384
|
// everywhere else, so this per-page line should too, rather than the raw WCAG SC id.
|
|
381
385
|
// `loadPack` once, outside the finding loop — never per-finding.
|
package/src/scan-local.ts
CHANGED
|
@@ -614,24 +614,48 @@ export function clicksAllowed(storageState: string | undefined, interactClicks:
|
|
|
614
614
|
* failure mode an accessibility report has: a reader sees a page sheet, a screenshot and a
|
|
615
615
|
* conformance rate, and none of it describes the page named at the top.
|
|
616
616
|
*
|
|
617
|
-
*
|
|
618
|
-
* the
|
|
619
|
-
*
|
|
617
|
+
* What counts as "the same page" is decided per component, because which component carries
|
|
618
|
+
* the route is the app's choice, not ours:
|
|
619
|
+
*
|
|
620
|
+
* - **path** always, trailing slash folded (`/aide/` ≡ `/aide`).
|
|
621
|
+
* - **fragment** only when the REQUEST had one. That is the hash-router case, where the
|
|
622
|
+
* fragment IS the route: asking for `#/admin` and landing on `#/login` is precisely the
|
|
623
|
+
* bounce this guard exists to catch, and comparing paths alone (both empty) would miss it.
|
|
624
|
+
* When the request carried no fragment, one the app appended to its own route is noise.
|
|
625
|
+
* - **query** on the same rule, for `?page=admin` style routing.
|
|
626
|
+
* - **host** always, since a same-path bounce to an IdP or a maintenance host is exactly the
|
|
627
|
+
* misattribution above. Only the scheme, the port and a `www.` prefix may differ — those
|
|
628
|
+
* are canonicalisation, not another page.
|
|
629
|
+
*
|
|
630
|
+
* `requested` must be the RESOLVED navigation target, not the raw config value: a relative
|
|
631
|
+
* `dist/index.html` becomes `file:///abs/dist/index.html` before `goto`, and comparing the
|
|
632
|
+
* two forms would drop every relative file target. Anything unparseable is treated as a
|
|
633
|
+
* match — this guard catches a redirect, it does not invent one. Exported for the
|
|
620
634
|
* browser-free policy test. */
|
|
621
635
|
export function landedOnRequestedPage(requested: string, landed: string): boolean {
|
|
622
636
|
if (!landed || requested === landed) return true;
|
|
623
|
-
const
|
|
637
|
+
const parse = (u: string): URL | undefined => {
|
|
624
638
|
try {
|
|
625
|
-
|
|
626
|
-
return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
|
|
639
|
+
return new URL(u, "http://x.invalid");
|
|
627
640
|
} catch {
|
|
628
641
|
return undefined;
|
|
629
642
|
}
|
|
630
643
|
};
|
|
631
|
-
const a =
|
|
632
|
-
const b =
|
|
633
|
-
if (a
|
|
634
|
-
|
|
644
|
+
const a = parse(requested);
|
|
645
|
+
const b = parse(landed);
|
|
646
|
+
if (!a || !b) return true;
|
|
647
|
+
|
|
648
|
+
const path = (u: URL): string => u.pathname.replace(/\/+$/, "");
|
|
649
|
+
if (path(a) !== path(b)) return false;
|
|
650
|
+
|
|
651
|
+
// `www.` is canonicalisation; a different host is a different site.
|
|
652
|
+
const host = (u: URL): string => u.hostname.replace(/^www\./, "");
|
|
653
|
+
if (host(a) !== host(b)) return false;
|
|
654
|
+
|
|
655
|
+
// Only compared when the REQUEST carried one — see the doc block.
|
|
656
|
+
if (a.hash && a.hash !== b.hash) return false;
|
|
657
|
+
if (a.search && a.search !== b.search) return false;
|
|
658
|
+
return true;
|
|
635
659
|
}
|
|
636
660
|
|
|
637
661
|
async function probeLiveRegion(page: Any, lang: Lang, allowClicks: boolean): Promise<ProbeHit[]> {
|
|
@@ -716,13 +740,26 @@ async function runOnPage(
|
|
|
716
740
|
const empty: ProbeHit[] = [];
|
|
717
741
|
try {
|
|
718
742
|
const url = isFile ? "file://" + resolve(target) : target;
|
|
719
|
-
await page.goto(url, { waitUntil: "load", timeout: 45000 });
|
|
743
|
+
const response = await page.goto(url, { waitUntil: "load", timeout: 45000 });
|
|
720
744
|
// Let the client hydrate and any framework JS inject content/landmarks (SPA routes,
|
|
721
745
|
// DSFR JS) before measuring — otherwise axe/probes see the pre-hydration DOM and
|
|
722
746
|
// report false "no h1 / not in a landmark". Bounded networkidle + a short settle.
|
|
723
747
|
await page.waitForLoadState("networkidle", { timeout: 8000 }).catch(() => {});
|
|
724
748
|
await page.waitForTimeout(1200);
|
|
725
749
|
|
|
750
|
+
// The identity of what is ABOUT to be measured, captured here and nowhere later. The
|
|
751
|
+
// probes below click buttons, and a click that routes (history.pushState) leaves
|
|
752
|
+
// `page.url()` on another route — read at the end, it would accuse the server of a
|
|
753
|
+
// redirect that never happened and throw away a page audited at the right address.
|
|
754
|
+
// `file://` is normalised through the same resolve() the navigation used, so a relative
|
|
755
|
+
// target compares against its own resolved form rather than against a bare path.
|
|
756
|
+
const landedUrl = (page.url() as string) || url;
|
|
757
|
+
// A framework can answer 404/500 with a full, valid document AT THE SAME URL —
|
|
758
|
+
// Next's `notFound()` is exactly that. The path guard cannot see it, and recording it
|
|
759
|
+
// would file an error page under a real page's name. `status()` is the only signal that
|
|
760
|
+
// separates them. `file://` navigations report no status; absent is not a failure.
|
|
761
|
+
const httpStatus: number | undefined = typeof response?.status === "function" ? (response.status() as number) : undefined;
|
|
762
|
+
|
|
726
763
|
// THE SNAPSHOT IS COLLECTED FIRST, on the PRISTINE page — before axe injects its source,
|
|
727
764
|
// before any probe fills an input, resizes the viewport or bolts on the text-spacing
|
|
728
765
|
// stylesheet. A snapshot is meant to be "the page as the browser built it"; collected
|
|
@@ -792,6 +829,8 @@ async function runOnPage(
|
|
|
792
829
|
const liveRegion = opts.interact ? await probeLiveRegion(page, l, opts.allowClicks).catch(() => empty) : [];
|
|
793
830
|
return {
|
|
794
831
|
url: (page.url() as string) || target,
|
|
832
|
+
landedUrl,
|
|
833
|
+
...(httpStatus !== undefined ? { httpStatus } : {}),
|
|
795
834
|
violations,
|
|
796
835
|
reflow,
|
|
797
836
|
focusVisible: dialogFocus.length ? [...focusVisible, ...dialogFocus] : focusVisible,
|
|
@@ -923,12 +962,23 @@ export async function runSampleScanLocal(pages: SamplePage[], opts: LocalManyOpt
|
|
|
923
962
|
snapshot: Boolean(opts.snapshotRoot),
|
|
924
963
|
});
|
|
925
964
|
// The declared identity is applied to whatever the browser had on screen, so a page
|
|
926
|
-
// that bounced elsewhere
|
|
927
|
-
// Drop it instead, and say which one and
|
|
928
|
-
// bug in the sample or the seeded state, and both
|
|
929
|
-
// the wrong name is neither — it is a false
|
|
930
|
-
|
|
931
|
-
|
|
965
|
+
// that bounced elsewhere — or answered an error at the same address — would be filed
|
|
966
|
+
// under the name of the page nobody looked at. Drop it instead, and say which one and
|
|
967
|
+
// why: a page reported missing is a bug in the sample or the seeded state, and both
|
|
968
|
+
// are fixable. A page reported under the wrong name is neither — it is a false
|
|
969
|
+
// conformance claim.
|
|
970
|
+
//
|
|
971
|
+
// Compared against the RESOLVED target and the URL captured before the probes ran
|
|
972
|
+
// (`landedUrl`), never `out.url`: that one is read at the end of the run, so a probe
|
|
973
|
+
// click that routed would look exactly like a server redirect.
|
|
974
|
+
const requestedUrl = isFile ? "file://" + resolve(page.url) : page.url;
|
|
975
|
+
const landedUrl = out.landedUrl ?? out.url;
|
|
976
|
+
if (!landedOnRequestedPage(requestedUrl, landedUrl)) {
|
|
977
|
+
redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "redirect" });
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
if (out.httpStatus !== undefined && out.httpStatus >= 400) {
|
|
981
|
+
redirected.push({ id: page.id, name: page.name, requested: page.url, landed: landedUrl, reason: "http-status", status: out.httpStatus });
|
|
932
982
|
continue;
|
|
933
983
|
}
|
|
934
984
|
findings.push(...tagSampleFindings(toDynamicResult(out, page.url, lang, LOCAL_ENGINE).findings, page));
|
|
@@ -940,13 +990,20 @@ export async function runSampleScanLocal(pages: SamplePage[], opts: LocalManyOpt
|
|
|
940
990
|
} finally {
|
|
941
991
|
await browser.close();
|
|
942
992
|
}
|
|
993
|
+
// The sample RECORDED is the sample actually scanned. Keeping a dropped page here would be
|
|
994
|
+
// worse than losing it: `pagesOf` re-adds every declared page to the grid with
|
|
995
|
+
// `basis: "attributed"` — the same basis as a page that was really visited — so the
|
|
996
|
+
// deliverable would positively claim we looked at a page we refused to record. The pages
|
|
997
|
+
// that were dropped travel in `redirected`, which says so out loud.
|
|
998
|
+
const dropped = new Set(redirected.map((r) => r.id));
|
|
999
|
+
const scanned = pages.filter((p) => !dropped.has(p.id));
|
|
943
1000
|
return {
|
|
944
1001
|
tool: "ultra11y",
|
|
945
1002
|
engine: LOCAL_ENGINE,
|
|
946
|
-
target: `${pages.length} page(s) (échantillon)`,
|
|
1003
|
+
target: `${scanned.length}/${pages.length} page(s) (échantillon)`,
|
|
947
1004
|
date: today(),
|
|
948
1005
|
findings,
|
|
949
|
-
sample: sampleScope({ pages }),
|
|
1006
|
+
sample: sampleScope({ pages: scanned }),
|
|
950
1007
|
testedScs: localTestedScs(interact),
|
|
951
1008
|
...(snapshots.length ? { snapshots } : {}),
|
|
952
1009
|
...(redirected.length ? { redirected } : {}),
|
package/src/scan.ts
CHANGED
|
@@ -171,6 +171,15 @@ export interface RunnerOutput {
|
|
|
171
171
|
// base64). Absent when snapshotting was off or the collection failed — in which case the
|
|
172
172
|
// page keeps its findings but earns no snapshot, and therefore no conforming-by-silence.
|
|
173
173
|
snapshot?: CollectedPage & { screenshot?: string };
|
|
174
|
+
// WHERE the browser was when the measurement started — read right after goto + settle,
|
|
175
|
+
// before any probe could click something that routes. `url` above is read at the END and
|
|
176
|
+
// is the address the run finished on; only this one is the identity of what was audited.
|
|
177
|
+
// Local runtime only; absent from a Docker RunnerOutput.
|
|
178
|
+
landedUrl?: string;
|
|
179
|
+
// The navigation's HTTP status. A framework can answer 404/500 with a full document at the
|
|
180
|
+
// requested URL (Next's `notFound()`), which no URL comparison can detect. Absent for
|
|
181
|
+
// `file://` and for the Docker runner — absent is never treated as a failure.
|
|
182
|
+
httpStatus?: number;
|
|
174
183
|
}
|
|
175
184
|
|
|
176
185
|
/** The page id for a scanned target. A served URL slugifies from its PATH, which is the
|
|
@@ -541,6 +550,18 @@ export function mergeDynamic(audit: AuditResult, dynamic: DynamicResult, lang: L
|
|
|
541
550
|
// Record the normative page sample the dynamic tier ran over (Task 5) — drives the
|
|
542
551
|
// report's « Constats par page » section. Storage-state paths were already dropped upstream.
|
|
543
552
|
if (dynamic.sample) merged.scope.sample = dynamic.sample;
|
|
553
|
+
// Pages the scan refused to record. They are NOT in `sample`, so without this the audit
|
|
554
|
+
// would simply be quietly shorter than the sample the repository declares — and a shorter
|
|
555
|
+
// deliverable that says nothing reads as a complete one. Unioned across merges by id, so a
|
|
556
|
+
// page recovered by a later scan stops being reported as missing.
|
|
557
|
+
if (dynamic.redirected?.length) {
|
|
558
|
+
const byId = new Map((merged.scope.redirected ?? []).map((r) => [r.id, r]));
|
|
559
|
+
for (const r of dynamic.redirected) byId.set(r.id, r);
|
|
560
|
+
const scannedIds = new Set((merged.scope.sample?.pages ?? []).map((p) => p.id));
|
|
561
|
+
const still = [...byId.values()].filter((r) => !scannedIds.has(r.id));
|
|
562
|
+
if (still.length) merged.scope.redirected = still;
|
|
563
|
+
else delete merged.scope.redirected;
|
|
564
|
+
}
|
|
544
565
|
// Record which needs-rendering SCs this scan actually MEASURED (union across merges) —
|
|
545
566
|
// the partial-audit advisory keys on this, so a Docker run (reflow only) never silently
|
|
546
567
|
// suppresses the banner for the local-only probes it did not run.
|
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 = "
|
|
3
|
+
export const VERSION = "5.0.0";
|
|
4
4
|
export const SCHEMA_VERSION = 2;
|
|
5
5
|
|
|
6
6
|
// Lang = the UI FRAME's language — the `L` localization tables each module (report,
|
|
@@ -57,6 +57,12 @@ export interface ScanRedirect {
|
|
|
57
57
|
name: string;
|
|
58
58
|
requested: string;
|
|
59
59
|
landed: string;
|
|
60
|
+
// WHY it was refused. `redirect`: the browser ended up at another address. `http-status`:
|
|
61
|
+
// it stayed at the requested one but the server answered an error — a framework's own
|
|
62
|
+
// not-found page is a full, valid document at the right URL, which no address comparison
|
|
63
|
+
// can tell from the real thing.
|
|
64
|
+
reason?: "redirect" | "http-status";
|
|
65
|
+
status?: number;
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
// ---- WCAG 2.2 canonical core (src/data/wcag.json, produced by scripts/build-standards.mjs)
|
|
@@ -478,6 +484,12 @@ export interface AuditResult {
|
|
|
478
484
|
// merges. Drives the partial-audit advisory (src/report.ts untestedNeedsRendering) so
|
|
479
485
|
// the banner never claims a probe ran when it did not. Optional/additive.
|
|
480
486
|
scan?: { testedScs: string[] };
|
|
487
|
+
// Sample pages a `scan --sample` REFUSED to record, and why. They are deliberately absent
|
|
488
|
+
// from `sample` above — a page kept there would be re-added to the per-page grid with the
|
|
489
|
+
// same basis as one really visited, so the deliverable would claim a page nobody looked
|
|
490
|
+
// at. Carried here instead so the report can say what is missing rather than hide it.
|
|
491
|
+
// Optional/additive — absent when every page was reached.
|
|
492
|
+
redirected?: ScanRedirect[];
|
|
481
493
|
};
|
|
482
494
|
guidelines: GuidelineTally[];
|
|
483
495
|
criteria: CriterionResult[];
|