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.
- 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/cypress-plugin.d.ts +1 -1
- package/dist/cypress.d.ts +2 -2
- package/dist/cypress.mjs +19 -0
- package/dist/payload-B6NDkLYJ.d.ts +51 -0
- package/dist/playwright.d.ts +54 -2
- package/dist/playwright.mjs +72 -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 +50 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +6 -3
- package/src/integrations/cypress.ts +9 -1
- package/src/integrations/payload.ts +24 -0
- package/src/integrations/playwright.ts +121 -1
- 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.0
|
|
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
|
|
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
|
|
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
|
|
30
|
+
const ULTRA11Y_PLUGIN_VERSION = "5.2.0";
|
|
31
31
|
|
|
32
32
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
33
|
|
package/dist/cypress-plugin.d.ts
CHANGED
package/dist/cypress.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as CheckOptions } from './payload-
|
|
2
|
-
export { s as slugify } from './payload-
|
|
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 };
|
package/dist/playwright.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { C as CheckOptions, A as AuditLike } from './payload-
|
|
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 };
|
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 = [
|
|
@@ -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
|
|
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",
|
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.0
|
|
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
|
|
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
|
|
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 } : {} };
|
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.0
|
|
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
|
|
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
|
|
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[];
|