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.
@@ -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.0",
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.0",
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.0",
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.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.2.1";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
@@ -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 };
@@ -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.0",
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",
@@ -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.0";
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.0
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.0";
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 } : {} };
@@ -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.0
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.0";
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.0";
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[];