@monoes/monobrowse 1.0.13 → 1.0.14

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.
Files changed (107) hide show
  1. package/dist/src/__tests__/bridge-instruments.test.d.ts +2 -0
  2. package/dist/src/__tests__/bridge-instruments.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/bridge-instruments.test.js +132 -0
  4. package/dist/src/__tests__/bridge-instruments.test.js.map +1 -0
  5. package/dist/src/__tests__/bridge-transport.test.d.ts +2 -0
  6. package/dist/src/__tests__/bridge-transport.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/bridge-transport.test.js +287 -0
  8. package/dist/src/__tests__/bridge-transport.test.js.map +1 -0
  9. package/dist/src/__tests__/cdp-transport.test.d.ts +2 -0
  10. package/dist/src/__tests__/cdp-transport.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/cdp-transport.test.js +167 -0
  12. package/dist/src/__tests__/cdp-transport.test.js.map +1 -0
  13. package/dist/src/browser/bridge.d.ts +119 -0
  14. package/dist/src/browser/bridge.d.ts.map +1 -0
  15. package/dist/src/browser/bridge.js +264 -0
  16. package/dist/src/browser/bridge.js.map +1 -0
  17. package/dist/src/browser/cdp.d.ts +11 -1
  18. package/dist/src/browser/cdp.d.ts.map +1 -1
  19. package/dist/src/browser/cdp.js +60 -56
  20. package/dist/src/browser/cdp.js.map +1 -1
  21. package/dist/src/browser/index.d.ts +2 -0
  22. package/dist/src/browser/index.d.ts.map +1 -1
  23. package/dist/src/browser/index.js +2 -0
  24. package/dist/src/browser/index.js.map +1 -1
  25. package/dist/src/browser/transport.d.ts +62 -0
  26. package/dist/src/browser/transport.d.ts.map +1 -0
  27. package/dist/src/browser/transport.js +65 -0
  28. package/dist/src/browser/transport.js.map +1 -0
  29. package/dist/src/cli/commander-adapter.d.ts +16 -0
  30. package/dist/src/cli/commander-adapter.d.ts.map +1 -0
  31. package/dist/src/cli/commander-adapter.js +68 -0
  32. package/dist/src/cli/commander-adapter.js.map +1 -0
  33. package/dist/src/cli/commands-context.d.ts +14 -0
  34. package/dist/src/cli/commands-context.d.ts.map +1 -0
  35. package/dist/src/cli/commands-context.js +351 -0
  36. package/dist/src/cli/commands-context.js.map +1 -0
  37. package/dist/src/cli/commands-data.d.ts +12 -0
  38. package/dist/src/cli/commands-data.d.ts.map +1 -0
  39. package/dist/src/cli/commands-data.js +246 -0
  40. package/dist/src/cli/commands-data.js.map +1 -0
  41. package/dist/src/cli/commands-element.d.ts +15 -0
  42. package/dist/src/cli/commands-element.d.ts.map +1 -0
  43. package/dist/src/cli/commands-element.js +262 -0
  44. package/dist/src/cli/commands-element.js.map +1 -0
  45. package/dist/src/cli/commands-files.d.ts +9 -0
  46. package/dist/src/cli/commands-files.d.ts.map +1 -0
  47. package/dist/src/cli/commands-files.js +207 -0
  48. package/dist/src/cli/commands-files.js.map +1 -0
  49. package/dist/src/cli/commands-input.d.ts +26 -0
  50. package/dist/src/cli/commands-input.d.ts.map +1 -0
  51. package/dist/src/cli/commands-input.js +399 -0
  52. package/dist/src/cli/commands-input.js.map +1 -0
  53. package/dist/src/cli/commands-navigate.d.ts +12 -0
  54. package/dist/src/cli/commands-navigate.d.ts.map +1 -0
  55. package/dist/src/cli/commands-navigate.js +389 -0
  56. package/dist/src/cli/commands-navigate.js.map +1 -0
  57. package/dist/src/cli/commands-page.d.ts +25 -0
  58. package/dist/src/cli/commands-page.d.ts.map +1 -0
  59. package/dist/src/cli/commands-page.js +377 -0
  60. package/dist/src/cli/commands-page.js.map +1 -0
  61. package/dist/src/cli/commands-report.d.ts +8 -0
  62. package/dist/src/cli/commands-report.d.ts.map +1 -0
  63. package/dist/src/cli/commands-report.js +256 -0
  64. package/dist/src/cli/commands-report.js.map +1 -0
  65. package/dist/src/cli/commands-script.d.ts +43 -0
  66. package/dist/src/cli/commands-script.d.ts.map +1 -0
  67. package/dist/src/cli/commands-script.js +168 -0
  68. package/dist/src/cli/commands-script.js.map +1 -0
  69. package/dist/src/cli/commands-session.d.ts +15 -0
  70. package/dist/src/cli/commands-session.d.ts.map +1 -0
  71. package/dist/src/cli/commands-session.js +357 -0
  72. package/dist/src/cli/commands-session.js.map +1 -0
  73. package/dist/src/cli/commands-trace.d.ts +12 -0
  74. package/dist/src/cli/commands-trace.d.ts.map +1 -0
  75. package/dist/src/cli/commands-trace.js +347 -0
  76. package/dist/src/cli/commands-trace.js.map +1 -0
  77. package/dist/src/cli/commands.d.ts +22 -42
  78. package/dist/src/cli/commands.d.ts.map +1 -1
  79. package/dist/src/cli/commands.js +42 -3648
  80. package/dist/src/cli/commands.js.map +1 -1
  81. package/dist/src/cli/session.d.ts +47 -0
  82. package/dist/src/cli/session.d.ts.map +1 -0
  83. package/dist/src/cli/session.js +343 -0
  84. package/dist/src/cli/session.js.map +1 -0
  85. package/dist/tsconfig.tsbuildinfo +1 -1
  86. package/package.json +1 -1
  87. package/src/__tests__/bridge-instruments.test.ts +180 -0
  88. package/src/__tests__/bridge-transport.test.ts +365 -0
  89. package/src/__tests__/cdp-transport.test.ts +198 -0
  90. package/src/browser/bridge.ts +354 -0
  91. package/src/browser/cdp.ts +57 -52
  92. package/src/browser/index.ts +2 -0
  93. package/src/browser/transport.ts +111 -0
  94. package/src/cli/commander-adapter.ts +65 -0
  95. package/src/cli/commands-context.ts +367 -0
  96. package/src/cli/commands-data.ts +248 -0
  97. package/src/cli/commands-element.ts +305 -0
  98. package/src/cli/commands-files.ts +248 -0
  99. package/src/cli/commands-input.ts +434 -0
  100. package/src/cli/commands-navigate.ts +442 -0
  101. package/src/cli/commands-page.ts +420 -0
  102. package/src/cli/commands-report.ts +275 -0
  103. package/src/cli/commands-script.ts +183 -0
  104. package/src/cli/commands-session.ts +376 -0
  105. package/src/cli/commands-trace.ts +349 -0
  106. package/src/cli/commands.ts +95 -3943
  107. package/src/cli/session.ts +412 -0
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Reading the page — accessibility snapshots, page/element properties, and
3
+ * diffing one snapshot against another.
4
+ *
5
+ * `snapshot` is what mints the `@eN` refs every element command resolves
6
+ * against; `get` reads url/title/text/html/box/attributes back out.
7
+ */
8
+
9
+ import { output } from './output.js';
10
+ import { ensureConnected, getBrowser, print, session, truncateForOutput } from './session.js';
11
+ import type { Command, CommandContext, CommandResult } from './types.js';
12
+
13
+ export const snapshotCommand: Command = {
14
+ name: 'snapshot',
15
+ description: 'Capture accessibility snapshot with ref-based element handles (@e1, @e2, ...)',
16
+ options: [
17
+ {
18
+ name: 'interactive',
19
+ short: 'i',
20
+ type: 'boolean',
21
+ description: 'Interactive elements only (93% token reduction)',
22
+ default: false,
23
+ },
24
+ {
25
+ name: 'compact',
26
+ short: 'c',
27
+ type: 'boolean',
28
+ description: 'Compact output format',
29
+ default: false,
30
+ },
31
+ { name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
32
+ { name: 'depth', short: 'd', type: 'number', description: 'Max depth of AX tree to show' },
33
+ {
34
+ name: 'selector',
35
+ short: 's',
36
+ type: 'string',
37
+ description: 'Scope snapshot to a CSS selector',
38
+ },
39
+ {
40
+ name: 'save',
41
+ type: 'string',
42
+ description: 'Save snapshot text to file (baseline for --diff)',
43
+ },
44
+ {
45
+ name: 'diff',
46
+ type: 'string',
47
+ description: 'Compare current snapshot against a saved baseline file',
48
+ },
49
+ {
50
+ name: 'content-boundaries',
51
+ type: 'boolean',
52
+ description: 'Wrap output in sentinel markers to prevent page-content injection attacks',
53
+ default: false,
54
+ },
55
+ {
56
+ name: 'max-output',
57
+ type: 'number',
58
+ description:
59
+ 'Truncate output to N characters (prevents context window blowout on large pages)',
60
+ },
61
+ ],
62
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
63
+ const { client, sessionId } = await ensureConnected(session.port);
64
+ const browser = await getBrowser();
65
+
66
+ const result = await browser.captureSnapshot(client, sessionId, {
67
+ interactiveOnly: ctx.flags.interactive as boolean,
68
+ compact: ctx.flags.compact as boolean,
69
+ maxDepth: ctx.flags.depth as number | undefined,
70
+ selector: ctx.flags.selector as string | undefined,
71
+ });
72
+
73
+ session.refs = result.refs;
74
+ await browser.saveRefCache(session.targetId, result.url, session.refs);
75
+
76
+ const applyOutputLimits = (text: string): string => {
77
+ const maxOutput = ctx.flags['max-output'] as number | undefined;
78
+ let out = maxOutput ? truncateForOutput(text, maxOutput) : text;
79
+ if (ctx.flags['content-boundaries']) {
80
+ const nonce = Math.random().toString(36).slice(2, 10);
81
+ out = `MONOMIND_PAGE_CONTENT nonce=${nonce} origin=${result.url}\n${out}\nEND_MONOMIND_PAGE_CONTENT nonce=${nonce}`;
82
+ }
83
+ return out;
84
+ };
85
+
86
+ // --save: write snapshot text to baseline file
87
+ if (ctx.flags.save) {
88
+ const { writeFile, mkdir } = await import('node:fs/promises');
89
+ const { dirname } = await import('node:path');
90
+ const savePath = ctx.flags.save as string;
91
+ await mkdir(dirname(savePath), { recursive: true }).catch(() => {});
92
+ await writeFile(savePath, result.text, 'utf8');
93
+ output.printSuccess(`Snapshot saved to: ${savePath}`);
94
+ return { success: true, data: { path: savePath } };
95
+ }
96
+
97
+ // --diff: compare against baseline file
98
+ if (ctx.flags.diff) {
99
+ const { readFile } = await import('node:fs/promises');
100
+ const baselinePath = ctx.flags.diff as string;
101
+ let baseline: string;
102
+ try {
103
+ baseline = await readFile(baselinePath, 'utf8');
104
+ } catch {
105
+ throw new Error(`Baseline not found: ${baselinePath}. Run snapshot --save first.`);
106
+ }
107
+ const currentLines = result.text.split('\n');
108
+ const baselineLines = baseline.split('\n');
109
+ const added: string[] = [],
110
+ removed: string[] = [];
111
+ const baseSet = new Set(baselineLines);
112
+ const curSet = new Set(currentLines);
113
+ for (const l of currentLines) if (!baseSet.has(l)) added.push(l);
114
+ for (const l of baselineLines) if (!curSet.has(l)) removed.push(l);
115
+ const changed = added.length > 0 || removed.length > 0;
116
+ if (ctx.flags.json) {
117
+ print(
118
+ JSON.stringify({
119
+ changed,
120
+ additions: added.length,
121
+ removals: removed.length,
122
+ added,
123
+ removed,
124
+ }),
125
+ );
126
+ } else {
127
+ if (!changed) {
128
+ output.printSuccess('No snapshot changes detected');
129
+ } else {
130
+ output.printWarning(`Snapshot changed: +${added.length} lines, -${removed.length} lines`);
131
+ for (const l of added) print(`\x1b[32m+ ${l}\x1b[0m`);
132
+ for (const l of removed) print(`\x1b[31m- ${l}\x1b[0m`);
133
+ }
134
+ }
135
+ return {
136
+ success: true,
137
+ data: { changed, additions: added.length, removals: removed.length },
138
+ };
139
+ }
140
+
141
+ if (ctx.flags.json) {
142
+ const refsObj = Object.fromEntries([...result.refs.entries()].map(([k, v]) => [k, v]));
143
+ print(
144
+ JSON.stringify({
145
+ url: result.url,
146
+ title: result.title,
147
+ refs: refsObj,
148
+ snapshot: result.text,
149
+ }),
150
+ );
151
+ } else {
152
+ print(`[${result.title}] ${result.url}\n`);
153
+ print(applyOutputLimits(result.text));
154
+ }
155
+
156
+ return { success: true, data: result };
157
+ },
158
+ };
159
+
160
+ export const getCommand: Command = {
161
+ name: 'get',
162
+ description:
163
+ 'Get page info. Usage: monomind browse get url|title|text|html|value|attr|count|box|styles [@ref] [attrName]',
164
+ options: [{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false }],
165
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
166
+ const { client, sessionId } = await ensureConnected(session.port);
167
+ const browser = await getBrowser();
168
+
169
+ const what = ctx.args[0] as string;
170
+ if (!what)
171
+ throw new Error('Usage: monomind browse get url|title|text|html|value|attr|count|box|styles');
172
+
173
+ let value: unknown;
174
+
175
+ switch (what) {
176
+ case 'url':
177
+ value = await browser.getCurrentUrl(client, sessionId);
178
+ break;
179
+ case 'title':
180
+ value = await browser.getCurrentTitle(client, sessionId);
181
+ break;
182
+ case 'text': {
183
+ const refArg = ctx.args[1] as string | undefined;
184
+ if (refArg) {
185
+ const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
186
+ const ref = session.refs.get(refKey);
187
+ if (!ref) throw new Error(`Ref @${refKey} not found`);
188
+ const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
189
+ if (!objectId) throw new Error('Element not in DOM');
190
+ const result = await client.send<{ result: { value?: string } }>(
191
+ 'Runtime.callFunctionOn',
192
+ {
193
+ functionDeclaration:
194
+ 'function() { return this.innerText || this.textContent || ""; }',
195
+ objectId,
196
+ returnByValue: true,
197
+ },
198
+ sessionId,
199
+ );
200
+ value = result.result?.value ?? '';
201
+ } else {
202
+ value = (await browser.evaluateJs(
203
+ client,
204
+ sessionId,
205
+ 'document.body?.innerText ?? ""',
206
+ )) as string;
207
+ }
208
+ break;
209
+ }
210
+ case 'html':
211
+ value = (await browser.evaluateJs(
212
+ client,
213
+ sessionId,
214
+ 'document.documentElement.outerHTML',
215
+ )) as string;
216
+ break;
217
+ case 'value': {
218
+ const refArg = ctx.args[1] as string;
219
+ if (!refArg) throw new Error('Usage: monomind browse get value @ref');
220
+ const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
221
+ const ref = session.refs.get(refKey);
222
+ if (!ref) throw new Error(`Ref @${refKey} not found`);
223
+ const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
224
+ if (!objectId) throw new Error('Element not in DOM');
225
+ const r = await client.send<{ result: { value?: string } }>(
226
+ 'Runtime.callFunctionOn',
227
+ {
228
+ functionDeclaration: 'function() { return this.value ?? null; }',
229
+ objectId,
230
+ returnByValue: true,
231
+ },
232
+ sessionId,
233
+ );
234
+ value = r.result?.value ?? null;
235
+ break;
236
+ }
237
+ case 'attr': {
238
+ const refArg = ctx.args[1] as string;
239
+ const attrName = ctx.args[2] as string;
240
+ if (!refArg || !attrName)
241
+ throw new Error('Usage: monomind browse get attr @ref <attrName>');
242
+ const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
243
+ const ref = session.refs.get(refKey);
244
+ if (!ref) throw new Error(`Ref @${refKey} not found`);
245
+ const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
246
+ if (!objectId) throw new Error('Element not in DOM');
247
+ const r = await client.send<{ result: { value?: string } }>(
248
+ 'Runtime.callFunctionOn',
249
+ {
250
+ functionDeclaration: `function() { return this.getAttribute(${JSON.stringify(attrName)}); }`,
251
+ objectId,
252
+ returnByValue: true,
253
+ },
254
+ sessionId,
255
+ );
256
+ value = r.result?.value ?? null;
257
+ break;
258
+ }
259
+ case 'count': {
260
+ const selector = ctx.args[1] as string;
261
+ if (!selector) throw new Error('Usage: monomind browse get count <cssSelector>');
262
+ value = await browser.evaluateJs(
263
+ client,
264
+ sessionId,
265
+ `document.querySelectorAll(${JSON.stringify(selector)}).length`,
266
+ );
267
+ break;
268
+ }
269
+ case 'box': {
270
+ const refArg = ctx.args[1] as string;
271
+ if (!refArg) throw new Error('Usage: monomind browse get box @ref');
272
+ const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
273
+ const ref = session.refs.get(refKey);
274
+ if (!ref) throw new Error(`Ref @${refKey} not found`);
275
+ const center = await browser.getElementBox(client, sessionId, ref);
276
+ value = deriveBoxOutput(center);
277
+ break;
278
+ }
279
+ case 'styles': {
280
+ const refArg = ctx.args[1] as string;
281
+ if (!refArg) throw new Error('Usage: monomind browse get styles @ref');
282
+ const refKey = refArg.startsWith('@') ? refArg.slice(1) : refArg;
283
+ const ref = session.refs.get(refKey);
284
+ if (!ref) throw new Error(`Ref @${refKey} not found`);
285
+ const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
286
+ if (!objectId) throw new Error('Element not in DOM');
287
+ const r = await client.send<{ result: { value?: string } }>(
288
+ 'Runtime.callFunctionOn',
289
+ {
290
+ functionDeclaration:
291
+ 'function() { const s = window.getComputedStyle(this); return JSON.stringify(Object.fromEntries([...s].map(k => [k, s.getPropertyValue(k)]))); }',
292
+ objectId,
293
+ returnByValue: true,
294
+ },
295
+ sessionId,
296
+ );
297
+ try {
298
+ value = JSON.parse(r.result?.value ?? '{}');
299
+ } catch {
300
+ value = {};
301
+ }
302
+ break;
303
+ }
304
+ default:
305
+ throw new Error(`Unknown: ${what}. Use: url|title|text|html|value|attr|count|box|styles`);
306
+ }
307
+
308
+ if (ctx.flags.json) {
309
+ print(JSON.stringify({ data: { [what]: value } }));
310
+ } else {
311
+ print(typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value ?? ''));
312
+ }
313
+
314
+ return { success: true, data: { [what]: value } };
315
+ },
316
+ };
317
+
318
+ export function deriveBoxOutput(
319
+ center: { x: number; y: number; width: number; height: number } | null,
320
+ ): {
321
+ x: number;
322
+ y: number;
323
+ width: number;
324
+ height: number;
325
+ centerX: number;
326
+ centerY: number;
327
+ } | null {
328
+ if (!center) return null;
329
+ return {
330
+ x: center.x - center.width / 2,
331
+ y: center.y - center.height / 2,
332
+ width: center.width,
333
+ height: center.height,
334
+ centerX: center.x,
335
+ centerY: center.y,
336
+ };
337
+ }
338
+
339
+ export const diffCommand: Command = {
340
+ name: 'diff',
341
+ description:
342
+ 'Compare two URLs or snapshots. Usage: monomind browse diff url <url1> <url2> [--interactive] [--json]',
343
+ options: [
344
+ {
345
+ name: 'interactive',
346
+ short: 'i',
347
+ type: 'boolean',
348
+ description: 'Snapshot interactive elements only',
349
+ default: false,
350
+ },
351
+ { name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
352
+ ],
353
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
354
+ const { client, sessionId } = await ensureConnected(session.port);
355
+ const browser = await getBrowser();
356
+ const subAction = ctx.args[0] as string;
357
+
358
+ if (subAction === 'url') {
359
+ const url1 = ctx.args[1] as string;
360
+ const url2 = ctx.args[2] as string;
361
+ if (!url1 || !url2) throw new Error('Usage: monomind browse diff url <url1> <url2>');
362
+
363
+ // Capture snapshot at url1
364
+ await browser.openUrl(client, sessionId, url1);
365
+ await browser.waitFor(client, sessionId, { load: 'load', timeout: 15000 });
366
+ const snap1 = await browser.captureSnapshot(client, sessionId, {
367
+ interactiveOnly: ctx.flags.interactive as boolean,
368
+ });
369
+
370
+ // Capture snapshot at url2
371
+ await browser.openUrl(client, sessionId, url2);
372
+ await browser.waitFor(client, sessionId, { load: 'load', timeout: 15000 });
373
+ const snap2 = await browser.captureSnapshot(client, sessionId, {
374
+ interactiveOnly: ctx.flags.interactive as boolean,
375
+ });
376
+
377
+ session.refs = snap2.refs;
378
+ await browser.saveRefCache(session.targetId, snap2.url, session.refs);
379
+
380
+ // Text diff
381
+ const lines1 = snap1.text.split('\n');
382
+ const lines2 = snap2.text.split('\n');
383
+ const set1 = new Set(lines1);
384
+ const set2 = new Set(lines2);
385
+ const onlyIn1: string[] = lines1.filter((l) => !set2.has(l));
386
+ const onlyIn2: string[] = lines2.filter((l) => !set1.has(l));
387
+ const changed = onlyIn1.length > 0 || onlyIn2.length > 0;
388
+
389
+ if (ctx.flags.json) {
390
+ print(
391
+ JSON.stringify({
392
+ changed,
393
+ url1,
394
+ url2,
395
+ onlyIn1,
396
+ onlyIn2,
397
+ additions: onlyIn2.length,
398
+ removals: onlyIn1.length,
399
+ }),
400
+ );
401
+ } else {
402
+ if (!changed) {
403
+ output.printSuccess(`No differences between ${url1} and ${url2}`);
404
+ } else {
405
+ output.printWarning(
406
+ `Diff: ${url1} vs ${url2} — +${onlyIn2.length} lines, -${onlyIn1.length} lines`,
407
+ );
408
+ for (const l of onlyIn1) print(`\x1b[31m- ${l}\x1b[0m`);
409
+ for (const l of onlyIn2) print(`\x1b[32m+ ${l}\x1b[0m`);
410
+ }
411
+ }
412
+ return {
413
+ success: true,
414
+ data: { changed, url1, url2, additions: onlyIn2.length, removals: onlyIn1.length },
415
+ };
416
+ }
417
+
418
+ throw new Error('Usage: monomind browse diff url <url1> <url2>');
419
+ },
420
+ };
@@ -0,0 +1,275 @@
1
+ /**
2
+ * `report` — one command that opens a page, runs the checks (console errors,
3
+ * failed requests, Web Vitals against budgets, accessibility), writes an HTML
4
+ * report and returns a pass/fail verdict.
5
+ */
6
+
7
+ import { output } from './output.js';
8
+ import { ensureConnected, getBrowser, print, session } from './session.js';
9
+ import type { Command, CommandContext, CommandResult } from './types.js';
10
+
11
+ export const reportCommand: Command = {
12
+ name: 'report',
13
+ description:
14
+ 'Test a page and write one self-contained HTML report + sibling JSON. Usage: monomind browse report <url>',
15
+ options: [
16
+ { name: 'out', short: 'o', type: 'string', description: 'Output .html path, or a directory' },
17
+ {
18
+ name: 'budget',
19
+ short: 'b',
20
+ type: 'string',
21
+ description: 'Budget JSON file path, or inline JSON',
22
+ },
23
+ {
24
+ name: 'devices',
25
+ short: 'd',
26
+ type: 'string',
27
+ description: 'Comma-separated device names for the screenshot matrix',
28
+ },
29
+ {
30
+ name: 'wait',
31
+ short: 'w',
32
+ type: 'string',
33
+ description: 'Extra settle step: a CSS selector to wait for, or milliseconds',
34
+ },
35
+ { name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
36
+ {
37
+ name: 'full-page',
38
+ type: 'boolean',
39
+ description: 'Full-page screenshot (--no-full-page for viewport only)',
40
+ default: true,
41
+ },
42
+ {
43
+ name: 'vitals-wait',
44
+ type: 'number',
45
+ description: 'Milliseconds to let the web-vitals observers run',
46
+ default: 2500,
47
+ },
48
+ {
49
+ name: 'timeout',
50
+ type: 'number',
51
+ description: 'Milliseconds to wait for the page to go network-idle',
52
+ default: 20000,
53
+ },
54
+ {
55
+ name: 'keep-open',
56
+ type: 'boolean',
57
+ description: 'Leave the browser running after the report',
58
+ default: false,
59
+ },
60
+ {
61
+ name: 'repeat',
62
+ type: 'number',
63
+ description: 'Run the URL N times and report per-check flake rates (RIG-14)',
64
+ },
65
+ {
66
+ name: 'record',
67
+ type: 'boolean',
68
+ description: 'Record frames for the evidence timeline even when the run passes',
69
+ default: false,
70
+ },
71
+ {
72
+ name: 'history',
73
+ type: 'boolean',
74
+ description: 'Print the stored run history for this URL instead of running it',
75
+ default: false,
76
+ },
77
+ {
78
+ name: 'save',
79
+ type: 'boolean',
80
+ description: 'Save this run to the history store (--no-save to skip)',
81
+ default: true,
82
+ },
83
+ {
84
+ name: 'history-max',
85
+ type: 'number',
86
+ description: 'Runs kept per URL before the oldest are pruned (default 20)',
87
+ },
88
+ {
89
+ name: 'trend-window',
90
+ type: 'number',
91
+ description: 'Prior runs charted in the trend section (default 10)',
92
+ },
93
+ ],
94
+ examples: [
95
+ { command: 'monomind browse report https://example.com', description: 'Report with defaults' },
96
+ {
97
+ command: 'monomind browse report https://example.com --repeat 5',
98
+ description: 'Run five times and report flake rates',
99
+ },
100
+ {
101
+ command: 'monomind browse report https://example.com --history',
102
+ description: 'Show the stored run history for a URL',
103
+ },
104
+ {
105
+ command: 'monomind browse report https://example.com --record',
106
+ description: 'Attach a frame-by-frame evidence timeline',
107
+ },
108
+ {
109
+ command: 'monomind browse report https://example.com --out ./reports/home.html',
110
+ description: 'Choose the output path',
111
+ },
112
+ {
113
+ command: 'monomind browse report https://example.com --budget \'{"lcp":4000}\'',
114
+ description: 'Relax a budget inline',
115
+ },
116
+ {
117
+ command: 'monomind browse report https://example.com --devices "iPhone 14,iPad"',
118
+ description: 'Add a device screenshot matrix',
119
+ },
120
+ ],
121
+ action: async (ctx: CommandContext): Promise<CommandResult> => {
122
+ const url = ctx.args[0] as string;
123
+ if (!url) throw new Error('URL required. Usage: monomind browse report <url>');
124
+
125
+ // Imported lazily for the same reason getBrowser() is: this module loads
126
+ // on every CLI invocation, and nothing but `report` needs the renderer.
127
+ const { runReport, runReportRepeated, readHistory } = await import('../report/index.js');
128
+
129
+ // --history is a read of the store, so it deliberately never launches a
130
+ // browser — `report --history` must work offline and on a dead site.
131
+ if (ctx.flags.history) {
132
+ const { dir, runs } = await readHistory(url);
133
+ if (ctx.flags.json) {
134
+ print(JSON.stringify({ data: { dir, runs } }, null, 2));
135
+ } else if (!runs.length) {
136
+ output.printWarning(`No stored runs for ${url}`);
137
+ print(`Looked in: ${dir}`);
138
+ } else {
139
+ print(`${runs.length} run(s) for ${url}`);
140
+ print(`Stored in: ${dir}`);
141
+ for (const run of runs) {
142
+ const lcp = run.vitals.lcp === undefined ? '—' : `${Math.round(run.vitals.lcp)}ms`;
143
+ print(
144
+ ` ${run.capturedAt} ${run.verdict.toUpperCase().padEnd(4)} ` +
145
+ `LCP ${lcp.padStart(7)} ${run.counts.consoleErrors} console errors ` +
146
+ `${run.counts.failedRequests} failed requests ${run.counts.a11yErrors} a11y errors`,
147
+ );
148
+ }
149
+ }
150
+ return { success: true, data: { dir, runs } };
151
+ }
152
+
153
+ const repeatRaw = ctx.flags.repeat as number | undefined;
154
+ const repeat =
155
+ typeof repeatRaw === 'number' && Number.isFinite(repeatRaw) && repeatRaw > 1
156
+ ? Math.floor(repeatRaw)
157
+ : undefined;
158
+
159
+ const { client, sessionId } = await ensureConnected(session.port);
160
+ const browser = await getBrowser();
161
+
162
+ const rawDevices = ctx.flags.devices;
163
+ const devices =
164
+ typeof rawDevices === 'string'
165
+ ? rawDevices
166
+ .split(',')
167
+ .map((d) => d.trim())
168
+ .filter(Boolean)
169
+ : undefined;
170
+
171
+ const runOptions = {
172
+ url,
173
+ out: ctx.flags.out as string | undefined,
174
+ budget: ctx.flags.budget as string | undefined,
175
+ devices,
176
+ wait: ctx.flags.wait as string | undefined,
177
+ vitalsWaitMs: ctx.flags['vitals-wait'] as number | undefined,
178
+ loadTimeoutMs: ctx.flags.timeout as number | undefined,
179
+ fullPage: ctx.flags['full-page'] !== false,
180
+ cwd: ctx.cwd,
181
+ // A failing run records its own evidence without being asked; --record
182
+ // is for when you want the timeline from a run that passes.
183
+ record: ctx.flags.record === true,
184
+ history: ctx.flags.save !== false,
185
+ historyMax: ctx.flags['history-max'] as number | undefined,
186
+ trendWindow: ctx.flags['trend-window'] as number | undefined,
187
+ };
188
+
189
+ let result:
190
+ | Awaited<ReturnType<typeof runReport>>
191
+ | Awaited<ReturnType<typeof runReportRepeated>>;
192
+ try {
193
+ result = repeat
194
+ ? await runReportRepeated(client, sessionId, { ...runOptions, repeat })
195
+ : await runReport(client, sessionId, runOptions);
196
+ } finally {
197
+ // A report is a one-shot command — CI should not be left with an
198
+ // orphan Chrome. Only close a browser THIS process launched: an
199
+ // attached one belongs to the user's own `open`/`connect` session.
200
+ if (!ctx.flags['keep-open'] && browser.getLaunchedPid(session.port) !== undefined) {
201
+ browser.stopRequestCapture(sessionId);
202
+ browser.teardownConsoleCapture(sessionId);
203
+ try {
204
+ await browser.closeBrowser(client, session.port);
205
+ } catch {
206
+ /* best-effort */
207
+ }
208
+ session.client = null;
209
+ session.sessionId = '';
210
+ session.targetId = '';
211
+ session.refs = new Map();
212
+ await browser.clearActivePort();
213
+ await browser.clearRefCache();
214
+ }
215
+ }
216
+
217
+ const { report, htmlPath, jsonPath, summary, historyDir } = result;
218
+ const { flake } = result;
219
+ // With --repeat the flake verdict governs: a check that failed 2 of 5
220
+ // runs must not exit 0 just because the last run happened to be green.
221
+ const passed = flake ? flake.verdict === 'pass' : report.verdict === 'pass';
222
+
223
+ if (ctx.flags.json) {
224
+ const { toJsonReport } = await import('../report/index.js');
225
+ print(
226
+ JSON.stringify(
227
+ { data: { ...toJsonReport(report), flake, htmlPath, jsonPath, historyDir } },
228
+ null,
229
+ 2,
230
+ ),
231
+ );
232
+ } else {
233
+ if (passed) output.printSuccess(summary);
234
+ else output.printError(summary);
235
+ if (flake) {
236
+ print(` ${flake.confidenceNote}`);
237
+ for (const check of flake.checks.filter((c) => !c.stable)) {
238
+ print(` ${check.label}: failed ${check.failed} of ${check.runs} runs`);
239
+ }
240
+ for (const signal of flake.signals.slice(0, 5)) {
241
+ print(
242
+ ` ${signal.kind} "${signal.signature}" in ${signal.runs} of ${signal.total} runs`,
243
+ );
244
+ }
245
+ }
246
+ for (const failure of report.failures) {
247
+ print(` ${failure.budget}: expected ${failure.expected}, got ${failure.actual}`);
248
+ if (failure.detail) print(` ${failure.detail}`);
249
+ }
250
+ for (const headline of report.trend?.headlines ?? []) output.printWarning(headline);
251
+ if (report.diff?.structure) {
252
+ const d = report.diff.structure;
253
+ print(
254
+ ` vs previous run: ${d.gained.length} gained, ${d.lost.length} lost, ${d.renamed.length} renamed`,
255
+ );
256
+ }
257
+ if (report.diff?.pixels?.comparable) {
258
+ print(
259
+ ` vs previous run: ${report.diff.pixels.changedPercent.toFixed(2)}% of pixels changed`,
260
+ );
261
+ }
262
+ for (const note of report.notes) output.printWarning(note);
263
+ print(`Report: ${htmlPath}`);
264
+ print(`JSON: ${jsonPath}`);
265
+ if (historyDir) print(`History: ${historyDir}`);
266
+ }
267
+
268
+ // Non-zero exit is the point of RIG-06 — CI and agents gate on it.
269
+ return {
270
+ success: passed,
271
+ exitCode: passed ? 0 : 1,
272
+ data: { htmlPath, jsonPath, report, flake },
273
+ };
274
+ },
275
+ };