ultra11y 5.6.1 → 5.8.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.6.1",
13
+ "version": "5.8.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.6.1",
4
+ "version": "5.8.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.6.1",
4
+ "version": "5.8.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.6.1";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.8.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-BshGXnOO.js';
1
+ import { S as SnapshotPayload, F as FindingLike } from './payload-DzQb84Kw.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-BshGXnOO.js';
2
- export { s as slugify } from './payload-BshGXnOO.js';
1
+ import { C as CheckOptions } from './payload-DzQb84Kw.js';
2
+ export { s as slugify } from './payload-DzQb84Kw.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
@@ -61,8 +61,27 @@ interface CheckOptions {
61
61
  * and a suite should opt into that. Everything is restored before it returns, and the
62
62
  * snapshot has already been collected, so what they measure cannot alter what was recorded.
63
63
  * Pass an array to run only some of them (by criterion: "1.4.4", "1.4.10", "1.4.12",
64
- * "1.4.13", "2.4.7"). */
65
- probes?: boolean | string[];
64
+ * "1.4.13", "2.4.7"), or an object to also set the bounds — how wide the reflow probe
65
+ * narrows to, how many focusables are worth tabbing through, how many hits are worth
66
+ * recording. 320px is normative and stays the default; the other two are judgements about
67
+ * YOUR pages, and a screen with 400 focusables should be able to say so rather than being
68
+ * quietly cut off at 120. */
69
+ probes?: boolean | string[] | ProbeTuning;
70
+ }
71
+ /** The bounds a repository may put on the live probes. Stated once, here, because the same
72
+ * list is read from `.ultra11yrc.json`, accepted at the call site and handed to the engine —
73
+ * three copies of a field list is three chances for one of them to fall behind.
74
+ *
75
+ * `budgetMs` and `actionTimeoutMs` are not tuning in the same sense as the others: they are
76
+ * what stops a probe from spending SOMEBODY ELSE'S test timeout. See ProbeLimits. */
77
+ interface ProbeTuning {
78
+ only?: string[];
79
+ reflowWidth?: number;
80
+ maxFocusables?: number;
81
+ maxHits?: number;
82
+ maxTriggers?: number;
83
+ actionTimeoutMs?: number;
84
+ budgetMs?: number;
66
85
  }
67
86
 
68
87
  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-BshGXnOO.js';
1
+ import { C as CheckOptions, A as AuditLike } from './payload-DzQb84Kw.js';
2
2
 
3
3
  interface PlaywrightPage {
4
4
  evaluate(script: string): Promise<unknown>;
@@ -106,6 +106,20 @@ declare function samplePagesFor(opts?: Pick<SweepOptions, "cwd" | "only">): Samp
106
106
  * `scan`, which needs a real address); a Playwright test wants the path, so its `baseURL`
107
107
  * applies. Anything unparseable is already a path. */
108
108
  declare function sweepTarget(url: string): string;
109
+ /** The defaults every swept page is checked with, and the one seam a test can reach without
110
+ * a Playwright runtime in the room.
111
+ *
112
+ * `probes: true` is the default HERE and opt-in on `checkA11y`, and the asymmetry is the
113
+ * point. `checkA11y` is called from a test that owns its page and may be asserting on focus
114
+ * or hover a line later; a sweep exists for nothing but recording the sample, and the probes
115
+ * are the only thing that can decide zoom, reflow, text spacing and content-on-hover on
116
+ * screens a cold scanner never reaches. Measured on egapro: 15 of 20 pages came from a sweep
117
+ * that never probed, and because conformity is an AND across every page in scope, those four
118
+ * criteria stayed « à évaluer » for the whole audit — including on the five pages that HAD
119
+ * been probed. Opt out with `check: { probes: false }`. */
120
+ declare function sweepCheckOptions(check?: SweepOptions["check"]): SweepOptions["check"] & {
121
+ failOn: false | undefined;
122
+ };
109
123
  declare function sweepSample(opts?: SweepOptions): void;
110
124
 
111
- export { AuditLike, CheckOptions, type PlaywrightCheckOptions, type SamplePageLike, type SweepOptions, checkA11y, samplePagesFor, sweepSample, sweepTarget, test };
125
+ export { AuditLike, CheckOptions, type PlaywrightCheckOptions, type SamplePageLike, type SweepOptions, checkA11y, samplePagesFor, sweepCheckOptions, sweepSample, sweepTarget, test };
@@ -154,6 +154,22 @@ const __vis = (e) => {
154
154
  };
155
155
  const __html = (e) => (e.outerHTML || '').slice(0, 160);
156
156
  `;
157
+ var PROBE_DEFAULTS = {
158
+ reflowWidth: 320,
159
+ maxFocusables: 120,
160
+ maxHits: 20,
161
+ maxTriggers: 60,
162
+ actionTimeoutMs: 1e3,
163
+ budgetMs: 2e4
164
+ };
165
+ function probeDeadline(budgetMs, now = Date.now) {
166
+ const end = now() + budgetMs;
167
+ return { out: () => now() >= end, left: () => Math.max(0, end - now()) };
168
+ }
169
+ function actionTimeout(limits, deadline) {
170
+ const left = deadline ? deadline.left() : limits.actionTimeoutMs;
171
+ return Math.max(1, Math.min(limits.actionTimeoutMs, left || limits.actionTimeoutMs));
172
+ }
157
173
  var REFLOW_PROBE = `(() => {
158
174
  const el = document.scrollingElement || document.documentElement;
159
175
  return { horizontalScroll: el.scrollWidth > el.clientWidth + 2 };
@@ -193,7 +209,7 @@ var TEXT_SPACING_PROBE = `(() => { ${PRELUDE}
193
209
  }
194
210
  return hits;
195
211
  })()`;
196
- function focusSetupExpr(scope = "") {
212
+ function focusSetupExpr(scope = "", maxFocusables = PROBE_DEFAULTS.maxFocusables) {
197
213
  const rootExpr = scope ? `document.querySelectorAll(${JSON.stringify(scope)})` : `[document.documentElement]`;
198
214
  return `(() => { ${PRELUDE}
199
215
  const sel = 'a[href],button:not([disabled]),input:not([type=hidden]):not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"]),[role=button]:not([disabled])';
@@ -227,7 +243,7 @@ function focusSetupExpr(scope = "") {
227
243
  proxy.setAttribute('data-u11y-fp', key);
228
244
  window.__u11yF[key] = { rest: snap(proxy), sel: __sel(proxy), html: __html(proxy) };
229
245
  n++;
230
- if (n >= 120) break;
246
+ if (n >= ${maxFocusables}) break;
231
247
  }
232
248
  return n;
233
249
  })()`;
@@ -266,13 +282,14 @@ function hoverVisibleExpr(id, wantHidden = false) {
266
282
  const j = JSON.stringify(id);
267
283
  return `(() => { const t = document.getElementById(${j}); if (!t) return ${wantHidden ? "true" : "false"}; const s = getComputedStyle(t); const shown = s.display !== 'none' && s.visibility !== 'hidden' && t.getBoundingClientRect().height > 0; return ${wantHidden ? "!shown" : "shown"}; })()`;
268
284
  }
269
- async function probeFocusVisible(page, scope = "") {
270
- const count = await page.evaluate(focusSetupExpr(scope));
285
+ async function probeFocusVisible(page, scope = "", limits = PROBE_DEFAULTS, deadline) {
286
+ const count = await page.evaluate(focusSetupExpr(scope, limits.maxFocusables));
271
287
  if (!count) return [];
272
288
  const hits = [];
273
289
  const seen = /* @__PURE__ */ new Set();
274
- const limit = Math.min(count + 2, 130);
290
+ const limit = Math.min(count + 2, limits.maxFocusables + 10);
275
291
  for (let i = 0; i < limit; i++) {
292
+ if (deadline?.out()) break;
276
293
  await page.keyboard.press("Tab");
277
294
  const r = await page.evaluate(FOCUS_CHECK_PROBE);
278
295
  if (!r) continue;
@@ -289,12 +306,13 @@ async function probeFocusVisible(page, scope = "") {
289
306
  }
290
307
  return hits;
291
308
  }
292
- async function probeHover(page) {
309
+ async function probeHover(page, limits = PROBE_DEFAULTS, deadline) {
293
310
  const triggers = await page.evaluate(HOVER_SETUP_PROBE);
294
311
  const hits = [];
295
- for (const tr of triggers) {
312
+ for (const tr of triggers.slice(0, Math.max(1, limits.maxTriggers))) {
313
+ if (deadline?.out()) break;
296
314
  try {
297
- await page.hover(`[data-u11y-h="${tr.key}"]`);
315
+ await page.hover(`[data-u11y-h="${tr.key}"]`, { timeout: actionTimeout(limits, deadline) });
298
316
  } catch {
299
317
  continue;
300
318
  }
@@ -313,13 +331,15 @@ async function probeHover(page) {
313
331
  detail: `Le contenu r\xE9v\xE9l\xE9 au survol (aria-describedby #${tr.target}) ne se masque pas avec \xC9chap \u2014 Contenu au survol ou au focus (1.4.13).`
314
332
  });
315
333
  }
316
- if (hits.length >= 8) break;
334
+ if (hits.length >= Math.min(limits.maxHits, 8)) break;
317
335
  }
318
336
  return hits;
319
337
  }
320
338
  async function runLiveProbes(page, opts = {}) {
339
+ const limits = { ...PROBE_DEFAULTS, ...opts.limits };
321
340
  const only = opts.only?.length ? new Set(opts.only) : null;
322
341
  const want = (id) => only === null || only.has(id);
342
+ const deadline = probeDeadline(limits.budgetMs);
323
343
  const canResize = typeof page.setViewportSize === "function" && typeof page.viewportSize === "function";
324
344
  const canType = !!page.keyboard && typeof page.keyboard.press === "function";
325
345
  const canHover = typeof page.hover === "function" && typeof page.waitForTimeout === "function";
@@ -330,45 +350,53 @@ async function runLiveProbes(page, opts = {}) {
330
350
  const skip = (sc, why) => {
331
351
  out.skipped?.push({ sc, why });
332
352
  };
353
+ const bounded = async (sc, run) => {
354
+ if (deadline.out()) {
355
+ skip(sc, `the probe budget of ${limits.budgetMs}ms was already spent when this criterion came up`);
356
+ return null;
357
+ }
358
+ let timer;
359
+ const CUT = /* @__PURE__ */ Symbol("cut");
360
+ const started = Date.now();
361
+ try {
362
+ const raced = await Promise.race([
363
+ run().catch((e) => {
364
+ throw e;
365
+ }),
366
+ new Promise((resolve) => {
367
+ timer = setTimeout(() => resolve(CUT), Math.max(1, deadline.left()));
368
+ })
369
+ ]);
370
+ if (raced === CUT) {
371
+ skip(sc, `the probe budget of ${limits.budgetMs}ms ran out after ${Date.now() - started}ms \u2014 this measurement did not complete`);
372
+ return null;
373
+ }
374
+ return raced;
375
+ } catch (e) {
376
+ skip(sc, String(e?.message ?? e).slice(0, 160));
377
+ return null;
378
+ } finally {
379
+ if (timer) clearTimeout(timer);
380
+ }
381
+ };
333
382
  if (!canResize) skip("1.4.10", "the page object cannot resize its viewport");
334
383
  if (!canType) skip("2.4.7", "the page object exposes no keyboard");
335
384
  if (!canHover) skip("1.4.13", "the page object cannot hover");
336
385
  if (!canStyle) skip("1.4.12", "the page object cannot inject a stylesheet");
337
- if (want("2.4.7") && canType) {
338
- const r = await probeFocusVisible(page).catch((e) => {
339
- skip("2.4.7", String(e?.message ?? e).slice(0, 160));
340
- return null;
341
- });
342
- if (r) {
343
- out.focusVisible = r;
344
- out.probed.push("2.4.7");
345
- }
346
- }
347
- if (want("1.4.13") && canHover && canType) {
348
- const r = await probeHover(page).catch((e) => {
349
- skip("1.4.13", String(e?.message ?? e).slice(0, 160));
350
- return null;
351
- });
386
+ if (want("1.4.4")) {
387
+ const r = await bounded("1.4.4", async () => await page.evaluate(REFLOW_ZOOM_PROBE));
352
388
  if (r) {
353
- out.hover = r;
354
- out.probed.push("1.4.13");
389
+ out.reflowZoom = r ?? [];
390
+ out.probed.push("1.4.4");
355
391
  }
356
392
  }
357
- if (want("1.4.4")) {
358
- out.reflowZoom = await page.evaluate(REFLOW_ZOOM_PROBE).catch(() => []) ?? [];
359
- out.probed.push("1.4.4");
360
- }
361
393
  if (want("1.4.10") && canResize) {
362
- let narrowed = true;
363
- await page.setViewportSize({ width: 320, height: restore.height }).catch((e) => {
364
- narrowed = false;
365
- skip("1.4.10", String(e?.message ?? e).slice(0, 160));
394
+ const narrowed = await bounded("1.4.10", async () => {
395
+ await page.setViewportSize({ width: limits.reflowWidth, height: restore.height });
396
+ return true;
366
397
  });
367
398
  if (narrowed) {
368
- const r = await page.evaluate(REFLOW_PROBE).catch((e) => {
369
- skip("1.4.10", String(e?.message ?? e).slice(0, 160));
370
- return null;
371
- });
399
+ const r = await bounded("1.4.10", async () => await page.evaluate(REFLOW_PROBE));
372
400
  await page.setViewportSize(restore).catch(() => {
373
401
  });
374
402
  if (r) {
@@ -378,15 +406,9 @@ async function runLiveProbes(page, opts = {}) {
378
406
  }
379
407
  }
380
408
  if (want("1.4.12") && canStyle) {
381
- const handle = await page.addStyleTag({ content: TEXT_SPACING_CSS }).catch((e) => {
382
- skip("1.4.12", String(e?.message ?? e).slice(0, 160));
383
- return null;
384
- });
409
+ const handle = await bounded("1.4.12", () => page.addStyleTag({ content: TEXT_SPACING_CSS }));
385
410
  if (handle) {
386
- const r = await page.evaluate(TEXT_SPACING_PROBE).catch((e) => {
387
- skip("1.4.12", String(e?.message ?? e).slice(0, 160));
388
- return null;
389
- });
411
+ const r = await bounded("1.4.12", async () => await page.evaluate(TEXT_SPACING_PROBE));
390
412
  await page.evaluate(REMOVE_TEXT_SPACING_STEP).catch(() => {
391
413
  });
392
414
  if (r) {
@@ -395,6 +417,20 @@ async function runLiveProbes(page, opts = {}) {
395
417
  }
396
418
  }
397
419
  }
420
+ if (want("2.4.7") && canType) {
421
+ const r = await bounded("2.4.7", () => probeFocusVisible(page, "", limits, deadline));
422
+ if (r) {
423
+ out.focusVisible = r;
424
+ out.probed.push("2.4.7");
425
+ }
426
+ }
427
+ if (want("1.4.13") && canHover && canType) {
428
+ const r = await bounded("1.4.13", () => probeHover(page, limits, deadline));
429
+ if (r) {
430
+ out.hover = r;
431
+ out.probed.push("1.4.13");
432
+ }
433
+ }
398
434
  return out;
399
435
  }
400
436
  var REMOVE_TEXT_SPACING_STEP = `(() => {
@@ -525,6 +561,23 @@ function writePagesReport(opts = {}, engine = enginePath()) {
525
561
  }
526
562
 
527
563
  // src/integrations/playwright.ts
564
+ function probeOptions(p) {
565
+ if (p === true || p === void 0) return fromConfig();
566
+ if (p === false) return {};
567
+ if (Array.isArray(p)) return { only: p, ...fromConfig() };
568
+ const { only, ...limits } = p;
569
+ return { ...only ? { only } : {}, limits };
570
+ }
571
+ function fromConfig() {
572
+ try {
573
+ const cfg = JSON.parse(readFileSync(join(process.cwd(), ".ultra11yrc.json"), "utf8"));
574
+ if (!cfg.probes) return {};
575
+ const { only, ...limits } = cfg.probes;
576
+ return { ...only ? { only } : {}, limits };
577
+ } catch {
578
+ return {};
579
+ }
580
+ }
528
581
  async function checkA11y(page, opts = {}) {
529
582
  const collected = await page.evaluate(COLLECT_SNAPSHOT);
530
583
  let shot;
@@ -543,7 +596,7 @@ async function checkA11y(page, opts = {}) {
543
596
  let probes;
544
597
  if (opts.probes) {
545
598
  try {
546
- probes = await runLiveProbes(page, Array.isArray(opts.probes) ? { only: opts.probes } : {});
599
+ probes = await runLiveProbes(page, probeOptions(opts.probes));
547
600
  } catch (e) {
548
601
  console.warn(
549
602
  `ultra11y: the live probes failed on this page \u2014 ${e instanceof Error ? e.message : String(e)}. The snapshot was still recorded; the criteria they decide stay to assess.`
@@ -591,6 +644,9 @@ function sweepTarget(url) {
591
644
  return url;
592
645
  }
593
646
  }
647
+ function sweepCheckOptions(check) {
648
+ return { failOn: false, probes: true, ...check };
649
+ }
594
650
  function sweepSample(opts = {}) {
595
651
  const pages = samplePagesFor(opts);
596
652
  const t = test;
@@ -609,8 +665,7 @@ function sweepSample(opts = {}) {
609
665
  `${target} answered HTTP ${status} \u2014 an error page at the requested address; nothing to record as "${p.name}"`
610
666
  );
611
667
  await checkA11y(page, {
612
- failOn: false,
613
- ...opts.check,
668
+ ...sweepCheckOptions(opts.check),
614
669
  as: p.id,
615
670
  name: p.name,
616
671
  ...p.auth !== void 0 ? { auth: p.auth } : {},
@@ -624,6 +679,7 @@ function sweepSample(opts = {}) {
624
679
  export {
625
680
  checkA11y,
626
681
  samplePagesFor,
682
+ sweepCheckOptions,
627
683
  sweepSample,
628
684
  sweepTarget,
629
685
  test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.6.1",
3
+ "version": "5.8.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",