@monoes/monobrowse 1.0.12 → 1.0.13

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 (175) hide show
  1. package/dist/src/__tests__/profiler-heap.test.d.ts +2 -0
  2. package/dist/src/__tests__/profiler-heap.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/profiler-heap.test.js +154 -0
  4. package/dist/src/__tests__/profiler-heap.test.js.map +1 -0
  5. package/dist/src/__tests__/report-a11y.test.d.ts +2 -0
  6. package/dist/src/__tests__/report-a11y.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/report-a11y.test.js +147 -0
  8. package/dist/src/__tests__/report-a11y.test.js.map +1 -0
  9. package/dist/src/__tests__/report-analyze.test.d.ts +2 -0
  10. package/dist/src/__tests__/report-analyze.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/report-analyze.test.js +157 -0
  12. package/dist/src/__tests__/report-analyze.test.js.map +1 -0
  13. package/dist/src/__tests__/report-budget.test.d.ts +2 -0
  14. package/dist/src/__tests__/report-budget.test.d.ts.map +1 -0
  15. package/dist/src/__tests__/report-budget.test.js +80 -0
  16. package/dist/src/__tests__/report-budget.test.js.map +1 -0
  17. package/dist/src/__tests__/report-diff.test.d.ts +2 -0
  18. package/dist/src/__tests__/report-diff.test.d.ts.map +1 -0
  19. package/dist/src/__tests__/report-diff.test.js +231 -0
  20. package/dist/src/__tests__/report-diff.test.js.map +1 -0
  21. package/dist/src/__tests__/report-evidence.test.d.ts +2 -0
  22. package/dist/src/__tests__/report-evidence.test.d.ts.map +1 -0
  23. package/dist/src/__tests__/report-evidence.test.js +214 -0
  24. package/dist/src/__tests__/report-evidence.test.js.map +1 -0
  25. package/dist/src/__tests__/report-flake.test.d.ts +2 -0
  26. package/dist/src/__tests__/report-flake.test.d.ts.map +1 -0
  27. package/dist/src/__tests__/report-flake.test.js +182 -0
  28. package/dist/src/__tests__/report-flake.test.js.map +1 -0
  29. package/dist/src/__tests__/report-history.test.d.ts +2 -0
  30. package/dist/src/__tests__/report-history.test.d.ts.map +1 -0
  31. package/dist/src/__tests__/report-history.test.js +229 -0
  32. package/dist/src/__tests__/report-history.test.js.map +1 -0
  33. package/dist/src/__tests__/report-output.test.d.ts +2 -0
  34. package/dist/src/__tests__/report-output.test.d.ts.map +1 -0
  35. package/dist/src/__tests__/report-output.test.js +169 -0
  36. package/dist/src/__tests__/report-output.test.js.map +1 -0
  37. package/dist/src/__tests__/report-png.test.d.ts +2 -0
  38. package/dist/src/__tests__/report-png.test.d.ts.map +1 -0
  39. package/dist/src/__tests__/report-png.test.js +84 -0
  40. package/dist/src/__tests__/report-png.test.js.map +1 -0
  41. package/dist/src/__tests__/report-render-extras.test.d.ts +2 -0
  42. package/dist/src/__tests__/report-render-extras.test.d.ts.map +1 -0
  43. package/dist/src/__tests__/report-render-extras.test.js +285 -0
  44. package/dist/src/__tests__/report-render-extras.test.js.map +1 -0
  45. package/dist/src/__tests__/report-render.test.d.ts +2 -0
  46. package/dist/src/__tests__/report-render.test.d.ts.map +1 -0
  47. package/dist/src/__tests__/report-render.test.js +143 -0
  48. package/dist/src/__tests__/report-render.test.js.map +1 -0
  49. package/dist/src/__tests__/report-run.test.d.ts +2 -0
  50. package/dist/src/__tests__/report-run.test.d.ts.map +1 -0
  51. package/dist/src/__tests__/report-run.test.js +234 -0
  52. package/dist/src/__tests__/report-run.test.js.map +1 -0
  53. package/dist/src/browser/index.d.ts +1 -0
  54. package/dist/src/browser/index.d.ts.map +1 -1
  55. package/dist/src/browser/index.js +1 -0
  56. package/dist/src/browser/index.js.map +1 -1
  57. package/dist/src/browser/profiler.d.ts.map +1 -1
  58. package/dist/src/browser/profiler.js +19 -22
  59. package/dist/src/browser/profiler.js.map +1 -1
  60. package/dist/src/cli/commands.d.ts.map +1 -1
  61. package/dist/src/cli/commands.js +254 -0
  62. package/dist/src/cli/commands.js.map +1 -1
  63. package/dist/src/report/a11y.d.ts +52 -0
  64. package/dist/src/report/a11y.d.ts.map +1 -0
  65. package/dist/src/report/a11y.js +231 -0
  66. package/dist/src/report/a11y.js.map +1 -0
  67. package/dist/src/report/analyze.d.ts +20 -0
  68. package/dist/src/report/analyze.d.ts.map +1 -0
  69. package/dist/src/report/analyze.js +136 -0
  70. package/dist/src/report/analyze.js.map +1 -0
  71. package/dist/src/report/budget.d.ts +27 -0
  72. package/dist/src/report/budget.d.ts.map +1 -0
  73. package/dist/src/report/budget.js +143 -0
  74. package/dist/src/report/budget.js.map +1 -0
  75. package/dist/src/report/collect-a11y.d.ts +25 -0
  76. package/dist/src/report/collect-a11y.d.ts.map +1 -0
  77. package/dist/src/report/collect-a11y.js +154 -0
  78. package/dist/src/report/collect-a11y.js.map +1 -0
  79. package/dist/src/report/collect.d.ts +45 -0
  80. package/dist/src/report/collect.d.ts.map +1 -0
  81. package/dist/src/report/collect.js +275 -0
  82. package/dist/src/report/collect.js.map +1 -0
  83. package/dist/src/report/evidence.d.ts +80 -0
  84. package/dist/src/report/evidence.d.ts.map +1 -0
  85. package/dist/src/report/evidence.js +279 -0
  86. package/dist/src/report/evidence.js.map +1 -0
  87. package/dist/src/report/flake.d.ts +88 -0
  88. package/dist/src/report/flake.d.ts.map +1 -0
  89. package/dist/src/report/flake.js +232 -0
  90. package/dist/src/report/flake.js.map +1 -0
  91. package/dist/src/report/history.d.ts +85 -0
  92. package/dist/src/report/history.d.ts.map +1 -0
  93. package/dist/src/report/history.js +176 -0
  94. package/dist/src/report/history.js.map +1 -0
  95. package/dist/src/report/index.d.ts +102 -0
  96. package/dist/src/report/index.d.ts.map +1 -0
  97. package/dist/src/report/index.js +216 -0
  98. package/dist/src/report/index.js.map +1 -0
  99. package/dist/src/report/pixel-diff.d.ts +30 -0
  100. package/dist/src/report/pixel-diff.d.ts.map +1 -0
  101. package/dist/src/report/pixel-diff.js +175 -0
  102. package/dist/src/report/pixel-diff.js.map +1 -0
  103. package/dist/src/report/png.d.ts +28 -0
  104. package/dist/src/report/png.d.ts.map +1 -0
  105. package/dist/src/report/png.js +259 -0
  106. package/dist/src/report/png.js.map +1 -0
  107. package/dist/src/report/render-evidence.d.ts +16 -0
  108. package/dist/src/report/render-evidence.d.ts.map +1 -0
  109. package/dist/src/report/render-evidence.js +174 -0
  110. package/dist/src/report/render-evidence.js.map +1 -0
  111. package/dist/src/report/render-trend.d.ts +16 -0
  112. package/dist/src/report/render-trend.d.ts.map +1 -0
  113. package/dist/src/report/render-trend.js +194 -0
  114. package/dist/src/report/render-trend.js.map +1 -0
  115. package/dist/src/report/render.d.ts +21 -0
  116. package/dist/src/report/render.d.ts.map +1 -0
  117. package/dist/src/report/render.js +286 -0
  118. package/dist/src/report/render.js.map +1 -0
  119. package/dist/src/report/run-diff.d.ts +18 -0
  120. package/dist/src/report/run-diff.d.ts.map +1 -0
  121. package/dist/src/report/run-diff.js +51 -0
  122. package/dist/src/report/run-diff.js.map +1 -0
  123. package/dist/src/report/structure.d.ts +41 -0
  124. package/dist/src/report/structure.d.ts.map +1 -0
  125. package/dist/src/report/structure.js +210 -0
  126. package/dist/src/report/structure.js.map +1 -0
  127. package/dist/src/report/trend.d.ts +26 -0
  128. package/dist/src/report/trend.d.ts.map +1 -0
  129. package/dist/src/report/trend.js +164 -0
  130. package/dist/src/report/trend.js.map +1 -0
  131. package/dist/src/report/types.d.ts +320 -0
  132. package/dist/src/report/types.d.ts.map +1 -0
  133. package/dist/src/report/types.js +11 -0
  134. package/dist/src/report/types.js.map +1 -0
  135. package/dist/src/report/util.d.ts +17 -0
  136. package/dist/src/report/util.d.ts.map +1 -0
  137. package/dist/src/report/util.js +45 -0
  138. package/dist/src/report/util.js.map +1 -0
  139. package/dist/tsconfig.tsbuildinfo +1 -1
  140. package/package.json +1 -1
  141. package/src/__tests__/profiler-heap.test.ts +191 -0
  142. package/src/__tests__/report-a11y.test.ts +180 -0
  143. package/src/__tests__/report-analyze.test.ts +202 -0
  144. package/src/__tests__/report-budget.test.ts +92 -0
  145. package/src/__tests__/report-diff.test.ts +272 -0
  146. package/src/__tests__/report-evidence.test.ts +276 -0
  147. package/src/__tests__/report-flake.test.ts +214 -0
  148. package/src/__tests__/report-history.test.ts +293 -0
  149. package/src/__tests__/report-output.test.ts +210 -0
  150. package/src/__tests__/report-png.test.ts +95 -0
  151. package/src/__tests__/report-render-extras.test.ts +315 -0
  152. package/src/__tests__/report-render.test.ts +175 -0
  153. package/src/__tests__/report-run.test.ts +264 -0
  154. package/src/browser/index.ts +1 -0
  155. package/src/browser/profiler.ts +28 -21
  156. package/src/cli/commands.ts +272 -0
  157. package/src/report/a11y.ts +242 -0
  158. package/src/report/analyze.ts +181 -0
  159. package/src/report/budget.ts +155 -0
  160. package/src/report/collect-a11y.ts +200 -0
  161. package/src/report/collect.ts +408 -0
  162. package/src/report/evidence.ts +379 -0
  163. package/src/report/flake.ts +303 -0
  164. package/src/report/history.ts +219 -0
  165. package/src/report/index.ts +307 -0
  166. package/src/report/pixel-diff.ts +197 -0
  167. package/src/report/png.ts +290 -0
  168. package/src/report/render-evidence.ts +194 -0
  169. package/src/report/render-trend.ts +225 -0
  170. package/src/report/render.ts +314 -0
  171. package/src/report/run-diff.ts +63 -0
  172. package/src/report/structure.ts +212 -0
  173. package/src/report/trend.ts +189 -0
  174. package/src/report/types.ts +356 -0
  175. package/src/report/util.ts +48 -0
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Persistent run history for `monobrowse report` (RIG-13).
3
+ *
4
+ * Every run is appended to a per-URL directory under the monomind data dir,
5
+ * so the next run can say "LCP has crept up 400ms over nine runs" instead of
6
+ * reporting a number with no context. Deliberately not a database: one JSON
7
+ * file per run plus its screenshot is greppable, diffable, rsync-able, and
8
+ * survives this tool being uninstalled.
9
+ *
10
+ * ~/.monomind/browser-reports/
11
+ * shop.test-checkout-1f4c9a2b/
12
+ * url.txt <- the full URL, for humans
13
+ * 20260921T100000Z-8f2a1c4d.json <- HistoryRun
14
+ * 20260921T100000Z-8f2a1c4d.png <- main screenshot, for the diff
15
+ *
16
+ * Set `MONOBROWSE_HISTORY_DIR` to relocate the root (the tests do).
17
+ */
18
+
19
+ import { createHash, randomBytes } from 'node:crypto';
20
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
21
+ import { homedir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { pngFromDataUrl } from './png.js';
24
+ import type { ImageSize, Report, StructureNode, VerdictValue, VitalsData } from './types.js';
25
+
26
+ /** Runs kept per URL before the oldest are pruned. Override with --history-max. */
27
+ export const DEFAULT_HISTORY_MAX = 20;
28
+
29
+ /**
30
+ * Screenshots above this are not archived for the diff. A 30MB full-page PNG
31
+ * per run would turn a 20-run history into 600MB on someone's laptop, and the
32
+ * pixel diff is the only consumer.
33
+ */
34
+ const MAX_ARCHIVED_SCREENSHOT_BYTES = 8 * 1024 * 1024;
35
+
36
+ export interface HistoryRun {
37
+ /** `<compact ISO timestamp>-<8 hex>`, also the basename of its files. */
38
+ id: string;
39
+ url: string;
40
+ finalUrl: string;
41
+ title: string;
42
+ capturedAt: string;
43
+ durationMs: number;
44
+ verdict: VerdictValue;
45
+ counts: Report['counts'];
46
+ vitals: VitalsData;
47
+ failures: Array<{ budget: string; expected: string; actual: string }>;
48
+ /** `<rule>@<locator>` per finding, so a11y regressions are attributable. */
49
+ a11ySignatures: string[];
50
+ structure: StructureNode[];
51
+ /** Basename of the sibling PNG, when one was archived. */
52
+ screenshotFile?: string;
53
+ screenshotSize?: ImageSize;
54
+ }
55
+
56
+ export function historyRoot(): string {
57
+ const override = process.env.MONOBROWSE_HISTORY_DIR;
58
+ if (override?.trim()) return override.trim();
59
+ return join(homedir(), '.monomind', 'browser-reports');
60
+ }
61
+
62
+ /**
63
+ * Directory name for a URL: readable prefix plus a hash of the full URL.
64
+ *
65
+ * The hash is what makes it correct — `?page=2` and `?page=3` must not share
66
+ * a history — and the prefix is what makes `ls` useful.
67
+ */
68
+ export function historyKey(url: string): string {
69
+ const hash = createHash('sha256').update(url).digest('hex').slice(0, 8);
70
+ let readable: string;
71
+ try {
72
+ const parsed = new URL(url);
73
+ const path = parsed.pathname.replace(/\/+$/, '').replace(/[^a-z0-9]+/gi, '-');
74
+ readable = `${parsed.hostname}${path}`;
75
+ } catch {
76
+ readable = url.replace(/[^a-z0-9]+/gi, '-');
77
+ }
78
+ readable = readable.replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase().slice(0, 60);
79
+ return `${readable || 'page'}-${hash}`;
80
+ }
81
+
82
+ export function historyDirFor(url: string, root = historyRoot()): string {
83
+ return join(root, historyKey(url));
84
+ }
85
+
86
+ export function compactTimestamp(iso: string): string {
87
+ return iso.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
88
+ }
89
+
90
+ export function newRunId(capturedAt: string): string {
91
+ return `${compactTimestamp(capturedAt)}-${randomBytes(4).toString('hex')}`;
92
+ }
93
+
94
+ /** The record we persist — a Report minus the megabytes of inline base64. */
95
+ export function toHistoryRun(report: Report, id: string): HistoryRun {
96
+ return {
97
+ id,
98
+ url: report.url,
99
+ finalUrl: report.finalUrl,
100
+ title: report.title,
101
+ capturedAt: report.capturedAt,
102
+ durationMs: report.durationMs,
103
+ verdict: report.verdict,
104
+ counts: report.counts,
105
+ vitals: report.vitals,
106
+ failures: report.failures.map((f) => ({
107
+ budget: String(f.budget),
108
+ expected: f.expected,
109
+ actual: f.actual,
110
+ })),
111
+ a11ySignatures: report.a11y.map((f) => `${f.rule}@${f.locator}`),
112
+ structure: report.structure ?? [],
113
+ };
114
+ }
115
+
116
+ const RUN_FILE = /^(\d{8}T\d{6}Z)-([0-9a-f]{8})\.json$/;
117
+
118
+ /**
119
+ * Every run for a URL, oldest first.
120
+ *
121
+ * A file that will not parse is skipped rather than thrown: a half-written
122
+ * record from a killed process must not permanently break the next report.
123
+ */
124
+ export async function listRuns(dir: string): Promise<HistoryRun[]> {
125
+ let names: string[];
126
+ try {
127
+ names = await readdir(dir);
128
+ } catch {
129
+ return [];
130
+ }
131
+ const runs: HistoryRun[] = [];
132
+ for (const name of names.filter((n) => RUN_FILE.test(n)).sort()) {
133
+ try {
134
+ const parsed = JSON.parse(await readFile(join(dir, name), 'utf8')) as HistoryRun;
135
+ if (parsed && typeof parsed.capturedAt === 'string') runs.push(parsed);
136
+ } catch {
137
+ // Unreadable record — ignore it and carry on.
138
+ }
139
+ }
140
+ return runs.sort((a, b) => a.capturedAt.localeCompare(b.capturedAt));
141
+ }
142
+
143
+ /** Delete all but the newest `max` runs, taking each one's PNG with it. */
144
+ export async function pruneHistory(dir: string, max: number): Promise<string[]> {
145
+ if (!Number.isFinite(max) || max <= 0) return [];
146
+ const runs = await listRuns(dir);
147
+ if (runs.length <= max) return [];
148
+ const doomed = runs.slice(0, runs.length - max);
149
+ const removed: string[] = [];
150
+ for (const run of doomed) {
151
+ for (const file of [`${run.id}.json`, run.screenshotFile]) {
152
+ if (!file) continue;
153
+ await rm(join(dir, file), { force: true }).catch(() => {});
154
+ removed.push(file);
155
+ }
156
+ }
157
+ return removed;
158
+ }
159
+
160
+ export interface SaveRunOptions {
161
+ root?: string;
162
+ max?: number;
163
+ /** Fixed id, for deterministic tests. */
164
+ id?: string;
165
+ }
166
+
167
+ export interface SaveRunResult {
168
+ dir: string;
169
+ file: string;
170
+ run: HistoryRun;
171
+ pruned: string[];
172
+ }
173
+
174
+ /**
175
+ * Append this run to its URL's history and prune the tail. Callers treat a
176
+ * rejection as non-fatal — a read-only home directory should cost you the
177
+ * trend section, not the report.
178
+ */
179
+ export async function saveRun(
180
+ report: Report,
181
+ options: SaveRunOptions = {},
182
+ ): Promise<SaveRunResult> {
183
+ const root = options.root ?? historyRoot();
184
+ const dir = join(root, historyKey(report.url));
185
+ await mkdir(dir, { recursive: true });
186
+ await writeFile(join(dir, 'url.txt'), `${report.url}\n`, 'utf8').catch(() => {});
187
+
188
+ const id = options.id ?? newRunId(report.capturedAt);
189
+ const run = toHistoryRun(report, id);
190
+
191
+ const main = report.screenshots.find((s) => s.label === 'page') ?? report.screenshots[0];
192
+ if (main?.dataUrl) {
193
+ try {
194
+ const bytes = pngFromDataUrl(main.dataUrl);
195
+ if (bytes.length <= MAX_ARCHIVED_SCREENSHOT_BYTES) {
196
+ const file = `${id}.png`;
197
+ await writeFile(join(dir, file), bytes);
198
+ run.screenshotFile = file;
199
+ run.screenshotSize = { width: main.width, height: main.height };
200
+ }
201
+ } catch {
202
+ // No archived screenshot means no pixel diff next run. Not fatal.
203
+ }
204
+ }
205
+
206
+ const file = join(dir, `${id}.json`);
207
+ await writeFile(file, `${JSON.stringify(run, null, 2)}\n`, 'utf8');
208
+ const pruned = await pruneHistory(dir, options.max ?? DEFAULT_HISTORY_MAX);
209
+ return { dir, file, run, pruned };
210
+ }
211
+
212
+ export async function loadRunScreenshot(dir: string, run: HistoryRun): Promise<Uint8Array | null> {
213
+ if (!run.screenshotFile) return null;
214
+ try {
215
+ return new Uint8Array(await readFile(join(dir, run.screenshotFile)));
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * `monobrowse report <url>` — open a page, measure it, judge it against a
3
+ * budget, and leave behind one self-contained HTML file plus a sibling JSON.
4
+ *
5
+ * collect.ts (drive the browser) -> analyze.ts (a11y + budgets -> verdict)
6
+ * -> history/trend/diff (what changed since last time) -> render.ts (HTML).
7
+ * This module is the seam that wires them together and writes the two files.
8
+ */
9
+
10
+ import { mkdir, stat, writeFile } from 'node:fs/promises';
11
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
12
+ import type { CdpClient } from '../browser/cdp.js';
13
+ import { buildReport, summarizeVerdict } from './analyze.js';
14
+ import { loadBudget } from './budget.js';
15
+ import type { CollectOptions } from './collect.js';
16
+ import { collect } from './collect.js';
17
+ import { buildEvidence } from './evidence.js';
18
+ import { analyzeFlake, type FlakeReport, summarizeFlake } from './flake.js';
19
+ import {
20
+ DEFAULT_HISTORY_MAX,
21
+ type HistoryRun,
22
+ historyDirFor,
23
+ historyRoot,
24
+ listRuns,
25
+ saveRun,
26
+ } from './history.js';
27
+ import { renderHtml } from './render.js';
28
+ import { buildRunDiff } from './run-diff.js';
29
+ import { buildTrend } from './trend.js';
30
+ import type { Budget, CaptureData, Report } from './types.js';
31
+
32
+ export * from './a11y.js';
33
+ export * from './analyze.js';
34
+ export * from './budget.js';
35
+ export * from './collect.js';
36
+ export * from './collect-a11y.js';
37
+ export * from './evidence.js';
38
+ export * from './flake.js';
39
+ export * from './history.js';
40
+ export * from './pixel-diff.js';
41
+ export * from './png.js';
42
+ export * from './render.js';
43
+ export * from './run-diff.js';
44
+ export * from './structure.js';
45
+ export * from './trend.js';
46
+ export * from './types.js';
47
+
48
+ /** Prior runs a trend is charted over. Older runs stay on disk, off the chart. */
49
+ export const DEFAULT_TREND_WINDOW = 10;
50
+
51
+ export interface RunReportOptions extends CollectOptions {
52
+ /** Output path: a `.html` file, or a directory to drop the default name in. */
53
+ out?: string;
54
+ /** Budget file path or inline JSON. Omitted means DEFAULT_BUDGET. */
55
+ budget?: string | Budget;
56
+ cwd?: string;
57
+ /** Persist this run and compare against previous ones. Default true. */
58
+ history?: boolean;
59
+ /** Runs kept per URL. Default DEFAULT_HISTORY_MAX. */
60
+ historyMax?: number;
61
+ /** Root of the history store. Default `~/.monomind/browser-reports`. */
62
+ historyDir?: string;
63
+ /** Prior runs charted. Default DEFAULT_TREND_WINDOW. */
64
+ trendWindow?: number;
65
+ }
66
+
67
+ export interface ReportResult {
68
+ report: Report;
69
+ htmlPath: string;
70
+ jsonPath: string;
71
+ summary: string;
72
+ /** Present when the run was saved to the history store. */
73
+ historyDir?: string;
74
+ /** Present only for a `--repeat` run (RIG-14); see RepeatResult. */
75
+ flake?: FlakeReport;
76
+ }
77
+
78
+ function slugFor(url: string): string {
79
+ try {
80
+ const parsed = new URL(url);
81
+ const path = parsed.pathname.replace(/\/+$/, '').replace(/[^a-z0-9]+/gi, '-');
82
+ return `${parsed.hostname}${path}`.replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase();
83
+ } catch {
84
+ return (
85
+ url
86
+ .replace(/[^a-z0-9]+/gi, '-')
87
+ .replace(/^-|-$/g, '')
88
+ .toLowerCase() || 'page'
89
+ );
90
+ }
91
+ }
92
+
93
+ function timestamp(iso: string): string {
94
+ return iso.replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
95
+ }
96
+
97
+ /**
98
+ * Resolve `--out` into an absolute .html path. A path that is an existing
99
+ * directory, or that ends in a separator, gets the generated filename
100
+ * appended — `--out ./reports` should not silently produce a file called
101
+ * `reports`.
102
+ */
103
+ export async function resolveOutPath(
104
+ out: string | undefined,
105
+ url: string,
106
+ capturedAt: string,
107
+ cwd: string,
108
+ ): Promise<string> {
109
+ const defaultName = `monobrowse-report-${slugFor(url)}-${timestamp(capturedAt)}.html`;
110
+ if (!out) return join(cwd, defaultName);
111
+
112
+ const abs = isAbsolute(out) ? out : resolve(cwd, out);
113
+ if (/[\\/]$/.test(out)) return join(abs, defaultName);
114
+ const isDir = await stat(abs)
115
+ .then((s) => s.isDirectory())
116
+ .catch(() => false);
117
+ if (isDir) return join(abs, defaultName);
118
+ return abs.toLowerCase().endsWith('.html') || abs.toLowerCase().endsWith('.htm')
119
+ ? abs
120
+ : `${abs}.html`;
121
+ }
122
+
123
+ /**
124
+ * The JSON sibling drops the base64 image payloads — they are megabytes of
125
+ * noise for anything reading this programmatically, and the HTML already
126
+ * carries them inline. `path` still points at the PNG on disk. The pixel
127
+ * diff's highlight image and the evidence frames go the same way: every
128
+ * measurement survives, only the pixels are dropped.
129
+ */
130
+ export function toJsonReport(report: Report): Record<string, unknown> {
131
+ const { screenshots, recording, diff, evidence, ...rest } = report;
132
+ void recording;
133
+ return {
134
+ ...rest,
135
+ screenshots: screenshots.map(({ label, width, height, path }) => ({
136
+ label,
137
+ width,
138
+ height,
139
+ path,
140
+ })),
141
+ ...(diff
142
+ ? {
143
+ diff: {
144
+ ...diff,
145
+ pixels: diff.pixels ? { ...diff.pixels, diffDataUrl: undefined } : undefined,
146
+ },
147
+ }
148
+ : {}),
149
+ ...(evidence
150
+ ? {
151
+ evidence: {
152
+ ...evidence,
153
+ frames: evidence.frames.map(({ offsetMs, bytes }) => ({ offsetMs, bytes })),
154
+ },
155
+ }
156
+ : {}),
157
+ };
158
+ }
159
+
160
+ export async function writeReportFiles(
161
+ report: Report,
162
+ htmlPath: string,
163
+ extras: { flake?: FlakeReport } = {},
164
+ ): Promise<{ htmlPath: string; jsonPath: string }> {
165
+ const jsonPath = `${htmlPath.replace(/\.html?$/i, '')}.json`;
166
+ await mkdir(dirname(htmlPath), { recursive: true });
167
+ await writeFile(htmlPath, renderHtml(report, extras), 'utf8');
168
+ const json = extras.flake
169
+ ? { ...toJsonReport(report), flake: extras.flake }
170
+ : toJsonReport(report);
171
+ await writeFile(jsonPath, `${JSON.stringify(json, null, 2)}\n`, 'utf8');
172
+ return { htmlPath, jsonPath };
173
+ }
174
+
175
+ /**
176
+ * Attach the trend and the diff, then persist the run.
177
+ *
178
+ * Order matters: history is read BEFORE the run is saved, so a run never
179
+ * trends or diffs against itself. A failure anywhere in here becomes a note
180
+ * on the report rather than an error — an unwritable home directory should
181
+ * cost you the trend section, not the report.
182
+ */
183
+ async function applyHistory(
184
+ report: Report,
185
+ options: RunReportOptions,
186
+ ): Promise<string | undefined> {
187
+ if (options.history === false) return undefined;
188
+ const root = options.historyDir ?? historyRoot();
189
+ const dir = historyDirFor(report.url, root);
190
+ try {
191
+ const previous = await listRuns(dir);
192
+ const window = options.trendWindow ?? DEFAULT_TREND_WINDOW;
193
+ report.trend = buildTrend(report, previous.slice(-window));
194
+ report.diff = await buildRunDiff(dir, previous.at(-1), report);
195
+ await saveRun(report, { root, max: options.historyMax ?? DEFAULT_HISTORY_MAX });
196
+ return dir;
197
+ } catch (err) {
198
+ report.notes.push(
199
+ `Run history unavailable (${(err as Error).message}) — no trend or diff for this run.`,
200
+ );
201
+ return undefined;
202
+ }
203
+ }
204
+
205
+ /** Turn the recorder's raw frames into the evidence block, when warranted. */
206
+ function applyEvidence(report: Report, data: CaptureData): void {
207
+ const recording = data.recording;
208
+ if (!recording) return;
209
+ if (report.verdict !== 'fail' && !recording.requested) return;
210
+ report.evidence = buildEvidence({
211
+ reason: report.verdict === 'fail' ? 'budget-failure' : 'requested',
212
+ startedAtMs: recording.startedAtMs,
213
+ frames: recording.frames,
214
+ bufferDropped: recording.droppedFrames,
215
+ console: report.console,
216
+ pageErrors: report.pageErrors,
217
+ requests: report.requests,
218
+ failures: report.failures,
219
+ });
220
+ }
221
+
222
+ async function resolveBudget(options: RunReportOptions): Promise<Budget> {
223
+ return options.budget && typeof options.budget === 'object'
224
+ ? options.budget
225
+ : await loadBudget(options.budget as string | undefined);
226
+ }
227
+
228
+ /** One run: collect, judge, compare against history, write the two files. */
229
+ export async function runReport(
230
+ client: CdpClient,
231
+ sessionId: string,
232
+ options: RunReportOptions,
233
+ ): Promise<ReportResult> {
234
+ const budget = await resolveBudget(options);
235
+ const data: CaptureData = await collect(client, sessionId, options);
236
+ const report = buildReport(data, budget);
237
+
238
+ applyEvidence(report, data);
239
+ const historyDir = await applyHistory(report, options);
240
+ // Working material, not report content — and megabytes of it.
241
+ report.recording = undefined;
242
+
243
+ const htmlPath = await resolveOutPath(
244
+ options.out,
245
+ options.url,
246
+ data.capturedAt,
247
+ options.cwd ?? process.cwd(),
248
+ );
249
+ const paths = await writeReportFiles(report, htmlPath);
250
+ return { report, ...paths, summary: summarizeVerdict(report), historyDir };
251
+ }
252
+
253
+ export interface RepeatResult extends ReportResult {
254
+ flake: FlakeReport;
255
+ /** Every run, in order. `report` is the last of them. */
256
+ reports: Report[];
257
+ }
258
+
259
+ /**
260
+ * `--repeat N`: run the same URL N times and judge the set (RIG-14).
261
+ *
262
+ * Only the last run is written out in full — N runs' worth of inlined
263
+ * screenshots is not something anyone wants on disk — but the flake analysis
264
+ * covers all of them, and it is the flake verdict that decides the exit code.
265
+ * A check that passed three times and failed twice must not exit 0.
266
+ */
267
+ export async function runReportRepeated(
268
+ client: CdpClient,
269
+ sessionId: string,
270
+ options: RunReportOptions & { repeat: number },
271
+ ): Promise<RepeatResult> {
272
+ const times = Math.max(1, Math.floor(options.repeat));
273
+ const budget = await resolveBudget(options);
274
+ const reports: Report[] = [];
275
+
276
+ for (let i = 0; i < times; i++) {
277
+ const data = await collect(client, sessionId, options);
278
+ const report = buildReport(data, budget);
279
+ applyEvidence(report, data);
280
+ report.recording = undefined;
281
+ reports.push(report);
282
+ }
283
+
284
+ const flake = analyzeFlake(reports);
285
+ const report = reports[reports.length - 1];
286
+ // Only the run we keep is persisted: writing N history entries for one
287
+ // command would let a single `--repeat 20` evict the whole trend window.
288
+ const historyDir = await applyHistory(report, options);
289
+
290
+ const htmlPath = await resolveOutPath(
291
+ options.out,
292
+ options.url,
293
+ report.capturedAt,
294
+ options.cwd ?? process.cwd(),
295
+ );
296
+ const paths = await writeReportFiles(report, htmlPath, { flake });
297
+ return { report, reports, flake, ...paths, summary: summarizeFlake(flake), historyDir };
298
+ }
299
+
300
+ /** `--history <url>`: the stored runs for a URL, oldest first. */
301
+ export async function readHistory(
302
+ url: string,
303
+ options: { historyDir?: string } = {},
304
+ ): Promise<{ dir: string; runs: HistoryRun[] }> {
305
+ const dir = historyDirFor(url, options.historyDir ?? historyRoot());
306
+ return { dir, runs: await listRuns(dir) };
307
+ }