@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 +11 -0
- package/package.json +7 -3
- package/src/cli.mjs +18 -2
- package/src/fixtures.mjs +29 -10
- package/src/index.d.ts +48 -9
- package/src/index.mjs +1 -1
- package/src/journey.d.ts +54 -0
- package/src/journey.mjs +162 -0
- package/src/page-audit.mjs +208 -39
- package/src/run.mjs +66 -11
- package/src/self-test.mjs +35 -3
- package/src/settle.mjs +5 -1
- package/src/visual.d.ts +35 -8
- package/src/visual.mjs +35 -8
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.
|
|
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.
|
|
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")
|
|
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({
|
|
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 = "") =>
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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[];
|
|
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;
|
|
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(
|
|
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(
|
|
26
|
-
|
|
27
|
-
|
|
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;
|
|
35
|
-
|
|
36
|
-
|
|
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";
|
package/src/journey.d.ts
ADDED
|
@@ -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 };
|
package/src/journey.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/page-audit.mjs
CHANGED
|
@@ -10,8 +10,12 @@ const nameOf = (el) => {
|
|
|
10
10
|
const id = el.dataset?.testid;
|
|
11
11
|
return el.tagName.toLowerCase() + (id ? "[" + id + "]" : "") + "." + className(el, 2);
|
|
12
12
|
};
|
|
13
|
-
const scrolls = (el) =>
|
|
14
|
-
|
|
13
|
+
const scrolls = (el) =>
|
|
14
|
+
["auto", "scroll"].includes(getComputedStyle(el).overflowY) ||
|
|
15
|
+
["auto", "scroll"].includes(getComputedStyle(el).overflowX);
|
|
16
|
+
const meets = (ra, rb) =>
|
|
17
|
+
Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left) > 2 &&
|
|
18
|
+
Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top) > 2;
|
|
15
19
|
const apart = (a, b) => !a.contains(b) && !b.contains(a);
|
|
16
20
|
|
|
17
21
|
/** An element that pokes out only matters when nothing clips it: decorative layers (aria-hidden) and anything inside a clipping or scrolling ancestor are meant to extend. */
|
|
@@ -27,7 +31,12 @@ function wideOf(doc) {
|
|
|
27
31
|
return [...document.querySelectorAll("body *")]
|
|
28
32
|
.filter((el) => {
|
|
29
33
|
const r = el.getBoundingClientRect();
|
|
30
|
-
return
|
|
34
|
+
return (
|
|
35
|
+
r.width > 0 &&
|
|
36
|
+
getComputedStyle(el).visibility !== "hidden" &&
|
|
37
|
+
(r.right > doc.clientWidth + 1 || r.left < -1) &&
|
|
38
|
+
!clipped(el)
|
|
39
|
+
);
|
|
31
40
|
})
|
|
32
41
|
.slice(0, 3)
|
|
33
42
|
.map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(" ").slice(0, 3).join(".")}`);
|
|
@@ -36,12 +45,21 @@ function wideOf(doc) {
|
|
|
36
45
|
/** A button's accessible name: its text, aria-label, aria-labelledby, title — or a <label for> pointing at it (a checkbox, a switch). */
|
|
37
46
|
function named(b) {
|
|
38
47
|
const labelled = !!b.id && !!document.querySelector(`label[for="${CSS.escape(b.id)}"]`);
|
|
39
|
-
return !!(
|
|
48
|
+
return !!(
|
|
49
|
+
(b.textContent || "").trim() ||
|
|
50
|
+
b.getAttribute("aria-label") ||
|
|
51
|
+
b.getAttribute("aria-labelledby") ||
|
|
52
|
+
b.title ||
|
|
53
|
+
labelled
|
|
54
|
+
);
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
function unnamedOf() {
|
|
43
58
|
const unnamed = [...document.querySelectorAll("button")].filter((b) => !named(b));
|
|
44
|
-
return {
|
|
59
|
+
return {
|
|
60
|
+
unnamedButtons: unnamed.length,
|
|
61
|
+
unnamedSamples: unnamed.slice(0, 2).map((b) => b.outerHTML.slice(0, 160).replace(/\s+/g, " ")),
|
|
62
|
+
};
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
function headerActionOf(doc) {
|
|
@@ -68,7 +86,14 @@ function keptBetween(el, panel) {
|
|
|
68
86
|
}
|
|
69
87
|
|
|
70
88
|
function notDrawn(el, r) {
|
|
71
|
-
return
|
|
89
|
+
return (
|
|
90
|
+
r.width === 0 ||
|
|
91
|
+
r.height === 0 ||
|
|
92
|
+
getComputedStyle(el).position === "fixed" ||
|
|
93
|
+
!!el.closest(ARIA_HIDDEN) ||
|
|
94
|
+
!!el.closest("[data-radix-popper-content-wrapper]") ||
|
|
95
|
+
folded(el)
|
|
96
|
+
);
|
|
72
97
|
}
|
|
73
98
|
|
|
74
99
|
function runsOut(el, panel, box) {
|
|
@@ -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)
|
|
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))
|
|
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
|
|
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(
|
|
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))
|
|
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
|
|
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(
|
|
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(
|
|
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")]
|
|
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(
|
|
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(
|
|
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(
|
|
232
|
-
|
|
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)
|
|
254
|
-
|
|
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(
|
|
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(
|
|
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 {
|
|
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(
|
|
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) =>
|
|
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 = [
|
|
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)
|
|
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(
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
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 = [
|
|
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)
|
|
383
|
-
|
|
384
|
-
if (result.
|
|
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 [
|
|
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)
|
|
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/]) =>
|
|
12
|
+
export const isPageError = (message, ignore = [/Failed to load resource/]) =>
|
|
13
|
+
message.type() === "error" && !ignore.some((re) => re.test(message.text()));
|
|
13
14
|
|
|
14
15
|
async function chromiumOf(launch) {
|
|
15
16
|
if (launch) return launch();
|
|
@@ -55,7 +56,10 @@ const nameOfRoute = (r) => r.name ?? r.url;
|
|
|
55
56
|
*/
|
|
56
57
|
export async function routesFromSitemap(base, { first = ["/"], limit = 14, fetcher = fetch } = {}) {
|
|
57
58
|
const origin = base.replace(/\/$/, "");
|
|
58
|
-
const xml = await fetcher(`${origin}/sitemap.xml`).then(
|
|
59
|
+
const xml = await fetcher(`${origin}/sitemap.xml`).then(
|
|
60
|
+
(r) => (r.ok ? r.text() : null),
|
|
61
|
+
() => null,
|
|
62
|
+
);
|
|
59
63
|
if (xml === null) return null;
|
|
60
64
|
const paths = [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => new URL(m[1]).pathname || "/");
|
|
61
65
|
return [...new Set([...first, ...paths])].slice(0, limit);
|
|
@@ -66,8 +70,18 @@ async function a11yOf(a11y) {
|
|
|
66
70
|
if (!a11y) return null;
|
|
67
71
|
const lib = await import("@krizaka/a11y-audit");
|
|
68
72
|
const kinds = { public: { collector: new lib.A11yCollector(a11y.app), baseline: a11y.baseline } };
|
|
69
|
-
if (a11y.session)
|
|
70
|
-
|
|
73
|
+
if (a11y.session)
|
|
74
|
+
kinds.session = {
|
|
75
|
+
collector: new lib.A11yCollector(a11y.session.app),
|
|
76
|
+
baseline: a11y.session.baseline,
|
|
77
|
+
optional: true,
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
lib,
|
|
81
|
+
kinds,
|
|
82
|
+
widths: a11y.widths ?? [390, 1440],
|
|
83
|
+
log: a11y.log ?? ((line) => process.stdout.write(`${line}\n`)),
|
|
84
|
+
};
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
/** The ratchet's verdict, per kind. A baseline not recorded yet (`optional`) is counted, not enforced, until `--a11y-init`. */
|
|
@@ -75,7 +89,9 @@ function a11yProblems(state) {
|
|
|
75
89
|
const problems = [];
|
|
76
90
|
for (const { collector, baseline, optional } of Object.values(state.kinds)) {
|
|
77
91
|
if (optional && !existsSync(baseline) && !process.argv.includes("--a11y-init")) {
|
|
78
|
-
state.log(
|
|
92
|
+
state.log(
|
|
93
|
+
`· a11y baseline missing: counted, not enforced (${collector.summary().join("; ") || "no violation"}); record it with --a11y-init`,
|
|
94
|
+
);
|
|
79
95
|
continue;
|
|
80
96
|
}
|
|
81
97
|
const guard = state.lib.finish(collector, { baseline });
|
|
@@ -92,7 +108,10 @@ function statusOf(response, checks) {
|
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
/** One route at one width: load, settle, measure, screenshot. */
|
|
95
|
-
async function measure(
|
|
111
|
+
async function measure(
|
|
112
|
+
page,
|
|
113
|
+
{ base, spec, viewport, settleMs, prepare, checks, errors, where, shots, a11y, kind, theme },
|
|
114
|
+
) {
|
|
96
115
|
await resizeSettled(page, viewport);
|
|
97
116
|
// « load » and the faces, not « networkidle »: router prefetches can keep the page from ever going idle.
|
|
98
117
|
const response = await loadSettled(page, `${base}${spec.url}`);
|
|
@@ -131,7 +150,9 @@ async function auditPageRoutes(page, routes, o) {
|
|
|
131
150
|
for (const spec of routes) {
|
|
132
151
|
for (const w of o.widths) {
|
|
133
152
|
const viewport = viewportOf(w, o.height);
|
|
134
|
-
problems.push(
|
|
153
|
+
problems.push(
|
|
154
|
+
...(await measureWithRetries(page, o, spec, viewport, `${nameOfRoute(spec)} @${viewport.width} ${o.theme}`)),
|
|
155
|
+
);
|
|
135
156
|
}
|
|
136
157
|
}
|
|
137
158
|
return problems;
|
|
@@ -146,7 +167,11 @@ async function newPage(browser, o, extra = {}) {
|
|
|
146
167
|
|
|
147
168
|
/** The signed-in pages of one theme: a context with the session (a stored state, or `signIn` on the context), the routes the session names, then the flagged ones. */
|
|
148
169
|
async function auditSession(browser, session, flagged, o) {
|
|
149
|
-
const { context, page, errors } = await newPage(
|
|
170
|
+
const { context, page, errors } = await newPage(
|
|
171
|
+
browser,
|
|
172
|
+
o,
|
|
173
|
+
session.storageState ? { storageState: session.storageState } : {},
|
|
174
|
+
);
|
|
150
175
|
try {
|
|
151
176
|
if (session.signIn) await session.signIn(context, { base: o.base, theme: o.theme, page });
|
|
152
177
|
const own = session.routes ? await session.routes(page, { base: o.base, theme: o.theme }) : [];
|
|
@@ -175,7 +200,23 @@ async function auditSession(browser, session, flagged, o) {
|
|
|
175
200
|
* @param {() => Promise<object>} [o.launch] returns a Playwright browser (default: chromium of playwright-core)
|
|
176
201
|
* @returns {Promise<string[]>} the problems, empty when the pages are fine
|
|
177
202
|
*/
|
|
178
|
-
export async function auditRoutes({
|
|
203
|
+
export async function auditRoutes({
|
|
204
|
+
base,
|
|
205
|
+
routes,
|
|
206
|
+
widths = WIDTHS,
|
|
207
|
+
themes = THEMES,
|
|
208
|
+
height = 900,
|
|
209
|
+
themeKey = "kz-theme",
|
|
210
|
+
shots,
|
|
211
|
+
settleMs = 300,
|
|
212
|
+
checks = {},
|
|
213
|
+
ignoreConsole,
|
|
214
|
+
attempts = 1,
|
|
215
|
+
a11y,
|
|
216
|
+
session,
|
|
217
|
+
prepare,
|
|
218
|
+
launch,
|
|
219
|
+
}) {
|
|
179
220
|
const browser = await chromiumOf(launch);
|
|
180
221
|
const state = await a11yOf(a11y);
|
|
181
222
|
const specs = routes.map(specOf);
|
|
@@ -184,11 +225,25 @@ export async function auditRoutes({ base, routes, widths = WIDTHS, themes = THEM
|
|
|
184
225
|
const problems = [];
|
|
185
226
|
try {
|
|
186
227
|
for (const theme of themes) {
|
|
187
|
-
const o = {
|
|
228
|
+
const o = {
|
|
229
|
+
base,
|
|
230
|
+
widths,
|
|
231
|
+
height,
|
|
232
|
+
theme,
|
|
233
|
+
themeKey,
|
|
234
|
+
shots,
|
|
235
|
+
settleMs,
|
|
236
|
+
checks,
|
|
237
|
+
ignoreConsole,
|
|
238
|
+
attempts,
|
|
239
|
+
prepare,
|
|
240
|
+
a11y: state,
|
|
241
|
+
};
|
|
188
242
|
const { context, page, errors } = await newPage(browser, o);
|
|
189
243
|
problems.push(...(await auditPageRoutes(page, plain, { ...o, errors, kind: "public" })));
|
|
190
244
|
await context.close();
|
|
191
|
-
if (session && (session.routes || flagged.length))
|
|
245
|
+
if (session && (session.routes || flagged.length))
|
|
246
|
+
problems.push(...(await auditSession(browser, session, flagged, o)));
|
|
192
247
|
}
|
|
193
248
|
} finally {
|
|
194
249
|
await browser.close();
|
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 {
|
|
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 = {
|
|
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)))
|
|
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(
|
|
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 {
|
|
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;
|
|
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 {
|
|
16
|
-
|
|
17
|
-
|
|
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;
|
|
24
|
-
|
|
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({
|
|
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 =
|
|
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
|
-
|
|
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({
|
|
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(
|
|
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 ({
|
|
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(
|
|
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(), {
|
|
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 });
|