ultra11y 5.0.1 → 5.2.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.
@@ -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.0.1",
13
+ "version": "5.2.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": "5.0.1",
4
+ "version": "5.2.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": "5.0.1",
4
+ "version": "5.2.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 = "5.0.1";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.2.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
@@ -1,4 +1,4 @@
1
- import { S as SnapshotPayload, F as FindingLike } from './payload-D0VQYlGS.js';
1
+ import { S as SnapshotPayload, F as FindingLike } from './payload-B6NDkLYJ.js';
2
2
 
3
3
  type On = (event: string, handler: any) => void;
4
4
  interface TaskPayload extends SnapshotPayload {
package/dist/cypress.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as CheckOptions } from './payload-D0VQYlGS.js';
2
- export { s as slugify } from './payload-D0VQYlGS.js';
1
+ import { C as CheckOptions } from './payload-B6NDkLYJ.js';
2
+ export { s as slugify } from './payload-B6NDkLYJ.js';
3
3
 
4
4
  interface CypressCheckOptions extends CheckOptions {
5
5
  /** Capture a viewport screenshot so the pixel tier can run. On by default — the plugin
package/dist/cypress.mjs CHANGED
@@ -120,6 +120,20 @@ function slugify(url) {
120
120
  const slug = path.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
121
121
  return slug || (path === "/" || path === "" ? "accueil" : "page");
122
122
  }
123
+ function stayedOnPage(expected, actual) {
124
+ if (!expected || !actual) return true;
125
+ const path = (u) => {
126
+ try {
127
+ return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
128
+ } catch {
129
+ return void 0;
130
+ }
131
+ };
132
+ const a = path(expected);
133
+ const b = path(actual);
134
+ if (a === void 0 || b === void 0) return true;
135
+ return a === b;
136
+ }
123
137
  function buildPayload(collected, url, runner, opts, screenshot) {
124
138
  const id = opts.as || slugify(url);
125
139
  return {
@@ -151,6 +165,11 @@ function registerUltra11yCommand() {
151
165
  return cy.window({ log: false }).then((win) => {
152
166
  const collected = win.eval(COLLECT_SNAPSHOT);
153
167
  const url = collected.url || win.location.href;
168
+ if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) {
169
+ throw new Error(
170
+ `ultra11y: ${opts.expectPath} landed on ${url} \u2014 not recording it as "${opts.as ?? opts.name ?? "this page"}". The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`
171
+ );
172
+ }
154
173
  const payload = {
155
174
  ...buildPayload(collected, url, "cypress", opts),
156
175
  failOn: opts.failOn,
@@ -0,0 +1,51 @@
1
+ interface FindingLike {
2
+ ruleId: string;
3
+ criteriaId: string;
4
+ file: string;
5
+ line: number;
6
+ severity: string;
7
+ message: string;
8
+ advisory?: boolean;
9
+ origin?: {
10
+ sourceFile?: string;
11
+ sourceLine?: number;
12
+ };
13
+ }
14
+ interface AuditLike {
15
+ findings?: FindingLike[];
16
+ }
17
+ interface SnapshotPayload {
18
+ meta: Record<string, unknown>;
19
+ dom: string;
20
+ styles?: unknown;
21
+ boxes?: unknown;
22
+ css?: unknown;
23
+ screenshot?: string;
24
+ }
25
+ /** Same slug rule as the engine (src/snapshot.ts slugifyPageId): accent-folded URL path. */
26
+ declare function slugify(url: string): string;
27
+ interface CheckOptions {
28
+ /** The page id (directory name). Defaults to the slugified URL path. */
29
+ as?: string;
30
+ /** Human page name for the report. Defaults to the document title. */
31
+ name?: string;
32
+ /** Severity at which the check fails, or `false` to record without ever failing. */
33
+ failOn?: "blocking" | "major" | "minor" | false;
34
+ /** Mark the page as sitting behind authentication. */
35
+ auth?: boolean;
36
+ /** Source files that rendered it — findings are then reported against YOUR code. */
37
+ sources?: string[];
38
+ /** Reproduction notes carried into the auditor ticket. */
39
+ notes?: string;
40
+ /** Capture a viewport screenshot for the pixel tier (Playwright: on by default). */
41
+ screenshot?: boolean;
42
+ /** The path you NAVIGATED to. Given it, the fixture refuses to record a page the browser
43
+ * did not stay on — a guarded route that redirected, a session that expired — instead of
44
+ * filing that other screen under this page's `as`/`name`. Nothing about such a snapshot
45
+ * looks wrong, which is what makes it the worst thing an accessibility report can carry.
46
+ * Compared by path (a query or fragment the app appends to its own route is the same
47
+ * page); a full URL works too. `scan --sample` applies the same rule. */
48
+ expectPath?: string;
49
+ }
50
+
51
+ export { type AuditLike as A, type CheckOptions as C, type FindingLike as F, type SnapshotPayload as S, slugify as s };
@@ -1,4 +1,4 @@
1
- import { C as CheckOptions, A as AuditLike } from './payload-D0VQYlGS.js';
1
+ import { C as CheckOptions, A as AuditLike } from './payload-B6NDkLYJ.js';
2
2
 
3
3
  interface PlaywrightPage {
4
4
  evaluate(script: string): Promise<unknown>;
@@ -31,5 +31,57 @@ 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
+ settle?: (page: PlaywrightPage) => Promise<void>;
46
+ /** Passed to every `checkA11y`. `failOn: false` (the default here) records without
47
+ * asserting: the durable output of a sweep is the snapshot, not a red test. */
48
+ check?: Omit<PlaywrightCheckOptions, "as" | "name" | "auth" | "notes" | "sources" | "expectPath">;
49
+ }
50
+ interface SamplePageLike {
51
+ id: string;
52
+ name: string;
53
+ url: string;
54
+ auth?: boolean;
55
+ notes?: string;
56
+ sources?: string[];
57
+ }
58
+ /** Declare one Playwright test per page of the `.ultra11yrc.json` sample.
59
+ *
60
+ * The sample already says what every page is — id, name, URL, whether it sits behind a login,
61
+ * the sources that render it. A repo that then hand-writes a route table to drive its sweep is
62
+ * keeping a second copy of that, and the two drift: a page renamed in one place keeps its old
63
+ * identity in the report, which is the failure this whole tier exists to prevent.
64
+ *
65
+ * So the sample drives the sweep:
66
+ *
67
+ * ```ts
68
+ * import { test } from "@playwright/test";
69
+ * import { sweepSample } from "ultra11y/playwright";
70
+ *
71
+ * sweepSample({ settle: async (page) => { await page.waitForLoadState("networkidle"); } });
72
+ * ```
73
+ *
74
+ * Each page is navigated, checked against `expectPath` and its HTTP status, and recorded. A
75
+ * page the browser did not stay on — or that answered an error at the requested address — is
76
+ * `test.skip`ped with the reason, never filed under the requested name.
77
+ *
78
+ * Pages your own specs drive (a funnel step that needs state seeded) are excluded with
79
+ * `only`. */
80
+ declare function samplePagesFor(opts?: Pick<SweepOptions, "cwd" | "only">): SamplePageLike[];
81
+ /** The path a sample URL points at. The sample stores absolute URLs (it is also read by
82
+ * `scan`, which needs a real address); a Playwright test wants the path, so its `baseURL`
83
+ * applies. Anything unparseable is already a path. */
84
+ declare function sweepTarget(url: string): string;
85
+ declare function sweepSample(opts?: SweepOptions): void;
34
86
 
35
- export { AuditLike, CheckOptions, type PlaywrightCheckOptions, checkA11y, test };
87
+ export { AuditLike, CheckOptions, type PlaywrightCheckOptions, type SamplePageLike, type SweepOptions, checkA11y, samplePagesFor, sweepSample, sweepTarget, test };
@@ -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 = [
@@ -143,6 +145,20 @@ function slugify(url) {
143
145
  const slug = path.normalize("NFD").replace(new RegExp("\\p{Diacritic}", "gu"), "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
144
146
  return slug || (path === "/" || path === "" ? "accueil" : "page");
145
147
  }
148
+ function stayedOnPage(expected, actual) {
149
+ if (!expected || !actual) return true;
150
+ const path = (u) => {
151
+ try {
152
+ return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
153
+ } catch {
154
+ return void 0;
155
+ }
156
+ };
157
+ const a = path(expected);
158
+ const b = path(actual);
159
+ if (a === void 0 || b === void 0) return true;
160
+ return a === b;
161
+ }
146
162
  function buildPayload(collected, url, runner, opts, screenshot) {
147
163
  const id = opts.as || slugify(url);
148
164
  return {
@@ -221,6 +237,11 @@ async function checkA11y(page, opts = {}) {
221
237
  }
222
238
  }
223
239
  const url = collected.url || page.url();
240
+ if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) {
241
+ throw new Error(
242
+ `ultra11y: ${opts.expectPath} landed on ${url} \u2014 not recording it as "${opts.as ?? opts.name ?? "this page"}". The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`
243
+ );
244
+ }
224
245
  const payload = buildPayload(collected, url, "playwright", opts, shot);
225
246
  const result = auditSnapshot(payload);
226
247
  if (opts.report) writePagesReport(typeof opts.report === "object" ? opts.report : {});
@@ -244,7 +265,58 @@ var test = (() => {
244
265
  }
245
266
  return void 0;
246
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
+ }
247
316
  export {
248
317
  checkA11y,
318
+ samplePagesFor,
319
+ sweepSample,
320
+ sweepTarget,
249
321
  test
250
322
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.0.1",
3
+ "version": "5.2.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",
@@ -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.0.1";
9
+ var VERSION = "5.2.0";
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.0.1
8
+ version: 5.2.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 = "5.0.1";
9
+ var VERSION = "5.2.0";
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 } : {} };
@@ -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.0.1
7
+ version: 5.2.0
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -33,6 +33,56 @@ than restating them, and a test evaluates the generated `failingFindings` alongs
33
33
  one over the same findings. A fixture that gated differently from the published plugin would
34
34
  fail two projects differently while claiming to be the same tool.
35
35
 
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
+
66
+ ## A page the browser did not stay on (`expectPath`)
67
+
68
+ `as`/`name` is the identity the report speaks, and the fixture applies it to whatever is on
69
+ screen. So a route your test could not open — a guarded step, an expired session — gets filed
70
+ under the requested page's name, and its sheet, screenshot and rate all describe another
71
+ screen. Nothing about the document looks wrong.
72
+
73
+ Pass the path you navigated to and the fixture refuses instead:
74
+
75
+ ```js
76
+ await checkA11y(page, { as: "declaration-etape-4", expectPath: "/declaration/etape/4" });
77
+ // throws: /declaration/etape/4 landed on /declaration/etape/1 — not recording it as
78
+ // "declaration-etape-4". The state that opens this route is not the one the test built.
79
+ ```
80
+
81
+ Opt-in, and path-only: a query or fragment the app appends to its own route is the same page.
82
+ `scan --sample` applies the same rule without being asked, because there the URL *is* the
83
+ instruction. A page reported missing is a bug in your seeding; a page reported under the wrong
84
+ name is a false conformance claim.
85
+
36
86
  ## Playwright
37
87
 
38
88
  ```js
@@ -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.0.1";
9
+ var VERSION = "5.2.0";
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 } : {} };
@@ -10,7 +10,7 @@
10
10
  // must be wired in `cypress.config.js` too — Cypress test code cannot write to disk, so the
11
11
  // collected page round-trips through a task.
12
12
  import { COLLECT_SNAPSHOT } from "../collector.js";
13
- import { type CheckOptions, buildPayload, slugify } from "./payload.js";
13
+ import { type CheckOptions, buildPayload, slugify, stayedOnPage } from "./payload.js";
14
14
 
15
15
  // Cypress's globals are not a dependency of this package; they exist at run time only.
16
16
  declare const Cypress: {
@@ -45,6 +45,14 @@ export function registerUltra11yCommand(): void {
45
45
  return cy.window({ log: false }).then((win: { eval(s: string): unknown; location: { href: string } }) => {
46
46
  const collected = win.eval(COLLECT_SNAPSHOT) as Parameters<typeof buildPayload>[0];
47
47
  const url = collected.url || win.location.href;
48
+ // Same refusal as the Playwright fixture: a page the browser did not stay on must not
49
+ // be filed under the requested page's identity. See CheckOptions.expectPath.
50
+ if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) {
51
+ throw new Error(
52
+ `ultra11y: ${opts.expectPath} landed on ${url} — not recording it as "${opts.as ?? opts.name ?? "this page"}". ` +
53
+ `The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`,
54
+ );
55
+ }
48
56
  const payload = {
49
57
  ...buildPayload(collected, url, "cypress", opts),
50
58
  failOn: opts.failOn,
@@ -98,6 +98,30 @@ export interface CheckOptions {
98
98
  notes?: string;
99
99
  /** Capture a viewport screenshot for the pixel tier (Playwright: on by default). */
100
100
  screenshot?: boolean;
101
+ /** The path you NAVIGATED to. Given it, the fixture refuses to record a page the browser
102
+ * did not stay on — a guarded route that redirected, a session that expired — instead of
103
+ * filing that other screen under this page's `as`/`name`. Nothing about such a snapshot
104
+ * looks wrong, which is what makes it the worst thing an accessibility report can carry.
105
+ * Compared by path (a query or fragment the app appends to its own route is the same
106
+ * page); a full URL works too. `scan --sample` applies the same rule. */
107
+ expectPath?: string;
108
+ }
109
+
110
+ /** Did the browser stay on the page the caller asked for? Path-only, trailing slash folded;
111
+ * anything unparseable passes — this catches a redirect, it does not invent one. */
112
+ export function stayedOnPage(expected: string, actual: string): boolean {
113
+ if (!expected || !actual) return true;
114
+ const path = (u: string): string | undefined => {
115
+ try {
116
+ return new URL(u, "http://x.invalid").pathname.replace(/\/+$/, "");
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ };
121
+ const a = path(expected);
122
+ const b = path(actual);
123
+ if (a === undefined || b === undefined) return true;
124
+ return a === b;
101
125
  }
102
126
 
103
127
  /** Build the snapshot payload from a collected page. Shared so every runner stamps the
@@ -16,9 +16,11 @@
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
- import { type AuditLike, type CheckOptions, auditSnapshot, buildPayload, gate, writePagesReport } from "./core.js";
23
+ import { type AuditLike, type CheckOptions, auditSnapshot, buildPayload, gate, stayedOnPage, writePagesReport } from "./core.js";
22
24
 
23
25
  // Playwright's own types are not a dependency of this package (it is a peer of YOUR repo),
24
26
  // so the page object is structurally typed to exactly what is used.
@@ -52,6 +54,16 @@ export async function checkA11y(page: PlaywrightPage, opts: PlaywrightCheckOptio
52
54
  }
53
55
 
54
56
  const url = collected.url || page.url();
57
+ // Refuse a page the browser did not stay on, when the caller said where it went. The
58
+ // snapshot's identity is `as`/`name`, applied to whatever is on screen — so a guarded route
59
+ // that redirected would be filed under the requested page's name, and the resulting sheet,
60
+ // screenshot and rate would all describe another screen. Skipping loudly beats that.
61
+ if (opts.expectPath && !stayedOnPage(opts.expectPath, url)) {
62
+ throw new Error(
63
+ `ultra11y: ${opts.expectPath} landed on ${url} — not recording it as "${opts.as ?? opts.name ?? "this page"}". ` +
64
+ `The state that opens this route is not the one the test built; seed it first, or drop the page from the sample.`,
65
+ );
66
+ }
55
67
  const payload = buildPayload(collected, url, "playwright", opts, shot);
56
68
  const result = auditSnapshot(payload);
57
69
  if (opts.report) writePagesReport(typeof opts.report === "object" ? opts.report : {});
@@ -92,4 +104,112 @@ export const test: any = (() => {
92
104
  return undefined;
93
105
  })();
94
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
+ settle?: (page: PlaywrightPage) => Promise<void>;
119
+ /** Passed to every `checkA11y`. `failOn: false` (the default here) records without
120
+ * asserting: the durable output of a sweep is the snapshot, not a red test. */
121
+ check?: Omit<PlaywrightCheckOptions, "as" | "name" | "auth" | "notes" | "sources" | "expectPath">;
122
+ }
123
+
124
+ export interface SamplePageLike {
125
+ id: string;
126
+ name: string;
127
+ url: string;
128
+ auth?: boolean;
129
+ notes?: string;
130
+ sources?: string[];
131
+ }
132
+
133
+ /** Declare one Playwright test per page of the `.ultra11yrc.json` sample.
134
+ *
135
+ * The sample already says what every page is — id, name, URL, whether it sits behind a login,
136
+ * the sources that render it. A repo that then hand-writes a route table to drive its sweep is
137
+ * keeping a second copy of that, and the two drift: a page renamed in one place keeps its old
138
+ * identity in the report, which is the failure this whole tier exists to prevent.
139
+ *
140
+ * So the sample drives the sweep:
141
+ *
142
+ * ```ts
143
+ * import { test } from "@playwright/test";
144
+ * import { sweepSample } from "ultra11y/playwright";
145
+ *
146
+ * sweepSample({ settle: async (page) => { await page.waitForLoadState("networkidle"); } });
147
+ * ```
148
+ *
149
+ * Each page is navigated, checked against `expectPath` and its HTTP status, and recorded. A
150
+ * page the browser did not stay on — or that answered an error at the requested address — is
151
+ * `test.skip`ped with the reason, never filed under the requested name.
152
+ *
153
+ * Pages your own specs drive (a funnel step that needs state seeded) are excluded with
154
+ * `only`. */
155
+ export function samplePagesFor(opts: Pick<SweepOptions, "cwd" | "only"> = {}): SamplePageLike[] {
156
+ const cwd = opts.cwd ?? process.cwd();
157
+ let declared: SamplePageLike[];
158
+ try {
159
+ const raw = readFileSync(join(cwd, ".ultra11yrc.json"), "utf8");
160
+ declared = (JSON.parse(raw) as { sample?: { pages?: SamplePageLike[] } }).sample?.pages ?? [];
161
+ } catch {
162
+ // Not "declare zero tests and pass" — that is indistinguishable from a sweep that ran.
163
+ throw new Error(`ultra11y: no readable .ultra11yrc.json in ${cwd} — sweepSample reads the page sample from it.`);
164
+ }
165
+ return declared.filter((p) => opts.only?.(p) ?? true);
166
+ }
167
+
168
+ /** The path a sample URL points at. The sample stores absolute URLs (it is also read by
169
+ * `scan`, which needs a real address); a Playwright test wants the path, so its `baseURL`
170
+ * applies. Anything unparseable is already a path. */
171
+ export function sweepTarget(url: string): string {
172
+ try {
173
+ return new URL(url).pathname || url;
174
+ } catch {
175
+ return url;
176
+ }
177
+ }
178
+
179
+ export function sweepSample(opts: SweepOptions = {}): void {
180
+ const pages = samplePagesFor(opts);
181
+ const t = test;
182
+ if (!t) throw new Error("ultra11y: @playwright/test could not be resolved — sweepSample needs it.");
183
+ if (!pages.length) return;
184
+
185
+ // Serial by default is NOT imposed here: pages are independent unless the repo's own state
186
+ // makes them otherwise, and that is the repo's call (`workers` in its config).
187
+ for (const p of pages) {
188
+ const target = sweepTarget(p.url);
189
+ t(`a11y — ${p.name}`, async ({ page }: { page: PlaywrightPage & { goto(u: string): Promise<{ status(): number } | null> } }) => {
190
+ const response = await page.goto(target);
191
+ if (opts.settle) await opts.settle(page);
192
+
193
+ const landed = page.url();
194
+ t.skip(!stayedOnPage(target, landed), `${target} landed on ${landed} — the current state does not open this screen; nothing to record as "${p.name}"`);
195
+ const status = response?.status();
196
+ t.skip(
197
+ status !== undefined && status >= 400,
198
+ `${target} answered HTTP ${status} — an error page at the requested address; nothing to record as "${p.name}"`,
199
+ );
200
+
201
+ await checkA11y(page, {
202
+ failOn: false,
203
+ ...opts.check,
204
+ as: p.id,
205
+ name: p.name,
206
+ ...(p.auth !== undefined ? { auth: p.auth } : {}),
207
+ ...(p.notes ? { notes: p.notes } : {}),
208
+ ...(p.sources ? { sources: p.sources } : {}),
209
+ expectPath: target,
210
+ });
211
+ });
212
+ }
213
+ }
214
+
95
215
  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.0.1";
3
+ export const VERSION = "5.2.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,
@@ -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[];