@krizaka/page-audit 0.5.1 → 0.7.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 +12 -1
- package/package.json +6 -2
- package/src/cli.mjs +18 -2
- package/src/fixtures.mjs +38 -10
- package/src/index.d.ts +50 -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 +264 -46
- package/src/run.mjs +66 -11
- package/src/self-test.mjs +45 -3
- package/src/settle.mjs +5 -1
- package/src/visual.d.ts +35 -8
- package/src/visual.mjs +32 -7
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Opt-in through `checks` (nothing changes for a repo that does not ask): `status`
|
|
|
25
25
|
(the signed-in frame, `[data-app-frame-slot]`, forces a scroll), `minAction` (a button-styled action under N px), `boxes` (a landmark
|
|
26
26
|
wider than its box), `landmarks` (`"public"` one footer naming Krizaka and a nav, `"app"`, `"auth"`), `pictures`, `emptyControls`,
|
|
27
27
|
`hardColors`, `maps` (an empty map, akinaza#488: a GL / Leaflet / `[data-kz-map]` box under 120 px on a side, a GL map with no canvas,
|
|
28
|
-
a Leaflet map with no loaded tile; `maps: { required: true }` also fails a page that should draw one but has none). Console noise the app tolerates: `ignoreConsole: [/…/]` (`[]` counts every error); `attempts` retries a route.
|
|
28
|
+
a Leaflet map with no loaded tile; `maps: { required: true }` also fails a page that should draw one but has none), `controlOverlaps` (two interactive elements that share pixels on screen, one not inside the other — akinaza#214: the composer's title over a row). Console noise the app tolerates: `ignoreConsole: [/…/]` (`[]` counts every error); `attempts` retries a route.
|
|
29
29
|
|
|
30
30
|
```js
|
|
31
31
|
const routes = await routesFromSitemap(base, { first: ["/", "/docs"], limit: 14 }); // a crawl as the route source (null: nothing answers)
|
|
@@ -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.7.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.19.0"
|
|
65
69
|
},
|
|
66
70
|
"engines": {
|
|
67
71
|
"node": ">=22"
|
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,54 @@ 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>`);
|
|
79
|
+
|
|
80
|
+
/** Two controls on top of each other: a title button over a row's button (akinaza#214, the composer's « Style » over « Réservation »). */
|
|
81
|
+
export const CONTROLS_OVERLAP = page(
|
|
82
|
+
`<button style="display:block;width:200px;height:40px">Réservation</button><button style="display:block;width:200px;height:40px;margin-top:-20px;position:relative">Style</button>`,
|
|
83
|
+
);
|
|
84
|
+
/** Controls that sit side by side, a control inside a label, a hidden checkbox and a control scrolled out of its list: none overlaps. */
|
|
85
|
+
export const CONTROLS_FINE = page(
|
|
86
|
+
`<div style="display:flex;gap:8px"><button style="width:100px;height:40px">A</button><button style="width:100px;height:40px">B</button></div><label style="display:block"><input type="checkbox"> Remember</label><div style="height:60px;overflow:auto"><button style="display:block;width:100px;height:40px">1</button><button style="display:block;width:100px;height:40px">2</button><button style="display:block;width:100px;height:40px">3</button></div><button style="display:block;width:100px;height:40px;margin-top:8px">After</button>`,
|
|
87
|
+
);
|
package/src/index.d.ts
CHANGED
|
@@ -5,35 +5,76 @@ 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 };
|
|
24
|
+
/** Two interactive elements (buttons, links, inputs, radios…) that share pixels on screen, one not inside the other: one of them cannot be clicked. The box is cut by clipping and scrolling ancestors. */
|
|
25
|
+
controlOverlaps?: boolean;
|
|
14
26
|
}
|
|
15
27
|
export function problemsOf(where: string, result: Record<string, unknown>, checks?: ProblemChecks): string[];
|
|
16
28
|
export function auditOptions<T extends object>(options?: T): T & { reducedMotion: "reduce" };
|
|
17
29
|
export function withoutViewTransitions(): void;
|
|
18
|
-
export function auditContext(
|
|
30
|
+
export function auditContext(
|
|
31
|
+
browser: { newContext(o?: object): Promise<BrowserContext> },
|
|
32
|
+
options?: object,
|
|
33
|
+
): Promise<BrowserContext>;
|
|
19
34
|
export function settled(page: Page): Promise<unknown>;
|
|
20
35
|
export function loadSettled(page: Page, url: string, options?: object): Promise<BrowserContext>;
|
|
21
36
|
export function resizeSettled(page: Page, size: { width: number; height: number }): Promise<void>;
|
|
22
37
|
export const WIDTHS: number[];
|
|
23
38
|
export const THEMES: string[];
|
|
24
39
|
export function isPageError(message: { type(): string; text(): string }, ignore?: RegExp[]): boolean;
|
|
25
|
-
export function routesFromSitemap(
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
export function routesFromSitemap(
|
|
41
|
+
base: string,
|
|
42
|
+
options?: { first?: string[]; limit?: number; fetcher?: typeof fetch },
|
|
43
|
+
): Promise<string[] | null>;
|
|
44
|
+
export interface RouteSpec {
|
|
45
|
+
url: string;
|
|
46
|
+
name?: string;
|
|
47
|
+
chrome?: Chrome;
|
|
48
|
+
session?: boolean;
|
|
49
|
+
}
|
|
50
|
+
export interface A11yOptions {
|
|
51
|
+
app: string;
|
|
52
|
+
baseline: string;
|
|
53
|
+
widths?: number[];
|
|
54
|
+
log?: (line: string) => void;
|
|
55
|
+
session?: { app: string; baseline: string };
|
|
56
|
+
}
|
|
28
57
|
export interface SessionOptions {
|
|
29
58
|
storageState?: object | string;
|
|
30
59
|
signIn?: (context: BrowserContext, o: { base: string; theme: string; page: Page }) => Promise<void>;
|
|
31
60
|
routes?: (page: Page, o: { base: string; theme: string }) => Promise<Array<string | RouteSpec>>;
|
|
32
61
|
}
|
|
33
62
|
export interface AuditRoutesOptions {
|
|
34
|
-
base: string;
|
|
35
|
-
|
|
36
|
-
|
|
63
|
+
base: string;
|
|
64
|
+
routes: Array<string | RouteSpec>;
|
|
65
|
+
widths?: Array<number | { width: number; height?: number }>;
|
|
66
|
+
themes?: string[];
|
|
67
|
+
height?: number;
|
|
68
|
+
themeKey?: string;
|
|
69
|
+
shots?: string;
|
|
70
|
+
settleMs?: number;
|
|
71
|
+
ignoreConsole?: RegExp[];
|
|
72
|
+
attempts?: number;
|
|
73
|
+
a11y?: A11yOptions;
|
|
74
|
+
session?: SessionOptions;
|
|
75
|
+
checks?: Omit<ProblemChecks, "errors">;
|
|
76
|
+
prepare?: (page: Page, route: string) => Promise<void>;
|
|
77
|
+
launch?: () => Promise<BrowserContext>;
|
|
37
78
|
}
|
|
38
79
|
export function auditRoutes(options: AuditRoutesOptions): Promise<string[]>;
|
|
39
80
|
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
|
+
}
|