@krizaka/page-audit 0.4.0 → 0.5.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 +23 -0
- package/package.json +6 -2
- package/src/visual.d.ts +26 -0
- package/src/visual.mjs +143 -0
package/README.md
CHANGED
|
@@ -40,3 +40,26 @@ await auditRoutes({
|
|
|
40
40
|
|
|
41
41
|
`a11y` runs axe through `@krizaka/a11y-audit` (an optional peer) and ratchets with the repo's own baseline file; `--a11y-update` and
|
|
42
42
|
`--a11y-init` work as there. The CLI takes `--sitemap`, `--status`, `--empty-main`, `--min-action`, `--a11y app:baseline.json`.
|
|
43
|
+
|
|
44
|
+
## Visual regression of the journeys (0.4)
|
|
45
|
+
|
|
46
|
+
`@krizaka/page-audit/visual` (krizaka/.github#375) compares screenshots of an app's main journeys against Linux baselines, with `@playwright/test`
|
|
47
|
+
as the runner. It freezes what moves (clock, motion, caret, View Transition, locale, time zone, fonts) and leaves the app its routes, its persona and its masks.
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
import { test, expect } from "@playwright/test";
|
|
51
|
+
import { defineJourneyTests } from "@krizaka/page-audit/visual";
|
|
52
|
+
defineJourneyTests({
|
|
53
|
+
test, expect,
|
|
54
|
+
signIn: async (page) => { /* the seeded persona signs in through the form */ },
|
|
55
|
+
journeys: [
|
|
56
|
+
{ name: "landing", path: "/fr" },
|
|
57
|
+
{ name: "home", path: "/fr/app", session: true, mask: ["[data-live]"] },
|
|
58
|
+
{ name: "home", path: "/fr/app", session: true, firstPaint: true, viewports: ["phone"] }, // the HTML the server sent
|
|
59
|
+
{ name: "home", path: "/fr/app", session: true, firstPaint: { hold: ["**/api/me"] } }, // hydrated, account query pending
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Baselines are taken in the official Playwright container by the shared workflows of krizaka/.github (`visual-regression.yml` to compare,
|
|
65
|
+
`update-visual-baselines.yml` to commit them on a branch); never on a Mac.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krizaka/page-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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,7 +34,11 @@
|
|
|
34
34
|
"import": "./src/settle.mjs"
|
|
35
35
|
},
|
|
36
36
|
"./fixtures": "./src/fixtures.mjs",
|
|
37
|
-
"./package.json": "./package.json"
|
|
37
|
+
"./package.json": "./package.json",
|
|
38
|
+
"./visual": {
|
|
39
|
+
"types": "./src/visual.d.ts",
|
|
40
|
+
"import": "./src/visual.mjs"
|
|
41
|
+
}
|
|
38
42
|
},
|
|
39
43
|
"files": [
|
|
40
44
|
"src",
|
package/src/visual.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Browser,Page } from "playwright-core";
|
|
2
|
+
|
|
3
|
+
export interface Size { width: number; height: number }
|
|
4
|
+
export const VIEWPORTS: Readonly<{ desktop: Size; phone: Size }>;
|
|
5
|
+
export const VISUAL_THEMES: readonly ["light", "dark"];
|
|
6
|
+
export const FROZEN_TIME: string;
|
|
7
|
+
export const VISUAL_LOCALE: string;
|
|
8
|
+
export const VISUAL_TIME_ZONE: string;
|
|
9
|
+
export const FREEZE_CSS: string;
|
|
10
|
+
export interface Journey {
|
|
11
|
+
name: string; path: string | ((page: Page, o: { origin: string }) => Promise<string>); session?: boolean; mask?: string[]; viewports?: string[];
|
|
12
|
+
ready?: string | ((page: Page) => Promise<unknown>);
|
|
13
|
+
firstPaint?: true | { hold: string[] };
|
|
14
|
+
}
|
|
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>;
|
|
18
|
+
export function initVisualPage(o: { theme: string; themeKey: string }): void;
|
|
19
|
+
export function prepareVisualPage(page: Page, o?: { theme?: string; themeKey?: string; now?: string }): Promise<void>;
|
|
20
|
+
export function settleForShot(page: Page, o?: { javaScript?: boolean }): Promise<void>;
|
|
21
|
+
export function shotOptions(o?: { mask?: string[]; page?: Page }): Record<string, unknown>;
|
|
22
|
+
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;
|
|
25
|
+
}): void;
|
|
26
|
+
export type { Browser };
|
package/src/visual.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Visual regression of the main journeys, one source for every app (krizaka/.github#375).
|
|
2
|
+
//
|
|
3
|
+
// What it fixes so that two runs of the same build give the same pixels: a frozen clock, reduced motion, no caret, no
|
|
4
|
+
// View Transition, the fonts loaded, a fixed locale and time zone, the app's theme key set before the page runs, and the
|
|
5
|
+
// live values masked. What it leaves to the app: its routes, how its persona signs in, what is live on its pages.
|
|
6
|
+
// The baselines are Linux ones, taken in the official Playwright container (the `visual-regression` workflow of
|
|
7
|
+
// krizaka/.github); the helper never compares against a baseline taken on a Mac.
|
|
8
|
+
//
|
|
9
|
+
// import { test, expect } from "@playwright/test";
|
|
10
|
+
// import { defineJourneyTests } from "@krizaka/page-audit/visual";
|
|
11
|
+
// defineJourneyTests({ test, expect, journeys: [{ name: "landing", path: "/fr" }, …], signIn });
|
|
12
|
+
|
|
13
|
+
import { settled, withoutViewTransitions } from "./settle.mjs";
|
|
14
|
+
|
|
15
|
+
/** The two sizes of every journey: a laptop and a phone. */
|
|
16
|
+
export const VIEWPORTS = Object.freeze({
|
|
17
|
+
desktop: { width: 1280, height: 800 },
|
|
18
|
+
phone: { width: 390, height: 844 },
|
|
19
|
+
});
|
|
20
|
+
export const VISUAL_THEMES = Object.freeze(["light", "dark"]);
|
|
21
|
+
/** The clock of every capture (a Thursday, in winter: no daylight-saving edge). */
|
|
22
|
+
export const FROZEN_TIME = "2026-01-15T15:00:00.000Z";
|
|
23
|
+
export const VISUAL_LOCALE = "fr-CA";
|
|
24
|
+
export const VISUAL_TIME_ZONE = "America/Toronto";
|
|
25
|
+
|
|
26
|
+
/** The viewport × theme cells of a journey, with the snapshot name of each (`<journey>-<viewport>-<theme>.png`). */
|
|
27
|
+
export function visualMatrix(journey, { viewports = VIEWPORTS, themes = VISUAL_THEMES } = {}) {
|
|
28
|
+
const only = journey.viewports ?? Object.keys(viewports);
|
|
29
|
+
const cells = [];
|
|
30
|
+
for (const viewport of only) {
|
|
31
|
+
if (!viewports[viewport]) throw new Error(`unknown viewport « ${viewport} » in journey « ${journey.name} »`);
|
|
32
|
+
for (const theme of themes) {
|
|
33
|
+
const suffix = journey.firstPaint ? "-first-paint" : "";
|
|
34
|
+
cells.push({ viewport, size: viewports[viewport], theme, snapshot: `${journey.name}${suffix}-${viewport}-${theme}.png` });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return cells;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The options of a browser context for a capture. `javaScript: false` shows the page as the server sent it. */
|
|
41
|
+
export function visualContextOptions({ size, theme, javaScript = true, storageState }) {
|
|
42
|
+
return {
|
|
43
|
+
viewport: size,
|
|
44
|
+
colorScheme: theme,
|
|
45
|
+
reducedMotion: "reduce",
|
|
46
|
+
locale: VISUAL_LOCALE,
|
|
47
|
+
timezoneId: VISUAL_TIME_ZONE,
|
|
48
|
+
deviceScaleFactor: 1,
|
|
49
|
+
javaScriptEnabled: javaScript,
|
|
50
|
+
serviceWorkers: "block",
|
|
51
|
+
...(storageState ? { storageState } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 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}";
|
|
57
|
+
|
|
58
|
+
/** Runs before the page's own scripts: the theme the app reads, no View Transition. */
|
|
59
|
+
export const initVisualPage = ({ theme, themeKey }) => {
|
|
60
|
+
try {
|
|
61
|
+
localStorage.setItem(themeKey, theme);
|
|
62
|
+
} catch {
|
|
63
|
+
/* storage can be blocked: the colour scheme of the context still applies */
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Prepares a page: fixed time, the theme key, no View Transition. Call before the first `goto`. */
|
|
68
|
+
export async function prepareVisualPage(page, { theme, themeKey = "kz-theme", now = FROZEN_TIME } = {}) {
|
|
69
|
+
// Date is fixed, timers still run: the app finishes loading.
|
|
70
|
+
await page.context().clock.setFixedTime(new Date(now));
|
|
71
|
+
await page.addInitScript(initVisualPage, { theme, themeKey });
|
|
72
|
+
await page.addInitScript(withoutViewTransitions);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Waits until what is on screen is final: the faces, the images, the transitions. */
|
|
76
|
+
export async function settleForShot(page, { javaScript = true } = {}) {
|
|
77
|
+
await page.addStyleTag({ content: FREEZE_CSS });
|
|
78
|
+
if (!javaScript) return;
|
|
79
|
+
await page.evaluate(() => document.fonts.ready);
|
|
80
|
+
await settled(page);
|
|
81
|
+
await page.evaluate(() =>
|
|
82
|
+
Promise.all([...document.images].filter((img) => !img.complete).map((img) => img.decode().catch(() => undefined))),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The options of `toHaveScreenshot`: a small tolerance for anti-aliasing, never for a missing element. */
|
|
87
|
+
export function shotOptions({ mask = [], page } = {}) {
|
|
88
|
+
return {
|
|
89
|
+
animations: "disabled",
|
|
90
|
+
caret: "hide",
|
|
91
|
+
scale: "css",
|
|
92
|
+
maxDiffPixelRatio: 0.002,
|
|
93
|
+
...(page && mask.length ? { mask: mask.map((selector) => page.locator(selector)) } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Registers one test per journey × viewport × theme.
|
|
99
|
+
*
|
|
100
|
+
* journey: { name, path (a string, or `(page, { origin }) => Promise<string>` that finds it, e.g. the first document), session?: boolean, mask?: string[] (selectors of live values), ready?: selector | (page) => Promise,
|
|
101
|
+
* viewports?: string[], firstPaint?: true | { hold: string[] } }
|
|
102
|
+
* firstPaint: the page before it is alive. `true` turns JavaScript off (the HTML the server sent); `{ hold }` keeps JavaScript
|
|
103
|
+
* but never answers the listed URL patterns (the account query, say), so the account mark is captured while its query is pending.
|
|
104
|
+
* 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
|
+
* signIn(page, { base }): signs the persona in; its cookies are kept and reused by every signed-in capture of the worker.
|
|
106
|
+
*/
|
|
107
|
+
export function defineJourneyTests({ test, expect, journeys, signIn, base, viewports = VIEWPORTS, themes = VISUAL_THEMES, themeKey = "kz-theme", now = FROZEN_TIME }) {
|
|
108
|
+
let session;
|
|
109
|
+
const sessionState = (browser) => {
|
|
110
|
+
session ??= (async () => {
|
|
111
|
+
const context = await browser.newContext(visualContextOptions({ size: viewports.desktop ?? Object.values(viewports)[0], theme: "light" }));
|
|
112
|
+
const page = await context.newPage();
|
|
113
|
+
await signIn(page, { base });
|
|
114
|
+
const state = await context.storageState();
|
|
115
|
+
await context.close();
|
|
116
|
+
return state;
|
|
117
|
+
})();
|
|
118
|
+
return session;
|
|
119
|
+
};
|
|
120
|
+
for (const journey of journeys) {
|
|
121
|
+
for (const cell of visualMatrix(journey, { viewports, themes })) {
|
|
122
|
+
test(`${journey.name} · ${cell.viewport} · ${cell.theme}${journey.firstPaint ? " · first paint" : ""}`, async ({ browser, baseURL }) => {
|
|
123
|
+
const origin = base ?? baseURL;
|
|
124
|
+
const javaScript = !(journey.firstPaint === true);
|
|
125
|
+
const storageState = journey.session ? await sessionState(browser) : undefined;
|
|
126
|
+
const context = await browser.newContext(visualContextOptions({ size: cell.size, theme: cell.theme, javaScript, storageState }));
|
|
127
|
+
try {
|
|
128
|
+
const page = await context.newPage();
|
|
129
|
+
await prepareVisualPage(page, { theme: cell.theme, themeKey, now });
|
|
130
|
+
for (const pattern of journey.firstPaint?.hold ?? []) await page.route(pattern, () => undefined);
|
|
131
|
+
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" });
|
|
133
|
+
if (typeof journey.ready === "string") await page.locator(journey.ready).first().waitFor();
|
|
134
|
+
else if (journey.ready) await journey.ready(page);
|
|
135
|
+
await settleForShot(page, { javaScript });
|
|
136
|
+
await expect(page).toHaveScreenshot(cell.snapshot, shotOptions({ mask: journey.mask, page }));
|
|
137
|
+
} finally {
|
|
138
|
+
await context.close();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|