@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.
- package/README.md +12 -1
- package/package.json +6 -2
- package/src/cli.mjs +18 -2
- package/src/fixtures.mjs +38 -10
- package/src/index.d.ts +50 -9
- package/src/index.mjs +1 -1
- package/src/journey.d.ts +54 -0
- package/src/journey.mjs +162 -0
- package/src/page-audit.mjs +264 -46
- package/src/run.mjs +66 -11
- package/src/self-test.mjs +45 -3
- package/src/settle.mjs +5 -1
- package/src/visual.d.ts +35 -8
- package/src/visual.mjs +32 -7
package/src/page-audit.mjs
CHANGED
|
@@ -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) =>
|
|
14
|
-
|
|
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
|
|
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 !!(
|
|
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 {
|
|
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
|
|
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)
|
|
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))
|
|
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
|
|
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(
|
|
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))
|
|
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
|
|
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(
|
|
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(
|
|
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")]
|
|
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(
|
|
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(
|
|
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(
|
|
232
|
-
|
|
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)
|
|
254
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
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(
|
|
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) =>
|
|
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 = [
|
|
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)
|
|
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
|
-
|
|
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
|
-
...(
|
|
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)
|
|
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)
|
|
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 = [
|
|
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)
|
|
383
|
-
|
|
384
|
-
if (result.
|
|
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 [
|
|
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)
|
|
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/]) =>
|
|
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(
|
|
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)
|
|
70
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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({
|
|
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 = {
|
|
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))
|
|
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();
|