@krizaka/page-audit 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/package.json +10 -3
- package/src/cli.mjs +13 -5
- package/src/fixtures.mjs +21 -0
- package/src/index.d.ts +18 -3
- package/src/index.mjs +1 -1
- package/src/page-audit.mjs +125 -7
- package/src/run.mjs +126 -24
- package/src/self-test.mjs +35 -10
package/README.md
CHANGED
|
@@ -18,3 +18,24 @@ const problems = await auditRoutes({ base, routes: ["/en", "/fr"], checks: { h1:
|
|
|
18
18
|
|
|
19
19
|
`auditContext` / `loadSettled` / `resizeSettled` open a context with reduced motion and no View Transition, and wait for none to run
|
|
20
20
|
before anything is measured (yomizaka#354). `prepare(page, route)` signs in or seeds; `launch` gives your own browser.
|
|
21
|
+
|
|
22
|
+
## The dev-preview crawls, in one audit (0.3)
|
|
23
|
+
|
|
24
|
+
Opt-in through `checks` (nothing changes for a repo that does not ask): `status` (HTTP 4xx/5xx or no response), `emptyMain`, `frame`
|
|
25
|
+
(the signed-in frame, `[data-app-frame-slot]`, forces a scroll), `minAction` (a button-styled action under N px), `boxes` (a landmark
|
|
26
|
+
wider than its box), `landmarks` (`"public"` one footer naming Krizaka and a nav, `"app"`, `"auth"`), `pictures`, `emptyControls`,
|
|
27
|
+
`hardColors`. Console noise the app tolerates: `ignoreConsole: [/…/]` (`[]` counts every error); `attempts` retries a route.
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
const routes = await routesFromSitemap(base, { first: ["/", "/docs"], limit: 14 }); // a crawl as the route source (null: nothing answers)
|
|
31
|
+
await auditRoutes({
|
|
32
|
+
base, routes: [...routes, { url: "/login", chrome: "auth" }, { url: "/chat", session: true, chrome: "app" }],
|
|
33
|
+
widths: [{ width: 1440, height: 900 }, { width: 390, height: 844 }],
|
|
34
|
+
session: { storageState }, // or { signIn(context, { base, theme }) {…}, routes: async (page) => […] } for pages found once signed in
|
|
35
|
+
a11y: { app: "my-app", baseline: "a11y-baseline.json", widths: [390, 1440], session: { app: "my-app-app", baseline: "a11y-baseline.app.json" } },
|
|
36
|
+
checks: { status: true, emptyMain: true, minAction: 36, landmarks: "public", frame: true },
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`a11y` runs axe through `@krizaka/a11y-audit` (an optional peer) and ratchets with the repo's own baseline file; `--a11y-update` and
|
|
41
|
+
`--a11y-init` work as there. The CLI takes `--sitemap`, `--status`, `--empty-main`, `--min-action`, `--a11y app:baseline.json`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krizaka/page-audit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The page audit of every Krizaka app, in one place: horizontal overflow, a card out of its panel, sections covering each other, a column cut off, top chrome overlaps, unnamed buttons, empty headings. Runs in a real browser (Playwright) over a route list, viewports and themes, with View Transitions neutralised and a self-test on its own fixtures.",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "The page audit of every Krizaka app, in one place: HTTP status, empty main, horizontal overflow, a card out of its panel, sections covering each other, a column cut off, top chrome overlaps, unnamed buttons, empty headings. Runs in a real browser (Playwright) over a route list, viewports and themes, with View Transitions neutralised and a self-test on its own fixtures.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"krizaka",
|
|
7
7
|
"audit",
|
|
@@ -43,14 +43,21 @@
|
|
|
43
43
|
],
|
|
44
44
|
"sideEffects": false,
|
|
45
45
|
"peerDependencies": {
|
|
46
|
+
"@krizaka/a11y-audit": ">=0.2.0",
|
|
46
47
|
"playwright-core": ">=1.40"
|
|
47
48
|
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@krizaka/a11y-audit": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
48
54
|
"devDependencies": {
|
|
55
|
+
"@krizaka/a11y-audit": "0.2.0",
|
|
49
56
|
"@types/node": "^22.10.0",
|
|
50
57
|
"eslint": "^9.39.0",
|
|
51
58
|
"playwright-core": "^1.55.0",
|
|
52
59
|
"vitest": "^4.1.11",
|
|
53
|
-
"@krizaka/config": "^0.
|
|
60
|
+
"@krizaka/config": "^0.14.0"
|
|
54
61
|
},
|
|
55
62
|
"engines": {
|
|
56
63
|
"node": ">=22"
|
package/src/cli.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// krizaka-page-audit --base http://localhost:3000 --routes /en,/fr [--widths 360,390,1440] [--themes dark,light]
|
|
3
|
-
// [--theme-key kz-theme] [--h1] [--shots dir]
|
|
4
|
-
|
|
3
|
+
// [--theme-key kz-theme] [--h1] [--shots dir]
|
|
4
|
+
// [--sitemap [--limit 14]] [--status] [--empty-main] [--min-action 36] [--a11y app:baseline.json] | krizaka-page-audit --self-test
|
|
5
|
+
import { auditRoutes, routesFromSitemap, THEMES, WIDTHS } from "./run.mjs";
|
|
5
6
|
import { selfTest } from "./self-test.mjs";
|
|
6
7
|
|
|
7
8
|
const arg = (name, fallback) => {
|
|
@@ -16,12 +17,19 @@ if (process.argv.includes("--self-test")) {
|
|
|
16
17
|
console.error(`page-audit self-test failed:\n ${failures.join("\n ")}`);
|
|
17
18
|
process.exit(1);
|
|
18
19
|
}
|
|
19
|
-
console.log("page-audit self-test:
|
|
20
|
+
console.log("page-audit self-test: every broken layout seen, the fine ones clean");
|
|
20
21
|
} else {
|
|
21
|
-
const
|
|
22
|
+
const base = arg("base", "http://localhost:3000");
|
|
23
|
+
const has = (name) => process.argv.includes(`--${name}`);
|
|
24
|
+
const routes = has("sitemap") ? await routesFromSitemap(base, { first: list(arg("routes"), ["/"]), limit: Number(arg("limit", 14)) }) : list(arg("routes"), ["/"]);
|
|
25
|
+
if (!routes) {
|
|
26
|
+
console.error(`Nothing answers at ${base}/sitemap.xml`);
|
|
27
|
+
process.exit(2);
|
|
28
|
+
}
|
|
29
|
+
const [app, baseline] = (arg("a11y") ?? "").split(":");
|
|
22
30
|
const widths = list(arg("widths"), WIDTHS).map(Number);
|
|
23
31
|
const themes = list(arg("themes"), THEMES);
|
|
24
|
-
const problems = await auditRoutes({ base
|
|
32
|
+
const problems = await auditRoutes({ base, routes, widths, themes, themeKey: arg("theme-key", "kz-theme"), shots: arg("shots"), a11y: baseline ? { app, baseline } : undefined, checks: { h1: has("h1"), status: has("status"), emptyMain: has("empty-main"), minAction: arg("min-action") ? Number(arg("min-action")) : undefined } });
|
|
25
33
|
if (problems.length) {
|
|
26
34
|
console.error(`${problems.length} page problem(s):\n ${problems.join("\n ")}`);
|
|
27
35
|
process.exit(1);
|
package/src/fixtures.mjs
CHANGED
|
@@ -27,3 +27,24 @@ export const HIDDEN_CONTENT = page(
|
|
|
27
27
|
export const FOLDED_SECTION = page(
|
|
28
28
|
`<div style="display:flex;flex-direction:column"><section data-testid="facts" style="height:80px">Faits</section><details><summary>Détails</summary><section data-testid="closing-note" style="padding:16px">Ce que nous avons vérifié</section></details><section data-testid="other" style="height:80px">Autres</section></div>`,
|
|
29
29
|
);
|
|
30
|
+
|
|
31
|
+
/** A page whose `<main>` draws nothing. */
|
|
32
|
+
export const EMPTY_MAIN = page("");
|
|
33
|
+
/** The signed-in frame asks 1000 px of a 700 px screen under a 60 px notice (akinaza#444). */
|
|
34
|
+
export const FRAME_FORCES_SCROLL = page(`<div style="height:60px">notice</div><div data-app-frame-slot><div style="min-height:1000px">shell</div></div>`);
|
|
35
|
+
/** The same frame, sized to what is left of the screen. */
|
|
36
|
+
export const FRAME_FITS = page(`<div style="height:60px">notice</div><div data-app-frame-slot><div style="min-height:500px">shell</div></div>`);
|
|
37
|
+
/** A button-styled action 24 px tall, under the 36 px tap target. */
|
|
38
|
+
export const SMALL_ACTION = page(`<a class="kz-button" href="/" style="display:block;height:24px;width:120px">Go</a>`);
|
|
39
|
+
/** A landmark whose content is wider than its box. */
|
|
40
|
+
export const BOX_TOO_WIDE = page(`<aside style="width:200px"><div style="width:500px">wide</div></aside>`);
|
|
41
|
+
/** Broken picture, a tile that draws nothing, an inline hard-coded colour. */
|
|
42
|
+
export const BROKEN_PARTS = page(`<img src="data:image/png;base64,AAAA" width="40" height="40" alt=""><a href="/" style="display:block;width:60px;height:60px"></a><p style="color:#ff0000">x</p>`);
|
|
43
|
+
/** Chrome that fits the family: one footer naming Krizaka, a nav. */
|
|
44
|
+
export const CHROME_FINE = `<!doctype html><html><body><nav>n</nav><main><p>Hello</p></main><footer>A Krizaka product</footer></body></html>`;
|
|
45
|
+
export const CHROME_BAD = `<!doctype html><html><body><main><p>Hello</p></main><footer>one</footer><footer>two</footer></body></html>`;
|
|
46
|
+
|
|
47
|
+
/** A short token in a table row stretched by a tall neighbour: the cell is tall, its text is one line. */
|
|
48
|
+
export const TOKEN_IN_STRETCHED_ROW = page(`<table style="width:600px"><tbody><tr><td>orazaka-media-pack</td><td>${"A long description. ".repeat(30)}</td></tr></tbody></table>`);
|
|
49
|
+
/** An identifier in a narrow cell that breaks every few characters. */
|
|
50
|
+
export const TOKEN_WRAPPED = page(`<table style="width:100px;table-layout:fixed"><tbody><tr><td style="word-break:break-all;font-size:20px">abcdefghijklmnopqrstuvwxyz0123456789</td></tr></tbody></table>`);
|
package/src/index.d.ts
CHANGED
|
@@ -3,7 +3,13 @@ export type Page = Record<string, unknown>;
|
|
|
3
3
|
export type BrowserContext = Record<string, unknown>;
|
|
4
4
|
export type Browser = { newContext(o?: object): Promise<BrowserContext>; close(): Promise<void> };
|
|
5
5
|
export const auditPage: string;
|
|
6
|
-
export
|
|
6
|
+
export type Chrome = "public" | "app" | "auth";
|
|
7
|
+
export interface ProblemChecks {
|
|
8
|
+
errors?: string[]; h1?: boolean; headerAction?: boolean; fileLabels?: boolean;
|
|
9
|
+
/** HTTP status of the load (`null`: no response); a 4xx/5xx is a problem. */
|
|
10
|
+
status?: number | null;
|
|
11
|
+
emptyMain?: boolean; frame?: boolean; minAction?: number; boxes?: boolean; landmarks?: Chrome; pictures?: boolean; emptyControls?: boolean; hardColors?: boolean;
|
|
12
|
+
}
|
|
7
13
|
export function problemsOf(where: string, result: Record<string, unknown>, checks?: ProblemChecks): string[];
|
|
8
14
|
export function auditOptions<T extends object>(options?: T): T & { reducedMotion: "reduce" };
|
|
9
15
|
export function withoutViewTransitions(): void;
|
|
@@ -13,9 +19,18 @@ export function loadSettled(page: Page, url: string, options?: object): Promise<
|
|
|
13
19
|
export function resizeSettled(page: Page, size: { width: number; height: number }): Promise<void>;
|
|
14
20
|
export const WIDTHS: number[];
|
|
15
21
|
export const THEMES: string[];
|
|
16
|
-
export function isPageError(message: { type(): string; text(): string }): boolean;
|
|
22
|
+
export function isPageError(message: { type(): string; text(): string }, ignore?: RegExp[]): boolean;
|
|
23
|
+
export function routesFromSitemap(base: string, options?: { first?: string[]; limit?: number; fetcher?: typeof fetch }): Promise<string[] | null>;
|
|
24
|
+
export interface RouteSpec { url: string; name?: string; chrome?: Chrome; session?: boolean }
|
|
25
|
+
export interface A11yOptions { app: string; baseline: string; widths?: number[]; log?: (line: string) => void; session?: { app: string; baseline: string } }
|
|
26
|
+
export interface SessionOptions {
|
|
27
|
+
storageState?: object | string;
|
|
28
|
+
signIn?: (context: BrowserContext, o: { base: string; theme: string; page: Page }) => Promise<void>;
|
|
29
|
+
routes?: (page: Page, o: { base: string; theme: string }) => Promise<Array<string | RouteSpec>>;
|
|
30
|
+
}
|
|
17
31
|
export interface AuditRoutesOptions {
|
|
18
|
-
base: string; routes: string
|
|
32
|
+
base: string; routes: Array<string | RouteSpec>; widths?: Array<number | { width: number; height?: number }>; themes?: string[]; height?: number; themeKey?: string; shots?: string; settleMs?: number;
|
|
33
|
+
ignoreConsole?: RegExp[]; attempts?: number; a11y?: A11yOptions; session?: SessionOptions;
|
|
19
34
|
checks?: Omit<ProblemChecks, "errors">; prepare?: (page: Page, route: string) => Promise<void>; launch?: () => Promise<BrowserContext>;
|
|
20
35
|
}
|
|
21
36
|
export function auditRoutes(options: AuditRoutesOptions): Promise<string[]>;
|
package/src/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { auditPage, problemsOf } from "./page-audit.mjs";
|
|
2
|
-
export { auditRoutes, isPageError, THEMES,WIDTHS } from "./run.mjs";
|
|
2
|
+
export { auditRoutes, isPageError, routesFromSitemap, THEMES, WIDTHS } from "./run.mjs";
|
|
3
3
|
export { selfTest } from "./self-test.mjs";
|
|
4
4
|
export { auditContext, auditOptions, loadSettled, resizeSettled,settled, withoutViewTransitions } from "./settle.mjs";
|
package/src/page-audit.mjs
CHANGED
|
@@ -17,7 +17,7 @@ const apart = (a, b) => !a.contains(b) && !b.contains(a);
|
|
|
17
17
|
/** 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. */
|
|
18
18
|
function clipped(el) {
|
|
19
19
|
for (let n = el; n && n !== document.body; n = n.parentElement) {
|
|
20
|
-
if (n.getAttribute("aria-hidden") === "true") return true;
|
|
20
|
+
if (n.getAttribute("aria-hidden") === "true" || n.hasAttribute("inert")) return true;
|
|
21
21
|
if (n !== el && ["hidden", "clip", "auto", "scroll"].includes(getComputedStyle(n).overflowX)) return true;
|
|
22
22
|
}
|
|
23
23
|
return false;
|
|
@@ -27,7 +27,7 @@ function wideOf(doc) {
|
|
|
27
27
|
return [...document.querySelectorAll("body *")]
|
|
28
28
|
.filter((el) => {
|
|
29
29
|
const r = el.getBoundingClientRect();
|
|
30
|
-
return r.width > 0 && (r.right > doc.clientWidth + 1 || r.left < -1) && !clipped(el);
|
|
30
|
+
return r.width > 0 && getComputedStyle(el).visibility !== "hidden" && (r.right > doc.clientWidth + 1 || r.left < -1) && !clipped(el);
|
|
31
31
|
})
|
|
32
32
|
.slice(0, 3)
|
|
33
33
|
.map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(" ").slice(0, 3).join(".")}`);
|
|
@@ -164,14 +164,15 @@ function clipsOf() {
|
|
|
164
164
|
.map(nameOf);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
/** A long token (an id, an address) that wraps character by character: a cell taller
|
|
167
|
+
/** A long token (an id, an address) that wraps character by character: text without a space whose own lines (not the cell's, which a taller neighbour in the row stretches) are more than two and a half. */
|
|
168
168
|
function tooTall(el) {
|
|
169
169
|
const text = (el.textContent || "").trim();
|
|
170
170
|
if (text.length < 12 || /\s/.test(text) || el.children.length > 1) return false;
|
|
171
171
|
const cs = getComputedStyle(el);
|
|
172
172
|
const lh = Number.parseFloat(cs.lineHeight) || Number.parseFloat(cs.fontSize) * 1.4;
|
|
173
|
-
const
|
|
174
|
-
|
|
173
|
+
const range = document.createRange();
|
|
174
|
+
range.selectNodeContents(el);
|
|
175
|
+
return range.getBoundingClientRect().height > lh * 2.5;
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
function wrappedOf() {
|
|
@@ -192,6 +193,74 @@ function unlabelledFilesOf() {
|
|
|
192
193
|
return [...document.querySelectorAll("input[type=file]")].filter((i) => !(i.getAttribute("aria-label") || i.getAttribute("aria-labelledby") || i.title || i.labels?.length)).length;
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
/** `<main>` with no text at all: the page answered but drew nothing (a route that fell through, a failed fetch). */
|
|
197
|
+
function emptyMainOf() {
|
|
198
|
+
return document.querySelector("main")?.innerText.trim().length === 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Height of every action of the page's main (a button-styled link): the tap targets the responsive guard keeps over `minAction`. */
|
|
202
|
+
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));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The signed-in frame's own screen (akinaza#444): its shell (`min-height`) and its sticky rail must fit in what is left of the viewport
|
|
208
|
+
* under the notice, or every page, an empty one included, scrolls by the notice's height. `null` when the page has no `[data-app-frame-slot]`.
|
|
209
|
+
*/
|
|
210
|
+
function frameOf(doc) {
|
|
211
|
+
const shell = document.querySelector("[data-app-frame-slot] > *");
|
|
212
|
+
if (!shell) return null;
|
|
213
|
+
const rail = [...shell.querySelectorAll("nav")].find((n) => getComputedStyle(n).position === "sticky");
|
|
214
|
+
return {
|
|
215
|
+
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)),
|
|
217
|
+
viewport: doc.clientHeight,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A landmark (`main`, `aside`, `header`, `nav`) whose content is wider than its own box, with nothing to scroll it. */
|
|
222
|
+
function boxesOf() {
|
|
223
|
+
return [...document.querySelectorAll("main, aside, header, nav")]
|
|
224
|
+
.filter((box) => getComputedStyle(box).overflowX === "visible" && box.scrollWidth > box.clientWidth + 1 && box.clientWidth > 0)
|
|
225
|
+
.slice(0, 3)
|
|
226
|
+
.map((box) => `<${box.tagName.toLowerCase()}> ${box.scrollWidth}px in ${box.clientWidth}px`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 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
|
+
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") };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A picture drawn at a real size that never loaded. */
|
|
236
|
+
function brokenPicturesOf() {
|
|
237
|
+
return [...document.querySelectorAll("img")]
|
|
238
|
+
.filter((img) => {
|
|
239
|
+
const box = img.getBoundingClientRect();
|
|
240
|
+
return box.width > 8 && box.height > 8 && img.complete && img.naturalWidth === 0 && !!img.currentSrc;
|
|
241
|
+
})
|
|
242
|
+
.slice(0, 3)
|
|
243
|
+
.map((img) => img.currentSrc.slice(0, 80));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A control of the main that collapsed (under 4 px tall), or a tile that renders nothing (no text, label or image). */
|
|
247
|
+
function emptyControlsOf() {
|
|
248
|
+
const found = [];
|
|
249
|
+
for (const el of document.querySelectorAll("main a[href], main button")) {
|
|
250
|
+
const box = el.getBoundingClientRect();
|
|
251
|
+
const style = getComputedStyle(el);
|
|
252
|
+
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");
|
|
255
|
+
}
|
|
256
|
+
return [...new Set(found)].slice(0, 3);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Elements whose inline style carries a colour that is not a token (`var(--kz-*)`). */
|
|
260
|
+
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;
|
|
262
|
+
}
|
|
263
|
+
|
|
195
264
|
function measurePage() {
|
|
196
265
|
const doc = document.documentElement;
|
|
197
266
|
const panels = [...document.querySelectorAll("[data-panel], main section, main [role=region]")].filter((el) => !el.closest(ARIA_HIDDEN) && !folded(el));
|
|
@@ -213,11 +282,19 @@ function measurePage() {
|
|
|
213
282
|
emptyHeadings: emptyHeadingsOf(),
|
|
214
283
|
unlabelledFiles: unlabelledFilesOf(),
|
|
215
284
|
wrapped: wrappedOf(),
|
|
285
|
+
emptyMain: emptyMainOf(),
|
|
286
|
+
actions: actionsOf(),
|
|
287
|
+
frame: frameOf(doc),
|
|
288
|
+
boxes: boxesOf(),
|
|
289
|
+
landmarks: landmarksOf(),
|
|
290
|
+
brokenPictures: brokenPicturesOf(),
|
|
291
|
+
emptyControls: emptyControlsOf(),
|
|
292
|
+
hardColors: hardColorsOf(),
|
|
216
293
|
segmentError: !!document.querySelector('[data-testid="segment-error"]'),
|
|
217
294
|
};
|
|
218
295
|
}
|
|
219
296
|
|
|
220
|
-
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, measurePage];
|
|
297
|
+
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, measurePage];
|
|
221
298
|
|
|
222
299
|
/** The expression Playwright runs in the page (`page.evaluate(auditPage)`): the probes, serialised together, then the measure. */
|
|
223
300
|
export const auditPage = `(() => {\nconst ARIA_HIDDEN = ${JSON.stringify(ARIA_HIDDEN)};\n${PROBES.map((f) => `const ${f.name} = ${f.toString()};`).join("\n")}\nreturn measurePage();\n})()`;
|
|
@@ -234,8 +311,17 @@ function accessibilityProblems(where, result, fileLabels) {
|
|
|
234
311
|
return problems;
|
|
235
312
|
}
|
|
236
313
|
|
|
237
|
-
export function problemsOf(where, result, { errors = [], h1 = false, headerAction = false, fileLabels = false } = {}) {
|
|
314
|
+
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 } = {}) {
|
|
238
315
|
return [
|
|
316
|
+
...statusProblems(where, status),
|
|
317
|
+
...(emptyMain && result.emptyMain ? [`${where}: empty <main>`] : []),
|
|
318
|
+
...(frame ? frameProblems(where, result.frame) : []),
|
|
319
|
+
...(minAction ? actionProblems(where, result.actions ?? [], minAction) : []),
|
|
320
|
+
...(boxes ? boxProblems(where, result.boxes ?? []) : []),
|
|
321
|
+
...(landmarks ? landmarkProblems(where, result.landmarks, landmarks) : []),
|
|
322
|
+
...(pictures && result.brokenPictures?.length ? [`${where}: broken picture ${result.brokenPictures.join(", ")}`] : []),
|
|
323
|
+
...(emptyControls ? (result.emptyControls ?? []).map((c) => `${where}: ${c}`) : []),
|
|
324
|
+
...(hardColors && result.hardColors ? [`${where}: ${result.hardColors} element(s) with a hard-coded colour in an inline style`] : []),
|
|
239
325
|
...errorsOf(where, errors),
|
|
240
326
|
...layoutProblems(where, result),
|
|
241
327
|
...(h1 ? namingProblems(where, result) : []),
|
|
@@ -285,3 +371,35 @@ function headerActionProblems(where, action) {
|
|
|
285
371
|
function chipProblems(where, chips) {
|
|
286
372
|
return chips.some((h) => h < 44) ? [`${where}: a chip is ${Math.min(...chips)} px tall (< 44)`] : [];
|
|
287
373
|
}
|
|
374
|
+
|
|
375
|
+
/** The HTTP status of the page's own load (`undefined`: not asked): a 4xx or 5xx, or no response at all. */
|
|
376
|
+
function statusProblems(where, status) {
|
|
377
|
+
if (status === undefined) return [];
|
|
378
|
+
return status === null || status >= 400 ? [`${where}: HTTP ${status ?? "none"}`] : [];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 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. */
|
|
382
|
+
function frameProblems(where, frame) {
|
|
383
|
+
if (!frame || frame.top + frame.forced <= frame.viewport + 1) return [];
|
|
384
|
+
return [`${where}: the frame forces a scroll (starts at ${frame.top} px and asks ${frame.forced} px of a ${frame.viewport} px screen)`];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** A button-styled action under `min` px tall is hard to tap. */
|
|
388
|
+
function actionProblems(where, actions, min) {
|
|
389
|
+
return actions.some((h) => h < min) ? [`${where}: an action is ${Math.min(...actions)} px tall (< ${min})`] : [];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function boxProblems(where, boxes) {
|
|
393
|
+
return boxes.map((b) => `${where}: never overflows — ${b} content wider than its box`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** `kind`: "public" (one page footer that names Krizaka, a nav), "app" (at most one footer, a nav), "auth" (at most one footer, no nav asked). */
|
|
397
|
+
function landmarkProblems(where, found, kind) {
|
|
398
|
+
const problems = [];
|
|
399
|
+
if (kind === "public") {
|
|
400
|
+
if (found.footers !== 1) problems.push(`${where}: ${found.footers} page footers (one « A Krizaka product » footer expected)`);
|
|
401
|
+
else if (!found.footerNamesKrizaka) problems.push(`${where}: the footer does not name Krizaka`);
|
|
402
|
+
} else if (found.footers > 1) problems.push(`${where}: ${found.footers} page footers (at most one)`);
|
|
403
|
+
if (kind !== "auth" && !found.nav) problems.push(`${where}: no <nav> landmark`);
|
|
404
|
+
return problems;
|
|
405
|
+
}
|
package/src/run.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The audit over a route list: every route × viewport width × theme, in a real browser, the problems in words.
|
|
2
|
-
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { auditPage, problemsOf } from "./page-audit.mjs";
|
|
@@ -8,8 +8,8 @@ import { auditContext, loadSettled, resizeSettled } from "./settle.mjs";
|
|
|
8
8
|
export const WIDTHS = [360, 390, 430, 768, 1024, 1440];
|
|
9
9
|
export const THEMES = ["dark", "light"];
|
|
10
10
|
|
|
11
|
-
/** A blocked third-party resource is not a page error; a script error and a hydration mismatch are. */
|
|
12
|
-
export const isPageError = (message) => message.type() === "error" &&
|
|
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()));
|
|
13
13
|
|
|
14
14
|
async function chromiumOf(launch) {
|
|
15
15
|
if (launch) return launch();
|
|
@@ -27,10 +27,10 @@ function rememberTheme([key, value]) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** The page's console errors and uncaught exceptions, read and cleared by `take()`. */
|
|
30
|
-
function collectErrors(page) {
|
|
30
|
+
function collectErrors(page, ignore) {
|
|
31
31
|
let errors = [];
|
|
32
32
|
page.on("console", (m) => {
|
|
33
|
-
if (isPageError(m)) errors.push(m.text().slice(0, 140));
|
|
33
|
+
if (isPageError(m, ignore)) errors.push(m.text().slice(0, 140));
|
|
34
34
|
});
|
|
35
35
|
page.on("pageerror", (e) => errors.push(String(e).slice(0, 140)));
|
|
36
36
|
return {
|
|
@@ -42,55 +42,157 @@ function collectErrors(page) {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
const viewportOf = (w, height) => (typeof w === "number" ? { width: w, height } : { height, ...w });
|
|
46
|
+
const specOf = (r) => (typeof r === "string" ? { url: r } : { ...r, url: r.url ?? r.route });
|
|
47
|
+
const nameOfRoute = (r) => r.name ?? r.url;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A route source that is a crawl: the paths of the site's own sitemap (a page added there is audited), the `first` ones always first,
|
|
51
|
+
* at most `limit`. Returns `null` when nothing answers at `${base}/sitemap.xml` (the caller says so and stops).
|
|
52
|
+
* @param {string} base
|
|
53
|
+
* @param {{ first?: string[], limit?: number, fetcher?: typeof fetch }} [o]
|
|
54
|
+
* @returns {Promise<string[] | null>}
|
|
55
|
+
*/
|
|
56
|
+
export async function routesFromSitemap(base, { first = ["/"], limit = 14, fetcher = fetch } = {}) {
|
|
57
|
+
const origin = base.replace(/\/$/, "");
|
|
58
|
+
const xml = await fetcher(`${origin}/sitemap.xml`).then((r) => (r.ok ? r.text() : null), () => null);
|
|
59
|
+
if (xml === null) return null;
|
|
60
|
+
const paths = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => new URL(m[1]).pathname || "/");
|
|
61
|
+
return [...new Set([...first, ...paths])].slice(0, limit);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The optional axe step: `@krizaka/a11y-audit` (a peer, loaded only when asked), one collector per kind of page, each ratcheted by the repo's own baseline file. */
|
|
65
|
+
async function a11yOf(a11y) {
|
|
66
|
+
if (!a11y) return null;
|
|
67
|
+
const lib = await import("@krizaka/a11y-audit");
|
|
68
|
+
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`)) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The ratchet's verdict, per kind. A baseline not recorded yet (`optional`) is counted, not enforced, until `--a11y-init`. */
|
|
74
|
+
function a11yProblems(state) {
|
|
75
|
+
const problems = [];
|
|
76
|
+
for (const { collector, baseline, optional } of Object.values(state.kinds)) {
|
|
77
|
+
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`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const guard = state.lib.finish(collector, { baseline });
|
|
82
|
+
for (const note of guard.notes) state.log(note);
|
|
83
|
+
problems.push(...guard.problems);
|
|
84
|
+
}
|
|
85
|
+
return problems;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The HTTP status of the load when asked for (`null`: no response), else `undefined`. */
|
|
89
|
+
function statusOf(response, checks) {
|
|
90
|
+
if (!checks.status) return undefined;
|
|
91
|
+
return response ? response.status() : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
45
94
|
/** One route at one width: load, settle, measure, screenshot. */
|
|
46
|
-
async function measure(page, { base,
|
|
47
|
-
await resizeSettled(page,
|
|
95
|
+
async function measure(page, { base, spec, viewport, settleMs, prepare, checks, errors, where, shots, a11y, kind, theme }) {
|
|
96
|
+
await resizeSettled(page, viewport);
|
|
48
97
|
// « load » and the faces, not « networkidle »: router prefetches can keep the page from ever going idle.
|
|
49
|
-
await loadSettled(page, `${base}${
|
|
50
|
-
if (prepare) await prepare(page,
|
|
98
|
+
const response = await loadSettled(page, `${base}${spec.url}`);
|
|
99
|
+
if (prepare) await prepare(page, spec.url);
|
|
51
100
|
if (settleMs) await page.waitForTimeout(settleMs);
|
|
52
101
|
const result = await page.evaluate(auditPage);
|
|
102
|
+
if (a11y?.widths.includes(viewport.width)) {
|
|
103
|
+
const { collector } = a11y.kinds[kind] ?? a11y.kinds.public;
|
|
104
|
+
collector.add({ route: spec.url, theme }, await a11y.lib.auditPage(page));
|
|
105
|
+
}
|
|
53
106
|
if (shots) {
|
|
54
107
|
mkdirSync(shots, { recursive: true });
|
|
55
108
|
await page.screenshot({ path: join(shots, `${where.replace(/[/?=&@ ]/g, "_")}.png`) });
|
|
56
109
|
}
|
|
57
|
-
|
|
110
|
+
const status = statusOf(response, checks);
|
|
111
|
+
const landmarks = spec.chrome ?? checks.landmarks;
|
|
112
|
+
return problemsOf(where, result, { ...checks, landmarks, status, errors: errors.take() });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One route at one width, with `attempts` tries before it counts as « could not be audited ». */
|
|
116
|
+
async function measureWithRetries(page, o, spec, viewport, where) {
|
|
117
|
+
let failure;
|
|
118
|
+
for (let attempt = 1; attempt <= o.attempts; attempt++) {
|
|
119
|
+
try {
|
|
120
|
+
return await measure(page, { ...o, spec, viewport, where });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
failure = error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return [`${where}: could not be audited — ${String(failure?.message).split("\n")[0]}`];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Audits the routes of one page (one context), each width. */
|
|
129
|
+
async function auditPageRoutes(page, routes, o) {
|
|
130
|
+
const problems = [];
|
|
131
|
+
for (const spec of routes) {
|
|
132
|
+
for (const w of o.widths) {
|
|
133
|
+
const viewport = viewportOf(w, o.height);
|
|
134
|
+
problems.push(...(await measureWithRetries(page, o, spec, viewport, `${nameOfRoute(spec)} @${viewport.width} ${o.theme}`)));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return problems;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function newPage(browser, o, extra = {}) {
|
|
141
|
+
const context = await auditContext(browser, { colorScheme: o.theme, ...extra });
|
|
142
|
+
if (o.themeKey) await context.addInitScript(rememberTheme, [o.themeKey, o.theme]);
|
|
143
|
+
const page = await context.newPage();
|
|
144
|
+
return { context, page, errors: collectErrors(page, o.ignoreConsole) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 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
|
+
async function auditSession(browser, session, flagged, o) {
|
|
149
|
+
const { context, page, errors } = await newPage(browser, o, session.storageState ? { storageState: session.storageState } : {});
|
|
150
|
+
try {
|
|
151
|
+
if (session.signIn) await session.signIn(context, { base: o.base, theme: o.theme, page });
|
|
152
|
+
const own = session.routes ? await session.routes(page, { base: o.base, theme: o.theme }) : [];
|
|
153
|
+
return await auditPageRoutes(page, [...own, ...flagged].map(specOf), { ...o, errors, kind: "session" });
|
|
154
|
+
} finally {
|
|
155
|
+
await context.close();
|
|
156
|
+
}
|
|
58
157
|
}
|
|
59
158
|
|
|
60
159
|
/**
|
|
61
160
|
* @param {object} o
|
|
62
161
|
* @param {string} o.base origin of the running app
|
|
63
|
-
* @param {string
|
|
64
|
-
* @param {number
|
|
162
|
+
* @param {Array<string | { url: string, name?: string, chrome?: "public" | "app" | "auth", session?: boolean }>} o.routes paths to audit (a route flagged `session` is audited signed in; `chrome` replaces `checks.landmarks` for that route)
|
|
163
|
+
* @param {Array<number | { width: number, height?: number }>} [o.widths] viewports (default widths 360 to 1440)
|
|
65
164
|
* @param {string[]} [o.themes] "dark" | "light" (default both): emulated colour scheme and, when `themeKey` is set, localStorage
|
|
66
165
|
* @param {number} [o.height] viewport height (default 900)
|
|
67
166
|
* @param {string} [o.themeKey] localStorage key the app persists its theme in (default "kz-theme")
|
|
68
167
|
* @param {string} [o.shots] directory for one screenshot per route × width × theme
|
|
69
168
|
* @param {number} [o.settleMs] pause after load, for late layout (default 300)
|
|
70
|
-
* @param {object} [o.checks] options of `problemsOf
|
|
169
|
+
* @param {object} [o.checks] options of `problemsOf`: h1, headerAction, fileLabels, and the opt-in status, emptyMain, frame, minAction, boxes, landmarks, pictures, emptyControls, hardColors
|
|
170
|
+
* @param {RegExp[]} [o.ignoreConsole] console errors the app tolerates (default: « Failed to load resource »; `[]` counts every error)
|
|
171
|
+
* @param {number} [o.attempts] tries per route before it counts as not audited (default 1)
|
|
172
|
+
* @param {{ app: string, baseline: string, widths?: number[], log?: (line: string) => void, session?: { app: string, baseline: string } }} [o.a11y] the axe step (`@krizaka/a11y-audit`, a peer) and the repo's ratchet file
|
|
173
|
+
* @param {{ storageState?: object | string, signIn?: Function, routes?: Function }} [o.session] how the signed-in pages are reached
|
|
71
174
|
* @param {(page: object, route: string) => Promise<void>} [o.prepare] before each route is measured (sign in, seed)
|
|
72
175
|
* @param {() => Promise<object>} [o.launch] returns a Playwright browser (default: chromium of playwright-core)
|
|
73
176
|
* @returns {Promise<string[]>} the problems, empty when the pages are fine
|
|
74
177
|
*/
|
|
75
|
-
export async function auditRoutes({ base, routes, widths = WIDTHS, themes = THEMES, height = 900, themeKey = "kz-theme", shots, settleMs = 300, checks = {}, prepare, launch }) {
|
|
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 }) {
|
|
76
179
|
const browser = await chromiumOf(launch);
|
|
180
|
+
const state = await a11yOf(a11y);
|
|
181
|
+
const specs = routes.map(specOf);
|
|
182
|
+
const plain = specs.filter((r) => !r.session);
|
|
183
|
+
const flagged = specs.filter((r) => r.session);
|
|
77
184
|
const problems = [];
|
|
78
185
|
try {
|
|
79
186
|
for (const theme of themes) {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const errors = collectErrors(page);
|
|
84
|
-
for (const route of routes) {
|
|
85
|
-
for (const width of widths) {
|
|
86
|
-
const where = `${route} @${width} ${theme}`;
|
|
87
|
-
problems.push(...(await measure(page, { base, route, width, height, settleMs, prepare, checks, errors, where, shots })));
|
|
88
|
-
}
|
|
89
|
-
}
|
|
187
|
+
const o = { base, widths, height, theme, themeKey, shots, settleMs, checks, ignoreConsole, attempts, prepare, a11y: state };
|
|
188
|
+
const { context, page, errors } = await newPage(browser, o);
|
|
189
|
+
problems.push(...(await auditPageRoutes(page, plain, { ...o, errors, kind: "public" })));
|
|
90
190
|
await context.close();
|
|
191
|
+
if (session && (session.routes || flagged.length)) problems.push(...(await auditSession(browser, session, flagged, o)));
|
|
91
192
|
}
|
|
92
193
|
} finally {
|
|
93
194
|
await browser.close();
|
|
94
195
|
}
|
|
196
|
+
if (state) problems.push(...a11yProblems(state));
|
|
95
197
|
return problems;
|
|
96
198
|
}
|
package/src/self-test.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The audit proven on its own fixtures, in a real browser: each broken fixture must be reported, the fine ones must not.
|
|
2
2
|
// A guard that stopped seeing is itself a red build.
|
|
3
|
-
import { CARD_OVER_NEXT, COLUMN_CUT, FINE, FOLDED_SECTION, HIDDEN_CONTENT, SECTIONS_OVERLAP } from "./fixtures.mjs";
|
|
3
|
+
import { BOX_TOO_WIDE, BROKEN_PARTS, CARD_OVER_NEXT, CHROME_BAD, CHROME_FINE, COLUMN_CUT, EMPTY_MAIN, FINE, FOLDED_SECTION, FRAME_FITS, FRAME_FORCES_SCROLL, HIDDEN_CONTENT, SECTIONS_OVERLAP, SMALL_ACTION, TOKEN_IN_STRETCHED_ROW, TOKEN_WRAPPED } from "./fixtures.mjs";
|
|
4
4
|
import { auditPage, problemsOf } from "./page-audit.mjs";
|
|
5
5
|
import { auditContext } from "./settle.mjs";
|
|
6
6
|
|
|
@@ -9,12 +9,40 @@ const EXPECT = [
|
|
|
9
9
|
["a card over the next section", CARD_OVER_NEXT, /covers a neighbouring section/],
|
|
10
10
|
["two sections on top of each other", SECTIONS_OVERLAP, /covers a neighbouring section/],
|
|
11
11
|
["a column cut by a clipping wrapper", COLUMN_CUT, /is cut off/],
|
|
12
|
+
["an identifier that breaks every few characters", TOKEN_WRAPPED, /wraps one character per line/],
|
|
13
|
+
["an empty <main>", EMPTY_MAIN, /empty <main>/, { emptyMain: true }],
|
|
14
|
+
["a frame that forces a scroll", FRAME_FORCES_SCROLL, /the frame forces a scroll/, { frame: true }],
|
|
15
|
+
["an action under the tap target", SMALL_ACTION, /an action is 24 px tall \(< 36\)/, { minAction: 36 }],
|
|
16
|
+
["a landmark wider than its box", BOX_TOO_WIDE, /content wider than its box/, { boxes: true }],
|
|
17
|
+
["a broken picture", BROKEN_PARTS, /broken picture/, { pictures: true }],
|
|
18
|
+
["a tile that draws nothing", BROKEN_PARTS, /empty tile/, { emptyControls: true }],
|
|
19
|
+
["a hard-coded inline colour", BROKEN_PARTS, /hard-coded colour/, { hardColors: true }],
|
|
20
|
+
["two page footers and no nav", CHROME_BAD, /2 page footers/, { landmarks: "public" }],
|
|
21
|
+
["no nav landmark", CHROME_BAD, /no <nav> landmark/, { landmarks: "app" }],
|
|
12
22
|
];
|
|
13
23
|
const FINES = [
|
|
14
24
|
["a fine layout", FINE],
|
|
15
25
|
["content that is not drawn (a visually hidden table, a folded details)", HIDDEN_CONTENT],
|
|
16
26
|
["a section folded in a details", FOLDED_SECTION],
|
|
27
|
+
["a short token in a row a taller neighbour stretches", TOKEN_IN_STRETCHED_ROW],
|
|
17
28
|
];
|
|
29
|
+
/** Fixtures that must stay clean even with every opt-in check on. */
|
|
30
|
+
const ALL = { emptyMain: true, frame: true, minAction: 36, boxes: true, pictures: true, emptyControls: true, hardColors: true, landmarks: "public" };
|
|
31
|
+
const FINES_STRICT = [
|
|
32
|
+
["a frame that fits its screen", FRAME_FITS, { frame: true, emptyMain: false }],
|
|
33
|
+
["the family chrome", CHROME_FINE, ALL],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
async function problemsOn(page, html, checks) {
|
|
37
|
+
await page.setContent(html);
|
|
38
|
+
return problemsOf("fixture", await page.evaluate(auditPage), checks);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The failure for a fixture that must be clean and is not (none, when it is). */
|
|
42
|
+
async function reported(page, name, html, checks) {
|
|
43
|
+
const fine = await problemsOn(page, html, checks);
|
|
44
|
+
return fine.length ? [`the audit reports ${String(name)}: ${fine.join(" | ")}`] : [];
|
|
45
|
+
}
|
|
18
46
|
|
|
19
47
|
/** @returns {Promise<string[]>} the failures (empty when the audit sees what it must and nothing else) */
|
|
20
48
|
export async function selfTest({ launch } = {}) {
|
|
@@ -22,16 +50,13 @@ export async function selfTest({ launch } = {}) {
|
|
|
22
50
|
const failures = [];
|
|
23
51
|
try {
|
|
24
52
|
const page = await (await auditContext(browser, { viewport: { width: 1280, height: 800 } })).newPage();
|
|
25
|
-
for (const [name, html, pattern] of EXPECT) {
|
|
26
|
-
await page
|
|
27
|
-
|
|
28
|
-
if (!problems.some((p) => pattern.test(p))) failures.push(`the audit does not see ${name}: ${problems.join(" | ") || "no problem reported"}`);
|
|
29
|
-
}
|
|
30
|
-
for (const [name, html] of FINES) {
|
|
31
|
-
await page.setContent(html);
|
|
32
|
-
const fine = problemsOf("fixture", await page.evaluate(auditPage));
|
|
33
|
-
if (fine.length) failures.push(`the audit reports ${name}: ${fine.join(" | ")}`);
|
|
53
|
+
for (const [name, html, pattern, checks] of EXPECT) {
|
|
54
|
+
const problems = await problemsOn(page, html, checks);
|
|
55
|
+
if (!problems.some((p) => pattern.test(p))) failures.push(`the audit does not see ${String(name)}: ${problems.join(" | ") || "no problem reported"}`);
|
|
34
56
|
}
|
|
57
|
+
for (const [name, html] of FINES) failures.push(...(await reported(page, name, html)));
|
|
58
|
+
await page.setViewportSize({ width: 1280, height: 700 });
|
|
59
|
+
for (const [name, html, checks] of FINES_STRICT) failures.push(...(await reported(page, name, html, checks)));
|
|
35
60
|
} finally {
|
|
36
61
|
await browser.close();
|
|
37
62
|
}
|