@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.
@@ -0,0 +1,964 @@
1
+ import { createRequire } from "node:module";
2
+ import { RpcError, VERIFY_PROBE, createDiagnostic } from "@mostra/protocol";
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import pixelmatch from "pixelmatch";
6
+ import { chromium } from "playwright-core";
7
+ import { PNG } from "pngjs";
8
+ //#region src/recorder.ts
9
+ /** The global the recorder keeps its entries on. */
10
+ const RECORDER_GLOBAL = "__mostraVerifyLog";
11
+ /** The recorder, as Playwright installs it in every frame before the page's scripts run. */
12
+ function recorder() {
13
+ const scope = globalThis;
14
+ if (scope["__mostraVerifyLog"]) return;
15
+ const entries = [];
16
+ let seq = 0;
17
+ const codesOf = (value) => {
18
+ const codes = [];
19
+ let current = value;
20
+ for (let depth = 0; depth < 8 && current && typeof current === "object"; depth += 1) {
21
+ if (typeof current.code === "string") codes.push(current.code);
22
+ current = current.cause;
23
+ }
24
+ return codes;
25
+ };
26
+ const describe = (value) => {
27
+ if (value instanceof Error) return value.stack ?? `${value.name}: ${value.message}`;
28
+ if (typeof value === "string") return value;
29
+ try {
30
+ return JSON.stringify(value) ?? String(value);
31
+ } catch {
32
+ return String(value);
33
+ }
34
+ };
35
+ const record = (entry) => {
36
+ if (entries.length < 500) entries.push({
37
+ seq: ++seq,
38
+ ...entry
39
+ });
40
+ };
41
+ for (const level of ["error", "warn"]) {
42
+ const original = console[level].bind(console);
43
+ console[level] = (...args) => {
44
+ record({
45
+ kind: level,
46
+ text: args.map(describe).join(" ").slice(0, 2e3),
47
+ codes: args.flatMap(codesOf)
48
+ });
49
+ original(...args);
50
+ };
51
+ }
52
+ addEventListener("error", (event) => {
53
+ const error = event.error;
54
+ record({
55
+ kind: "uncaught",
56
+ text: (error === void 0 ? event.message : describe(error)).slice(0, 2e3),
57
+ codes: codesOf(error)
58
+ });
59
+ });
60
+ addEventListener("unhandledrejection", (event) => {
61
+ const reason = event.reason;
62
+ const codes = codesOf(reason);
63
+ const dropped = codes.includes("MOSTRA_VERIFY_ACTION_SKIPPED");
64
+ if (dropped) event.preventDefault();
65
+ record({
66
+ kind: "rejection",
67
+ text: describe(reason).slice(0, 2e3),
68
+ codes,
69
+ ...dropped ? { dropped: true } : {}
70
+ });
71
+ });
72
+ Object.defineProperty(scope, "__mostraVerifyReport", { value: (error) => record({
73
+ kind: "caught",
74
+ text: describe(error).slice(0, 2e3),
75
+ codes: codesOf(error)
76
+ }) });
77
+ Object.defineProperty(scope, "__mostraVerifyLog", { value: entries });
78
+ }
79
+ //#endregion
80
+ //#region src/observe.ts
81
+ /**
82
+ * How the runner observes a rendered page: the quiescence wait, the recorder's entries, and the
83
+ * judgement of what the page reported (D72, D138 items 2 and 3).
84
+ */
85
+ /**
86
+ * Waits until the frame's document has had no mutation for 200 ms, no request is in flight (the
87
+ * page's event stream aside) and the client runtime waits on no server work (calls, runs, first
88
+ * live values: the probe's `pending`, whose answers arrive on the event stream), at most `maxMs`,
89
+ * or `serverMaxMs` while server work is still pending. A quiescence wait, never a fixed sleep: it
90
+ * returns as soon as the page is still (EVAL-01b; Opus review finding 1).
91
+ */
92
+ async function settle(frame, network, deadline, maxMs = 3e3, serverMaxMs = 1e4) {
93
+ const start = Date.now();
94
+ const end = (pending) => Math.min(start + (pending ? Math.max(maxMs, serverMaxMs) : maxMs), deadline);
95
+ try {
96
+ await frame.evaluate(() => {
97
+ const scope = globalThis;
98
+ if (scope.__mostraQuiet) return;
99
+ let last = performance.now();
100
+ new MutationObserver(() => {
101
+ last = performance.now();
102
+ }).observe(document, {
103
+ subtree: true,
104
+ childList: true,
105
+ attributes: true,
106
+ characterData: true
107
+ });
108
+ scope.__mostraQuiet = () => performance.now() - last;
109
+ });
110
+ for (;;) {
111
+ const state = await frame.evaluate((probe) => {
112
+ const scope = globalThis;
113
+ return {
114
+ quiet: scope["__mostraQuiet"](),
115
+ pending: scope[probe]?.pending?.() ?? 0
116
+ };
117
+ }, VERIFY_PROBE);
118
+ if (state.quiet >= 200 && network.inflight() === 0 && state.pending === 0) return true;
119
+ if (Date.now() >= end(state.pending > 0)) return false;
120
+ await new Promise((resolve) => setTimeout(resolve, 50));
121
+ }
122
+ } catch {}
123
+ return false;
124
+ }
125
+ /** Folds identical findings into one with a count. */
126
+ function dedupe(findings) {
127
+ const seen = /* @__PURE__ */ new Map();
128
+ for (const finding of findings) {
129
+ const key = `${finding.code}\u0000${finding.message}`;
130
+ const known = seen.get(key);
131
+ if (known) seen.set(key, {
132
+ ...known,
133
+ count: (known.count ?? 1) + (finding.count ?? 1)
134
+ });
135
+ else seen.set(key, finding);
136
+ }
137
+ return [...seen.values()];
138
+ }
139
+ /** The recorder's entries in a frame from `from` on. */
140
+ async function recorded(frame, from = 0) {
141
+ try {
142
+ return await frame.evaluate(([name, start]) => (globalThis[name] ?? []).slice(start), [RECORDER_GLOBAL, from]);
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
147
+ const momentText = (moment) => moment.kind === "load" ? "while the page loaded" : `after the smoke test used ${moment.control}`;
148
+ /**
149
+ * Judges what the page reported in one moment (D138 items 2 and 3): its own faults are errors,
150
+ * console warnings hints; a fault whose `cause` chain is verify's refusal, and any console error
151
+ * in a moment when verify refused an action, is a hint naming the action; a missing secret is
152
+ * the environment's, a warning.
153
+ */
154
+ function judge(entries, moment, skipped, viewport) {
155
+ const findings = [];
156
+ const location = { viewport };
157
+ for (const entry of entries) {
158
+ if (entry.dropped) continue;
159
+ const text = entry.text.split("\n").slice(0, 6).join("\n");
160
+ const ownSkip = entry.codes.includes("MOSTRA_VERIFY_ACTION_SKIPPED");
161
+ const action = skipped.length > 0 ? skipped.join(", ") : "an action";
162
+ const inWindow = moment.kind === "interaction" && entry.kind === "error" && skipped.length > 0;
163
+ if (ownSkip || inWindow) {
164
+ findings.push(createDiagnostic("MOSTRA_VERIFY_SKIP_CONSEQUENCE", {
165
+ message: `After verify skipped ${action} ${momentText(moment)}, the page reported: ${text}`,
166
+ location
167
+ }));
168
+ continue;
169
+ }
170
+ if (entry.codes.includes("MOSTRA_SECRET_MISSING")) {
171
+ findings.push(createDiagnostic("MOSTRA_VERIFY_SECRET_MISSING", {
172
+ message: `Server code needed a secret ${momentText(moment)}: ${text}`,
173
+ location
174
+ }));
175
+ continue;
176
+ }
177
+ if (entry.kind === "warn") findings.push(createDiagnostic("MOSTRA_CONSOLE_WARNING", {
178
+ message: `The page logged a warning ${momentText(moment)}: ${text}`,
179
+ location
180
+ }));
181
+ else if (entry.kind === "error") findings.push(createDiagnostic("MOSTRA_CONSOLE_ERROR", {
182
+ message: `The page logged an error ${momentText(moment)}: ${text}`,
183
+ location
184
+ }));
185
+ else findings.push(createDiagnostic("MOSTRA_RUNTIME_PAGE_ERROR", {
186
+ message: `${entry.kind === "rejection" ? "A promise rejected with nothing to handle it" : entry.kind === "caught" ? "An error boundary caught an error" : "An error was thrown and nothing caught it"} ${momentText(moment)}: ${text}`,
187
+ location
188
+ }));
189
+ }
190
+ return findings;
191
+ }
192
+ //#endregion
193
+ //#region src/network.ts
194
+ /**
195
+ * What a verify render's page does on the network (D138 item 3): requests in flight (for the
196
+ * quiescence wait), failed requests for resources the artifact's frame loaded, the sandbox's
197
+ * refusals of actions that didn't opt into `dryRun()`, and the field writes the page made.
198
+ *
199
+ * A failed request counts when the artifact's frame loaded it: its document, scripts, styles,
200
+ * images, fonts and fetches. Requests the browser makes on its own (`/favicon.ico`) and Mostra's
201
+ * protocol (`/_mostra/…`, whose answers the client runtime handles) don't. Chrome's own
202
+ * "Failed to load resource" console line is never counted: the request's finding stands for it.
203
+ */
204
+ /** Whether a request is the page's event stream, which stays open. */
205
+ const isStream = (url) => /\/_mostra\/v\/[^/]+\/events(\/poll)?$/.test(url.pathname);
206
+ /** Watches a page's network; `frame` finds the artifact's frame. */
207
+ function watchNetwork(page, frame, viewport) {
208
+ const open = /* @__PURE__ */ new Set();
209
+ /** Events in order: skips, writes and protocol requests, each at its position. */
210
+ const log = [];
211
+ const failures = /* @__PURE__ */ new Map();
212
+ /** Protocol answers still being read: they count as in flight until their body is in. */
213
+ let reading = 0;
214
+ const fromArtifact = (request) => {
215
+ try {
216
+ const own = frame();
217
+ return own !== void 0 && request.frame() === own;
218
+ } catch {
219
+ return false;
220
+ }
221
+ };
222
+ const counted = (url, artifactOrigin) => {
223
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
224
+ if (url.origin === artifactOrigin && url.pathname.startsWith("/_mostra/")) return false;
225
+ return url.pathname !== "/favicon.ico";
226
+ };
227
+ const fail = (request, why) => {
228
+ if (!fromArtifact(request)) return;
229
+ const url = new URL(request.url());
230
+ let artifactOrigin;
231
+ try {
232
+ artifactOrigin = new URL(frame()?.url() ?? "").origin;
233
+ } catch {
234
+ artifactOrigin = void 0;
235
+ }
236
+ if (!counted(url, artifactOrigin)) return;
237
+ const own = url.origin === artifactOrigin;
238
+ const shown = own ? url.pathname : `${url.origin}${url.pathname}`;
239
+ const key = `${request.method()} ${shown} ${why}`;
240
+ const known = failures.get(key);
241
+ if (known) {
242
+ failures.set(key, {
243
+ ...known,
244
+ count: (known.count ?? 1) + 1
245
+ });
246
+ return;
247
+ }
248
+ failures.set(key, createDiagnostic(own ? "MOSTRA_NETWORK_REQUEST_FAILED" : "MOSTRA_NETWORK_EXTERNAL_FAILED", {
249
+ message: `${request.resourceType()} ${request.method()} ${shown} ${why}.`,
250
+ location: { viewport }
251
+ }));
252
+ };
253
+ page.on("request", (request) => {
254
+ const url = new URL(request.url());
255
+ if (isStream(url)) return;
256
+ open.add(request);
257
+ if (fromArtifact(request) && !url.pathname.startsWith("/_mostra/")) log.push({ kind: "request" });
258
+ });
259
+ const done = (request) => {
260
+ open.delete(request);
261
+ };
262
+ page.on("requestfinished", done);
263
+ page.on("requestfailed", (request) => {
264
+ done(request);
265
+ const text = request.failure()?.errorText ?? "failed";
266
+ if (/ERR_ABORTED|NS_BINDING_ABORTED|cancelled/i.test(text)) return;
267
+ fail(request, `failed (${text})`);
268
+ });
269
+ page.on("response", (response) => {
270
+ const request = response.request();
271
+ const url = new URL(request.url());
272
+ const status = response.status();
273
+ if (url.pathname.includes("/_mostra/v/")) {
274
+ reading += 1;
275
+ onProtocol(request, url, response).finally(() => {
276
+ reading -= 1;
277
+ });
278
+ return;
279
+ }
280
+ if (status >= 400) fail(request, `answered ${status}`);
281
+ });
282
+ const onProtocol = async (request, url, response) => {
283
+ if (request.method() !== "POST" || isStream(url)) return;
284
+ const route = /\/_mostra\/v\/[^/]+\/(fields|call\/.+|runs|signal|submit)$/.exec(url.pathname)?.[1];
285
+ if (!route) return;
286
+ log.push({ kind: "protocol" });
287
+ if (route === "fields" && response.ok()) {
288
+ const body = await response.json().catch(() => ({}));
289
+ log.push({
290
+ kind: "write",
291
+ ops: Array.isArray(body.applied) ? body.applied.length : 1
292
+ });
293
+ return;
294
+ }
295
+ if (response.status() !== 409 || !(route === "runs" || route.startsWith("call/"))) return;
296
+ if ((await response.json().catch(() => ({}))).code !== "MOSTRA_VERIFY_ACTION_SKIPPED") return;
297
+ const fn = route.startsWith("call/") ? decodeURIComponent(route.slice(5)) : String(request.postDataJSON()?.fnId ?? "an action");
298
+ log.push({
299
+ kind: "skip",
300
+ fn
301
+ });
302
+ };
303
+ return {
304
+ inflight: () => open.size + reading,
305
+ mark: () => log.length,
306
+ skippedSince: (from) => [...new Set(log.slice(from).flatMap((entry) => entry.kind === "skip" ? [entry.fn] : []))],
307
+ writesSince: (from) => log.slice(from).reduce((sum, entry) => sum + (entry.kind === "write" ? entry.ops : 0), 0),
308
+ requestsSince: (from) => log.slice(from).filter((entry) => entry.kind !== "write").length,
309
+ findings: () => [...failures.values()]
310
+ };
311
+ }
312
+ //#endregion
313
+ //#region src/smoke.ts
314
+ /**
315
+ * The smoke test (ROUND-3 §9, D55, D72, D138): uses every enabled control of the rendered page
316
+ * once, within 200 interactions or 60 s, and reports what it observed.
317
+ *
318
+ * - Buttons, tabs, menus and other triggers are clicked; tooltip and hover-card triggers hovered;
319
+ * context-menu triggers right-clicked. Disabled controls and already-active ones (the selected
320
+ * tab, the current page) are left alone, as EVAL-01's calibration showed they rightly do nothing.
321
+ * - Inputs, text areas, selects, checkboxes and radios are filled or toggled with values that fit
322
+ * their type, then blurred so the page saves; one whose value saves nothing is unbound (a hint).
323
+ * - Links to other origins, `mailto:`, downloads and links that open a new tab aren't followed.
324
+ * - A control with no observable effect (no change to the page, no request, no saved field, no
325
+ * navigation) is a warning that says what was observed, never an error (D72).
326
+ * - Actions that didn't opt into `dryRun()` are refused by the sandbox; faults that follow from
327
+ * the refusal are attributed to it (see `judge`).
328
+ */
329
+ const MARK = "data-mostra-smoke";
330
+ /** How many levels of revealed controls the smoke test follows: a dialog, then its menu. */
331
+ const REVEAL_DEPTH = 2;
332
+ /**
333
+ * Finds the page's controls not marked yet and marks each with its index, numbering from `start`
334
+ * (runs in the frame): the page's controls at first, then those an interaction revealed.
335
+ */
336
+ function findControls([mark, start]) {
337
+ const selector = [
338
+ "button",
339
+ "[role=button]",
340
+ "a[href]",
341
+ "input:not([type=hidden])",
342
+ "textarea",
343
+ "select",
344
+ "summary",
345
+ "[role=tab]",
346
+ "[role=checkbox]",
347
+ "[role=switch]",
348
+ "[role=radio]",
349
+ "[role=menuitem]",
350
+ "[role=combobox]",
351
+ "[role=option]",
352
+ "[aria-haspopup]",
353
+ "[data-slot$=\"-trigger\"]",
354
+ "[contenteditable=\"true\"]",
355
+ "[contenteditable=\"\"]"
356
+ ].join(",");
357
+ const controls = [];
358
+ const seen = /* @__PURE__ */ new Set();
359
+ for (const element of document.querySelectorAll(selector)) {
360
+ if (seen.has(element) || controls.length >= 400 || element.hasAttribute(mark)) continue;
361
+ seen.add(element);
362
+ const html = element;
363
+ const tag = element.tagName.toLowerCase();
364
+ const type = (element.getAttribute("type") ?? "").toLowerCase();
365
+ const role = element.getAttribute("role") ?? "";
366
+ const slot = element.getAttribute("data-slot") ?? "";
367
+ const name = (element.getAttribute("aria-label") ?? (html.labels?.[0]?.textContent || void 0) ?? element.getAttribute("placeholder") ?? element.getAttribute("title") ?? html.innerText ?? "").trim().replace(/\s+/g, " ").slice(0, 60);
368
+ let use = "click";
369
+ if (tag === "a") {
370
+ const anchor = element;
371
+ let url;
372
+ try {
373
+ url = new URL(anchor.href, location.href);
374
+ } catch {
375
+ url = void 0;
376
+ }
377
+ if (!url || url.origin !== location.origin || anchor.target === "_blank" || anchor.hasAttribute("download")) use = "skip";
378
+ } else if (slot === "tooltip-trigger" || slot === "hover-card-trigger") use = "hover";
379
+ else if (slot === "context-menu-trigger") use = "context";
380
+ else if (tag === "select") use = "select";
381
+ else if (tag === "textarea" || html.isContentEditable) use = "fill";
382
+ else if (tag === "input") {
383
+ if (type === "checkbox" || type === "radio") use = "toggle";
384
+ else if ([
385
+ "submit",
386
+ "button",
387
+ "reset",
388
+ "image"
389
+ ].includes(type)) use = "click";
390
+ else if (type === "file") use = "skip";
391
+ else use = "fill";
392
+ }
393
+ const index = start + controls.length;
394
+ element.setAttribute(mark, String(index));
395
+ controls.push({
396
+ index,
397
+ tag,
398
+ role,
399
+ type,
400
+ name,
401
+ use
402
+ });
403
+ }
404
+ return controls;
405
+ }
406
+ /** Whether a control is disabled or already active, read live (runs in the frame). */
407
+ function stateOf(element) {
408
+ const disabled = element.disabled === true || element.getAttribute("aria-disabled") === "true" || element.closest("fieldset[disabled]") !== null;
409
+ const current = element.getAttribute("aria-current");
410
+ return {
411
+ disabled,
412
+ active: element.getAttribute("aria-selected") === "true" || current !== null && current !== "false" || element.getAttribute("data-state") === "active" || element.hasAttribute("data-active") || element.hasAttribute("data-selected")
413
+ };
414
+ }
415
+ /** The attributes that show a control's state, as one string (runs in the frame). */
416
+ function attributesOf(element) {
417
+ return [
418
+ "aria-expanded",
419
+ "aria-checked",
420
+ "aria-selected",
421
+ "aria-pressed",
422
+ "data-state",
423
+ "data-checked",
424
+ "data-open",
425
+ "data-popup-open",
426
+ "data-pressed",
427
+ "data-panel-open",
428
+ "open"
429
+ ].map((name) => `${name}=${element.getAttribute(name)}`).join("|");
430
+ }
431
+ /** A value that fits an input's type. */
432
+ function valueFor(type) {
433
+ return {
434
+ email: "verify@example.com",
435
+ number: "7",
436
+ range: "7",
437
+ date: "2026-01-15",
438
+ time: "12:30",
439
+ "datetime-local": "2026-01-15T12:30",
440
+ month: "2026-01",
441
+ week: "2026-W03",
442
+ url: "https://example.com/",
443
+ tel: "5550100",
444
+ color: "#336699",
445
+ password: "verify-pass"
446
+ }[type] ?? "Verify";
447
+ }
448
+ const describe = (control) => {
449
+ const kind = control.role || (control.tag === "input" ? `${control.type || "text"} input` : control.tag);
450
+ return control.name ? `${kind} "${control.name}"` : kind;
451
+ };
452
+ /** Counts the frame document's mutations from now on (runs in the frame). */
453
+ function countMutations() {
454
+ const scope = globalThis;
455
+ if (scope.__mostraSmoke) return;
456
+ const state = { count: 0 };
457
+ scope.__mostraSmoke = state;
458
+ new MutationObserver((records) => {
459
+ state.count += records.length;
460
+ }).observe(document, {
461
+ subtree: true,
462
+ childList: true,
463
+ attributes: true,
464
+ characterData: true
465
+ });
466
+ }
467
+ const mutations = (frame) => frame.evaluate(() => globalThis.__mostraSmoke?.count ?? -1).catch(() => -1);
468
+ /** Runs the smoke test on a rendered page. */
469
+ async function runSmoke(options) {
470
+ const { page, frame, network, viewport } = options;
471
+ const findings = [];
472
+ const startMark = network.mark();
473
+ let seen = options.from;
474
+ let interactions = 0;
475
+ let budget = false;
476
+ let stopped = false;
477
+ await frame.evaluate(countMutations).catch(() => void 0);
478
+ const controls = await frame.evaluate(findControls, [MARK, 0]).catch(() => []);
479
+ const usable = controls.filter((control) => control.use !== "skip");
480
+ let next = controls.length;
481
+ /**
482
+ * Uses a control once and judges what the page reported meanwhile. Controls it revealed (a
483
+ * dialog's buttons, a menu's items, a tab's panel) are used too, before anything is dismissed,
484
+ * down to {@link REVEAL_DEPTH} levels (GPT review finding 3).
485
+ */
486
+ const exercise = async (control, depth = 0) => {
487
+ const none = {
488
+ used: false,
489
+ changed: false,
490
+ writes: 0,
491
+ navigated: false,
492
+ newDocument: false,
493
+ mark: network.mark()
494
+ };
495
+ if (frame.isDetached()) return none;
496
+ const target = frame.locator(`[${MARK}="${control.index}"]`);
497
+ if (!await target.isVisible().catch(() => false)) return none;
498
+ const state = await target.evaluate(stateOf).catch(() => ({
499
+ disabled: true,
500
+ active: false
501
+ }));
502
+ if (state.disabled || state.active) return none;
503
+ const mark = network.mark();
504
+ const beforeMutations = await mutations(frame);
505
+ const beforeAttributes = await target.evaluate(attributesOf).catch(() => "");
506
+ const beforeUrl = frame.url();
507
+ try {
508
+ const timeout = Math.max(500, Math.min(2e3, options.deadline - Date.now()));
509
+ if (control.use === "hover") await target.hover({ timeout });
510
+ else if (control.use === "context") await target.click({
511
+ button: "right",
512
+ timeout
513
+ });
514
+ else if (control.use === "fill") {
515
+ await target.fill(valueFor(control.type), { timeout });
516
+ await target.press("Tab", { timeout });
517
+ } else if (control.use === "toggle") {
518
+ await target.click({ timeout });
519
+ await target.press("Tab", { timeout }).catch(() => void 0);
520
+ } else if (control.use === "select") {
521
+ const option = await target.evaluate((element) => {
522
+ return [...element.options].find((entry) => !entry.selected && !entry.disabled)?.value;
523
+ });
524
+ if (option === void 0) return none;
525
+ await target.selectOption(option, { timeout });
526
+ await target.press("Tab", { timeout }).catch(() => void 0);
527
+ } else await target.click({ timeout });
528
+ } catch {
529
+ return none;
530
+ }
531
+ interactions += 1;
532
+ await settle(frame, network, options.deadline, 2e3, 5e3);
533
+ const navigated = frame.url() !== beforeUrl;
534
+ const afterMutations = await mutations(frame);
535
+ const newDocument = afterMutations === -1;
536
+ const afterAttributes = newDocument ? beforeAttributes : await target.evaluate(attributesOf).catch(() => beforeAttributes);
537
+ const reported = (await recorded(frame, seen)).length > 0;
538
+ const changed = navigated || newDocument || reported || network.requestsSince(mark) > 0 || afterAttributes !== beforeAttributes || afterMutations > beforeMutations && beforeMutations >= 0;
539
+ const moment = {
540
+ kind: "interaction",
541
+ control: describe(control)
542
+ };
543
+ if (newDocument) {
544
+ findings.push(...judge(await recorded(frame), moment, network.skippedSince(mark), viewport));
545
+ stopped = true;
546
+ } else {
547
+ const entries = await recorded(frame, seen);
548
+ seen += entries.length;
549
+ findings.push(...judge(entries, moment, network.skippedSince(mark), viewport));
550
+ if (depth < REVEAL_DEPTH) await exerciseRevealed(depth);
551
+ if (depth === 0) {
552
+ await page.keyboard.press("Escape").catch(() => void 0);
553
+ await page.mouse.move(0, 0).catch(() => void 0);
554
+ }
555
+ }
556
+ return {
557
+ used: true,
558
+ changed,
559
+ writes: network.writesSince(mark),
560
+ navigated,
561
+ newDocument,
562
+ mark
563
+ };
564
+ };
565
+ const within = () => !stopped && interactions < options.maxInteractions && Date.now() < options.deadline;
566
+ /**
567
+ * Uses the controls the last interaction revealed. They are judged for what the page reports
568
+ * and for inputs that save nothing, never for having no effect: a second try would need their
569
+ * container opened again, and silence is the conservative answer (D72).
570
+ */
571
+ const exerciseRevealed = async (depth) => {
572
+ if (!within() || frame.isDetached()) return;
573
+ const revealed = await frame.evaluate(findControls, [MARK, next]).catch(() => []);
574
+ next += revealed.length;
575
+ for (const control of revealed) {
576
+ if (!within()) break;
577
+ if (control.use === "skip" || control.tag === "a") continue;
578
+ const outcome = await exercise(control, depth + 1);
579
+ if (!outcome.used) continue;
580
+ unbound(control, outcome);
581
+ }
582
+ };
583
+ /** Reports an input the smoke test changed whose value saved nothing (a hint). */
584
+ const unbound = (control, outcome) => {
585
+ if (control.use !== "fill" && control.use !== "toggle" && control.use !== "select") return;
586
+ if (outcome.writes > 0 || outcome.navigated || outcome.newDocument) return;
587
+ findings.push(createDiagnostic("MOSTRA_FIELD_UNBOUND", {
588
+ message: `The smoke test changed ${describe(control)} and the page saved nothing: the input isn't bound to a field.`,
589
+ location: {
590
+ viewport,
591
+ selector: `[${MARK}="${control.index}"]`
592
+ }
593
+ }));
594
+ };
595
+ /** Controls that changed nothing the first time: tried again once the others were used. */
596
+ const again = [];
597
+ let used = 0;
598
+ const links = usable.filter((control) => control.tag === "a");
599
+ for (const control of usable.filter((entry) => entry.tag !== "a")) {
600
+ if (!within()) break;
601
+ used += 1;
602
+ const outcome = await exercise(control);
603
+ if (!outcome.used) continue;
604
+ if (control.use === "fill" || control.use === "toggle" || control.use === "select") unbound(control, outcome);
605
+ else if (!outcome.changed) again.push(control);
606
+ }
607
+ for (const control of again) {
608
+ if (!within()) break;
609
+ const outcome = await exercise(control);
610
+ if (!outcome.used || outcome.changed) continue;
611
+ findings.push(createDiagnostic("MOSTRA_SMOKE_NO_EFFECT", {
612
+ message: `Observed: ${control.use === "hover" ? "hovering over" : "clicking"} ${describe(control)} twice changed nothing verify could see (no change to the page, no request, no saved field, no navigation).`,
613
+ location: {
614
+ viewport,
615
+ selector: `[${MARK}="${control.index}"]`
616
+ }
617
+ }));
618
+ }
619
+ for (const control of links) {
620
+ if (!within()) break;
621
+ used += 1;
622
+ await exercise(control);
623
+ }
624
+ budget = !stopped && used < usable.length;
625
+ if (budget) findings.push(createDiagnostic("MOSTRA_SMOKE_BUDGET", {
626
+ message: `The smoke test stopped after ${interactions} interactions, at its budget of ${options.maxInteractions} interactions or 60 s; ${usable.length - used} controls weren't used.`,
627
+ location: { viewport }
628
+ }));
629
+ return {
630
+ findings,
631
+ interactions,
632
+ stateChanges: network.writesSince(startMark),
633
+ budget
634
+ };
635
+ }
636
+ //#endregion
637
+ //#region src/runner.ts
638
+ /**
639
+ * The verify runner (ARCHITECTURE §1, ROUND-3 §9, D138): one warm headless Chromium that renders
640
+ * capability links the supervisor's sandbox minted, each in a fresh browser context, and reports
641
+ * what the page did. It decides what a page's behavior means for the verdict (D72): definite
642
+ * failures are errors, observations warnings or hints, and faults that follow from verify's own
643
+ * refusal of an action are attributed to it. The supervisor decides everything else.
644
+ */
645
+ const require = createRequire(import.meta.url);
646
+ /** How long the runner keeps its browser without work (ROUND-3 §9): 10 minutes. */
647
+ const RUNNER_IDLE_MS = 6e5;
648
+ /** The longest a page may take to report `mostra/ready` before it is judged never ready. */
649
+ const READY_WAIT_MS = 15e3;
650
+ /**
651
+ * How long after its deadline a render is abandoned: its browser context is closed, which ends a
652
+ * page that blocks its own main thread without touching other runs' pages (Opus review finding 5).
653
+ */
654
+ const RENDER_ABANDON_MS = 1e3;
655
+ /** The time the smoke test leaves before the run's deadline, for the regression stage after it. */
656
+ const SMOKE_RESERVE_MS = 5e3;
657
+ /** Whether a launch failed because the browser isn't installed. */
658
+ function browserMissing(error) {
659
+ return /Executable doesn't exist|browserType\.launch: .*(not found|ENOENT)/i.test(String(error?.message ?? error));
660
+ }
661
+ /** Creates the runner; its browser starts on the first render. */
662
+ function createRunner(options = {}) {
663
+ let browser;
664
+ let idleTimer;
665
+ let busy = 0;
666
+ let axeSource;
667
+ const touch = () => {
668
+ if (idleTimer) clearTimeout(idleTimer);
669
+ idleTimer = void 0;
670
+ if (busy > 0 || !options.onIdle) return;
671
+ idleTimer = setTimeout(() => options.onIdle?.(), options.idleMs ?? 6e5);
672
+ idleTimer.unref?.();
673
+ };
674
+ const launch = () => {
675
+ browser ??= chromium.launch({ headless: true }).catch((error) => {
676
+ browser = void 0;
677
+ if (browserMissing(error)) throw new RpcError(createDiagnostic("MOSTRA_ENV_BROWSER_MISSING", { message: "verify's browser, Playwright's Chromium headless shell, isn't installed for this runtime." }));
678
+ throw error;
679
+ });
680
+ return browser;
681
+ };
682
+ const work = async (task) => {
683
+ busy += 1;
684
+ touch();
685
+ try {
686
+ return await task();
687
+ } finally {
688
+ busy -= 1;
689
+ touch();
690
+ }
691
+ };
692
+ return {
693
+ render: (request) => work(async () => {
694
+ axeSource ??= await readFile(require.resolve("axe-core/axe.min.js"), "utf8");
695
+ return renderBounded(await launch(), request, axeSource);
696
+ }),
697
+ compare: (request) => work(() => compareImages(request)),
698
+ async close() {
699
+ if (idleTimer) clearTimeout(idleTimer);
700
+ const open = browser;
701
+ browser = void 0;
702
+ if (open) await (await open.catch(() => void 0))?.close();
703
+ }
704
+ };
705
+ }
706
+ /** The artifact's frame: the shell's only child frame. */
707
+ function artifactFrame(page) {
708
+ return page.frames().find((frame) => frame.parentFrame() === page.mainFrame());
709
+ }
710
+ /** What the frame shows: whether any element has a visible box of nonzero area (D138 item 3). */
711
+ function showsSomething() {
712
+ const visible = (element) => {
713
+ const rect = element.getBoundingClientRect();
714
+ if (rect.width <= 0 || rect.height <= 0) return false;
715
+ return element.checkVisibility({
716
+ opacityProperty: true,
717
+ visibilityProperty: true
718
+ });
719
+ };
720
+ for (const element of document.querySelectorAll("img, svg, canvas, video")) if (visible(element)) return true;
721
+ if (!document.body) return false;
722
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
723
+ while (walker.nextNode()) {
724
+ const node = walker.currentNode;
725
+ if (!node.textContent?.trim() || !node.parentElement || !visible(node.parentElement)) continue;
726
+ const range = document.createRange();
727
+ range.selectNodeContents(node);
728
+ const rect = range.getBoundingClientRect();
729
+ if (rect.width > 0 && rect.height > 0) return true;
730
+ }
731
+ return false;
732
+ }
733
+ /** Whether a PNG is one uniform color. */
734
+ function uniform(png) {
735
+ const { data } = PNG.sync.read(png);
736
+ for (let offset = 4; offset < data.length; offset += 4) if (data[offset] !== data[0] || data[offset + 1] !== data[1] || data[offset + 2] !== data[2] || data[offset + 3] !== data[3]) return false;
737
+ return true;
738
+ }
739
+ /**
740
+ * Waits for `work`, and gives up on it `graceMs` past `deadline`: `abandon()` is called (for a
741
+ * render, closing its context, which ends a page whose code blocks its main thread) and `late` is
742
+ * the answer, also when `work` then fails because of the abandon. A failure before that is
743
+ * `work`'s own and is thrown (Opus review finding 5).
744
+ */
745
+ async function bounded(work, options) {
746
+ let abandoning = false;
747
+ let timer;
748
+ const abandoned = new Promise((done) => {
749
+ timer = setTimeout(() => {
750
+ abandoning = true;
751
+ const closing = Promise.resolve(options.abandon()).catch(() => void 0);
752
+ Promise.race([closing, new Promise((wait) => setTimeout(wait, 2e3))]).then(() => done(options.late));
753
+ }, Math.max(0, options.deadline - Date.now()) + (options.graceMs ?? RENDER_ABANDON_MS));
754
+ });
755
+ const guarded = work.catch((error) => {
756
+ if (abandoning) return options.late;
757
+ throw error;
758
+ });
759
+ try {
760
+ return await Promise.race([guarded, abandoned]);
761
+ } finally {
762
+ clearTimeout(timer);
763
+ }
764
+ }
765
+ /**
766
+ * Renders one capability link, and abandons it when it is still running past its deadline: its
767
+ * context is closed (the page's renderer with it), and the render reports it ran out of time.
768
+ */
769
+ async function renderBounded(browser, request, axeSource) {
770
+ const created = {};
771
+ return bounded(renderPage(browser, request, axeSource, created), {
772
+ deadline: request.deadline,
773
+ late: {
774
+ ready: false,
775
+ findings: [],
776
+ timedOut: true,
777
+ timingsMs: {}
778
+ },
779
+ abandon: () => created.context?.close()
780
+ });
781
+ }
782
+ /** Renders one capability link and reports what the page did. */
783
+ async function renderPage(browser, request, axeSource, created) {
784
+ const started = Date.now();
785
+ const timings = {};
786
+ const findings = [];
787
+ const viewport = request.viewport.name;
788
+ const location = { viewport };
789
+ let timedOut = false;
790
+ const left = () => request.deadline - Date.now();
791
+ const context = await browser.newContext({
792
+ viewport: {
793
+ width: request.viewport.width,
794
+ height: request.viewport.height
795
+ },
796
+ deviceScaleFactor: 1,
797
+ reducedMotion: "reduce"
798
+ });
799
+ created.context = context;
800
+ try {
801
+ await context.clock.setFixedTime(request.clockMs);
802
+ await context.addInitScript(recorder);
803
+ const page = await context.newPage();
804
+ context.on("page", (other) => {
805
+ if (other !== page) other.close().catch(() => void 0);
806
+ });
807
+ const network = watchNetwork(page, () => artifactFrame(page), viewport);
808
+ await page.goto(request.url, {
809
+ waitUntil: "commit",
810
+ timeout: Math.max(1e3, left())
811
+ });
812
+ let ready = false;
813
+ try {
814
+ await page.waitForFunction(() => {
815
+ const state = document.documentElement.dataset["shell"];
816
+ return state === "ready" || state === "error";
817
+ }, void 0, { timeout: Math.max(1, Math.min(READY_WAIT_MS, left())) });
818
+ ready = await page.evaluate(() => document.documentElement.dataset["shell"]) === "ready";
819
+ } catch {
820
+ ready = false;
821
+ timedOut = left() <= 0;
822
+ }
823
+ timings["ready"] = Date.now() - started;
824
+ const frame = artifactFrame(page);
825
+ if (!ready || !frame) {
826
+ const shellError = await page.evaluate(() => document.documentElement.dataset["shellError"] ?? "").catch(() => "");
827
+ findings.push(createDiagnostic("MOSTRA_RUNTIME_NOT_READY", {
828
+ message: `The page didn't report it was ready${shellError ? ` (the shell reported ${shellError})` : ""}.`,
829
+ location
830
+ }));
831
+ if (frame) findings.push(...judge(await recorded(frame), { kind: "load" }, network.skippedSince(0), viewport));
832
+ findings.push(...network.findings());
833
+ return {
834
+ ready: false,
835
+ findings: dedupe(findings),
836
+ timedOut,
837
+ timingsMs: timings
838
+ };
839
+ }
840
+ await frame.evaluate(() => document.fonts.ready.then(() => true)).catch(() => false);
841
+ await settle(frame, network, request.deadline);
842
+ timings["settled"] = Date.now() - started;
843
+ let screenshot;
844
+ let shot;
845
+ if (request.collect.screenshot) {
846
+ const path = join(request.outDir, `${request.name}.png`);
847
+ shot = await page.screenshot({
848
+ path,
849
+ animations: "disabled",
850
+ caret: "hide"
851
+ });
852
+ screenshot = {
853
+ path,
854
+ width: request.viewport.width,
855
+ height: request.viewport.height
856
+ };
857
+ timings["screenshot"] = Date.now() - started;
858
+ }
859
+ if (!await frame.evaluate(showsSomething).catch(() => true) && shot && uniform(shot)) findings.push(createDiagnostic("MOSTRA_RUNTIME_BLANK", {
860
+ message: "The page reported ready but shows nothing: no visible text, image, SVG, canvas or video, and its screenshot is one color.",
861
+ location
862
+ }));
863
+ let aria;
864
+ if (request.collect.aria) {
865
+ const text = await frame.locator("body").ariaSnapshot({ timeout: Math.max(1e3, Math.min(1e4, left())) }).catch(() => {
866
+ if (left() <= 0) timedOut = true;
867
+ return "";
868
+ });
869
+ aria = {
870
+ path: join(request.outDir, `${request.name}.aria.yml`),
871
+ text
872
+ };
873
+ timings["aria"] = Date.now() - started;
874
+ }
875
+ let fields;
876
+ if (request.collect.fields) fields = await frame.evaluate((name) => globalThis[name]?.fields?.() ?? [], VERIFY_PROBE).catch(() => []);
877
+ if (request.collect.axe && left() <= 0) timedOut = true;
878
+ if (request.collect.axe && left() > 0) {
879
+ findings.push(...await runAxe(frame, axeSource, viewport));
880
+ timings["axe"] = Date.now() - started;
881
+ }
882
+ const loadEntries = await recorded(frame);
883
+ findings.push(...judge(loadEntries, { kind: "load" }, network.skippedSince(0), viewport));
884
+ let smoke;
885
+ if (request.collect.smoke && left() > 0) {
886
+ const outcome = await runSmoke({
887
+ page,
888
+ frame,
889
+ network,
890
+ viewport,
891
+ from: loadEntries.length,
892
+ maxInteractions: request.collect.smoke.maxInteractions,
893
+ deadline: Math.min(request.deadline - SMOKE_RESERVE_MS, Date.now() + request.collect.smoke.maxMs)
894
+ });
895
+ findings.push(...outcome.findings);
896
+ smoke = {
897
+ interactions: outcome.interactions,
898
+ stateChanges: outcome.stateChanges,
899
+ skipped: network.skippedSince(0),
900
+ budget: outcome.budget
901
+ };
902
+ timings["smoke"] = Date.now() - started;
903
+ }
904
+ findings.push(...network.findings());
905
+ return {
906
+ ready: true,
907
+ findings: dedupe(findings),
908
+ ...screenshot ? { screenshot } : {},
909
+ ...aria ? { aria } : {},
910
+ ...fields ? { fields } : {},
911
+ ...smoke ? { smoke } : {},
912
+ timedOut,
913
+ timingsMs: timings
914
+ };
915
+ } finally {
916
+ await context.close().catch(() => void 0);
917
+ }
918
+ }
919
+ /**
920
+ * axe-core's violations in the frame (axe's `incomplete` results aren't reported): critical and
921
+ * serious are warnings, moderate and minor hints (D138 item 3). Injected unmodified.
922
+ */
923
+ async function runAxe(frame, source, viewport) {
924
+ try {
925
+ await frame.evaluate(source);
926
+ return (await frame.evaluate(() => globalThis.axe.run(document, { resultTypes: ["violations"] }))).violations.map((violation) => {
927
+ const targets = violation.nodes.slice(0, 3).map((node) => node.target.map(String).join(" "));
928
+ const serious = violation.impact === "critical" || violation.impact === "serious";
929
+ return createDiagnostic(serious ? "MOSTRA_A11Y_SERIOUS" : "MOSTRA_A11Y_MINOR", {
930
+ message: `axe ${violation.id} (${violation.impact ?? "unrated"}): ${violation.help}. Found at ${targets.join(", ")}${violation.nodes.length > 3 ? " and more" : ""}.`,
931
+ location: {
932
+ viewport,
933
+ ...targets[0] ? { selector: targets[0] } : {}
934
+ },
935
+ count: Math.max(1, violation.nodes.length)
936
+ });
937
+ });
938
+ } catch {
939
+ return [];
940
+ }
941
+ }
942
+ /** Compares two screenshots with pixelmatch's perceptual threshold and writes the difference. */
943
+ async function compareImages(request) {
944
+ const a = PNG.sync.read(await readFile(request.a));
945
+ const b = PNG.sync.read(await readFile(request.b));
946
+ if (a.width !== b.width || a.height !== b.height) return {
947
+ ratio: 1,
948
+ sizeChanged: true
949
+ };
950
+ const diff = new PNG({
951
+ width: a.width,
952
+ height: a.height
953
+ });
954
+ const changed = pixelmatch(a.data, b.data, diff.data, a.width, a.height, { threshold: .1 });
955
+ await writeFile(request.diff, PNG.sync.write(diff));
956
+ return {
957
+ ratio: changed / (a.width * a.height),
958
+ sizeChanged: false
959
+ };
960
+ }
961
+ //#endregion
962
+ export { uniform as a, recorder as c, createRunner as i, bounded as n, dedupe as o, compareImages as r, judge as s, RUNNER_IDLE_MS as t };
963
+
964
+ //# sourceMappingURL=runner-DCmzgY2w.js.map