@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 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.2.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.13.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] | krizaka-page-audit --self-test
4
- import { auditRoutes, THEMES, WIDTHS } from "./run.mjs";
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: 4 broken layouts seen, the fine ones clean");
20
+ console.log("page-audit self-test: every broken layout seen, the fine ones clean");
20
21
  } else {
21
- const routes = list(arg("routes"), ["/"]);
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: arg("base", "http://localhost:3000"), routes, widths, themes, themeKey: arg("theme-key", "kz-theme"), shots: arg("shots"), checks: { h1: process.argv.includes("--h1") } });
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 interface ProblemChecks { errors?: string[]; h1?: boolean; headerAction?: boolean; fileLabels?: boolean }
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[]; widths?: number[]; themes?: string[]; height?: number; themeKey?: string; shots?: string; settleMs?: number;
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";
@@ -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 than two lines for text without a space. */
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 content = el.getBoundingClientRect().height - Number.parseFloat(cs.paddingTop) - Number.parseFloat(cs.paddingBottom);
174
- return content > lh * 2.5;
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" && !/Failed to load resource/.test(message.text());
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, route, width, height, settleMs, prepare, checks, errors, where, shots }) {
47
- await resizeSettled(page, { width, height });
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}${route}`);
50
- if (prepare) await prepare(page, route);
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
- return problemsOf(where, result, { ...checks, errors: errors.take() });
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[]} o.routes paths to audit
64
- * @param {number[]} [o.widths] viewport widths (default 360 to 1440)
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` (h1, headerAction, fileLabels)
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 context = await auditContext(browser, { colorScheme: theme });
81
- if (themeKey) await context.addInitScript(rememberTheme, [themeKey, theme]);
82
- const page = await context.newPage();
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.setContent(html);
27
- const problems = problemsOf("fixture", await page.evaluate(auditPage));
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
  }