@krizaka/page-audit 0.5.1 → 0.7.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,8 +10,12 @@ const nameOf = (el) => {
10
10
  const id = el.dataset?.testid;
11
11
  return el.tagName.toLowerCase() + (id ? "[" + id + "]" : "") + "." + className(el, 2);
12
12
  };
13
- const scrolls = (el) => ["auto", "scroll"].includes(getComputedStyle(el).overflowY) || ["auto", "scroll"].includes(getComputedStyle(el).overflowX);
14
- const meets = (ra, rb) => Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left) > 2 && Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top) > 2;
13
+ const scrolls = (el) =>
14
+ ["auto", "scroll"].includes(getComputedStyle(el).overflowY) ||
15
+ ["auto", "scroll"].includes(getComputedStyle(el).overflowX);
16
+ const meets = (ra, rb) =>
17
+ Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left) > 2 &&
18
+ Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top) > 2;
15
19
  const apart = (a, b) => !a.contains(b) && !b.contains(a);
16
20
 
17
21
  /** An element that pokes out only matters when nothing clips it: decorative layers (aria-hidden) and anything inside a clipping or scrolling ancestor are meant to extend. */
@@ -27,7 +31,12 @@ function wideOf(doc) {
27
31
  return [...document.querySelectorAll("body *")]
28
32
  .filter((el) => {
29
33
  const r = el.getBoundingClientRect();
30
- return r.width > 0 && getComputedStyle(el).visibility !== "hidden" && (r.right > doc.clientWidth + 1 || r.left < -1) && !clipped(el);
34
+ return (
35
+ r.width > 0 &&
36
+ getComputedStyle(el).visibility !== "hidden" &&
37
+ (r.right > doc.clientWidth + 1 || r.left < -1) &&
38
+ !clipped(el)
39
+ );
31
40
  })
32
41
  .slice(0, 3)
33
42
  .map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(" ").slice(0, 3).join(".")}`);
@@ -36,12 +45,21 @@ function wideOf(doc) {
36
45
  /** A button's accessible name: its text, aria-label, aria-labelledby, title — or a <label for> pointing at it (a checkbox, a switch). */
37
46
  function named(b) {
38
47
  const labelled = !!b.id && !!document.querySelector(`label[for="${CSS.escape(b.id)}"]`);
39
- return !!((b.textContent || "").trim() || b.getAttribute("aria-label") || b.getAttribute("aria-labelledby") || b.title || labelled);
48
+ return !!(
49
+ (b.textContent || "").trim() ||
50
+ b.getAttribute("aria-label") ||
51
+ b.getAttribute("aria-labelledby") ||
52
+ b.title ||
53
+ labelled
54
+ );
40
55
  }
41
56
 
42
57
  function unnamedOf() {
43
58
  const unnamed = [...document.querySelectorAll("button")].filter((b) => !named(b));
44
- return { unnamedButtons: unnamed.length, unnamedSamples: unnamed.slice(0, 2).map((b) => b.outerHTML.slice(0, 160).replace(/\s+/g, " ")) };
59
+ return {
60
+ unnamedButtons: unnamed.length,
61
+ unnamedSamples: unnamed.slice(0, 2).map((b) => b.outerHTML.slice(0, 160).replace(/\s+/g, " ")),
62
+ };
45
63
  }
46
64
 
47
65
  function headerActionOf(doc) {
@@ -68,7 +86,14 @@ function keptBetween(el, panel) {
68
86
  }
69
87
 
70
88
  function notDrawn(el, r) {
71
- return r.width === 0 || r.height === 0 || getComputedStyle(el).position === "fixed" || !!el.closest(ARIA_HIDDEN) || !!el.closest("[data-radix-popper-content-wrapper]") || folded(el);
89
+ return (
90
+ r.width === 0 ||
91
+ r.height === 0 ||
92
+ getComputedStyle(el).position === "fixed" ||
93
+ !!el.closest(ARIA_HIDDEN) ||
94
+ !!el.closest("[data-radix-popper-content-wrapper]") ||
95
+ folded(el)
96
+ );
72
97
  }
73
98
 
74
99
  function runsOut(el, panel, box) {
@@ -92,14 +117,54 @@ function spillOf(panels) {
92
117
  return { overflowing, spill };
93
118
  }
94
119
 
120
+ /** What is seen of a control: its box cut by every clipping or scrolling ancestor (a control scrolled out of its list is not on screen). */
121
+ function seenBox(el) {
122
+ const r = el.getBoundingClientRect();
123
+ let box = { left: r.left, top: r.top, right: r.right, bottom: r.bottom };
124
+ for (let n = el.parentElement; n && n !== document.body; n = n.parentElement) {
125
+ const st = getComputedStyle(n);
126
+ if (st.overflowX === "visible" && st.overflowY === "visible") continue;
127
+ const c = n.getBoundingClientRect();
128
+ box = {
129
+ left: Math.max(box.left, st.overflowX === "visible" ? box.left : c.left),
130
+ right: Math.min(box.right, st.overflowX === "visible" ? box.right : c.right),
131
+ top: Math.max(box.top, st.overflowY === "visible" ? box.top : c.top),
132
+ bottom: Math.min(box.bottom, st.overflowY === "visible" ? box.bottom : c.bottom),
133
+ };
134
+ }
135
+ return box;
136
+ }
137
+
138
+ /** `controlOverlaps`: two interactive elements, neither inside the other, that share pixels on screen (a header over a row, a radio over its card's text): one of them cannot be clicked. */
139
+ function controlOverlapsOf() {
140
+ const controls =
141
+ 'button, a[href], input:not([type="hidden"]), select, textarea, summary, [role="button"], [role="radio"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"], [role="link"], [role="combobox"]';
142
+ const drawn = [...document.querySelectorAll(controls)]
143
+ .filter(
144
+ (el) => !clipped(el) && getComputedStyle(el).visibility !== "hidden" && getComputedStyle(el).display !== "none",
145
+ )
146
+ .map((el) => ({ el, box: seenBox(el) }))
147
+ .filter(({ box }) => box.right - box.left > 4 && box.bottom - box.top > 4);
148
+ const overlaps = [];
149
+ drawn.forEach((a, i) => {
150
+ for (const b of drawn.slice(i + 1))
151
+ if (apart(a.el, b.el) && meets(a.box, b.box)) overlaps.push(nameOf(a.el) + " over " + nameOf(b.el));
152
+ });
153
+ return overlaps;
154
+ }
155
+
95
156
  /** `overlaps`: two sections that are not nested and cover each other, or what ran out of its panel and lands on another section (the card covering « Dernières vérifications »). */
96
157
  function overlapsOf(panels, spill) {
97
158
  const overlaps = [];
98
159
  for (const el of spill) {
99
- for (const other of panels) if (apart(el, other) && meets(el.getBoundingClientRect(), other.getBoundingClientRect())) overlaps.push(`${nameOf(el)} over ${nameOf(other)}`);
160
+ for (const other of panels)
161
+ if (apart(el, other) && meets(el.getBoundingClientRect(), other.getBoundingClientRect()))
162
+ overlaps.push(`${nameOf(el)} over ${nameOf(other)}`);
100
163
  }
101
164
  panels.forEach((a, i) => {
102
- for (const b of panels.slice(i + 1)) if (apart(a, b) && meets(a.getBoundingClientRect(), b.getBoundingClientRect())) overlaps.push(`${nameOf(a)} over ${nameOf(b)}`);
165
+ for (const b of panels.slice(i + 1))
166
+ if (apart(a, b) && meets(a.getBoundingClientRect(), b.getBoundingClientRect()))
167
+ overlaps.push(`${nameOf(a)} over ${nameOf(b)}`);
103
168
  });
104
169
  return overlaps;
105
170
  }
@@ -138,16 +203,27 @@ function isTopBar(el, width) {
138
203
  const pos = getComputedStyle(el).position;
139
204
  if (pos !== "fixed" && pos !== "sticky") return false;
140
205
  const r = topBoxOf(el).getBoundingClientRect();
141
- return r.width > width * 0.5 && r.height > 8 && r.height < 200 && r.top < 160 && r.bottom > 0 && !el.closest("[data-top-chrome]");
206
+ return (
207
+ r.width > width * 0.5 &&
208
+ r.height > 8 &&
209
+ r.height < 200 &&
210
+ r.top < 160 &&
211
+ r.bottom > 0 &&
212
+ !el.closest("[data-top-chrome]")
213
+ );
142
214
  }
143
215
 
144
216
  function chromeOverlapsOf(doc) {
145
- const chrome = [...document.querySelectorAll("[data-top-chrome]")].filter((el) => el.getBoundingClientRect().height > 0);
217
+ const chrome = [...document.querySelectorAll("[data-top-chrome]")].filter(
218
+ (el) => el.getBoundingClientRect().height > 0,
219
+ );
146
220
  const bars = [...document.querySelectorAll("body *")].filter((el) => isTopBar(el, doc.clientWidth)).map(topBoxOf);
147
221
  const stack = [...chrome, ...bars];
148
222
  const found = [];
149
223
  stack.forEach((a, i) => {
150
- for (const b of stack.slice(i + 1)) if (apart(a, b) && meets(a.getBoundingClientRect(), b.getBoundingClientRect())) found.push(`${nameOf(a)} / ${nameOf(b)}`);
224
+ for (const b of stack.slice(i + 1))
225
+ if (apart(a, b) && meets(a.getBoundingClientRect(), b.getBoundingClientRect()))
226
+ found.push(`${nameOf(a)} / ${nameOf(b)}`);
151
227
  });
152
228
  return found;
153
229
  }
@@ -158,7 +234,14 @@ function clipsOf() {
158
234
  .filter((el) => {
159
235
  const r = el.getBoundingClientRect();
160
236
  const style = getComputedStyle(el);
161
- return r.width > 4 && r.height > 4 && ["hidden", "clip"].includes(style.overflowX) && style.textOverflow !== "ellipsis" && el.scrollWidth > el.clientWidth + 1 && !clipped(el);
237
+ return (
238
+ r.width > 4 &&
239
+ r.height > 4 &&
240
+ ["hidden", "clip"].includes(style.overflowX) &&
241
+ style.textOverflow !== "ellipsis" &&
242
+ el.scrollWidth > el.clientWidth + 1 &&
243
+ !clipped(el)
244
+ );
162
245
  })
163
246
  .slice(0, 3)
164
247
  .map(nameOf);
@@ -185,12 +268,16 @@ function wrappedOf() {
185
268
  /** The whole measure of a page. Decorative layers (aria-hidden), fixed elements and anything inside a scroller are left out of the layout probes (yomizaka#435). */
186
269
  /** A heading with no text (and no image or labelled icon) says nothing to a screen reader. */
187
270
  function emptyHeadingsOf() {
188
- return [...document.querySelectorAll("h1, h2, h3")].filter((h) => !(h.textContent || "").trim() && !h.querySelector("img, svg[aria-label]")).length;
271
+ return [...document.querySelectorAll("h1, h2, h3")].filter(
272
+ (h) => !(h.textContent || "").trim() && !h.querySelector("img, svg[aria-label]"),
273
+ ).length;
189
274
  }
190
275
 
191
276
  /** A file input that no label, `aria-label` or title names. */
192
277
  function unlabelledFilesOf() {
193
- return [...document.querySelectorAll("input[type=file]")].filter((i) => !(i.getAttribute("aria-label") || i.getAttribute("aria-labelledby") || i.title || i.labels?.length)).length;
278
+ return [...document.querySelectorAll("input[type=file]")].filter(
279
+ (i) => !(i.getAttribute("aria-label") || i.getAttribute("aria-labelledby") || i.title || i.labels?.length),
280
+ ).length;
194
281
  }
195
282
 
196
283
  /** `<main>` with no text at all: the page answered but drew nothing (a route that fell through, a failed fetch). */
@@ -200,7 +287,9 @@ function emptyMainOf() {
200
287
 
201
288
  /** Height of every action of the page's main (a button-styled link): the tap targets the responsive guard keeps over `minAction`. */
202
289
  function actionsOf() {
203
- return [...document.querySelectorAll("main a[class*='kz-button'], main a[data-slot='button'], main .kz-button")].filter((a) => a.getBoundingClientRect().width > 0).map((a) => Math.round(a.getBoundingClientRect().height));
290
+ return [...document.querySelectorAll("main a[class*='kz-button'], main a[data-slot='button'], main .kz-button")]
291
+ .filter((a) => a.getBoundingClientRect().width > 0)
292
+ .map((a) => Math.round(a.getBoundingClientRect().height));
204
293
  }
205
294
 
206
295
  /**
@@ -213,7 +302,12 @@ function frameOf(doc) {
213
302
  const rail = [...shell.querySelectorAll("nav")].find((n) => getComputedStyle(n).position === "sticky");
214
303
  return {
215
304
  top: Math.round(shell.getBoundingClientRect().top + window.scrollY),
216
- forced: Math.round(Math.max(Number.parseFloat(getComputedStyle(shell).minHeight) || 0, rail ? rail.getBoundingClientRect().height : 0)),
305
+ forced: Math.round(
306
+ Math.max(
307
+ Number.parseFloat(getComputedStyle(shell).minHeight) || 0,
308
+ rail ? rail.getBoundingClientRect().height : 0,
309
+ ),
310
+ ),
217
311
  viewport: doc.clientHeight,
218
312
  };
219
313
  }
@@ -221,15 +315,24 @@ function frameOf(doc) {
221
315
  /** A landmark (`main`, `aside`, `header`, `nav`) whose content is wider than its own box, with nothing to scroll it. */
222
316
  function boxesOf() {
223
317
  return [...document.querySelectorAll("main, aside, header, nav")]
224
- .filter((box) => getComputedStyle(box).overflowX === "visible" && box.scrollWidth > box.clientWidth + 1 && box.clientWidth > 0)
318
+ .filter(
319
+ (box) =>
320
+ getComputedStyle(box).overflowX === "visible" && box.scrollWidth > box.clientWidth + 1 && box.clientWidth > 0,
321
+ )
225
322
  .slice(0, 3)
226
323
  .map((box) => `<${box.tagName.toLowerCase()}> ${box.scrollWidth}px in ${box.clientWidth}px`);
227
324
  }
228
325
 
229
326
  /** The family contract of the page chrome: the page's own footers (not those of an article or a dialog), whether the first names Krizaka, a navigation landmark. */
230
327
  function landmarksOf() {
231
- const footers = [...document.querySelectorAll("footer")].filter((f) => !f.closest("main, article, section, dialog, [role=dialog]"));
232
- return { footers: footers.length, footerNamesKrizaka: /krizaka/i.test(footers[0]?.textContent || ""), nav: !!document.querySelector("nav") };
328
+ const footers = [...document.querySelectorAll("footer")].filter(
329
+ (f) => !f.closest("main, article, section, dialog, [role=dialog]"),
330
+ );
331
+ return {
332
+ footers: footers.length,
333
+ footerNamesKrizaka: /krizaka/i.test(footers[0]?.textContent || ""),
334
+ nav: !!document.querySelector("nav"),
335
+ };
233
336
  }
234
337
 
235
338
  /** A picture drawn at a real size that never loaded. */
@@ -250,15 +353,27 @@ function emptyControlsOf() {
250
353
  const box = el.getBoundingClientRect();
251
354
  const style = getComputedStyle(el);
252
355
  if (style.visibility === "hidden" || style.display === "none") continue;
253
- if (box.width > 0 && box.height > 0 && box.height < 4) found.push(`collapsed control ${(el.getAttribute("aria-label") || el.textContent || "").trim().slice(0, 30)}`);
254
- else if (box.width > 24 && box.height > 24 && !el.textContent?.trim() && !el.getAttribute("aria-label") && !el.getAttribute("aria-labelledby") && !el.querySelector("img, svg, video, picture, canvas")) found.push("empty tile");
356
+ if (box.width > 0 && box.height > 0 && box.height < 4)
357
+ found.push(`collapsed control ${(el.getAttribute("aria-label") || el.textContent || "").trim().slice(0, 30)}`);
358
+ else if (
359
+ box.width > 24 &&
360
+ box.height > 24 &&
361
+ !el.textContent?.trim() &&
362
+ !el.getAttribute("aria-label") &&
363
+ !el.getAttribute("aria-labelledby") &&
364
+ !el.querySelector("img, svg, video, picture, canvas")
365
+ )
366
+ found.push("empty tile");
255
367
  }
256
368
  return [...new Set(found)].slice(0, 3);
257
369
  }
258
370
 
259
371
  /** Elements whose inline style carries a colour that is not a token (`var(--kz-*)`). */
260
372
  function hardColorsOf() {
261
- return [...document.querySelectorAll("[style]")].filter((e) => /#[0-9a-f]{3,8}\b|rgba?\(/i.test(e.getAttribute("style") || "") && !/var\(/.test(e.getAttribute("style") || "")).length;
373
+ return [...document.querySelectorAll("[style]")].filter(
374
+ (e) =>
375
+ /#[0-9a-f]{3,8}\b|rgba?\(/i.test(e.getAttribute("style") || "") && !/var\(/.test(e.getAttribute("style") || ""),
376
+ ).length;
262
377
  }
263
378
 
264
379
  /**
@@ -268,19 +383,36 @@ function hardColorsOf() {
268
383
  */
269
384
  function mapsOf() {
270
385
  return [...document.querySelectorAll(".maplibregl-map, .mapboxgl-map, .leaflet-container, [data-kz-map]")]
271
- .filter((el) => el.getClientRects().length > 0 && !el.closest(ARIA_HIDDEN) && !folded(el) && !el.parentElement?.closest(".maplibregl-map, .mapboxgl-map, .leaflet-container, [data-kz-map]"))
386
+ .filter(
387
+ (el) =>
388
+ el.getClientRects().length > 0 &&
389
+ !el.closest(ARIA_HIDDEN) &&
390
+ !folded(el) &&
391
+ !el.parentElement?.closest(".maplibregl-map, .mapboxgl-map, .leaflet-container, [data-kz-map]"),
392
+ )
272
393
  .map((el) => {
273
394
  const box = el.getBoundingClientRect();
274
395
  const canvas = el.querySelector("canvas")?.getBoundingClientRect();
275
- return { name: nameOf(el), width: Math.round(box.width), height: Math.round(box.height), canvas: !!canvas && canvas.width > 0 && canvas.height > 0, leaflet: el.classList.contains("leaflet-container"), tiles: el.querySelectorAll(".leaflet-tile-loaded").length };
396
+ return {
397
+ name: nameOf(el),
398
+ width: Math.round(box.width),
399
+ height: Math.round(box.height),
400
+ canvas: !!canvas && canvas.width > 0 && canvas.height > 0,
401
+ leaflet: el.classList.contains("leaflet-container"),
402
+ tiles: el.querySelectorAll(".leaflet-tile-loaded").length,
403
+ };
276
404
  });
277
405
  }
278
406
 
279
407
  function measurePage() {
280
408
  const doc = document.documentElement;
281
- const panels = [...document.querySelectorAll("[data-panel], main section, main [role=region]")].filter((el) => !el.closest(ARIA_HIDDEN) && !folded(el));
409
+ const panels = [...document.querySelectorAll("[data-panel], main section, main [role=region]")].filter(
410
+ (el) => !el.closest(ARIA_HIDDEN) && !folded(el),
411
+ );
282
412
  const { overflowing, spill } = spillOf(panels);
283
- const chips = [...document.querySelectorAll('[data-testid="audience-picker"] a')].map((a) => Math.round(a.getBoundingClientRect().height));
413
+ const chips = [...document.querySelectorAll('[data-testid="audience-picker"] a')].map((a) =>
414
+ Math.round(a.getBoundingClientRect().height),
415
+ );
284
416
  return {
285
417
  h1: document.querySelectorAll("h1").length,
286
418
  scrollWidth: doc.scrollWidth,
@@ -293,6 +425,7 @@ function measurePage() {
293
425
  overlaps: overlapsOf(panels, spill).slice(0, 3),
294
426
  cutColumns: cutColumnsOf(),
295
427
  chromeOverlaps: chromeOverlapsOf(doc).slice(0, 3),
428
+ controlOverlaps: controlOverlapsOf().slice(0, 5),
296
429
  clips: clipsOf(),
297
430
  emptyHeadings: emptyHeadingsOf(),
298
431
  unlabelledFiles: unlabelledFilesOf(),
@@ -310,7 +443,46 @@ function measurePage() {
310
443
  };
311
444
  }
312
445
 
313
- const PROBES = [tooTall, wrappedOf, className, nameOf, scrolls, meets, apart, clipped, wideOf, named, unnamedOf, headerActionOf, folded, keptBetween, notDrawn, runsOut, spillOf, overlapsOf, cutBy, cutColumnsOf, topBoxOf, isTopBar, chromeOverlapsOf, clipsOf, emptyHeadingsOf, unlabelledFilesOf, emptyMainOf, actionsOf, frameOf, boxesOf, landmarksOf, brokenPicturesOf, emptyControlsOf, hardColorsOf, mapsOf, measurePage];
446
+ const PROBES = [
447
+ tooTall,
448
+ wrappedOf,
449
+ className,
450
+ nameOf,
451
+ scrolls,
452
+ meets,
453
+ apart,
454
+ clipped,
455
+ wideOf,
456
+ named,
457
+ unnamedOf,
458
+ headerActionOf,
459
+ folded,
460
+ keptBetween,
461
+ notDrawn,
462
+ runsOut,
463
+ spillOf,
464
+ seenBox,
465
+ controlOverlapsOf,
466
+ overlapsOf,
467
+ cutBy,
468
+ cutColumnsOf,
469
+ topBoxOf,
470
+ isTopBar,
471
+ chromeOverlapsOf,
472
+ clipsOf,
473
+ emptyHeadingsOf,
474
+ unlabelledFilesOf,
475
+ emptyMainOf,
476
+ actionsOf,
477
+ frameOf,
478
+ boxesOf,
479
+ landmarksOf,
480
+ brokenPicturesOf,
481
+ emptyControlsOf,
482
+ hardColorsOf,
483
+ mapsOf,
484
+ measurePage,
485
+ ];
314
486
 
315
487
  /** The expression Playwright runs in the page (`page.evaluate(auditPage)`): the probes, serialised together, then the measure. */
316
488
  export const auditPage = `(() => {\nconst ARIA_HIDDEN = ${JSON.stringify(ARIA_HIDDEN)};\n${PROBES.map((f) => `const ${f.name} = ${f.toString()};`).join("\n")}\nreturn measurePage();\n})()`;
@@ -323,22 +495,56 @@ export const auditPage = `(() => {\nconst ARIA_HIDDEN = ${JSON.stringify(ARIA_HI
323
495
  function accessibilityProblems(where, result, fileLabels) {
324
496
  const problems = [];
325
497
  if (result.emptyHeadings) problems.push(`${where}: ${result.emptyHeadings} empty heading(s)`);
326
- if (fileLabels && result.unlabelledFiles) problems.push(`${where}: ${result.unlabelledFiles} file input(s) without a label`);
498
+ if (fileLabels && result.unlabelledFiles)
499
+ problems.push(`${where}: ${result.unlabelledFiles} file input(s) without a label`);
327
500
  return problems;
328
501
  }
329
502
 
330
- export function problemsOf(where, result, { errors = [], h1 = false, headerAction = false, fileLabels = false, status, emptyMain = false, frame = false, minAction, boxes = false, landmarks, pictures = false, emptyControls = false, hardColors = false, maps = false } = {}) {
503
+ /**
504
+ * The checks switched on by a flag of `problemsOf` (a flag that is off produces nothing). A table keeps the function short: each
505
+ * entry reads the page's result and says what the flag asks for.
506
+ */
507
+ const FLAGGED_CHECKS = [
508
+ ["emptyMain", (where, result) => (result.emptyMain ? [`${where}: empty <main>`] : [])],
509
+ ["frame", (where, result) => frameProblems(where, result.frame)],
510
+ ["minAction", (where, result, minAction) => actionProblems(where, result.actions ?? [], minAction)],
511
+ ["boxes", (where, result) => boxProblems(where, result.boxes ?? [])],
512
+ ["landmarks", (where, result, landmarks) => landmarkProblems(where, result.landmarks, landmarks)],
513
+ [
514
+ "pictures",
515
+ (where, result) =>
516
+ result.brokenPictures?.length ? [`${where}: broken picture ${result.brokenPictures.join(", ")}`] : [],
517
+ ],
518
+ ["emptyControls", (where, result) => (result.emptyControls ?? []).map((c) => `${where}: ${c}`)],
519
+ [
520
+ "hardColors",
521
+ (where, result) =>
522
+ result.hardColors
523
+ ? [`${where}: ${result.hardColors} element(s) with a hard-coded colour in an inline style`]
524
+ : [],
525
+ ],
526
+ ["maps", (where, result, maps) => mapProblems(where, result.maps ?? [], maps)],
527
+ [
528
+ "controlOverlaps",
529
+ (where, result) =>
530
+ result.controlOverlaps?.length
531
+ ? [`${where}: controls overlap, one cannot be clicked (${result.controlOverlaps.join(", ")})`]
532
+ : [],
533
+ ],
534
+ ];
535
+
536
+ function flaggedProblems(where, result, flags) {
537
+ return FLAGGED_CHECKS.flatMap(([flag, check]) => (flags[flag] ? check(where, result, flags[flag]) : []));
538
+ }
539
+
540
+ export function problemsOf(
541
+ where,
542
+ result,
543
+ { errors = [], h1 = false, headerAction = false, fileLabels = false, status, ...flags } = {},
544
+ ) {
331
545
  return [
332
546
  ...statusProblems(where, status),
333
- ...(emptyMain && result.emptyMain ? [`${where}: empty <main>`] : []),
334
- ...(frame ? frameProblems(where, result.frame) : []),
335
- ...(minAction ? actionProblems(where, result.actions ?? [], minAction) : []),
336
- ...(boxes ? boxProblems(where, result.boxes ?? []) : []),
337
- ...(landmarks ? landmarkProblems(where, result.landmarks, landmarks) : []),
338
- ...(pictures && result.brokenPictures?.length ? [`${where}: broken picture ${result.brokenPictures.join(", ")}`] : []),
339
- ...(emptyControls ? (result.emptyControls ?? []).map((c) => `${where}: ${c}`) : []),
340
- ...(hardColors && result.hardColors ? [`${where}: ${result.hardColors} element(s) with a hard-coded colour in an inline style`] : []),
341
- ...(maps ? mapProblems(where, result.maps ?? [], maps) : []),
547
+ ...flaggedProblems(where, result, flags),
342
548
  ...errorsOf(where, errors),
343
549
  ...layoutProblems(where, result),
344
550
  ...(h1 ? namingProblems(where, result) : []),
@@ -361,7 +567,8 @@ function mapProblems(where, found, option) {
361
567
  const out = [];
362
568
  for (const m of found) {
363
569
  if (m.width < min || m.height < min) out.push(`${where}: the map ${m.name} is ${m.width}x${m.height} px (empty)`);
364
- else if (m.leaflet ? m.tiles === 0 : !m.canvas) out.push(`${where}: the map ${m.name} ${m.leaflet ? "has no loaded tile" : "has no canvas"}`);
570
+ else if (m.leaflet ? m.tiles === 0 : !m.canvas)
571
+ out.push(`${where}: the map ${m.name} ${m.leaflet ? "has no loaded tile" : "has no canvas"}`);
365
572
  }
366
573
  return out;
367
574
  }
@@ -373,15 +580,23 @@ function errorsOf(where, errors) {
373
580
  /** Overflow, elements past the edge, boxes that cover or cut one another, a token wrapped per character, the error state. */
374
581
  function layoutProblems(where, result) {
375
582
  const problems = [];
376
- if (result.scrollWidth > result.clientWidth) problems.push(`${where}: scrollWidth ${result.scrollWidth} > ${result.clientWidth} (${result.wide.join(", ")})`);
583
+ if (result.scrollWidth > result.clientWidth)
584
+ problems.push(`${where}: scrollWidth ${result.scrollWidth} > ${result.clientWidth} (${result.wide.join(", ")})`);
377
585
  else if (result.wide.length) problems.push(`${where}: elements past the edge (${result.wide.join(", ")})`);
378
- const lists = [[result.overflowing, "overflows its panel"], [result.overlaps, "covers a neighbouring section"], [result.cutColumns, "is cut off"]];
586
+ const lists = [
587
+ [result.overflowing, "overflows its panel"],
588
+ [result.overlaps, "covers a neighbouring section"],
589
+ [result.cutColumns, "is cut off"],
590
+ ];
379
591
  for (const [list, words] of lists) {
380
592
  if (list?.length) problems.push(`${where}: ${words} (${list.join(", ")})`);
381
593
  }
382
- if (result.wrapped?.length) problems.push(`${where}: a token wraps one character per line (${result.wrapped.join(", ")}) — truncate it`);
383
- if (result.segmentError) problems.push(`${where}: the page rendered its error state (segment-error), e.g. React #441`);
384
- if (result.chromeOverlaps?.length) problems.push(`${where}: the top chrome chromeOverlaps (${result.chromeOverlaps.slice(0, 2).join(", ")})`);
594
+ if (result.wrapped?.length)
595
+ problems.push(`${where}: a token wraps one character per line (${result.wrapped.join(", ")}) — truncate it`);
596
+ if (result.segmentError)
597
+ problems.push(`${where}: the page rendered its error state (segment-error), e.g. React #441`);
598
+ if (result.chromeOverlaps?.length)
599
+ problems.push(`${where}: the top chrome chromeOverlaps (${result.chromeOverlaps.slice(0, 2).join(", ")})`);
385
600
  if (result.clips?.length) problems.push(`${where}: content cut inside its box (${result.clips.join(", ")})`);
386
601
  return problems;
387
602
  }
@@ -416,7 +631,9 @@ function statusProblems(where, status) {
416
631
  /** The signed-in frame forces a scroll when what it asks of the screen (its shell, its rail) does not fit under what is above it. */
417
632
  function frameProblems(where, frame) {
418
633
  if (!frame || frame.top + frame.forced <= frame.viewport + 1) return [];
419
- return [`${where}: the frame forces a scroll (starts at ${frame.top} px and asks ${frame.forced} px of a ${frame.viewport} px screen)`];
634
+ return [
635
+ `${where}: the frame forces a scroll (starts at ${frame.top} px and asks ${frame.forced} px of a ${frame.viewport} px screen)`,
636
+ ];
420
637
  }
421
638
 
422
639
  /** A button-styled action under `min` px tall is hard to tap. */
@@ -432,7 +649,8 @@ function boxProblems(where, boxes) {
432
649
  function landmarkProblems(where, found, kind) {
433
650
  const problems = [];
434
651
  if (kind === "public") {
435
- if (found.footers !== 1) problems.push(`${where}: ${found.footers} page footers (one « A Krizaka product » footer expected)`);
652
+ if (found.footers !== 1)
653
+ problems.push(`${where}: ${found.footers} page footers (one « A Krizaka product » footer expected)`);
436
654
  else if (!found.footerNamesKrizaka) problems.push(`${where}: the footer does not name Krizaka`);
437
655
  } else if (found.footers > 1) problems.push(`${where}: ${found.footers} page footers (at most one)`);
438
656
  if (kind !== "auth" && !found.nav) problems.push(`${where}: no <nav> landmark`);
package/src/run.mjs CHANGED
@@ -9,7 +9,8 @@ export const WIDTHS = [360, 390, 430, 768, 1024, 1440];
9
9
  export const THEMES = ["dark", "light"];
10
10
 
11
11
  /** A blocked third-party resource is not a page error; a script error and a hydration mismatch are. `ignore`: the messages the app tolerates. */
12
- export const isPageError = (message, ignore = [/Failed to load resource/]) => message.type() === "error" && !ignore.some((re) => re.test(message.text()));
12
+ export const isPageError = (message, ignore = [/Failed to load resource/]) =>
13
+ message.type() === "error" && !ignore.some((re) => re.test(message.text()));
13
14
 
14
15
  async function chromiumOf(launch) {
15
16
  if (launch) return launch();
@@ -55,7 +56,10 @@ const nameOfRoute = (r) => r.name ?? r.url;
55
56
  */
56
57
  export async function routesFromSitemap(base, { first = ["/"], limit = 14, fetcher = fetch } = {}) {
57
58
  const origin = base.replace(/\/$/, "");
58
- const xml = await fetcher(`${origin}/sitemap.xml`).then((r) => (r.ok ? r.text() : null), () => null);
59
+ const xml = await fetcher(`${origin}/sitemap.xml`).then(
60
+ (r) => (r.ok ? r.text() : null),
61
+ () => null,
62
+ );
59
63
  if (xml === null) return null;
60
64
  const paths = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => new URL(m[1]).pathname || "/");
61
65
  return [...new Set([...first, ...paths])].slice(0, limit);
@@ -66,8 +70,18 @@ async function a11yOf(a11y) {
66
70
  if (!a11y) return null;
67
71
  const lib = await import("@krizaka/a11y-audit");
68
72
  const kinds = { public: { collector: new lib.A11yCollector(a11y.app), baseline: a11y.baseline } };
69
- if (a11y.session) kinds.session = { collector: new lib.A11yCollector(a11y.session.app), baseline: a11y.session.baseline, optional: true };
70
- return { lib, kinds, widths: a11y.widths ?? [390, 1440], log: a11y.log ?? ((line) => process.stdout.write(`${line}\n`)) };
73
+ if (a11y.session)
74
+ kinds.session = {
75
+ collector: new lib.A11yCollector(a11y.session.app),
76
+ baseline: a11y.session.baseline,
77
+ optional: true,
78
+ };
79
+ return {
80
+ lib,
81
+ kinds,
82
+ widths: a11y.widths ?? [390, 1440],
83
+ log: a11y.log ?? ((line) => process.stdout.write(`${line}\n`)),
84
+ };
71
85
  }
72
86
 
73
87
  /** The ratchet's verdict, per kind. A baseline not recorded yet (`optional`) is counted, not enforced, until `--a11y-init`. */
@@ -75,7 +89,9 @@ function a11yProblems(state) {
75
89
  const problems = [];
76
90
  for (const { collector, baseline, optional } of Object.values(state.kinds)) {
77
91
  if (optional && !existsSync(baseline) && !process.argv.includes("--a11y-init")) {
78
- state.log(`· a11y baseline missing: counted, not enforced (${collector.summary().join("; ") || "no violation"}); record it with --a11y-init`);
92
+ state.log(
93
+ `· a11y baseline missing: counted, not enforced (${collector.summary().join("; ") || "no violation"}); record it with --a11y-init`,
94
+ );
79
95
  continue;
80
96
  }
81
97
  const guard = state.lib.finish(collector, { baseline });
@@ -92,7 +108,10 @@ function statusOf(response, checks) {
92
108
  }
93
109
 
94
110
  /** One route at one width: load, settle, measure, screenshot. */
95
- async function measure(page, { base, spec, viewport, settleMs, prepare, checks, errors, where, shots, a11y, kind, theme }) {
111
+ async function measure(
112
+ page,
113
+ { base, spec, viewport, settleMs, prepare, checks, errors, where, shots, a11y, kind, theme },
114
+ ) {
96
115
  await resizeSettled(page, viewport);
97
116
  // « load » and the faces, not « networkidle »: router prefetches can keep the page from ever going idle.
98
117
  const response = await loadSettled(page, `${base}${spec.url}`);
@@ -131,7 +150,9 @@ async function auditPageRoutes(page, routes, o) {
131
150
  for (const spec of routes) {
132
151
  for (const w of o.widths) {
133
152
  const viewport = viewportOf(w, o.height);
134
- problems.push(...(await measureWithRetries(page, o, spec, viewport, `${nameOfRoute(spec)} @${viewport.width} ${o.theme}`)));
153
+ problems.push(
154
+ ...(await measureWithRetries(page, o, spec, viewport, `${nameOfRoute(spec)} @${viewport.width} ${o.theme}`)),
155
+ );
135
156
  }
136
157
  }
137
158
  return problems;
@@ -146,7 +167,11 @@ async function newPage(browser, o, extra = {}) {
146
167
 
147
168
  /** The signed-in pages of one theme: a context with the session (a stored state, or `signIn` on the context), the routes the session names, then the flagged ones. */
148
169
  async function auditSession(browser, session, flagged, o) {
149
- const { context, page, errors } = await newPage(browser, o, session.storageState ? { storageState: session.storageState } : {});
170
+ const { context, page, errors } = await newPage(
171
+ browser,
172
+ o,
173
+ session.storageState ? { storageState: session.storageState } : {},
174
+ );
150
175
  try {
151
176
  if (session.signIn) await session.signIn(context, { base: o.base, theme: o.theme, page });
152
177
  const own = session.routes ? await session.routes(page, { base: o.base, theme: o.theme }) : [];
@@ -175,7 +200,23 @@ async function auditSession(browser, session, flagged, o) {
175
200
  * @param {() => Promise<object>} [o.launch] returns a Playwright browser (default: chromium of playwright-core)
176
201
  * @returns {Promise<string[]>} the problems, empty when the pages are fine
177
202
  */
178
- export async function auditRoutes({ base, routes, widths = WIDTHS, themes = THEMES, height = 900, themeKey = "kz-theme", shots, settleMs = 300, checks = {}, ignoreConsole, attempts = 1, a11y, session, prepare, launch }) {
203
+ export async function auditRoutes({
204
+ base,
205
+ routes,
206
+ widths = WIDTHS,
207
+ themes = THEMES,
208
+ height = 900,
209
+ themeKey = "kz-theme",
210
+ shots,
211
+ settleMs = 300,
212
+ checks = {},
213
+ ignoreConsole,
214
+ attempts = 1,
215
+ a11y,
216
+ session,
217
+ prepare,
218
+ launch,
219
+ }) {
179
220
  const browser = await chromiumOf(launch);
180
221
  const state = await a11yOf(a11y);
181
222
  const specs = routes.map(specOf);
@@ -184,11 +225,25 @@ export async function auditRoutes({ base, routes, widths = WIDTHS, themes = THEM
184
225
  const problems = [];
185
226
  try {
186
227
  for (const theme of themes) {
187
- const o = { base, widths, height, theme, themeKey, shots, settleMs, checks, ignoreConsole, attempts, prepare, a11y: state };
228
+ const o = {
229
+ base,
230
+ widths,
231
+ height,
232
+ theme,
233
+ themeKey,
234
+ shots,
235
+ settleMs,
236
+ checks,
237
+ ignoreConsole,
238
+ attempts,
239
+ prepare,
240
+ a11y: state,
241
+ };
188
242
  const { context, page, errors } = await newPage(browser, o);
189
243
  problems.push(...(await auditPageRoutes(page, plain, { ...o, errors, kind: "public" })));
190
244
  await context.close();
191
- if (session && (session.routes || flagged.length)) problems.push(...(await auditSession(browser, session, flagged, o)));
245
+ if (session && (session.routes || flagged.length))
246
+ problems.push(...(await auditSession(browser, session, flagged, o)));
192
247
  }
193
248
  } finally {
194
249
  await browser.close();