ultra11y 5.1.0 → 5.2.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/dist/playwright.d.ts +58 -1
- package/dist/playwright.mjs +53 -0
- package/package.json +1 -1
- package/scripts/ultra11y.mjs +6 -3
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +6 -3
- package/skills/ultra11y/SKILL.md +1 -1
- package/skills/ultra11y/references/e2e.md +29 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +6 -3
- package/src/integrations/playwright.ts +116 -0
- package/src/sample.ts +4 -1
- package/src/types.ts +7 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "ultra11y",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
|
|
13
|
-
"version": "5.1
|
|
13
|
+
"version": "5.2.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": "5.1
|
|
4
|
+
"version": "5.2.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": "5.1
|
|
4
|
+
"version": "5.2.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 = "5.1
|
|
30
|
+
const ULTRA11Y_PLUGIN_VERSION = "5.2.1";
|
|
31
31
|
|
|
32
32
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
33
|
|
package/dist/playwright.d.ts
CHANGED
|
@@ -31,5 +31,62 @@ declare function checkA11y(page: PlaywrightPage, opts?: PlaywrightCheckOptions):
|
|
|
31
31
|
* Resolution is lazy and guarded either way, so importing this in a Cypress-only repo
|
|
32
32
|
* gives `undefined` rather than a crash. */
|
|
33
33
|
declare const test: any;
|
|
34
|
+
/** Options for {@link sweepSample}. */
|
|
35
|
+
interface SweepOptions {
|
|
36
|
+
/** Where `.ultra11yrc.json` lives. Defaults to the process cwd. */
|
|
37
|
+
cwd?: string;
|
|
38
|
+
/** Which declared pages this sweep owns. Return false for a page the repo snapshots itself
|
|
39
|
+
* — a wizard step that needs application state seeded first, say. Skipped pages are simply
|
|
40
|
+
* not declared here; they are still in the sample, so the report still expects them. */
|
|
41
|
+
only?: (page: SamplePageLike) => boolean;
|
|
42
|
+
/** Awaited after navigation, before collecting. This is where a framework's readiness goes:
|
|
43
|
+
* a design system that boots asynchronously will otherwise be serialized half-mounted, and
|
|
44
|
+
* the audit reports non-conformities about markup no user ever meets.
|
|
45
|
+
*
|
|
46
|
+
* Typed loosely on purpose. The caller writes this against Playwright's real `Page` — with
|
|
47
|
+
* `waitForLoadState`, `waitForFunction`, locators — and Playwright is not a dependency of
|
|
48
|
+
* this package, so the narrow structural shape the collector needs would reject the very
|
|
49
|
+
* function this option exists to take. Same reasoning as `test` below. */
|
|
50
|
+
settle?: (page: any) => Promise<void> | void;
|
|
51
|
+
/** Passed to every `checkA11y`. `failOn: false` (the default here) records without
|
|
52
|
+
* asserting: the durable output of a sweep is the snapshot, not a red test. */
|
|
53
|
+
check?: Omit<PlaywrightCheckOptions, "as" | "name" | "auth" | "notes" | "sources" | "expectPath">;
|
|
54
|
+
}
|
|
55
|
+
interface SamplePageLike {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
url: string;
|
|
59
|
+
auth?: boolean;
|
|
60
|
+
notes?: string;
|
|
61
|
+
sources?: string[];
|
|
62
|
+
}
|
|
63
|
+
/** Declare one Playwright test per page of the `.ultra11yrc.json` sample.
|
|
64
|
+
*
|
|
65
|
+
* The sample already says what every page is — id, name, URL, whether it sits behind a login,
|
|
66
|
+
* the sources that render it. A repo that then hand-writes a route table to drive its sweep is
|
|
67
|
+
* keeping a second copy of that, and the two drift: a page renamed in one place keeps its old
|
|
68
|
+
* identity in the report, which is the failure this whole tier exists to prevent.
|
|
69
|
+
*
|
|
70
|
+
* So the sample drives the sweep:
|
|
71
|
+
*
|
|
72
|
+
* ```ts
|
|
73
|
+
* import { test } from "@playwright/test";
|
|
74
|
+
* import { sweepSample } from "ultra11y/playwright";
|
|
75
|
+
*
|
|
76
|
+
* sweepSample({ settle: async (page) => { await page.waitForLoadState("networkidle"); } });
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* Each page is navigated, checked against `expectPath` and its HTTP status, and recorded. A
|
|
80
|
+
* page the browser did not stay on — or that answered an error at the requested address — is
|
|
81
|
+
* `test.skip`ped with the reason, never filed under the requested name.
|
|
82
|
+
*
|
|
83
|
+
* Pages your own specs drive (a funnel step that needs state seeded) are excluded with
|
|
84
|
+
* `only`. */
|
|
85
|
+
declare function samplePagesFor(opts?: Pick<SweepOptions, "cwd" | "only">): SamplePageLike[];
|
|
86
|
+
/** The path a sample URL points at. The sample stores absolute URLs (it is also read by
|
|
87
|
+
* `scan`, which needs a real address); a Playwright test wants the path, so its `baseURL`
|
|
88
|
+
* applies. Anything unparseable is already a path. */
|
|
89
|
+
declare function sweepTarget(url: string): string;
|
|
90
|
+
declare function sweepSample(opts?: SweepOptions): void;
|
|
34
91
|
|
|
35
|
-
export { AuditLike, CheckOptions, type PlaywrightCheckOptions, checkA11y, test };
|
|
92
|
+
export { AuditLike, CheckOptions, type PlaywrightCheckOptions, type SamplePageLike, type SweepOptions, checkA11y, samplePagesFor, sweepSample, sweepTarget, test };
|
package/dist/playwright.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// src/integrations/playwright.ts
|
|
2
|
+
import { readFileSync } from "fs";
|
|
2
3
|
import { createRequire as createRequire2 } from "module";
|
|
4
|
+
import { join } from "path";
|
|
3
5
|
|
|
4
6
|
// src/collector.ts
|
|
5
7
|
var COLLECTED_CSS = [
|
|
@@ -263,7 +265,58 @@ var test = (() => {
|
|
|
263
265
|
}
|
|
264
266
|
return void 0;
|
|
265
267
|
})();
|
|
268
|
+
function samplePagesFor(opts = {}) {
|
|
269
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
270
|
+
let declared;
|
|
271
|
+
try {
|
|
272
|
+
const raw = readFileSync(join(cwd, ".ultra11yrc.json"), "utf8");
|
|
273
|
+
declared = JSON.parse(raw).sample?.pages ?? [];
|
|
274
|
+
} catch {
|
|
275
|
+
throw new Error(`ultra11y: no readable .ultra11yrc.json in ${cwd} \u2014 sweepSample reads the page sample from it.`);
|
|
276
|
+
}
|
|
277
|
+
return declared.filter((p) => opts.only?.(p) ?? true);
|
|
278
|
+
}
|
|
279
|
+
function sweepTarget(url) {
|
|
280
|
+
try {
|
|
281
|
+
return new URL(url).pathname || url;
|
|
282
|
+
} catch {
|
|
283
|
+
return url;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function sweepSample(opts = {}) {
|
|
287
|
+
const pages = samplePagesFor(opts);
|
|
288
|
+
const t = test;
|
|
289
|
+
if (!t) throw new Error("ultra11y: @playwright/test could not be resolved \u2014 sweepSample needs it.");
|
|
290
|
+
if (!pages.length) return;
|
|
291
|
+
for (const p of pages) {
|
|
292
|
+
const target = sweepTarget(p.url);
|
|
293
|
+
t(`a11y \u2014 ${p.name}`, async ({ page }) => {
|
|
294
|
+
const response = await page.goto(target);
|
|
295
|
+
if (opts.settle) await opts.settle(page);
|
|
296
|
+
const landed = page.url();
|
|
297
|
+
t.skip(!stayedOnPage(target, landed), `${target} landed on ${landed} \u2014 the current state does not open this screen; nothing to record as "${p.name}"`);
|
|
298
|
+
const status = response?.status();
|
|
299
|
+
t.skip(
|
|
300
|
+
status !== void 0 && status >= 400,
|
|
301
|
+
`${target} answered HTTP ${status} \u2014 an error page at the requested address; nothing to record as "${p.name}"`
|
|
302
|
+
);
|
|
303
|
+
await checkA11y(page, {
|
|
304
|
+
failOn: false,
|
|
305
|
+
...opts.check,
|
|
306
|
+
as: p.id,
|
|
307
|
+
name: p.name,
|
|
308
|
+
...p.auth !== void 0 ? { auth: p.auth } : {},
|
|
309
|
+
...p.notes ? { notes: p.notes } : {},
|
|
310
|
+
...p.sources ? { sources: p.sources } : {},
|
|
311
|
+
expectPath: target
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
266
316
|
export {
|
|
267
317
|
checkA11y,
|
|
318
|
+
samplePagesFor,
|
|
319
|
+
sweepSample,
|
|
320
|
+
sweepTarget,
|
|
268
321
|
test
|
|
269
322
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
|
-
"version": "5.1
|
|
3
|
+
"version": "5.2.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",
|
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 = "5.1
|
|
9
|
+
var VERSION = "5.2.1";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -57306,7 +57306,7 @@ function validateSample(raw) {
|
|
|
57306
57306
|
const ids = /* @__PURE__ */ new Set();
|
|
57307
57307
|
pages?.forEach((pg, i2) => {
|
|
57308
57308
|
if (typeof pg !== "object" || pg === null || Array.isArray(pg)) {
|
|
57309
|
-
err2(`sample.pages[${i2}]`, "each page must be an object { id, name, url, auth?, storageState?, notes? }");
|
|
57309
|
+
err2(`sample.pages[${i2}]`, "each page must be an object { id, name, url, auth?, storageState?, notes?, sources? }");
|
|
57310
57310
|
return;
|
|
57311
57311
|
}
|
|
57312
57312
|
const p = pg;
|
|
@@ -57330,6 +57330,8 @@ function validateSample(raw) {
|
|
|
57330
57330
|
);
|
|
57331
57331
|
}
|
|
57332
57332
|
if (p.notes !== void 0 && typeof p.notes !== "string") err2(`sample.pages[${i2}].notes`, "notes must be a string");
|
|
57333
|
+
if (p.sources !== void 0 && (!Array.isArray(p.sources) || p.sources.some((s2) => typeof s2 !== "string")))
|
|
57334
|
+
err2(`sample.pages[${i2}].sources`, "sources must be an array of file paths");
|
|
57333
57335
|
});
|
|
57334
57336
|
if (s.transverse !== void 0) {
|
|
57335
57337
|
if (!Array.isArray(s.transverse)) {
|
|
@@ -57350,7 +57352,8 @@ function normalizeSample(s) {
|
|
|
57350
57352
|
url: String(p.url),
|
|
57351
57353
|
...typeof p.auth === "boolean" ? { auth: p.auth } : {},
|
|
57352
57354
|
...typeof p.storageState === "string" ? { storageState: p.storageState } : {},
|
|
57353
|
-
...typeof p.notes === "string" ? { notes: p.notes } : {}
|
|
57355
|
+
...typeof p.notes === "string" ? { notes: p.notes } : {},
|
|
57356
|
+
...Array.isArray(p.sources) ? { sources: p.sources } : {}
|
|
57354
57357
|
}));
|
|
57355
57358
|
const transverse = Array.isArray(s.transverse) ? s.transverse.map(String) : void 0;
|
|
57356
57359
|
return { pages, ...transverse?.length ? { transverse } : {} };
|
|
@@ -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: 5.1
|
|
8
|
+
version: 5.2.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 = "5.1
|
|
9
|
+
var VERSION = "5.2.1";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -57306,7 +57306,7 @@ function validateSample(raw) {
|
|
|
57306
57306
|
const ids = /* @__PURE__ */ new Set();
|
|
57307
57307
|
pages?.forEach((pg, i2) => {
|
|
57308
57308
|
if (typeof pg !== "object" || pg === null || Array.isArray(pg)) {
|
|
57309
|
-
err2(`sample.pages[${i2}]`, "each page must be an object { id, name, url, auth?, storageState?, notes? }");
|
|
57309
|
+
err2(`sample.pages[${i2}]`, "each page must be an object { id, name, url, auth?, storageState?, notes?, sources? }");
|
|
57310
57310
|
return;
|
|
57311
57311
|
}
|
|
57312
57312
|
const p = pg;
|
|
@@ -57330,6 +57330,8 @@ function validateSample(raw) {
|
|
|
57330
57330
|
);
|
|
57331
57331
|
}
|
|
57332
57332
|
if (p.notes !== void 0 && typeof p.notes !== "string") err2(`sample.pages[${i2}].notes`, "notes must be a string");
|
|
57333
|
+
if (p.sources !== void 0 && (!Array.isArray(p.sources) || p.sources.some((s2) => typeof s2 !== "string")))
|
|
57334
|
+
err2(`sample.pages[${i2}].sources`, "sources must be an array of file paths");
|
|
57333
57335
|
});
|
|
57334
57336
|
if (s.transverse !== void 0) {
|
|
57335
57337
|
if (!Array.isArray(s.transverse)) {
|
|
@@ -57350,7 +57352,8 @@ function normalizeSample(s) {
|
|
|
57350
57352
|
url: String(p.url),
|
|
57351
57353
|
...typeof p.auth === "boolean" ? { auth: p.auth } : {},
|
|
57352
57354
|
...typeof p.storageState === "string" ? { storageState: p.storageState } : {},
|
|
57353
|
-
...typeof p.notes === "string" ? { notes: p.notes } : {}
|
|
57355
|
+
...typeof p.notes === "string" ? { notes: p.notes } : {},
|
|
57356
|
+
...Array.isArray(p.sources) ? { sources: p.sources } : {}
|
|
57354
57357
|
}));
|
|
57355
57358
|
const transverse = Array.isArray(s.transverse) ? s.transverse.map(String) : void 0;
|
|
57356
57359
|
return { pages, ...transverse?.length ? { transverse } : {} };
|
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: 5.1
|
|
7
|
+
version: 5.2.1
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# ultra11y — audit WCAG 2.2 AA and write accessible markup
|
|
@@ -34,6 +34,35 @@ one over the same findings. A fixture that gated differently from the published
|
|
|
34
34
|
fail two projects differently while claiming to be the same tool.
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
|
|
38
|
+
## Sweeping the declared sample (`sweepSample`)
|
|
39
|
+
|
|
40
|
+
The sample in `.ultra11yrc.json` already says what every page is — id, name, URL, whether it
|
|
41
|
+
sits behind a login, the `sources` that render it. A repo that then hand-writes a route table
|
|
42
|
+
to drive its sweep keeps a second copy of that, and the two drift: a page renamed in one place
|
|
43
|
+
keeps its old identity in the report, which is the failure this tier exists to prevent.
|
|
44
|
+
|
|
45
|
+
So let the sample drive it:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { sweepSample } from "ultra11y/playwright";
|
|
49
|
+
|
|
50
|
+
sweepSample({
|
|
51
|
+
// Your framework's readiness. Without it a design system that boots asynchronously is
|
|
52
|
+
// serialized half-mounted, and the audit reports markup no user ever meets.
|
|
53
|
+
settle: async (page) => { await page.waitForLoadState("networkidle").catch(() => {}); },
|
|
54
|
+
// Pages your own specs drive — a wizard step that needs state seeded first.
|
|
55
|
+
only: (p) => !p.id.startsWith("declaration-etape-"),
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
One test per page, each navigated, checked against `expectPath` and its HTTP status, and
|
|
60
|
+
recorded under its declared identity. A page the browser did not stay on is `test.skip`ped
|
|
61
|
+
with the reason.
|
|
62
|
+
|
|
63
|
+
`samplePagesFor()` and `sweepTarget()` are the same logic without the runner, for a repo that
|
|
64
|
+
wants to declare its own tests.
|
|
65
|
+
|
|
37
66
|
## A page the browser did not stay on (`expectPath`)
|
|
38
67
|
|
|
39
68
|
`as`/`name` is the identity the report speaks, and the fixture applies it to whatever is on
|
|
@@ -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 = "5.1
|
|
9
|
+
var VERSION = "5.2.1";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
|
|
12
12
|
// src/audit.ts
|
|
@@ -57306,7 +57306,7 @@ function validateSample(raw) {
|
|
|
57306
57306
|
const ids = /* @__PURE__ */ new Set();
|
|
57307
57307
|
pages?.forEach((pg, i2) => {
|
|
57308
57308
|
if (typeof pg !== "object" || pg === null || Array.isArray(pg)) {
|
|
57309
|
-
err2(`sample.pages[${i2}]`, "each page must be an object { id, name, url, auth?, storageState?, notes? }");
|
|
57309
|
+
err2(`sample.pages[${i2}]`, "each page must be an object { id, name, url, auth?, storageState?, notes?, sources? }");
|
|
57310
57310
|
return;
|
|
57311
57311
|
}
|
|
57312
57312
|
const p = pg;
|
|
@@ -57330,6 +57330,8 @@ function validateSample(raw) {
|
|
|
57330
57330
|
);
|
|
57331
57331
|
}
|
|
57332
57332
|
if (p.notes !== void 0 && typeof p.notes !== "string") err2(`sample.pages[${i2}].notes`, "notes must be a string");
|
|
57333
|
+
if (p.sources !== void 0 && (!Array.isArray(p.sources) || p.sources.some((s2) => typeof s2 !== "string")))
|
|
57334
|
+
err2(`sample.pages[${i2}].sources`, "sources must be an array of file paths");
|
|
57333
57335
|
});
|
|
57334
57336
|
if (s.transverse !== void 0) {
|
|
57335
57337
|
if (!Array.isArray(s.transverse)) {
|
|
@@ -57350,7 +57352,8 @@ function normalizeSample(s) {
|
|
|
57350
57352
|
url: String(p.url),
|
|
57351
57353
|
...typeof p.auth === "boolean" ? { auth: p.auth } : {},
|
|
57352
57354
|
...typeof p.storageState === "string" ? { storageState: p.storageState } : {},
|
|
57353
|
-
...typeof p.notes === "string" ? { notes: p.notes } : {}
|
|
57355
|
+
...typeof p.notes === "string" ? { notes: p.notes } : {},
|
|
57356
|
+
...Array.isArray(p.sources) ? { sources: p.sources } : {}
|
|
57354
57357
|
}));
|
|
57355
57358
|
const transverse = Array.isArray(s.transverse) ? s.transverse.map(String) : void 0;
|
|
57356
57359
|
return { pages, ...transverse?.length ? { transverse } : {} };
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
// to `.ultra11y/pages/<id>/` (DOM + computed styles + boxes + stylesheets + screenshot), so
|
|
17
17
|
// the same page re-audits offline with no browser: that is how CI decides the rendering
|
|
18
18
|
// criteria without booting the app, and how the report speaks page by page. Commit it.
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
19
20
|
import { createRequire } from "node:module";
|
|
21
|
+
import { join } from "node:path";
|
|
20
22
|
import { COLLECT_SNAPSHOT } from "../collector.js";
|
|
21
23
|
import { type AuditLike, type CheckOptions, auditSnapshot, buildPayload, gate, stayedOnPage, writePagesReport } from "./core.js";
|
|
22
24
|
|
|
@@ -102,4 +104,118 @@ export const test: any = (() => {
|
|
|
102
104
|
return undefined;
|
|
103
105
|
})();
|
|
104
106
|
|
|
107
|
+
/** Options for {@link sweepSample}. */
|
|
108
|
+
export interface SweepOptions {
|
|
109
|
+
/** Where `.ultra11yrc.json` lives. Defaults to the process cwd. */
|
|
110
|
+
cwd?: string;
|
|
111
|
+
/** Which declared pages this sweep owns. Return false for a page the repo snapshots itself
|
|
112
|
+
* — a wizard step that needs application state seeded first, say. Skipped pages are simply
|
|
113
|
+
* not declared here; they are still in the sample, so the report still expects them. */
|
|
114
|
+
only?: (page: SamplePageLike) => boolean;
|
|
115
|
+
/** Awaited after navigation, before collecting. This is where a framework's readiness goes:
|
|
116
|
+
* a design system that boots asynchronously will otherwise be serialized half-mounted, and
|
|
117
|
+
* the audit reports non-conformities about markup no user ever meets.
|
|
118
|
+
*
|
|
119
|
+
* Typed loosely on purpose. The caller writes this against Playwright's real `Page` — with
|
|
120
|
+
* `waitForLoadState`, `waitForFunction`, locators — and Playwright is not a dependency of
|
|
121
|
+
* this package, so the narrow structural shape the collector needs would reject the very
|
|
122
|
+
* function this option exists to take. Same reasoning as `test` below. */
|
|
123
|
+
// biome-ignore lint/suspicious/noExplicitAny: the shape is Playwright's, which is not a dependency here
|
|
124
|
+
settle?: (page: any) => Promise<void> | void;
|
|
125
|
+
/** Passed to every `checkA11y`. `failOn: false` (the default here) records without
|
|
126
|
+
* asserting: the durable output of a sweep is the snapshot, not a red test. */
|
|
127
|
+
check?: Omit<PlaywrightCheckOptions, "as" | "name" | "auth" | "notes" | "sources" | "expectPath">;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface SamplePageLike {
|
|
131
|
+
id: string;
|
|
132
|
+
name: string;
|
|
133
|
+
url: string;
|
|
134
|
+
auth?: boolean;
|
|
135
|
+
notes?: string;
|
|
136
|
+
sources?: string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Declare one Playwright test per page of the `.ultra11yrc.json` sample.
|
|
140
|
+
*
|
|
141
|
+
* The sample already says what every page is — id, name, URL, whether it sits behind a login,
|
|
142
|
+
* the sources that render it. A repo that then hand-writes a route table to drive its sweep is
|
|
143
|
+
* keeping a second copy of that, and the two drift: a page renamed in one place keeps its old
|
|
144
|
+
* identity in the report, which is the failure this whole tier exists to prevent.
|
|
145
|
+
*
|
|
146
|
+
* So the sample drives the sweep:
|
|
147
|
+
*
|
|
148
|
+
* ```ts
|
|
149
|
+
* import { test } from "@playwright/test";
|
|
150
|
+
* import { sweepSample } from "ultra11y/playwright";
|
|
151
|
+
*
|
|
152
|
+
* sweepSample({ settle: async (page) => { await page.waitForLoadState("networkidle"); } });
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* Each page is navigated, checked against `expectPath` and its HTTP status, and recorded. A
|
|
156
|
+
* page the browser did not stay on — or that answered an error at the requested address — is
|
|
157
|
+
* `test.skip`ped with the reason, never filed under the requested name.
|
|
158
|
+
*
|
|
159
|
+
* Pages your own specs drive (a funnel step that needs state seeded) are excluded with
|
|
160
|
+
* `only`. */
|
|
161
|
+
export function samplePagesFor(opts: Pick<SweepOptions, "cwd" | "only"> = {}): SamplePageLike[] {
|
|
162
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
163
|
+
let declared: SamplePageLike[];
|
|
164
|
+
try {
|
|
165
|
+
const raw = readFileSync(join(cwd, ".ultra11yrc.json"), "utf8");
|
|
166
|
+
declared = (JSON.parse(raw) as { sample?: { pages?: SamplePageLike[] } }).sample?.pages ?? [];
|
|
167
|
+
} catch {
|
|
168
|
+
// Not "declare zero tests and pass" — that is indistinguishable from a sweep that ran.
|
|
169
|
+
throw new Error(`ultra11y: no readable .ultra11yrc.json in ${cwd} — sweepSample reads the page sample from it.`);
|
|
170
|
+
}
|
|
171
|
+
return declared.filter((p) => opts.only?.(p) ?? true);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The path a sample URL points at. The sample stores absolute URLs (it is also read by
|
|
175
|
+
* `scan`, which needs a real address); a Playwright test wants the path, so its `baseURL`
|
|
176
|
+
* applies. Anything unparseable is already a path. */
|
|
177
|
+
export function sweepTarget(url: string): string {
|
|
178
|
+
try {
|
|
179
|
+
return new URL(url).pathname || url;
|
|
180
|
+
} catch {
|
|
181
|
+
return url;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function sweepSample(opts: SweepOptions = {}): void {
|
|
186
|
+
const pages = samplePagesFor(opts);
|
|
187
|
+
const t = test;
|
|
188
|
+
if (!t) throw new Error("ultra11y: @playwright/test could not be resolved — sweepSample needs it.");
|
|
189
|
+
if (!pages.length) return;
|
|
190
|
+
|
|
191
|
+
// Serial by default is NOT imposed here: pages are independent unless the repo's own state
|
|
192
|
+
// makes them otherwise, and that is the repo's call (`workers` in its config).
|
|
193
|
+
for (const p of pages) {
|
|
194
|
+
const target = sweepTarget(p.url);
|
|
195
|
+
t(`a11y — ${p.name}`, async ({ page }: { page: PlaywrightPage & { goto(u: string): Promise<{ status(): number } | null> } }) => {
|
|
196
|
+
const response = await page.goto(target);
|
|
197
|
+
if (opts.settle) await opts.settle(page);
|
|
198
|
+
|
|
199
|
+
const landed = page.url();
|
|
200
|
+
t.skip(!stayedOnPage(target, landed), `${target} landed on ${landed} — the current state does not open this screen; nothing to record as "${p.name}"`);
|
|
201
|
+
const status = response?.status();
|
|
202
|
+
t.skip(
|
|
203
|
+
status !== undefined && status >= 400,
|
|
204
|
+
`${target} answered HTTP ${status} — an error page at the requested address; nothing to record as "${p.name}"`,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
await checkA11y(page, {
|
|
208
|
+
failOn: false,
|
|
209
|
+
...opts.check,
|
|
210
|
+
as: p.id,
|
|
211
|
+
name: p.name,
|
|
212
|
+
...(p.auth !== undefined ? { auth: p.auth } : {}),
|
|
213
|
+
...(p.notes ? { notes: p.notes } : {}),
|
|
214
|
+
...(p.sources ? { sources: p.sources } : {}),
|
|
215
|
+
expectPath: target,
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
105
221
|
export type { AuditLike, CheckOptions };
|
package/src/sample.ts
CHANGED
|
@@ -54,7 +54,7 @@ export function validateSample(raw: unknown): SampleValidation {
|
|
|
54
54
|
const ids = new Set<string>();
|
|
55
55
|
pages?.forEach((pg, i) => {
|
|
56
56
|
if (typeof pg !== "object" || pg === null || Array.isArray(pg)) {
|
|
57
|
-
err(`sample.pages[${i}]`, "each page must be an object { id, name, url, auth?, storageState?, notes? }");
|
|
57
|
+
err(`sample.pages[${i}]`, "each page must be an object { id, name, url, auth?, storageState?, notes?, sources? }");
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
60
|
const p = pg as Record<string, unknown>;
|
|
@@ -79,6 +79,8 @@ export function validateSample(raw: unknown): SampleValidation {
|
|
|
79
79
|
);
|
|
80
80
|
}
|
|
81
81
|
if (p.notes !== undefined && typeof p.notes !== "string") err(`sample.pages[${i}].notes`, "notes must be a string");
|
|
82
|
+
if (p.sources !== undefined && (!Array.isArray(p.sources) || p.sources.some((s: unknown) => typeof s !== "string")))
|
|
83
|
+
err(`sample.pages[${i}].sources`, "sources must be an array of file paths");
|
|
82
84
|
});
|
|
83
85
|
|
|
84
86
|
if (s.transverse !== undefined) {
|
|
@@ -103,6 +105,7 @@ function normalizeSample(s: Record<string, unknown>): SampleConfig {
|
|
|
103
105
|
...(typeof p.auth === "boolean" ? { auth: p.auth } : {}),
|
|
104
106
|
...(typeof p.storageState === "string" ? { storageState: p.storageState } : {}),
|
|
105
107
|
...(typeof p.notes === "string" ? { notes: p.notes } : {}),
|
|
108
|
+
...(Array.isArray(p.sources) ? { sources: p.sources as string[] } : {}),
|
|
106
109
|
}));
|
|
107
110
|
const transverse = Array.isArray(s.transverse) ? (s.transverse as string[]).map(String) : undefined;
|
|
108
111
|
return { pages, ...(transverse?.length ? { transverse } : {}) };
|
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 = "5.1
|
|
3
|
+
export const VERSION = "5.2.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,
|
|
@@ -34,6 +34,12 @@ export interface SamplePage {
|
|
|
34
34
|
auth?: boolean; // the page sits behind authentication (renders an auth badge)
|
|
35
35
|
storageState?: string; // Playwright storageState FILE PATH (content never read) — per-page auth
|
|
36
36
|
notes?: string; // reproduction steps / required state, surfaced in the ticket repro block
|
|
37
|
+
// Source files that RENDER this page. A finding raised on the page's DOM is then reported
|
|
38
|
+
// against your component instead of against `dom.html`, which is the difference between a
|
|
39
|
+
// report a developer can act on and one that points at a serialized blob. Declared here so
|
|
40
|
+
// the sample is the single place a page is described — a repo that drives its own sweep no
|
|
41
|
+
// longer needs a parallel route table to carry them.
|
|
42
|
+
sources?: string[];
|
|
37
43
|
}
|
|
38
44
|
export interface SampleConfig {
|
|
39
45
|
pages: SamplePage[];
|