@krizaka/page-audit 0.5.0 → 0.6.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 CHANGED
@@ -63,3 +63,14 @@ defineJourneyTests({
63
63
 
64
64
  Baselines are taken in the official Playwright container by the shared workflows of krizaka/.github (`visual-regression.yml` to compare,
65
65
  `update-visual-baselines.yml` to commit them on a branch); never on a Mac.
66
+
67
+ ## The journey kit (0.6)
68
+
69
+ `@krizaka/page-audit/journey` is the kit of the customer journeys (ADR-017, krizaka/.github#423). A product writes no copy of it.
70
+
71
+ - **Catalogue**: `validateJourney` checks a row of `journeys/journeys.json` (kebab-case id, a promise, a persona of the seeded cast, platforms, steps, a budget of 90 s by default); `checkCatalogue` fails a file without a row, a row without a file, an id twice; `journeysByTag(index, "smoke")` picks what runs on every pull request; `budgetProblem` names a journey over its budget.
72
+ - **Persona sign-in once per worker**: `personaSessions({ dir, signIn }).stateFor("eric")` signs the persona in on the first call and reuses the browser state after (parallel calls share one sign-in; one file per worker; a failed sign-in is not remembered).
73
+ - **Seeded cast**: `seededPersona(cast, "eric")` reads a member or names who exists.
74
+ - **Masks**: `journeyMaskSelectors({ extra })` = `time`, `[data-mask]`, `[data-date]`, `[data-testid$="-id"]`, `[data-run-id]` and the journey's own.
75
+ - **Video hooks**: `videoContextOptions(journey, { dir })` records only the journeys with `video: true`; `videoFile(journey, "web")` names the file.
76
+ - **Flake policy**: `assertNoRetries(playwrightConfig)` refuses retries above 0 (also per project); `quarantine(entries)` holds a named test with its reason and its issue, capped at one.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krizaka/page-audit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
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",
@@ -34,6 +34,10 @@
34
34
  "import": "./src/settle.mjs"
35
35
  },
36
36
  "./fixtures": "./src/fixtures.mjs",
37
+ "./journey": {
38
+ "types": "./src/journey.d.ts",
39
+ "import": "./src/journey.mjs"
40
+ },
37
41
  "./package.json": "./package.json",
38
42
  "./visual": {
39
43
  "types": "./src/visual.d.ts",
@@ -61,7 +65,7 @@
61
65
  "eslint": "^9.39.0",
62
66
  "playwright-core": "^1.55.0",
63
67
  "vitest": "^4.1.11",
64
- "@krizaka/config": "^0.14.0"
68
+ "@krizaka/config": "^0.18.0"
65
69
  },
66
70
  "engines": {
67
71
  "node": ">=22"
@@ -72,7 +76,7 @@
72
76
  "scripts": {
73
77
  "lint": "eslint . --max-warnings 0",
74
78
  "typecheck": "node --check src/index.mjs",
75
- "test": "vitest run",
79
+ "test": "vitest run --coverage --coverage.provider=v8 --coverage.reporter=lcov --coverage.reporter=text-summary \"--coverage.include=src/**/*.ts\"",
76
80
  "check": "pnpm run lint && pnpm run test"
77
81
  }
78
82
  }
package/src/cli.mjs CHANGED
@@ -21,7 +21,9 @@ if (process.argv.includes("--self-test")) {
21
21
  } else {
22
22
  const base = arg("base", "http://localhost:3000");
23
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"), ["/"]);
24
+ const routes = has("sitemap")
25
+ ? await routesFromSitemap(base, { first: list(arg("routes"), ["/"]), limit: Number(arg("limit", 14)) })
26
+ : list(arg("routes"), ["/"]);
25
27
  if (!routes) {
26
28
  console.error(`Nothing answers at ${base}/sitemap.xml`);
27
29
  process.exit(2);
@@ -29,7 +31,21 @@ if (process.argv.includes("--self-test")) {
29
31
  const [app, baseline] = (arg("a11y") ?? "").split(":");
30
32
  const widths = list(arg("widths"), WIDTHS).map(Number);
31
33
  const themes = list(arg("themes"), THEMES);
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 } });
34
+ const problems = await auditRoutes({
35
+ base,
36
+ routes,
37
+ widths,
38
+ themes,
39
+ themeKey: arg("theme-key", "kz-theme"),
40
+ shots: arg("shots"),
41
+ a11y: baseline ? { app, baseline } : undefined,
42
+ checks: {
43
+ h1: has("h1"),
44
+ status: has("status"),
45
+ emptyMain: has("empty-main"),
46
+ minAction: arg("min-action") ? Number(arg("min-action")) : undefined,
47
+ },
48
+ });
33
49
  if (problems.length) {
34
50
  console.error(`${problems.length} page problem(s):\n ${problems.join("\n ")}`);
35
51
  process.exit(1);
package/src/fixtures.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // Small pages that reproduce what the layout audit must catch (yomizaka#435) and one that is fine, for `check-page-audit.mjs`:
2
2
  // the guard is proven on its own fixtures before it is trusted on the app.
3
3
 
4
- const page = (body, css = "") => `<!doctype html><html><head><meta charset="utf-8"><style>*{box-sizing:border-box}body{margin:0;font:16px sans-serif}${css}</style></head><body><main>${body}</main></body></html>`;
4
+ const page = (body, css = "") =>
5
+ `<!doctype html><html><head><meta charset="utf-8"><style>*{box-sizing:border-box}body{margin:0;font:16px sans-serif}${css}</style></head><body><main>${body}</main></body></html>`;
5
6
 
6
7
  /** The binder as it was: the open folder's card is as tall as its grid row (a fixed height) and runs over the next section. */
7
8
  export const CARD_OVER_NEXT = page(
@@ -9,7 +10,9 @@ export const CARD_OVER_NEXT = page(
9
10
  "section{display:block}",
10
11
  );
11
12
  /** Two sections laid out on top of each other. */
12
- export const SECTIONS_OVERLAP = page(`<section data-testid="a" style="height:100px">A</section><section data-testid="b" style="margin-top:-60px;height:100px;position:relative">B</section>`);
13
+ export const SECTIONS_OVERLAP = page(
14
+ `<section data-testid="a" style="height:100px">A</section><section data-testid="b" style="margin-top:-60px;height:100px;position:relative">B</section>`,
15
+ );
13
16
  /** A two-column layout whose wrapper clips the right column. */
14
17
  export const COLUMN_CUT = page(
15
18
  `<div style="overflow:hidden;width:600px"><div style="display:grid;grid-template-columns:400px 400px"><section data-testid="main">Main</section><aside data-testid="activity"><section data-testid="activity-figures">Activité</section></aside></div></div>`,
@@ -31,29 +34,45 @@ export const FOLDED_SECTION = page(
31
34
  /** A page whose `<main>` draws nothing. */
32
35
  export const EMPTY_MAIN = page("");
33
36
  /** 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>`);
37
+ export const FRAME_FORCES_SCROLL = page(
38
+ `<div style="height:60px">notice</div><div data-app-frame-slot><div style="min-height:1000px">shell</div></div>`,
39
+ );
35
40
  /** 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>`);
41
+ export const FRAME_FITS = page(
42
+ `<div style="height:60px">notice</div><div data-app-frame-slot><div style="min-height:500px">shell</div></div>`,
43
+ );
37
44
  /** A button-styled action 24 px tall, under the 36 px tap target. */
38
45
  export const SMALL_ACTION = page(`<a class="kz-button" href="/" style="display:block;height:24px;width:120px">Go</a>`);
39
46
  /** A landmark whose content is wider than its box. */
40
47
  export const BOX_TOO_WIDE = page(`<aside style="width:200px"><div style="width:500px">wide</div></aside>`);
41
48
  /** 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>`);
49
+ export const BROKEN_PARTS = page(
50
+ `<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>`,
51
+ );
43
52
  /** Chrome that fits the family: one footer naming Krizaka, a nav. */
44
53
  export const CHROME_FINE = `<!doctype html><html><body><nav>n</nav><main><p>Hello</p></main><footer>A Krizaka product</footer></body></html>`;
45
54
  export const CHROME_BAD = `<!doctype html><html><body><main><p>Hello</p></main><footer>one</footer><footer>two</footer></body></html>`;
46
55
 
47
56
  /** 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>`);
57
+ export const TOKEN_IN_STRETCHED_ROW = page(
58
+ `<table style="width:600px"><tbody><tr><td>orazaka-media-pack</td><td>${"A long description. ".repeat(30)}</td></tr></tbody></table>`,
59
+ );
49
60
  /** 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>`);
61
+ export const TOKEN_WRAPPED = page(
62
+ `<table style="width:100px;table-layout:fixed"><tbody><tr><td style="word-break:break-all;font-size:20px">abcdefghijklmnopqrstuvwxyz0123456789</td></tr></tbody></table>`,
63
+ );
51
64
 
52
65
  /** A map that laid out at 0 px: `absolute inset-0` in a frame with no height of its own (akinaza#488). */
53
- export const MAP_COLLAPSED = page(`<section><div style="position:relative"><div class="maplibregl-map" style="position:absolute;inset:0"><canvas width="0" height="0"></canvas></div></div></section>`);
66
+ export const MAP_COLLAPSED = page(
67
+ `<section><div style="position:relative"><div class="maplibregl-map" style="position:absolute;inset:0"><canvas width="0" height="0"></canvas></div></div></section>`,
68
+ );
54
69
  /** A map box with a size and a canvas, and a Leaflet one with a loaded tile. */
55
- export const MAP_FINE = page(`<section><div class="maplibregl-map" style="height:300px;width:600px"><canvas style="width:600px;height:300px" width="600" height="300"></canvas></div><div class="leaflet-container" style="height:300px;width:600px"><img class="leaflet-tile-loaded" alt="" width="256" height="256"></div></section>`);
70
+ export const MAP_FINE = page(
71
+ `<section><div class="maplibregl-map" style="height:300px;width:600px"><canvas style="width:600px;height:300px" width="600" height="300"></canvas></div><div class="leaflet-container" style="height:300px;width:600px"><img class="leaflet-tile-loaded" alt="" width="256" height="256"></div></section>`,
72
+ );
56
73
  /** A GL map box of a good size where nothing was drawn (no canvas). */
57
- export const MAP_NO_CANVAS = page(`<section><div class="maplibregl-map" style="height:300px;width:600px"></div></section>`);
74
+ export const MAP_NO_CANVAS = page(
75
+ `<section><div class="maplibregl-map" style="height:300px;width:600px"></div></section>`,
76
+ );
58
77
  /** A map that is not drawn (display none: a closed tab, a small screen): nothing to measure. */
59
78
  export const MAP_HIDDEN = page(`<section><div class="maplibregl-map" style="display:none"></div></section>`);
package/src/index.d.ts CHANGED
@@ -5,35 +5,74 @@ export type Browser = { newContext(o?: object): Promise<BrowserContext>; close()
5
5
  export const auditPage: string;
6
6
  export type Chrome = "public" | "app" | "auth";
7
7
  export interface ProblemChecks {
8
- errors?: string[]; h1?: boolean; headerAction?: boolean; fileLabels?: boolean;
8
+ errors?: string[];
9
+ h1?: boolean;
10
+ headerAction?: boolean;
11
+ fileLabels?: boolean;
9
12
  /** HTTP status of the load (`null`: no response); a 4xx/5xx is a problem. */
10
13
  status?: number | null;
11
- emptyMain?: boolean; frame?: boolean; minAction?: number; boxes?: boolean; landmarks?: Chrome; pictures?: boolean; emptyControls?: boolean; hardColors?: boolean;
14
+ emptyMain?: boolean;
15
+ frame?: boolean;
16
+ minAction?: number;
17
+ boxes?: boolean;
18
+ landmarks?: Chrome;
19
+ pictures?: boolean;
20
+ emptyControls?: boolean;
21
+ hardColors?: boolean;
12
22
  /** Empty maps (a GL / Leaflet / `[data-kz-map]` box under 120 px, no canvas, no loaded tile); `{ min, required }` to tune, `required` fails a page with no map. */
13
23
  maps?: boolean | { min?: number; required?: boolean };
14
24
  }
15
25
  export function problemsOf(where: string, result: Record<string, unknown>, checks?: ProblemChecks): string[];
16
26
  export function auditOptions<T extends object>(options?: T): T & { reducedMotion: "reduce" };
17
27
  export function withoutViewTransitions(): void;
18
- export function auditContext(browser: { newContext(o?: object): Promise<BrowserContext> }, options?: object): Promise<BrowserContext>;
28
+ export function auditContext(
29
+ browser: { newContext(o?: object): Promise<BrowserContext> },
30
+ options?: object,
31
+ ): Promise<BrowserContext>;
19
32
  export function settled(page: Page): Promise<unknown>;
20
33
  export function loadSettled(page: Page, url: string, options?: object): Promise<BrowserContext>;
21
34
  export function resizeSettled(page: Page, size: { width: number; height: number }): Promise<void>;
22
35
  export const WIDTHS: number[];
23
36
  export const THEMES: string[];
24
37
  export function isPageError(message: { type(): string; text(): string }, ignore?: RegExp[]): boolean;
25
- export function routesFromSitemap(base: string, options?: { first?: string[]; limit?: number; fetcher?: typeof fetch }): Promise<string[] | null>;
26
- export interface RouteSpec { url: string; name?: string; chrome?: Chrome; session?: boolean }
27
- export interface A11yOptions { app: string; baseline: string; widths?: number[]; log?: (line: string) => void; session?: { app: string; baseline: string } }
38
+ export function routesFromSitemap(
39
+ base: string,
40
+ options?: { first?: string[]; limit?: number; fetcher?: typeof fetch },
41
+ ): Promise<string[] | null>;
42
+ export interface RouteSpec {
43
+ url: string;
44
+ name?: string;
45
+ chrome?: Chrome;
46
+ session?: boolean;
47
+ }
48
+ export interface A11yOptions {
49
+ app: string;
50
+ baseline: string;
51
+ widths?: number[];
52
+ log?: (line: string) => void;
53
+ session?: { app: string; baseline: string };
54
+ }
28
55
  export interface SessionOptions {
29
56
  storageState?: object | string;
30
57
  signIn?: (context: BrowserContext, o: { base: string; theme: string; page: Page }) => Promise<void>;
31
58
  routes?: (page: Page, o: { base: string; theme: string }) => Promise<Array<string | RouteSpec>>;
32
59
  }
33
60
  export interface AuditRoutesOptions {
34
- base: string; routes: Array<string | RouteSpec>; widths?: Array<number | { width: number; height?: number }>; themes?: string[]; height?: number; themeKey?: string; shots?: string; settleMs?: number;
35
- ignoreConsole?: RegExp[]; attempts?: number; a11y?: A11yOptions; session?: SessionOptions;
36
- checks?: Omit<ProblemChecks, "errors">; prepare?: (page: Page, route: string) => Promise<void>; launch?: () => Promise<BrowserContext>;
61
+ base: string;
62
+ routes: Array<string | RouteSpec>;
63
+ widths?: Array<number | { width: number; height?: number }>;
64
+ themes?: string[];
65
+ height?: number;
66
+ themeKey?: string;
67
+ shots?: string;
68
+ settleMs?: number;
69
+ ignoreConsole?: RegExp[];
70
+ attempts?: number;
71
+ a11y?: A11yOptions;
72
+ session?: SessionOptions;
73
+ checks?: Omit<ProblemChecks, "errors">;
74
+ prepare?: (page: Page, route: string) => Promise<void>;
75
+ launch?: () => Promise<BrowserContext>;
37
76
  }
38
77
  export function auditRoutes(options: AuditRoutesOptions): Promise<string[]>;
39
78
  export function selfTest(options?: { launch?: () => Promise<BrowserContext> }): Promise<string[]>;
package/src/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  export { auditPage, problemsOf } from "./page-audit.mjs";
2
2
  export { auditRoutes, isPageError, routesFromSitemap, THEMES, WIDTHS } from "./run.mjs";
3
3
  export { selfTest } from "./self-test.mjs";
4
- export { auditContext, auditOptions, loadSettled, resizeSettled,settled, withoutViewTransitions } from "./settle.mjs";
4
+ export { auditContext, auditOptions, loadSettled, resizeSettled, settled, withoutViewTransitions } from "./settle.mjs";
@@ -0,0 +1,54 @@
1
+ export type Platform = "web" | "mobile";
2
+
3
+ /** A row of `journeys/journeys.json` (ADR-017 §3). */
4
+ export interface JourneyRow {
5
+ id: string;
6
+ promise: string;
7
+ persona: string;
8
+ platforms: Platform[];
9
+ steps: string[];
10
+ video?: boolean;
11
+ budgetSeconds?: number;
12
+ tags?: string[];
13
+ }
14
+
15
+ export const DEFAULT_BUDGET_SECONDS: 90;
16
+ export function validateJourney(journey: Partial<JourneyRow>, options?: { cast?: readonly string[] }): string[];
17
+ export function checkCatalogue(input: { files: readonly string[]; index: readonly { id: string }[] }): string[];
18
+ export function journeysByTag<T extends { tags?: string[] }>(index: readonly T[], tag: string): T[];
19
+ export function budgetProblem(journey: Pick<JourneyRow, "id" | "budgetSeconds">, elapsedMs: number): string | undefined;
20
+
21
+ export interface PersonaSessions {
22
+ /** The path of the persona's browser state for this worker; signs in on the first call only. */
23
+ stateFor(persona: string): Promise<string>;
24
+ }
25
+ export function personaSessions(options: {
26
+ dir: string;
27
+ signIn: (persona: string, file: string) => Promise<unknown>;
28
+ worker?: number;
29
+ }): PersonaSessions;
30
+
31
+ export function seededPersona<T>(cast: Record<string, T>, id: string): T;
32
+
33
+ export const JOURNEY_MASKS: readonly string[];
34
+ export function journeyMaskSelectors(options?: { extra?: readonly string[]; defaults?: boolean }): string[];
35
+
36
+ export const VIDEO_SIZE: Readonly<{ width: number; height: number }>;
37
+ export function videoContextOptions(
38
+ journey: Pick<JourneyRow, "id" | "video">,
39
+ options: { dir: string; size?: { width: number; height: number } },
40
+ ): { recordVideo?: { dir: string; size: { width: number; height: number } } };
41
+ export function videoFile(journey: Pick<JourneyRow, "id">, platform: Platform): string;
42
+
43
+ export function assertNoRetries(config: { retries?: number; projects?: { name?: string; retries?: number }[] }): void;
44
+
45
+ export const QUARANTINE_CAP: 1;
46
+ export interface QuarantineEntry {
47
+ test: string;
48
+ reason: string;
49
+ issue: string;
50
+ }
51
+ export function quarantine(
52
+ entries: readonly QuarantineEntry[],
53
+ options?: { max?: number },
54
+ ): { entries: readonly QuarantineEntry[]; has(title: string): boolean };
@@ -0,0 +1,162 @@
1
+ // The shared journey kit (ADR-017 §7, krizaka/.github#423): what every product's customer journeys need, in one place.
2
+ //
3
+ // A journey is one customer promise told from a persona's point of view, run on the seeded cast. The kit gives them:
4
+ // the catalogue format and its check, a persona signed in once per worker, the seeded cast, the masks of the live
5
+ // values, the video hooks of the « videos from tests » rule, and the flake policy (retries 0, a capped quarantine).
6
+ //
7
+ // import { personaSessions, journeyMaskSelectors, videoContextOptions } from "@krizaka/page-audit/journey";
8
+
9
+ import { mkdir, stat } from "node:fs/promises";
10
+ import { dirname, join } from "node:path";
11
+
12
+ export const DEFAULT_BUDGET_SECONDS = 90;
13
+ const PLATFORMS = Object.freeze(["web", "mobile"]);
14
+ const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
+ const ISSUE = /^[\w.-]+\/[\w.-]+#\d+$/;
16
+ const JOURNEY_SUFFIX = /\.journey\.[cm]?[jt]sx?$/;
17
+ /** The journey id of a file path (`<id>.journey.ts`), or undefined. The base name is cut first: no backtracking over the path. */
18
+ function journeyId(file) {
19
+ const base = file.slice(Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")) + 1);
20
+ const match = JOURNEY_SUFFIX.exec(base);
21
+ return match && match.index > 0 ? base.slice(0, match.index) : undefined;
22
+ }
23
+
24
+ /** The problems of one catalogue row (`journeys/journeys.json`), empty when it is a good journey. `cast` = the seeded persona ids. */
25
+ export function validateJourney(journey, { cast } = {}) {
26
+ const problems = [];
27
+ const at = `journey « ${journey?.id ?? "?"} »`;
28
+ if (typeof journey?.id !== "string" || !KEBAB.test(journey.id)) problems.push(`${at}: id must be kebab-case`);
29
+ if (typeof journey?.promise !== "string" || !journey.promise.trim()) problems.push(`${at}: promise is empty`);
30
+ if (typeof journey?.persona !== "string" || !journey.persona) problems.push(`${at}: persona is missing`);
31
+ else if (cast && !cast.includes(journey.persona))
32
+ problems.push(`${at}: persona « ${journey.persona} » is not in the seeded cast (${cast.join(", ")})`);
33
+ if (!Array.isArray(journey?.platforms) || journey.platforms.length === 0) problems.push(`${at}: platforms is empty`);
34
+ else
35
+ for (const p of journey.platforms)
36
+ if (!PLATFORMS.includes(p)) problems.push(`${at}: platform « ${p} » is not one of ${PLATFORMS.join(", ")}`);
37
+ if (!Array.isArray(journey?.steps) || journey.steps.length === 0) problems.push(`${at}: steps is empty`);
38
+ const budget = journey?.budgetSeconds ?? DEFAULT_BUDGET_SECONDS;
39
+ if (!Number.isFinite(budget) || budget <= 0) problems.push(`${at}: budgetSeconds must be a positive number`);
40
+ return problems;
41
+ }
42
+
43
+ /** Files and rows must match: a file with no row, a row with no file, an id twice. `files` are paths of `<id>.journey.ts`. */
44
+ export function checkCatalogue({ files, index }) {
45
+ const problems = [];
46
+ const fileIds = new Set(files.map(journeyId).filter(Boolean));
47
+ const seen = new Set();
48
+ for (const row of index) {
49
+ if (seen.has(row.id)) problems.push(`journeys.json lists « ${row.id} » twice`);
50
+ seen.add(row.id);
51
+ if (!fileIds.has(row.id)) problems.push(`journeys.json row « ${row.id} » has no file`);
52
+ }
53
+ for (const id of fileIds) if (!seen.has(id)) problems.push(`journey file « ${id} » has no row in journeys.json`);
54
+ return problems;
55
+ }
56
+
57
+ /** The rows that carry a tag (`smoke` runs on every pull request, the rest in the nightly pass). */
58
+ export function journeysByTag(index, tag) {
59
+ return index.filter((j) => j.tags?.includes(tag));
60
+ }
61
+
62
+ /** The message when a run took longer than its journey's budget, `undefined` within it. */
63
+ export function budgetProblem(journey, elapsedMs) {
64
+ const seconds = journey.budgetSeconds ?? DEFAULT_BUDGET_SECONDS;
65
+ if (elapsedMs <= seconds * 1000) return undefined;
66
+ return `journey « ${journey.id} » took ${Math.round(elapsedMs / 1000)} s, over its budget of ${seconds} s: split it, or the product is slow`;
67
+ }
68
+
69
+ async function written(file) {
70
+ try {
71
+ return (await stat(file)).size > 0;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Persona sign-in once per worker. `signIn(persona, file)` signs in and writes the Playwright browser state to `file`
79
+ * (the app's own way: its form, its API). `stateFor(persona)` returns that file's path: the first call signs in, the
80
+ * next ones (also parallel ones) reuse it, so a worker never asks the sign-in more than once per persona (rate-limit
81
+ * safe). One file per worker; a failed sign-in is not remembered.
82
+ */
83
+ export function personaSessions({ dir, signIn, worker = Number(process.env.TEST_WORKER_INDEX ?? 0) }) {
84
+ const inFlight = new Map();
85
+ return {
86
+ stateFor(persona) {
87
+ const known = inFlight.get(persona);
88
+ if (known) return known;
89
+ const file = join(dir, `${persona}.w${worker}.json`);
90
+ const pending = (async () => {
91
+ if (!(await written(file))) {
92
+ await mkdir(dirname(file), { recursive: true });
93
+ await signIn(persona, file);
94
+ if (!(await written(file))) throw new Error(`sign-in of « ${persona} » wrote no storage state to ${file}`);
95
+ }
96
+ return file;
97
+ })();
98
+ inFlight.set(persona, pending);
99
+ pending.catch(() => inFlight.delete(persona));
100
+ return pending;
101
+ },
102
+ };
103
+ }
104
+
105
+ /** A member of the seeded cast (the same people locally and deployed), or an error naming who exists. */
106
+ export function seededPersona(cast, id) {
107
+ const person = cast[id];
108
+ if (!person) throw new Error(`« ${id} » is not in the seeded cast: ${Object.keys(cast).join(", ")}`);
109
+ return person;
110
+ }
111
+
112
+ /** What changes between two runs of the same build: dates, ids, anything marked `data-mask`. */
113
+ export const JOURNEY_MASKS = Object.freeze([
114
+ "time",
115
+ "[data-mask]",
116
+ "[data-date]",
117
+ '[data-testid$="-id"]',
118
+ "[data-run-id]",
119
+ ]);
120
+
121
+ /** The selectors to mask in a capture of a journey screen. */
122
+ export function journeyMaskSelectors({ extra = [], defaults = true } = {}) {
123
+ return [...new Set([...(defaults ? JOURNEY_MASKS : []), ...extra])];
124
+ }
125
+
126
+ /** The size of a recorded video: the laptop of the visual matrix. */
127
+ export const VIDEO_SIZE = Object.freeze({ width: 1280, height: 800 });
128
+
129
+ /** Context options that record the run of a journey whose `video` is true (the source of the product video), none otherwise. */
130
+ export function videoContextOptions(journey, { dir, size = VIDEO_SIZE }) {
131
+ if (!journey.video) return {};
132
+ return { recordVideo: { dir: join(dir, journey.id), size: { ...size } } };
133
+ }
134
+
135
+ /** The name the recorded video is published under. */
136
+ export function videoFile(journey, platform) {
137
+ return `${journey.id}.${platform}.webm`;
138
+ }
139
+
140
+ /** A flake is a bug, not a retry: retries are 0 (top level and every project). */
141
+ export function assertNoRetries(config) {
142
+ if ((config.retries ?? 0) > 0)
143
+ throw new Error(`retries is ${config.retries}: a flake is a bug, fix its cause (retries must be 0)`);
144
+ for (const project of config.projects ?? [])
145
+ if ((project.retries ?? 0) > 0)
146
+ throw new Error(`project « ${project.name} » sets retries ${project.retries}: retries must be 0`);
147
+ }
148
+
149
+ export const QUARANTINE_CAP = 1;
150
+
151
+ /** The quarantine: a named test, why, and the issue that fixes it. Capped; the audit keeps running, only the assertion is skipped. */
152
+ export function quarantine(entries, { max = QUARANTINE_CAP } = {}) {
153
+ if (entries.length > max) throw new Error(`the quarantine holds at most ${max} test(s), it has ${entries.length}`);
154
+ for (const e of entries) {
155
+ if (!e.test) throw new Error("a quarantine entry names its test");
156
+ if (!e.reason?.trim()) throw new Error(`quarantine « ${e.test} » needs a reason`);
157
+ if (!ISSUE.test(e.issue ?? ""))
158
+ throw new Error(`quarantine « ${e.test} » needs an issue as owner/repo#n, got « ${e.issue} »`);
159
+ }
160
+ const names = new Set(entries.map((e) => e.test));
161
+ return { entries, has: (title) => names.has(title) };
162
+ }
@@ -10,8 +10,12 @@ const nameOf = (el) => {
10
10
  const id = el.dataset?.testid;
11
11
  return el.tagName.toLowerCase() + (id ? "[" + id + "]" : "") + "." + className(el, 2);
12
12
  };
13
- const scrolls = (el) => ["auto", "scroll"].includes(getComputedStyle(el).overflowY) || ["auto", "scroll"].includes(getComputedStyle(el).overflowX);
14
- const meets = (ra, rb) => Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left) > 2 && Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top) > 2;
13
+ const scrolls = (el) =>
14
+ ["auto", "scroll"].includes(getComputedStyle(el).overflowY) ||
15
+ ["auto", "scroll"].includes(getComputedStyle(el).overflowX);
16
+ const meets = (ra, rb) =>
17
+ Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left) > 2 &&
18
+ Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top) > 2;
15
19
  const apart = (a, b) => !a.contains(b) && !b.contains(a);
16
20
 
17
21
  /** An element that pokes out only matters when nothing clips it: decorative layers (aria-hidden) and anything inside a clipping or scrolling ancestor are meant to extend. */
@@ -27,7 +31,12 @@ function wideOf(doc) {
27
31
  return [...document.querySelectorAll("body *")]
28
32
  .filter((el) => {
29
33
  const r = el.getBoundingClientRect();
30
- return r.width > 0 && getComputedStyle(el).visibility !== "hidden" && (r.right > doc.clientWidth + 1 || r.left < -1) && !clipped(el);
34
+ return (
35
+ r.width > 0 &&
36
+ getComputedStyle(el).visibility !== "hidden" &&
37
+ (r.right > doc.clientWidth + 1 || r.left < -1) &&
38
+ !clipped(el)
39
+ );
31
40
  })
32
41
  .slice(0, 3)
33
42
  .map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(" ").slice(0, 3).join(".")}`);
@@ -36,12 +45,21 @@ function wideOf(doc) {
36
45
  /** A button's accessible name: its text, aria-label, aria-labelledby, title — or a <label for> pointing at it (a checkbox, a switch). */
37
46
  function named(b) {
38
47
  const labelled = !!b.id && !!document.querySelector(`label[for="${CSS.escape(b.id)}"]`);
39
- return !!((b.textContent || "").trim() || b.getAttribute("aria-label") || b.getAttribute("aria-labelledby") || b.title || labelled);
48
+ return !!(
49
+ (b.textContent || "").trim() ||
50
+ b.getAttribute("aria-label") ||
51
+ b.getAttribute("aria-labelledby") ||
52
+ b.title ||
53
+ labelled
54
+ );
40
55
  }
41
56
 
42
57
  function unnamedOf() {
43
58
  const unnamed = [...document.querySelectorAll("button")].filter((b) => !named(b));
44
- return { unnamedButtons: unnamed.length, unnamedSamples: unnamed.slice(0, 2).map((b) => b.outerHTML.slice(0, 160).replace(/\s+/g, " ")) };
59
+ return {
60
+ unnamedButtons: unnamed.length,
61
+ unnamedSamples: unnamed.slice(0, 2).map((b) => b.outerHTML.slice(0, 160).replace(/\s+/g, " ")),
62
+ };
45
63
  }
46
64
 
47
65
  function headerActionOf(doc) {
@@ -68,7 +86,14 @@ function keptBetween(el, panel) {
68
86
  }
69
87
 
70
88
  function notDrawn(el, r) {
71
- return r.width === 0 || r.height === 0 || getComputedStyle(el).position === "fixed" || !!el.closest(ARIA_HIDDEN) || !!el.closest("[data-radix-popper-content-wrapper]") || folded(el);
89
+ return (
90
+ r.width === 0 ||
91
+ r.height === 0 ||
92
+ getComputedStyle(el).position === "fixed" ||
93
+ !!el.closest(ARIA_HIDDEN) ||
94
+ !!el.closest("[data-radix-popper-content-wrapper]") ||
95
+ folded(el)
96
+ );
72
97
  }
73
98
 
74
99
  function runsOut(el, panel, box) {
@@ -96,10 +121,14 @@ function spillOf(panels) {
96
121
  function overlapsOf(panels, spill) {
97
122
  const overlaps = [];
98
123
  for (const el of spill) {
99
- for (const other of panels) if (apart(el, other) && meets(el.getBoundingClientRect(), other.getBoundingClientRect())) overlaps.push(`${nameOf(el)} over ${nameOf(other)}`);
124
+ for (const other of panels)
125
+ if (apart(el, other) && meets(el.getBoundingClientRect(), other.getBoundingClientRect()))
126
+ overlaps.push(`${nameOf(el)} over ${nameOf(other)}`);
100
127
  }
101
128
  panels.forEach((a, i) => {
102
- for (const b of panels.slice(i + 1)) if (apart(a, b) && meets(a.getBoundingClientRect(), b.getBoundingClientRect())) overlaps.push(`${nameOf(a)} over ${nameOf(b)}`);
129
+ for (const b of panels.slice(i + 1))
130
+ if (apart(a, b) && meets(a.getBoundingClientRect(), b.getBoundingClientRect()))
131
+ overlaps.push(`${nameOf(a)} over ${nameOf(b)}`);
103
132
  });
104
133
  return overlaps;
105
134
  }
@@ -138,16 +167,27 @@ function isTopBar(el, width) {
138
167
  const pos = getComputedStyle(el).position;
139
168
  if (pos !== "fixed" && pos !== "sticky") return false;
140
169
  const r = topBoxOf(el).getBoundingClientRect();
141
- return r.width > width * 0.5 && r.height > 8 && r.height < 200 && r.top < 160 && r.bottom > 0 && !el.closest("[data-top-chrome]");
170
+ return (
171
+ r.width > width * 0.5 &&
172
+ r.height > 8 &&
173
+ r.height < 200 &&
174
+ r.top < 160 &&
175
+ r.bottom > 0 &&
176
+ !el.closest("[data-top-chrome]")
177
+ );
142
178
  }
143
179
 
144
180
  function chromeOverlapsOf(doc) {
145
- const chrome = [...document.querySelectorAll("[data-top-chrome]")].filter((el) => el.getBoundingClientRect().height > 0);
181
+ const chrome = [...document.querySelectorAll("[data-top-chrome]")].filter(
182
+ (el) => el.getBoundingClientRect().height > 0,
183
+ );
146
184
  const bars = [...document.querySelectorAll("body *")].filter((el) => isTopBar(el, doc.clientWidth)).map(topBoxOf);
147
185
  const stack = [...chrome, ...bars];
148
186
  const found = [];
149
187
  stack.forEach((a, i) => {
150
- for (const b of stack.slice(i + 1)) if (apart(a, b) && meets(a.getBoundingClientRect(), b.getBoundingClientRect())) found.push(`${nameOf(a)} / ${nameOf(b)}`);
188
+ for (const b of stack.slice(i + 1))
189
+ if (apart(a, b) && meets(a.getBoundingClientRect(), b.getBoundingClientRect()))
190
+ found.push(`${nameOf(a)} / ${nameOf(b)}`);
151
191
  });
152
192
  return found;
153
193
  }
@@ -158,7 +198,14 @@ function clipsOf() {
158
198
  .filter((el) => {
159
199
  const r = el.getBoundingClientRect();
160
200
  const style = getComputedStyle(el);
161
- return r.width > 4 && r.height > 4 && ["hidden", "clip"].includes(style.overflowX) && style.textOverflow !== "ellipsis" && el.scrollWidth > el.clientWidth + 1 && !clipped(el);
201
+ return (
202
+ r.width > 4 &&
203
+ r.height > 4 &&
204
+ ["hidden", "clip"].includes(style.overflowX) &&
205
+ style.textOverflow !== "ellipsis" &&
206
+ el.scrollWidth > el.clientWidth + 1 &&
207
+ !clipped(el)
208
+ );
162
209
  })
163
210
  .slice(0, 3)
164
211
  .map(nameOf);
@@ -185,12 +232,16 @@ function wrappedOf() {
185
232
  /** 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
233
  /** A heading with no text (and no image or labelled icon) says nothing to a screen reader. */
187
234
  function emptyHeadingsOf() {
188
- return [...document.querySelectorAll("h1, h2, h3")].filter((h) => !(h.textContent || "").trim() && !h.querySelector("img, svg[aria-label]")).length;
235
+ return [...document.querySelectorAll("h1, h2, h3")].filter(
236
+ (h) => !(h.textContent || "").trim() && !h.querySelector("img, svg[aria-label]"),
237
+ ).length;
189
238
  }
190
239
 
191
240
  /** A file input that no label, `aria-label` or title names. */
192
241
  function unlabelledFilesOf() {
193
- return [...document.querySelectorAll("input[type=file]")].filter((i) => !(i.getAttribute("aria-label") || i.getAttribute("aria-labelledby") || i.title || i.labels?.length)).length;
242
+ return [...document.querySelectorAll("input[type=file]")].filter(
243
+ (i) => !(i.getAttribute("aria-label") || i.getAttribute("aria-labelledby") || i.title || i.labels?.length),
244
+ ).length;
194
245
  }
195
246
 
196
247
  /** `<main>` with no text at all: the page answered but drew nothing (a route that fell through, a failed fetch). */
@@ -200,7 +251,9 @@ function emptyMainOf() {
200
251
 
201
252
  /** Height of every action of the page's main (a button-styled link): the tap targets the responsive guard keeps over `minAction`. */
202
253
  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));
254
+ return [...document.querySelectorAll("main a[class*='kz-button'], main a[data-slot='button'], main .kz-button")]
255
+ .filter((a) => a.getBoundingClientRect().width > 0)
256
+ .map((a) => Math.round(a.getBoundingClientRect().height));
204
257
  }
205
258
 
206
259
  /**
@@ -213,7 +266,12 @@ function frameOf(doc) {
213
266
  const rail = [...shell.querySelectorAll("nav")].find((n) => getComputedStyle(n).position === "sticky");
214
267
  return {
215
268
  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)),
269
+ forced: Math.round(
270
+ Math.max(
271
+ Number.parseFloat(getComputedStyle(shell).minHeight) || 0,
272
+ rail ? rail.getBoundingClientRect().height : 0,
273
+ ),
274
+ ),
217
275
  viewport: doc.clientHeight,
218
276
  };
219
277
  }
@@ -221,15 +279,24 @@ function frameOf(doc) {
221
279
  /** A landmark (`main`, `aside`, `header`, `nav`) whose content is wider than its own box, with nothing to scroll it. */
222
280
  function boxesOf() {
223
281
  return [...document.querySelectorAll("main, aside, header, nav")]
224
- .filter((box) => getComputedStyle(box).overflowX === "visible" && box.scrollWidth > box.clientWidth + 1 && box.clientWidth > 0)
282
+ .filter(
283
+ (box) =>
284
+ getComputedStyle(box).overflowX === "visible" && box.scrollWidth > box.clientWidth + 1 && box.clientWidth > 0,
285
+ )
225
286
  .slice(0, 3)
226
287
  .map((box) => `<${box.tagName.toLowerCase()}> ${box.scrollWidth}px in ${box.clientWidth}px`);
227
288
  }
228
289
 
229
290
  /** 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
291
  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") };
292
+ const footers = [...document.querySelectorAll("footer")].filter(
293
+ (f) => !f.closest("main, article, section, dialog, [role=dialog]"),
294
+ );
295
+ return {
296
+ footers: footers.length,
297
+ footerNamesKrizaka: /krizaka/i.test(footers[0]?.textContent || ""),
298
+ nav: !!document.querySelector("nav"),
299
+ };
233
300
  }
234
301
 
235
302
  /** A picture drawn at a real size that never loaded. */
@@ -250,15 +317,27 @@ function emptyControlsOf() {
250
317
  const box = el.getBoundingClientRect();
251
318
  const style = getComputedStyle(el);
252
319
  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");
320
+ if (box.width > 0 && box.height > 0 && box.height < 4)
321
+ found.push(`collapsed control ${(el.getAttribute("aria-label") || el.textContent || "").trim().slice(0, 30)}`);
322
+ else if (
323
+ box.width > 24 &&
324
+ box.height > 24 &&
325
+ !el.textContent?.trim() &&
326
+ !el.getAttribute("aria-label") &&
327
+ !el.getAttribute("aria-labelledby") &&
328
+ !el.querySelector("img, svg, video, picture, canvas")
329
+ )
330
+ found.push("empty tile");
255
331
  }
256
332
  return [...new Set(found)].slice(0, 3);
257
333
  }
258
334
 
259
335
  /** Elements whose inline style carries a colour that is not a token (`var(--kz-*)`). */
260
336
  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;
337
+ return [...document.querySelectorAll("[style]")].filter(
338
+ (e) =>
339
+ /#[0-9a-f]{3,8}\b|rgba?\(/i.test(e.getAttribute("style") || "") && !/var\(/.test(e.getAttribute("style") || ""),
340
+ ).length;
262
341
  }
263
342
 
264
343
  /**
@@ -268,19 +347,36 @@ function hardColorsOf() {
268
347
  */
269
348
  function mapsOf() {
270
349
  return [...document.querySelectorAll(".maplibregl-map, .mapboxgl-map, .leaflet-container, [data-kz-map]")]
271
- .filter((el) => el.getClientRects().length > 0 && !el.closest(ARIA_HIDDEN) && !folded(el) && !el.parentElement?.closest(".maplibregl-map, .mapboxgl-map, .leaflet-container, [data-kz-map]"))
350
+ .filter(
351
+ (el) =>
352
+ el.getClientRects().length > 0 &&
353
+ !el.closest(ARIA_HIDDEN) &&
354
+ !folded(el) &&
355
+ !el.parentElement?.closest(".maplibregl-map, .mapboxgl-map, .leaflet-container, [data-kz-map]"),
356
+ )
272
357
  .map((el) => {
273
358
  const box = el.getBoundingClientRect();
274
359
  const canvas = el.querySelector("canvas")?.getBoundingClientRect();
275
- return { name: nameOf(el), width: Math.round(box.width), height: Math.round(box.height), canvas: !!canvas && canvas.width > 0 && canvas.height > 0, leaflet: el.classList.contains("leaflet-container"), tiles: el.querySelectorAll(".leaflet-tile-loaded").length };
360
+ return {
361
+ name: nameOf(el),
362
+ width: Math.round(box.width),
363
+ height: Math.round(box.height),
364
+ canvas: !!canvas && canvas.width > 0 && canvas.height > 0,
365
+ leaflet: el.classList.contains("leaflet-container"),
366
+ tiles: el.querySelectorAll(".leaflet-tile-loaded").length,
367
+ };
276
368
  });
277
369
  }
278
370
 
279
371
  function measurePage() {
280
372
  const doc = document.documentElement;
281
- const panels = [...document.querySelectorAll("[data-panel], main section, main [role=region]")].filter((el) => !el.closest(ARIA_HIDDEN) && !folded(el));
373
+ const panels = [...document.querySelectorAll("[data-panel], main section, main [role=region]")].filter(
374
+ (el) => !el.closest(ARIA_HIDDEN) && !folded(el),
375
+ );
282
376
  const { overflowing, spill } = spillOf(panels);
283
- const chips = [...document.querySelectorAll('[data-testid="audience-picker"] a')].map((a) => Math.round(a.getBoundingClientRect().height));
377
+ const chips = [...document.querySelectorAll('[data-testid="audience-picker"] a')].map((a) =>
378
+ Math.round(a.getBoundingClientRect().height),
379
+ );
284
380
  return {
285
381
  h1: document.querySelectorAll("h1").length,
286
382
  scrollWidth: doc.scrollWidth,
@@ -310,7 +406,44 @@ function measurePage() {
310
406
  };
311
407
  }
312
408
 
313
- const PROBES = [tooTall, wrappedOf, className, nameOf, scrolls, meets, apart, clipped, wideOf, named, unnamedOf, headerActionOf, folded, keptBetween, notDrawn, runsOut, spillOf, overlapsOf, cutBy, cutColumnsOf, topBoxOf, isTopBar, chromeOverlapsOf, clipsOf, emptyHeadingsOf, unlabelledFilesOf, emptyMainOf, actionsOf, frameOf, boxesOf, landmarksOf, brokenPicturesOf, emptyControlsOf, hardColorsOf, mapsOf, measurePage];
409
+ const PROBES = [
410
+ tooTall,
411
+ wrappedOf,
412
+ className,
413
+ nameOf,
414
+ scrolls,
415
+ meets,
416
+ apart,
417
+ clipped,
418
+ wideOf,
419
+ named,
420
+ unnamedOf,
421
+ headerActionOf,
422
+ folded,
423
+ keptBetween,
424
+ notDrawn,
425
+ runsOut,
426
+ spillOf,
427
+ overlapsOf,
428
+ cutBy,
429
+ cutColumnsOf,
430
+ topBoxOf,
431
+ isTopBar,
432
+ chromeOverlapsOf,
433
+ clipsOf,
434
+ emptyHeadingsOf,
435
+ unlabelledFilesOf,
436
+ emptyMainOf,
437
+ actionsOf,
438
+ frameOf,
439
+ boxesOf,
440
+ landmarksOf,
441
+ brokenPicturesOf,
442
+ emptyControlsOf,
443
+ hardColorsOf,
444
+ mapsOf,
445
+ measurePage,
446
+ ];
314
447
 
315
448
  /** The expression Playwright runs in the page (`page.evaluate(auditPage)`): the probes, serialised together, then the measure. */
316
449
  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,11 +456,31 @@ export const auditPage = `(() => {\nconst ARIA_HIDDEN = ${JSON.stringify(ARIA_HI
323
456
  function accessibilityProblems(where, result, fileLabels) {
324
457
  const problems = [];
325
458
  if (result.emptyHeadings) problems.push(`${where}: ${result.emptyHeadings} empty heading(s)`);
326
- if (fileLabels && result.unlabelledFiles) problems.push(`${where}: ${result.unlabelledFiles} file input(s) without a label`);
459
+ if (fileLabels && result.unlabelledFiles)
460
+ problems.push(`${where}: ${result.unlabelledFiles} file input(s) without a label`);
327
461
  return problems;
328
462
  }
329
463
 
330
- export function problemsOf(where, result, { errors = [], h1 = false, headerAction = false, fileLabels = false, status, emptyMain = false, frame = false, minAction, boxes = false, landmarks, pictures = false, emptyControls = false, hardColors = false, maps = false } = {}) {
464
+ export function problemsOf(
465
+ where,
466
+ result,
467
+ {
468
+ errors = [],
469
+ h1 = false,
470
+ headerAction = false,
471
+ fileLabels = false,
472
+ status,
473
+ emptyMain = false,
474
+ frame = false,
475
+ minAction,
476
+ boxes = false,
477
+ landmarks,
478
+ pictures = false,
479
+ emptyControls = false,
480
+ hardColors = false,
481
+ maps = false,
482
+ } = {},
483
+ ) {
331
484
  return [
332
485
  ...statusProblems(where, status),
333
486
  ...(emptyMain && result.emptyMain ? [`${where}: empty <main>`] : []),
@@ -335,9 +488,13 @@ export function problemsOf(where, result, { errors = [], h1 = false, headerActio
335
488
  ...(minAction ? actionProblems(where, result.actions ?? [], minAction) : []),
336
489
  ...(boxes ? boxProblems(where, result.boxes ?? []) : []),
337
490
  ...(landmarks ? landmarkProblems(where, result.landmarks, landmarks) : []),
338
- ...(pictures && result.brokenPictures?.length ? [`${where}: broken picture ${result.brokenPictures.join(", ")}`] : []),
491
+ ...(pictures && result.brokenPictures?.length
492
+ ? [`${where}: broken picture ${result.brokenPictures.join(", ")}`]
493
+ : []),
339
494
  ...(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`] : []),
495
+ ...(hardColors && result.hardColors
496
+ ? [`${where}: ${result.hardColors} element(s) with a hard-coded colour in an inline style`]
497
+ : []),
341
498
  ...(maps ? mapProblems(where, result.maps ?? [], maps) : []),
342
499
  ...errorsOf(where, errors),
343
500
  ...layoutProblems(where, result),
@@ -361,7 +518,8 @@ function mapProblems(where, found, option) {
361
518
  const out = [];
362
519
  for (const m of found) {
363
520
  if (m.width < min || m.height < min) out.push(`${where}: the map ${m.name} is ${m.width}x${m.height} px (empty)`);
364
- else if (m.leaflet ? m.tiles === 0 : !m.canvas) out.push(`${where}: the map ${m.name} ${m.leaflet ? "has no loaded tile" : "has no canvas"}`);
521
+ else if (m.leaflet ? m.tiles === 0 : !m.canvas)
522
+ out.push(`${where}: the map ${m.name} ${m.leaflet ? "has no loaded tile" : "has no canvas"}`);
365
523
  }
366
524
  return out;
367
525
  }
@@ -373,15 +531,23 @@ function errorsOf(where, errors) {
373
531
  /** Overflow, elements past the edge, boxes that cover or cut one another, a token wrapped per character, the error state. */
374
532
  function layoutProblems(where, result) {
375
533
  const problems = [];
376
- if (result.scrollWidth > result.clientWidth) problems.push(`${where}: scrollWidth ${result.scrollWidth} > ${result.clientWidth} (${result.wide.join(", ")})`);
534
+ if (result.scrollWidth > result.clientWidth)
535
+ problems.push(`${where}: scrollWidth ${result.scrollWidth} > ${result.clientWidth} (${result.wide.join(", ")})`);
377
536
  else if (result.wide.length) problems.push(`${where}: elements past the edge (${result.wide.join(", ")})`);
378
- const lists = [[result.overflowing, "overflows its panel"], [result.overlaps, "covers a neighbouring section"], [result.cutColumns, "is cut off"]];
537
+ const lists = [
538
+ [result.overflowing, "overflows its panel"],
539
+ [result.overlaps, "covers a neighbouring section"],
540
+ [result.cutColumns, "is cut off"],
541
+ ];
379
542
  for (const [list, words] of lists) {
380
543
  if (list?.length) problems.push(`${where}: ${words} (${list.join(", ")})`);
381
544
  }
382
- if (result.wrapped?.length) problems.push(`${where}: a token wraps one character per line (${result.wrapped.join(", ")}) — truncate it`);
383
- if (result.segmentError) problems.push(`${where}: the page rendered its error state (segment-error), e.g. React #441`);
384
- if (result.chromeOverlaps?.length) problems.push(`${where}: the top chrome chromeOverlaps (${result.chromeOverlaps.slice(0, 2).join(", ")})`);
545
+ if (result.wrapped?.length)
546
+ problems.push(`${where}: a token wraps one character per line (${result.wrapped.join(", ")}) — truncate it`);
547
+ if (result.segmentError)
548
+ problems.push(`${where}: the page rendered its error state (segment-error), e.g. React #441`);
549
+ if (result.chromeOverlaps?.length)
550
+ problems.push(`${where}: the top chrome chromeOverlaps (${result.chromeOverlaps.slice(0, 2).join(", ")})`);
385
551
  if (result.clips?.length) problems.push(`${where}: content cut inside its box (${result.clips.join(", ")})`);
386
552
  return problems;
387
553
  }
@@ -416,7 +582,9 @@ function statusProblems(where, status) {
416
582
  /** 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
583
  function frameProblems(where, frame) {
418
584
  if (!frame || frame.top + frame.forced <= frame.viewport + 1) return [];
419
- return [`${where}: the frame forces a scroll (starts at ${frame.top} px and asks ${frame.forced} px of a ${frame.viewport} px screen)`];
585
+ return [
586
+ `${where}: the frame forces a scroll (starts at ${frame.top} px and asks ${frame.forced} px of a ${frame.viewport} px screen)`,
587
+ ];
420
588
  }
421
589
 
422
590
  /** A button-styled action under `min` px tall is hard to tap. */
@@ -432,7 +600,8 @@ function boxProblems(where, boxes) {
432
600
  function landmarkProblems(where, found, kind) {
433
601
  const problems = [];
434
602
  if (kind === "public") {
435
- if (found.footers !== 1) problems.push(`${where}: ${found.footers} page footers (one « A Krizaka product » footer expected)`);
603
+ if (found.footers !== 1)
604
+ problems.push(`${where}: ${found.footers} page footers (one « A Krizaka product » footer expected)`);
436
605
  else if (!found.footerNamesKrizaka) problems.push(`${where}: the footer does not name Krizaka`);
437
606
  } else if (found.footers > 1) problems.push(`${where}: ${found.footers} page footers (at most one)`);
438
607
  if (kind !== "auth" && !found.nav) problems.push(`${where}: no <nav> landmark`);
package/src/run.mjs CHANGED
@@ -9,7 +9,8 @@ export const WIDTHS = [360, 390, 430, 768, 1024, 1440];
9
9
  export const THEMES = ["dark", "light"];
10
10
 
11
11
  /** A blocked third-party resource is not a page error; a script error and a hydration mismatch are. `ignore`: the messages the app tolerates. */
12
- export const isPageError = (message, ignore = [/Failed to load resource/]) => message.type() === "error" && !ignore.some((re) => re.test(message.text()));
12
+ export const isPageError = (message, ignore = [/Failed to load resource/]) =>
13
+ message.type() === "error" && !ignore.some((re) => re.test(message.text()));
13
14
 
14
15
  async function chromiumOf(launch) {
15
16
  if (launch) return launch();
@@ -55,7 +56,10 @@ const nameOfRoute = (r) => r.name ?? r.url;
55
56
  */
56
57
  export async function routesFromSitemap(base, { first = ["/"], limit = 14, fetcher = fetch } = {}) {
57
58
  const origin = base.replace(/\/$/, "");
58
- const xml = await fetcher(`${origin}/sitemap.xml`).then((r) => (r.ok ? r.text() : null), () => null);
59
+ const xml = await fetcher(`${origin}/sitemap.xml`).then(
60
+ (r) => (r.ok ? r.text() : null),
61
+ () => null,
62
+ );
59
63
  if (xml === null) return null;
60
64
  const paths = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => new URL(m[1]).pathname || "/");
61
65
  return [...new Set([...first, ...paths])].slice(0, limit);
@@ -66,8 +70,18 @@ async function a11yOf(a11y) {
66
70
  if (!a11y) return null;
67
71
  const lib = await import("@krizaka/a11y-audit");
68
72
  const kinds = { public: { collector: new lib.A11yCollector(a11y.app), baseline: a11y.baseline } };
69
- if (a11y.session) kinds.session = { collector: new lib.A11yCollector(a11y.session.app), baseline: a11y.session.baseline, optional: true };
70
- return { lib, kinds, widths: a11y.widths ?? [390, 1440], log: a11y.log ?? ((line) => process.stdout.write(`${line}\n`)) };
73
+ if (a11y.session)
74
+ kinds.session = {
75
+ collector: new lib.A11yCollector(a11y.session.app),
76
+ baseline: a11y.session.baseline,
77
+ optional: true,
78
+ };
79
+ return {
80
+ lib,
81
+ kinds,
82
+ widths: a11y.widths ?? [390, 1440],
83
+ log: a11y.log ?? ((line) => process.stdout.write(`${line}\n`)),
84
+ };
71
85
  }
72
86
 
73
87
  /** The ratchet's verdict, per kind. A baseline not recorded yet (`optional`) is counted, not enforced, until `--a11y-init`. */
@@ -75,7 +89,9 @@ function a11yProblems(state) {
75
89
  const problems = [];
76
90
  for (const { collector, baseline, optional } of Object.values(state.kinds)) {
77
91
  if (optional && !existsSync(baseline) && !process.argv.includes("--a11y-init")) {
78
- state.log(`· a11y baseline missing: counted, not enforced (${collector.summary().join("; ") || "no violation"}); record it with --a11y-init`);
92
+ state.log(
93
+ `· a11y baseline missing: counted, not enforced (${collector.summary().join("; ") || "no violation"}); record it with --a11y-init`,
94
+ );
79
95
  continue;
80
96
  }
81
97
  const guard = state.lib.finish(collector, { baseline });
@@ -92,7 +108,10 @@ function statusOf(response, checks) {
92
108
  }
93
109
 
94
110
  /** One route at one width: load, settle, measure, screenshot. */
95
- async function measure(page, { base, spec, viewport, settleMs, prepare, checks, errors, where, shots, a11y, kind, theme }) {
111
+ async function measure(
112
+ page,
113
+ { base, spec, viewport, settleMs, prepare, checks, errors, where, shots, a11y, kind, theme },
114
+ ) {
96
115
  await resizeSettled(page, viewport);
97
116
  // « load » and the faces, not « networkidle »: router prefetches can keep the page from ever going idle.
98
117
  const response = await loadSettled(page, `${base}${spec.url}`);
@@ -131,7 +150,9 @@ async function auditPageRoutes(page, routes, o) {
131
150
  for (const spec of routes) {
132
151
  for (const w of o.widths) {
133
152
  const viewport = viewportOf(w, o.height);
134
- problems.push(...(await measureWithRetries(page, o, spec, viewport, `${nameOfRoute(spec)} @${viewport.width} ${o.theme}`)));
153
+ problems.push(
154
+ ...(await measureWithRetries(page, o, spec, viewport, `${nameOfRoute(spec)} @${viewport.width} ${o.theme}`)),
155
+ );
135
156
  }
136
157
  }
137
158
  return problems;
@@ -146,7 +167,11 @@ async function newPage(browser, o, extra = {}) {
146
167
 
147
168
  /** The signed-in pages of one theme: a context with the session (a stored state, or `signIn` on the context), the routes the session names, then the flagged ones. */
148
169
  async function auditSession(browser, session, flagged, o) {
149
- const { context, page, errors } = await newPage(browser, o, session.storageState ? { storageState: session.storageState } : {});
170
+ const { context, page, errors } = await newPage(
171
+ browser,
172
+ o,
173
+ session.storageState ? { storageState: session.storageState } : {},
174
+ );
150
175
  try {
151
176
  if (session.signIn) await session.signIn(context, { base: o.base, theme: o.theme, page });
152
177
  const own = session.routes ? await session.routes(page, { base: o.base, theme: o.theme }) : [];
@@ -175,7 +200,23 @@ async function auditSession(browser, session, flagged, o) {
175
200
  * @param {() => Promise<object>} [o.launch] returns a Playwright browser (default: chromium of playwright-core)
176
201
  * @returns {Promise<string[]>} the problems, empty when the pages are fine
177
202
  */
178
- export async function auditRoutes({ base, routes, widths = WIDTHS, themes = THEMES, height = 900, themeKey = "kz-theme", shots, settleMs = 300, checks = {}, ignoreConsole, attempts = 1, a11y, session, prepare, launch }) {
203
+ export async function auditRoutes({
204
+ base,
205
+ routes,
206
+ widths = WIDTHS,
207
+ themes = THEMES,
208
+ height = 900,
209
+ themeKey = "kz-theme",
210
+ shots,
211
+ settleMs = 300,
212
+ checks = {},
213
+ ignoreConsole,
214
+ attempts = 1,
215
+ a11y,
216
+ session,
217
+ prepare,
218
+ launch,
219
+ }) {
179
220
  const browser = await chromiumOf(launch);
180
221
  const state = await a11yOf(a11y);
181
222
  const specs = routes.map(specOf);
@@ -184,11 +225,25 @@ export async function auditRoutes({ base, routes, widths = WIDTHS, themes = THEM
184
225
  const problems = [];
185
226
  try {
186
227
  for (const theme of themes) {
187
- const o = { base, widths, height, theme, themeKey, shots, settleMs, checks, ignoreConsole, attempts, prepare, a11y: state };
228
+ const o = {
229
+ base,
230
+ widths,
231
+ height,
232
+ theme,
233
+ themeKey,
234
+ shots,
235
+ settleMs,
236
+ checks,
237
+ ignoreConsole,
238
+ attempts,
239
+ prepare,
240
+ a11y: state,
241
+ };
188
242
  const { context, page, errors } = await newPage(browser, o);
189
243
  problems.push(...(await auditPageRoutes(page, plain, { ...o, errors, kind: "public" })));
190
244
  await context.close();
191
- if (session && (session.routes || flagged.length)) problems.push(...(await auditSession(browser, session, flagged, o)));
245
+ if (session && (session.routes || flagged.length))
246
+ problems.push(...(await auditSession(browser, session, flagged, o)));
192
247
  }
193
248
  } finally {
194
249
  await browser.close();
package/src/self-test.mjs CHANGED
@@ -1,6 +1,27 @@
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 { 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, MAP_COLLAPSED, MAP_FINE, MAP_HIDDEN, MAP_NO_CANVAS, SECTIONS_OVERLAP, SMALL_ACTION, TOKEN_IN_STRETCHED_ROW, TOKEN_WRAPPED } from "./fixtures.mjs";
3
+ import {
4
+ BOX_TOO_WIDE,
5
+ BROKEN_PARTS,
6
+ CARD_OVER_NEXT,
7
+ CHROME_BAD,
8
+ CHROME_FINE,
9
+ COLUMN_CUT,
10
+ EMPTY_MAIN,
11
+ FINE,
12
+ FOLDED_SECTION,
13
+ FRAME_FITS,
14
+ FRAME_FORCES_SCROLL,
15
+ HIDDEN_CONTENT,
16
+ MAP_COLLAPSED,
17
+ MAP_FINE,
18
+ MAP_HIDDEN,
19
+ MAP_NO_CANVAS,
20
+ SECTIONS_OVERLAP,
21
+ SMALL_ACTION,
22
+ TOKEN_IN_STRETCHED_ROW,
23
+ TOKEN_WRAPPED,
24
+ } from "./fixtures.mjs";
4
25
  import { auditPage, problemsOf } from "./page-audit.mjs";
5
26
  import { auditContext } from "./settle.mjs";
6
27
 
@@ -30,7 +51,17 @@ const FINES = [
30
51
  ["a short token in a row a taller neighbour stretches", TOKEN_IN_STRETCHED_ROW],
31
52
  ];
32
53
  /** Fixtures that must stay clean even with every opt-in check on. */
33
- const ALL = { emptyMain: true, frame: true, minAction: 36, boxes: true, pictures: true, emptyControls: true, hardColors: true, landmarks: "public", maps: true };
54
+ const ALL = {
55
+ emptyMain: true,
56
+ frame: true,
57
+ minAction: 36,
58
+ boxes: true,
59
+ pictures: true,
60
+ emptyControls: true,
61
+ hardColors: true,
62
+ landmarks: "public",
63
+ maps: true,
64
+ };
34
65
  const FINES_STRICT = [
35
66
  ["a frame that fits its screen", FRAME_FITS, { frame: true, emptyMain: false }],
36
67
  ["the family chrome", CHROME_FINE, ALL],
@@ -57,7 +88,8 @@ export async function selfTest({ launch } = {}) {
57
88
  const page = await (await auditContext(browser, { viewport: { width: 1280, height: 800 } })).newPage();
58
89
  for (const [name, html, pattern, checks] of EXPECT) {
59
90
  const problems = await problemsOn(page, html, checks);
60
- if (!problems.some((p) => pattern.test(p))) failures.push(`the audit does not see ${String(name)}: ${problems.join(" | ") || "no problem reported"}`);
91
+ if (!problems.some((p) => pattern.test(p)))
92
+ failures.push(`the audit does not see ${String(name)}: ${problems.join(" | ") || "no problem reported"}`);
61
93
  }
62
94
  for (const [name, html] of FINES) failures.push(...(await reported(page, name, html)));
63
95
  await page.setViewportSize({ width: 1280, height: 700 });
package/src/settle.mjs CHANGED
@@ -28,7 +28,11 @@ export async function auditContext(browser, options) {
28
28
 
29
29
  /** Resolves once no view transition is running (none starts under reduced motion; this makes the wait explicit). */
30
30
  export const settled = (page) =>
31
- page.waitForFunction(() => !document.activeViewTransition && !document.documentElement.matches(":active-view-transition"), null, { timeout: 10_000 });
31
+ page.waitForFunction(
32
+ () => !document.activeViewTransition && !document.documentElement.matches(":active-view-transition"),
33
+ null,
34
+ { timeout: 10_000 },
35
+ );
32
36
 
33
37
  /**
34
38
  * Loads a page, then waits for the faces and for the transitions: what is read afterwards is final. Returns the response.
package/src/visual.d.ts CHANGED
@@ -1,6 +1,9 @@
1
- import type { Browser,Page } from "playwright-core";
1
+ import type { Browser, Page } from "playwright-core";
2
2
 
3
- export interface Size { width: number; height: number }
3
+ export interface Size {
4
+ width: number;
5
+ height: number;
6
+ }
4
7
  export const VIEWPORTS: Readonly<{ desktop: Size; phone: Size }>;
5
8
  export const VISUAL_THEMES: readonly ["light", "dark"];
6
9
  export const FROZEN_TIME: string;
@@ -8,19 +11,43 @@ export const VISUAL_LOCALE: string;
8
11
  export const VISUAL_TIME_ZONE: string;
9
12
  export const FREEZE_CSS: string;
10
13
  export interface Journey {
11
- name: string; path: string | ((page: Page, o: { origin: string }) => Promise<string>); session?: boolean; mask?: string[]; viewports?: string[];
14
+ name: string;
15
+ path: string | ((page: Page, o: { origin: string }) => Promise<string>);
16
+ session?: boolean;
17
+ mask?: string[];
18
+ viewports?: string[];
12
19
  ready?: string | ((page: Page) => Promise<unknown>);
13
20
  firstPaint?: true | { hold: string[] };
14
21
  }
15
- export interface Cell { viewport: string; size: Size; theme: string; snapshot: string }
16
- export function visualMatrix(journey: Journey, options?: { viewports?: Record<string, Size>; themes?: readonly string[] }): Cell[];
17
- export function visualContextOptions(o: { size: Size; theme: string; javaScript?: boolean; storageState?: unknown }): Record<string, unknown>;
22
+ export interface Cell {
23
+ viewport: string;
24
+ size: Size;
25
+ theme: string;
26
+ snapshot: string;
27
+ }
28
+ export function visualMatrix(
29
+ journey: Journey,
30
+ options?: { viewports?: Record<string, Size>; themes?: readonly string[] },
31
+ ): Cell[];
32
+ export function visualContextOptions(o: {
33
+ size: Size;
34
+ theme: string;
35
+ javaScript?: boolean;
36
+ storageState?: unknown;
37
+ }): Record<string, unknown>;
18
38
  export function initVisualPage(o: { theme: string; themeKey: string }): void;
19
39
  export function prepareVisualPage(page: Page, o?: { theme?: string; themeKey?: string; now?: string }): Promise<void>;
20
40
  export function settleForShot(page: Page, o?: { javaScript?: boolean }): Promise<void>;
21
41
  export function shotOptions(o?: { mask?: string[]; page?: Page }): Record<string, unknown>;
22
42
  export function defineJourneyTests(o: {
23
- test: unknown; expect: unknown; journeys: Journey[]; signIn: (page: Page, o: { base?: string }) => Promise<void>;
24
- base?: string; viewports?: Record<string, Size>; themes?: readonly string[]; themeKey?: string; now?: string;
43
+ test: unknown;
44
+ expect: unknown;
45
+ journeys: Journey[];
46
+ signIn: (page: Page, o: { base?: string }) => Promise<void>;
47
+ base?: string;
48
+ viewports?: Record<string, Size>;
49
+ themes?: readonly string[];
50
+ themeKey?: string;
51
+ now?: string;
25
52
  }): void;
26
53
  export type { Browser };
package/src/visual.mjs CHANGED
@@ -31,7 +31,12 @@ export function visualMatrix(journey, { viewports = VIEWPORTS, themes = VISUAL_T
31
31
  if (!viewports[viewport]) throw new Error(`unknown viewport « ${viewport} » in journey « ${journey.name} »`);
32
32
  for (const theme of themes) {
33
33
  const suffix = journey.firstPaint ? "-first-paint" : "";
34
- cells.push({ viewport, size: viewports[viewport], theme, snapshot: `${journey.name}${suffix}-${viewport}-${theme}.png` });
34
+ cells.push({
35
+ viewport,
36
+ size: viewports[viewport],
37
+ theme,
38
+ snapshot: `${journey.name}${suffix}-${viewport}-${theme}.png`,
39
+ });
35
40
  }
36
41
  }
37
42
  return cells;
@@ -53,7 +58,8 @@ export function visualContextOptions({ size, theme, javaScript = true, storageSt
53
58
  }
54
59
 
55
60
  /** The style every capture carries: nothing moves, nothing blinks. */
56
- export const FREEZE_CSS = "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}";
61
+ export const FREEZE_CSS =
62
+ "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}";
57
63
 
58
64
  /** Runs before the page's own scripts: the theme the app reads, no View Transition. */
59
65
  export const initVisualPage = ({ theme, themeKey }) => {
@@ -74,8 +80,10 @@ export async function prepareVisualPage(page, { theme, themeKey = "kz-theme", no
74
80
 
75
81
  /** Waits until what is on screen is final: the faces, the images, the transitions. */
76
82
  export async function settleForShot(page, { javaScript = true } = {}) {
77
- await page.addStyleTag({ content: FREEZE_CSS });
83
+ // With JavaScript off, Playwright cannot run a script in the page: addStyleTag never returns (the first paint hung for the whole test timeout).
84
+ // Nothing moves without scripts, and `animations: "disabled"` of the screenshot still freezes CSS animations.
78
85
  if (!javaScript) return;
86
+ await page.addStyleTag({ content: FREEZE_CSS });
79
87
  await page.evaluate(() => document.fonts.ready);
80
88
  await settled(page);
81
89
  await page.evaluate(() =>
@@ -104,11 +112,23 @@ export function shotOptions({ mask = [], page } = {}) {
104
112
  * now: the instant of the browser clock (default FROZEN_TIME); an app whose seed stamps rows with the day of the run passes that moment.
105
113
  * signIn(page, { base }): signs the persona in; its cookies are kept and reused by every signed-in capture of the worker.
106
114
  */
107
- export function defineJourneyTests({ test, expect, journeys, signIn, base, viewports = VIEWPORTS, themes = VISUAL_THEMES, themeKey = "kz-theme", now = FROZEN_TIME }) {
115
+ export function defineJourneyTests({
116
+ test,
117
+ expect,
118
+ journeys,
119
+ signIn,
120
+ base,
121
+ viewports = VIEWPORTS,
122
+ themes = VISUAL_THEMES,
123
+ themeKey = "kz-theme",
124
+ now = FROZEN_TIME,
125
+ }) {
108
126
  let session;
109
127
  const sessionState = (browser) => {
110
128
  session ??= (async () => {
111
- const context = await browser.newContext(visualContextOptions({ size: viewports.desktop ?? Object.values(viewports)[0], theme: "light" }));
129
+ const context = await browser.newContext(
130
+ visualContextOptions({ size: viewports.desktop ?? Object.values(viewports)[0], theme: "light" }),
131
+ );
112
132
  const page = await context.newPage();
113
133
  await signIn(page, { base });
114
134
  const state = await context.storageState();
@@ -119,17 +139,24 @@ export function defineJourneyTests({ test, expect, journeys, signIn, base, viewp
119
139
  };
120
140
  for (const journey of journeys) {
121
141
  for (const cell of visualMatrix(journey, { viewports, themes })) {
122
- test(`${journey.name} · ${cell.viewport} · ${cell.theme}${journey.firstPaint ? " · first paint" : ""}`, async ({ browser, baseURL }) => {
142
+ test(`${journey.name} · ${cell.viewport} · ${cell.theme}${journey.firstPaint ? " · first paint" : ""}`, async ({
143
+ browser,
144
+ baseURL,
145
+ }) => {
123
146
  const origin = base ?? baseURL;
124
147
  const javaScript = !(journey.firstPaint === true);
125
148
  const storageState = journey.session ? await sessionState(browser) : undefined;
126
- const context = await browser.newContext(visualContextOptions({ size: cell.size, theme: cell.theme, javaScript, storageState }));
149
+ const context = await browser.newContext(
150
+ visualContextOptions({ size: cell.size, theme: cell.theme, javaScript, storageState }),
151
+ );
127
152
  try {
128
153
  const page = await context.newPage();
129
154
  await prepareVisualPage(page, { theme: cell.theme, themeKey, now });
130
155
  for (const pattern of journey.firstPaint?.hold ?? []) await page.route(pattern, () => undefined);
131
156
  const path = typeof journey.path === "function" ? await journey.path(page, { origin }) : journey.path;
132
- await page.goto(new URL(path, origin).toString(), { waitUntil: journey.firstPaint ? "domcontentloaded" : "load" });
157
+ await page.goto(new URL(path, origin).toString(), {
158
+ waitUntil: journey.firstPaint ? "domcontentloaded" : "load",
159
+ });
133
160
  if (typeof journey.ready === "string") await page.locator(journey.ready).first().waitFor();
134
161
  else if (journey.ready) await journey.ready(page);
135
162
  await settleForShot(page, { javaScript });