@mostra/verify 0.0.0-placeholder → 0.1.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/LICENSE +202 -0
- package/README.md +5 -1
- package/dist/index.d.ts +89 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +48 -0
- package/dist/main.js.map +1 -0
- package/dist/runner-DCmzgY2w.js +964 -0
- package/dist/runner-DCmzgY2w.js.map +1 -0
- package/package.json +57 -4
- package/src/index.ts +19 -0
- package/src/main.ts +64 -0
- package/src/network.ts +161 -0
- package/src/observe.ts +159 -0
- package/src/recorder.ts +97 -0
- package/src/runner.ts +457 -0
- package/src/smoke.ts +400 -0
package/src/recorder.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* The recorder every frame of a verify render starts with (Playwright's init script): it keeps the
|
|
4
|
+
* page's own console errors and warnings, uncaught errors and unhandled rejections, each with the
|
|
5
|
+
* diagnostic codes along its `cause` chain, so the runner can tell the artifact's faults from those
|
|
6
|
+
* verify's synthetic refusal caused (D138 item 2). An unhandled rejection whose reason is that
|
|
7
|
+
* refusal is dropped, as the browser would never meet it for a viewer.
|
|
8
|
+
*
|
|
9
|
+
* It runs in the page, so it is one self-contained function: nothing from this module's scope.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** One thing the page reported. */
|
|
13
|
+
export interface RecordedEntry {
|
|
14
|
+
/** Its place in the page's order of entries, from 0. */
|
|
15
|
+
readonly seq: number;
|
|
16
|
+
/**
|
|
17
|
+
* `error` and `warn` (the console), `uncaught` (an error event), `rejection`, or `caught`
|
|
18
|
+
* (an error a React error boundary caught, reported by the page's root).
|
|
19
|
+
*/
|
|
20
|
+
readonly kind: "error" | "warn" | "uncaught" | "rejection" | "caught";
|
|
21
|
+
/** The message, or the error's name and message, as the page gave it. */
|
|
22
|
+
readonly text: string;
|
|
23
|
+
/** The `code`s of the value and its `cause` chain: `MostraError`s carry one. */
|
|
24
|
+
readonly codes: readonly string[];
|
|
25
|
+
/** Set on a rejection the recorder kept from the browser: verify's own refusal. */
|
|
26
|
+
readonly dropped?: true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The global the recorder keeps its entries on. */
|
|
30
|
+
export const RECORDER_GLOBAL = "__mostraVerifyLog";
|
|
31
|
+
|
|
32
|
+
/** The recorder, as Playwright installs it in every frame before the page's scripts run. */
|
|
33
|
+
export function recorder(): void {
|
|
34
|
+
const scope = globalThis as unknown as Record<string, unknown>;
|
|
35
|
+
if (scope["__mostraVerifyLog"]) return;
|
|
36
|
+
const entries: unknown[] = [];
|
|
37
|
+
let seq = 0;
|
|
38
|
+
const codesOf = (value: unknown): string[] => {
|
|
39
|
+
const codes: string[] = [];
|
|
40
|
+
let current = value as { code?: unknown; cause?: unknown } | null | undefined;
|
|
41
|
+
for (let depth = 0; depth < 8 && current && typeof current === "object"; depth += 1) {
|
|
42
|
+
if (typeof current.code === "string") codes.push(current.code);
|
|
43
|
+
current = current.cause as typeof current;
|
|
44
|
+
}
|
|
45
|
+
return codes;
|
|
46
|
+
};
|
|
47
|
+
const describe = (value: unknown): string => {
|
|
48
|
+
if (value instanceof Error) return value.stack ?? `${value.name}: ${value.message}`;
|
|
49
|
+
if (typeof value === "string") return value;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.stringify(value) ?? String(value);
|
|
52
|
+
} catch {
|
|
53
|
+
return String(value);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const record = (entry: Record<string, unknown>): void => {
|
|
57
|
+
if (entries.length < 500) entries.push({ seq: ++seq, ...entry });
|
|
58
|
+
};
|
|
59
|
+
for (const level of ["error", "warn"] as const) {
|
|
60
|
+
const original = console[level].bind(console);
|
|
61
|
+
console[level] = (...args: unknown[]) => {
|
|
62
|
+
record({
|
|
63
|
+
kind: level,
|
|
64
|
+
text: args.map(describe).join(" ").slice(0, 2000),
|
|
65
|
+
codes: args.flatMap(codesOf),
|
|
66
|
+
});
|
|
67
|
+
original(...args);
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
addEventListener("error", (event) => {
|
|
71
|
+
const error = (event as ErrorEvent).error as unknown;
|
|
72
|
+
record({
|
|
73
|
+
kind: "uncaught",
|
|
74
|
+
text: (error === undefined ? (event as ErrorEvent).message : describe(error)).slice(0, 2000),
|
|
75
|
+
codes: codesOf(error),
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
addEventListener("unhandledrejection", (event) => {
|
|
79
|
+
const reason = (event as PromiseRejectionEvent).reason as unknown;
|
|
80
|
+
const codes = codesOf(reason);
|
|
81
|
+
const dropped = codes.includes("MOSTRA_VERIFY_ACTION_SKIPPED");
|
|
82
|
+
if (dropped) event.preventDefault();
|
|
83
|
+
record({
|
|
84
|
+
kind: "rejection",
|
|
85
|
+
text: describe(reason).slice(0, 2000),
|
|
86
|
+
codes,
|
|
87
|
+
...(dropped ? { dropped: true } : {}),
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
// A page in the verify target reports what its error boundaries caught here (VERIFY_REPORT in
|
|
91
|
+
// @mostra/protocol; the recorder runs in the page, so the name is written out).
|
|
92
|
+
Object.defineProperty(scope, "__mostraVerifyReport", {
|
|
93
|
+
value: (error: unknown) =>
|
|
94
|
+
record({ kind: "caught", text: describe(error).slice(0, 2000), codes: codesOf(error) }),
|
|
95
|
+
});
|
|
96
|
+
Object.defineProperty(scope, "__mostraVerifyLog", { value: entries });
|
|
97
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* The verify runner (ARCHITECTURE §1, ROUND-3 §9, D138): one warm headless Chromium that renders
|
|
4
|
+
* capability links the supervisor's sandbox minted, each in a fresh browser context, and reports
|
|
5
|
+
* what the page did. It decides what a page's behavior means for the verdict (D72): definite
|
|
6
|
+
* failures are errors, observations warnings or hints, and faults that follow from verify's own
|
|
7
|
+
* refusal of an action are attributed to it. The supervisor decides everything else.
|
|
8
|
+
*/
|
|
9
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
type CompareRequest,
|
|
14
|
+
type CompareResult,
|
|
15
|
+
createDiagnostic,
|
|
16
|
+
type Diagnostic,
|
|
17
|
+
type ProbedField,
|
|
18
|
+
type RenderRequest,
|
|
19
|
+
type RenderResult,
|
|
20
|
+
RpcError,
|
|
21
|
+
VERIFY_PROBE,
|
|
22
|
+
} from "@mostra/protocol";
|
|
23
|
+
import pixelmatch from "pixelmatch";
|
|
24
|
+
import {
|
|
25
|
+
type Browser,
|
|
26
|
+
type BrowserContext,
|
|
27
|
+
chromium,
|
|
28
|
+
type Frame,
|
|
29
|
+
type Page,
|
|
30
|
+
} from "playwright-core";
|
|
31
|
+
import { PNG } from "pngjs";
|
|
32
|
+
import { watchNetwork } from "./network.ts";
|
|
33
|
+
import { dedupe, judge, recorded, settle } from "./observe.ts";
|
|
34
|
+
import { recorder } from "./recorder.ts";
|
|
35
|
+
import { runSmoke } from "./smoke.ts";
|
|
36
|
+
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
|
|
39
|
+
/** How long the runner keeps its browser without work (ROUND-3 §9): 10 minutes. */
|
|
40
|
+
export const RUNNER_IDLE_MS: number = 10 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
/** The longest a page may take to report `mostra/ready` before it is judged never ready. */
|
|
43
|
+
const READY_WAIT_MS = 15_000;
|
|
44
|
+
/**
|
|
45
|
+
* How long after its deadline a render is abandoned: its browser context is closed, which ends a
|
|
46
|
+
* page that blocks its own main thread without touching other runs' pages (Opus review finding 5).
|
|
47
|
+
*/
|
|
48
|
+
const RENDER_ABANDON_MS = 1_000;
|
|
49
|
+
/** The time the smoke test leaves before the run's deadline, for the regression stage after it. */
|
|
50
|
+
const SMOKE_RESERVE_MS = 5_000;
|
|
51
|
+
|
|
52
|
+
/** Options for {@link createRunner}. */
|
|
53
|
+
export interface RunnerOptions {
|
|
54
|
+
/** Called when the runner has been idle for `idleMs`: the process exits then. */
|
|
55
|
+
readonly onIdle?: () => void;
|
|
56
|
+
/** How long without work counts as idle; {@link RUNNER_IDLE_MS} by default. */
|
|
57
|
+
readonly idleMs?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The runner of one process. */
|
|
61
|
+
export interface Runner {
|
|
62
|
+
/** Renders one capability link in a fresh browser context and reports what the page did. */
|
|
63
|
+
render(request: RenderRequest): Promise<RenderResult>;
|
|
64
|
+
/** Compares two screenshots and writes the difference. */
|
|
65
|
+
compare(request: CompareRequest): Promise<CompareResult>;
|
|
66
|
+
/** Closes the browser. */
|
|
67
|
+
close(): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether a launch failed because the browser isn't installed. */
|
|
71
|
+
function browserMissing(error: unknown): boolean {
|
|
72
|
+
return /Executable doesn't exist|browserType\.launch: .*(not found|ENOENT)/i.test(
|
|
73
|
+
String((error as Error)?.message ?? error),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Creates the runner; its browser starts on the first render. */
|
|
78
|
+
export function createRunner(options: RunnerOptions = {}): Runner {
|
|
79
|
+
let browser: Promise<Browser> | undefined;
|
|
80
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
81
|
+
let busy = 0;
|
|
82
|
+
let axeSource: string | undefined;
|
|
83
|
+
|
|
84
|
+
const touch = (): void => {
|
|
85
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
86
|
+
idleTimer = undefined;
|
|
87
|
+
if (busy > 0 || !options.onIdle) return;
|
|
88
|
+
idleTimer = setTimeout(() => options.onIdle?.(), options.idleMs ?? RUNNER_IDLE_MS);
|
|
89
|
+
idleTimer.unref?.();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const launch = (): Promise<Browser> => {
|
|
93
|
+
browser ??= chromium.launch({ headless: true }).catch((error: unknown) => {
|
|
94
|
+
browser = undefined;
|
|
95
|
+
if (browserMissing(error)) {
|
|
96
|
+
throw new RpcError(
|
|
97
|
+
createDiagnostic("MOSTRA_ENV_BROWSER_MISSING", {
|
|
98
|
+
message:
|
|
99
|
+
"verify's browser, Playwright's Chromium headless shell, isn't installed for this runtime.",
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
throw error;
|
|
104
|
+
});
|
|
105
|
+
return browser;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const work = async <T>(task: () => Promise<T>): Promise<T> => {
|
|
109
|
+
busy += 1;
|
|
110
|
+
touch();
|
|
111
|
+
try {
|
|
112
|
+
return await task();
|
|
113
|
+
} finally {
|
|
114
|
+
busy -= 1;
|
|
115
|
+
touch();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
render: (request) =>
|
|
121
|
+
work(async () => {
|
|
122
|
+
axeSource ??= await readFile(require.resolve("axe-core/axe.min.js"), "utf8");
|
|
123
|
+
return renderBounded(await launch(), request, axeSource);
|
|
124
|
+
}),
|
|
125
|
+
compare: (request) => work(() => compareImages(request)),
|
|
126
|
+
async close() {
|
|
127
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
128
|
+
const open = browser;
|
|
129
|
+
browser = undefined;
|
|
130
|
+
if (open) await (await open.catch(() => undefined))?.close();
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The artifact's frame: the shell's only child frame. */
|
|
136
|
+
function artifactFrame(page: Page): Frame | undefined {
|
|
137
|
+
return page.frames().find((frame) => frame.parentFrame() === page.mainFrame());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What the frame shows: whether any element has a visible box of nonzero area (D138 item 3). */
|
|
141
|
+
function showsSomething(): boolean {
|
|
142
|
+
// Visible through its ancestors too: `checkVisibility` with these options is false when the
|
|
143
|
+
// element has no box (an ancestor is `display: none`), is `visibility: hidden`, or it or any
|
|
144
|
+
// ancestor has opacity 0 (CSSOM View; GPT review finding 5: a wrapper at opacity 0 hides
|
|
145
|
+
// children whose own opacity is 1).
|
|
146
|
+
const visible = (element: Element): boolean => {
|
|
147
|
+
const rect = element.getBoundingClientRect();
|
|
148
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
149
|
+
return element.checkVisibility({ opacityProperty: true, visibilityProperty: true });
|
|
150
|
+
};
|
|
151
|
+
for (const element of document.querySelectorAll("img, svg, canvas, video")) {
|
|
152
|
+
if (visible(element)) return true;
|
|
153
|
+
}
|
|
154
|
+
if (!document.body) return false;
|
|
155
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
156
|
+
while (walker.nextNode()) {
|
|
157
|
+
const node = walker.currentNode;
|
|
158
|
+
if (!node.textContent?.trim() || !node.parentElement || !visible(node.parentElement)) continue;
|
|
159
|
+
const range = document.createRange();
|
|
160
|
+
range.selectNodeContents(node);
|
|
161
|
+
const rect = range.getBoundingClientRect();
|
|
162
|
+
if (rect.width > 0 && rect.height > 0) return true;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether a PNG is one uniform color. */
|
|
168
|
+
export function uniform(png: Buffer): boolean {
|
|
169
|
+
const image = PNG.sync.read(png);
|
|
170
|
+
const { data } = image;
|
|
171
|
+
for (let offset = 4; offset < data.length; offset += 4) {
|
|
172
|
+
if (
|
|
173
|
+
data[offset] !== data[0] ||
|
|
174
|
+
data[offset + 1] !== data[1] ||
|
|
175
|
+
data[offset + 2] !== data[2] ||
|
|
176
|
+
data[offset + 3] !== data[3]
|
|
177
|
+
)
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Waits for `work`, and gives up on it `graceMs` past `deadline`: `abandon()` is called (for a
|
|
185
|
+
* render, closing its context, which ends a page whose code blocks its main thread) and `late` is
|
|
186
|
+
* the answer, also when `work` then fails because of the abandon. A failure before that is
|
|
187
|
+
* `work`'s own and is thrown (Opus review finding 5).
|
|
188
|
+
*/
|
|
189
|
+
export async function bounded<T>(
|
|
190
|
+
work: Promise<T>,
|
|
191
|
+
options: {
|
|
192
|
+
readonly deadline: number;
|
|
193
|
+
readonly late: T;
|
|
194
|
+
readonly abandon: () => Promise<unknown> | undefined;
|
|
195
|
+
readonly graceMs?: number;
|
|
196
|
+
},
|
|
197
|
+
): Promise<T> {
|
|
198
|
+
let abandoning = false;
|
|
199
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
200
|
+
const abandoned = new Promise<T>((done) => {
|
|
201
|
+
timer = setTimeout(
|
|
202
|
+
() => {
|
|
203
|
+
abandoning = true;
|
|
204
|
+
const closing = Promise.resolve(options.abandon()).catch(() => undefined);
|
|
205
|
+
void Promise.race([closing, new Promise((wait) => setTimeout(wait, 2000))]).then(() =>
|
|
206
|
+
done(options.late),
|
|
207
|
+
);
|
|
208
|
+
},
|
|
209
|
+
Math.max(0, options.deadline - Date.now()) + (options.graceMs ?? RENDER_ABANDON_MS),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
const guarded = work.catch((error: unknown) => {
|
|
213
|
+
if (abandoning) return options.late;
|
|
214
|
+
throw error;
|
|
215
|
+
});
|
|
216
|
+
try {
|
|
217
|
+
return await Promise.race([guarded, abandoned]);
|
|
218
|
+
} finally {
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Renders one capability link, and abandons it when it is still running past its deadline: its
|
|
225
|
+
* context is closed (the page's renderer with it), and the render reports it ran out of time.
|
|
226
|
+
*/
|
|
227
|
+
async function renderBounded(
|
|
228
|
+
browser: Browser,
|
|
229
|
+
request: RenderRequest,
|
|
230
|
+
axeSource: string,
|
|
231
|
+
): Promise<RenderResult> {
|
|
232
|
+
const created: { context?: BrowserContext } = {};
|
|
233
|
+
return bounded(renderPage(browser, request, axeSource, created), {
|
|
234
|
+
deadline: request.deadline,
|
|
235
|
+
late: { ready: false, findings: [], timedOut: true, timingsMs: {} },
|
|
236
|
+
abandon: () => created.context?.close(),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Renders one capability link and reports what the page did. */
|
|
241
|
+
async function renderPage(
|
|
242
|
+
browser: Browser,
|
|
243
|
+
request: RenderRequest,
|
|
244
|
+
axeSource: string,
|
|
245
|
+
created: { context?: BrowserContext },
|
|
246
|
+
): Promise<RenderResult> {
|
|
247
|
+
const started = Date.now();
|
|
248
|
+
const timings: Record<string, number> = {};
|
|
249
|
+
const findings: Diagnostic[] = [];
|
|
250
|
+
const viewport = request.viewport.name;
|
|
251
|
+
const location = { viewport };
|
|
252
|
+
let timedOut = false;
|
|
253
|
+
const left = (): number => request.deadline - Date.now();
|
|
254
|
+
const context: BrowserContext = await browser.newContext({
|
|
255
|
+
viewport: { width: request.viewport.width, height: request.viewport.height },
|
|
256
|
+
deviceScaleFactor: 1,
|
|
257
|
+
reducedMotion: "reduce",
|
|
258
|
+
});
|
|
259
|
+
created.context = context;
|
|
260
|
+
try {
|
|
261
|
+
// The same instant for every render of a run (D138 item 4): `Date` is fixed, timers run.
|
|
262
|
+
await context.clock.setFixedTime(request.clockMs);
|
|
263
|
+
await context.addInitScript(recorder);
|
|
264
|
+
const page = await context.newPage();
|
|
265
|
+
// A popup (window.open, target=_blank) is closed at once: verify follows no link out.
|
|
266
|
+
context.on("page", (other) => {
|
|
267
|
+
if (other !== page) void other.close().catch(() => undefined);
|
|
268
|
+
});
|
|
269
|
+
const network = watchNetwork(page, () => artifactFrame(page), viewport);
|
|
270
|
+
|
|
271
|
+
await page.goto(request.url, { waitUntil: "commit", timeout: Math.max(1000, left()) });
|
|
272
|
+
let ready = false;
|
|
273
|
+
try {
|
|
274
|
+
await page.waitForFunction(
|
|
275
|
+
() => {
|
|
276
|
+
const state = document.documentElement.dataset["shell"];
|
|
277
|
+
return state === "ready" || state === "error";
|
|
278
|
+
},
|
|
279
|
+
undefined,
|
|
280
|
+
{ timeout: Math.max(1, Math.min(READY_WAIT_MS, left())) },
|
|
281
|
+
);
|
|
282
|
+
ready = (await page.evaluate(() => document.documentElement.dataset["shell"])) === "ready";
|
|
283
|
+
} catch {
|
|
284
|
+
ready = false;
|
|
285
|
+
timedOut = left() <= 0;
|
|
286
|
+
}
|
|
287
|
+
timings["ready"] = Date.now() - started;
|
|
288
|
+
const frame = artifactFrame(page);
|
|
289
|
+
if (!ready || !frame) {
|
|
290
|
+
const shellError = await page
|
|
291
|
+
.evaluate(() => document.documentElement.dataset["shellError"] ?? "")
|
|
292
|
+
.catch(() => "");
|
|
293
|
+
findings.push(
|
|
294
|
+
createDiagnostic("MOSTRA_RUNTIME_NOT_READY", {
|
|
295
|
+
message: `The page didn't report it was ready${shellError ? ` (the shell reported ${shellError})` : ""}.`,
|
|
296
|
+
location,
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
if (frame)
|
|
300
|
+
findings.push(
|
|
301
|
+
...judge(await recorded(frame), { kind: "load" }, network.skippedSince(0), viewport),
|
|
302
|
+
);
|
|
303
|
+
findings.push(...network.findings());
|
|
304
|
+
return { ready: false, findings: dedupe(findings), timedOut, timingsMs: timings };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
await frame.evaluate(() => document.fonts.ready.then(() => true)).catch(() => false);
|
|
308
|
+
await settle(frame, network, request.deadline);
|
|
309
|
+
timings["settled"] = Date.now() - started;
|
|
310
|
+
|
|
311
|
+
let screenshot: RenderResult["screenshot"];
|
|
312
|
+
let shot: Buffer | undefined;
|
|
313
|
+
if (request.collect.screenshot) {
|
|
314
|
+
const path = join(request.outDir, `${request.name}.png`);
|
|
315
|
+
shot = await page.screenshot({ path, animations: "disabled", caret: "hide" });
|
|
316
|
+
screenshot = { path, width: request.viewport.width, height: request.viewport.height };
|
|
317
|
+
timings["screenshot"] = Date.now() - started;
|
|
318
|
+
}
|
|
319
|
+
// Blank only when nothing has a visible box and the picture is one color (D138 item 3).
|
|
320
|
+
const shows = await frame.evaluate(showsSomething).catch(() => true);
|
|
321
|
+
if (!shows && shot && uniform(shot)) {
|
|
322
|
+
findings.push(
|
|
323
|
+
createDiagnostic("MOSTRA_RUNTIME_BLANK", {
|
|
324
|
+
message:
|
|
325
|
+
"The page reported ready but shows nothing: no visible text, image, SVG, canvas or video, and its screenshot is one color.",
|
|
326
|
+
location,
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let aria: RenderResult["aria"];
|
|
332
|
+
if (request.collect.aria) {
|
|
333
|
+
const text = await frame
|
|
334
|
+
.locator("body")
|
|
335
|
+
.ariaSnapshot({ timeout: Math.max(1000, Math.min(10_000, left())) })
|
|
336
|
+
.catch(() => {
|
|
337
|
+
if (left() <= 0) timedOut = true;
|
|
338
|
+
return "";
|
|
339
|
+
});
|
|
340
|
+
// The supervisor writes it, through the instance's redactor (Opus review finding 8).
|
|
341
|
+
const path = join(request.outDir, `${request.name}.aria.yml`);
|
|
342
|
+
aria = { path, text };
|
|
343
|
+
timings["aria"] = Date.now() - started;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let fields: readonly ProbedField[] | undefined;
|
|
347
|
+
if (request.collect.fields) {
|
|
348
|
+
fields = await frame
|
|
349
|
+
.evaluate(
|
|
350
|
+
(name) =>
|
|
351
|
+
((globalThis as unknown as Record<string, { fields?: () => unknown } | undefined>)[
|
|
352
|
+
name
|
|
353
|
+
]?.fields?.() ?? []) as ProbedField[],
|
|
354
|
+
VERIFY_PROBE,
|
|
355
|
+
)
|
|
356
|
+
.catch(() => []);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (request.collect.axe && left() <= 0) timedOut = true;
|
|
360
|
+
if (request.collect.axe && left() > 0) {
|
|
361
|
+
findings.push(...(await runAxe(frame, axeSource, viewport)));
|
|
362
|
+
timings["axe"] = Date.now() - started;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Faults while loading, judged before the smoke test adds its own.
|
|
366
|
+
const loadEntries = await recorded(frame);
|
|
367
|
+
findings.push(...judge(loadEntries, { kind: "load" }, network.skippedSince(0), viewport));
|
|
368
|
+
|
|
369
|
+
let smoke: RenderResult["smoke"];
|
|
370
|
+
if (request.collect.smoke && left() > 0) {
|
|
371
|
+
const outcome = await runSmoke({
|
|
372
|
+
page,
|
|
373
|
+
frame,
|
|
374
|
+
network,
|
|
375
|
+
viewport,
|
|
376
|
+
from: loadEntries.length,
|
|
377
|
+
maxInteractions: request.collect.smoke.maxInteractions,
|
|
378
|
+
// The smoke test stops between interactions at its budget, which leaves the regression
|
|
379
|
+
// stage its time: stopping there is the budget's hint, never a timeout (Opus review
|
|
380
|
+
// finding 6).
|
|
381
|
+
deadline: Math.min(
|
|
382
|
+
request.deadline - SMOKE_RESERVE_MS,
|
|
383
|
+
Date.now() + request.collect.smoke.maxMs,
|
|
384
|
+
),
|
|
385
|
+
});
|
|
386
|
+
findings.push(...outcome.findings);
|
|
387
|
+
smoke = {
|
|
388
|
+
interactions: outcome.interactions,
|
|
389
|
+
stateChanges: outcome.stateChanges,
|
|
390
|
+
skipped: network.skippedSince(0),
|
|
391
|
+
budget: outcome.budget,
|
|
392
|
+
};
|
|
393
|
+
timings["smoke"] = Date.now() - started;
|
|
394
|
+
}
|
|
395
|
+
findings.push(...network.findings());
|
|
396
|
+
return {
|
|
397
|
+
ready: true,
|
|
398
|
+
findings: dedupe(findings),
|
|
399
|
+
...(screenshot ? { screenshot } : {}),
|
|
400
|
+
...(aria ? { aria } : {}),
|
|
401
|
+
...(fields ? { fields } : {}),
|
|
402
|
+
...(smoke ? { smoke } : {}),
|
|
403
|
+
timedOut,
|
|
404
|
+
timingsMs: timings,
|
|
405
|
+
};
|
|
406
|
+
} finally {
|
|
407
|
+
await context.close().catch(() => undefined);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
interface AxeResult {
|
|
412
|
+
readonly violations: readonly {
|
|
413
|
+
readonly id: string;
|
|
414
|
+
readonly impact?: string | null;
|
|
415
|
+
readonly help: string;
|
|
416
|
+
readonly nodes: readonly { readonly target: readonly unknown[] }[];
|
|
417
|
+
}[];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* axe-core's violations in the frame (axe's `incomplete` results aren't reported): critical and
|
|
422
|
+
* serious are warnings, moderate and minor hints (D138 item 3). Injected unmodified.
|
|
423
|
+
*/
|
|
424
|
+
async function runAxe(frame: Frame, source: string, viewport: string): Promise<Diagnostic[]> {
|
|
425
|
+
try {
|
|
426
|
+
await frame.evaluate(source);
|
|
427
|
+
const result = (await frame.evaluate(() =>
|
|
428
|
+
(
|
|
429
|
+
globalThis as unknown as {
|
|
430
|
+
axe: { run(context: unknown, options: unknown): Promise<unknown> };
|
|
431
|
+
}
|
|
432
|
+
).axe.run(document, { resultTypes: ["violations"] }),
|
|
433
|
+
)) as AxeResult;
|
|
434
|
+
return result.violations.map((violation) => {
|
|
435
|
+
const targets = violation.nodes.slice(0, 3).map((node) => node.target.map(String).join(" "));
|
|
436
|
+
const serious = violation.impact === "critical" || violation.impact === "serious";
|
|
437
|
+
return createDiagnostic(serious ? "MOSTRA_A11Y_SERIOUS" : "MOSTRA_A11Y_MINOR", {
|
|
438
|
+
message: `axe ${violation.id} (${violation.impact ?? "unrated"}): ${violation.help}. Found at ${targets.join(", ")}${violation.nodes.length > 3 ? " and more" : ""}.`,
|
|
439
|
+
location: { viewport, ...(targets[0] ? { selector: targets[0] } : {}) },
|
|
440
|
+
count: Math.max(1, violation.nodes.length),
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
} catch {
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Compares two screenshots with pixelmatch's perceptual threshold and writes the difference. */
|
|
449
|
+
export async function compareImages(request: CompareRequest): Promise<CompareResult> {
|
|
450
|
+
const a = PNG.sync.read(await readFile(request.a));
|
|
451
|
+
const b = PNG.sync.read(await readFile(request.b));
|
|
452
|
+
if (a.width !== b.width || a.height !== b.height) return { ratio: 1, sizeChanged: true };
|
|
453
|
+
const diff = new PNG({ width: a.width, height: a.height });
|
|
454
|
+
const changed = pixelmatch(a.data, b.data, diff.data, a.width, a.height, { threshold: 0.1 });
|
|
455
|
+
await writeFile(request.diff, PNG.sync.write(diff));
|
|
456
|
+
return { ratio: changed / (a.width * a.height), sizeChanged: false };
|
|
457
|
+
}
|