@monoes/monobrowse 1.0.12 → 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 (271) 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/__tests__/profiler-heap.test.d.ts +2 -0
  14. package/dist/src/__tests__/profiler-heap.test.d.ts.map +1 -0
  15. package/dist/src/__tests__/profiler-heap.test.js +154 -0
  16. package/dist/src/__tests__/profiler-heap.test.js.map +1 -0
  17. package/dist/src/__tests__/report-a11y.test.d.ts +2 -0
  18. package/dist/src/__tests__/report-a11y.test.d.ts.map +1 -0
  19. package/dist/src/__tests__/report-a11y.test.js +147 -0
  20. package/dist/src/__tests__/report-a11y.test.js.map +1 -0
  21. package/dist/src/__tests__/report-analyze.test.d.ts +2 -0
  22. package/dist/src/__tests__/report-analyze.test.d.ts.map +1 -0
  23. package/dist/src/__tests__/report-analyze.test.js +157 -0
  24. package/dist/src/__tests__/report-analyze.test.js.map +1 -0
  25. package/dist/src/__tests__/report-budget.test.d.ts +2 -0
  26. package/dist/src/__tests__/report-budget.test.d.ts.map +1 -0
  27. package/dist/src/__tests__/report-budget.test.js +80 -0
  28. package/dist/src/__tests__/report-budget.test.js.map +1 -0
  29. package/dist/src/__tests__/report-diff.test.d.ts +2 -0
  30. package/dist/src/__tests__/report-diff.test.d.ts.map +1 -0
  31. package/dist/src/__tests__/report-diff.test.js +231 -0
  32. package/dist/src/__tests__/report-diff.test.js.map +1 -0
  33. package/dist/src/__tests__/report-evidence.test.d.ts +2 -0
  34. package/dist/src/__tests__/report-evidence.test.d.ts.map +1 -0
  35. package/dist/src/__tests__/report-evidence.test.js +214 -0
  36. package/dist/src/__tests__/report-evidence.test.js.map +1 -0
  37. package/dist/src/__tests__/report-flake.test.d.ts +2 -0
  38. package/dist/src/__tests__/report-flake.test.d.ts.map +1 -0
  39. package/dist/src/__tests__/report-flake.test.js +182 -0
  40. package/dist/src/__tests__/report-flake.test.js.map +1 -0
  41. package/dist/src/__tests__/report-history.test.d.ts +2 -0
  42. package/dist/src/__tests__/report-history.test.d.ts.map +1 -0
  43. package/dist/src/__tests__/report-history.test.js +229 -0
  44. package/dist/src/__tests__/report-history.test.js.map +1 -0
  45. package/dist/src/__tests__/report-output.test.d.ts +2 -0
  46. package/dist/src/__tests__/report-output.test.d.ts.map +1 -0
  47. package/dist/src/__tests__/report-output.test.js +169 -0
  48. package/dist/src/__tests__/report-output.test.js.map +1 -0
  49. package/dist/src/__tests__/report-png.test.d.ts +2 -0
  50. package/dist/src/__tests__/report-png.test.d.ts.map +1 -0
  51. package/dist/src/__tests__/report-png.test.js +84 -0
  52. package/dist/src/__tests__/report-png.test.js.map +1 -0
  53. package/dist/src/__tests__/report-render-extras.test.d.ts +2 -0
  54. package/dist/src/__tests__/report-render-extras.test.d.ts.map +1 -0
  55. package/dist/src/__tests__/report-render-extras.test.js +285 -0
  56. package/dist/src/__tests__/report-render-extras.test.js.map +1 -0
  57. package/dist/src/__tests__/report-render.test.d.ts +2 -0
  58. package/dist/src/__tests__/report-render.test.d.ts.map +1 -0
  59. package/dist/src/__tests__/report-render.test.js +143 -0
  60. package/dist/src/__tests__/report-render.test.js.map +1 -0
  61. package/dist/src/__tests__/report-run.test.d.ts +2 -0
  62. package/dist/src/__tests__/report-run.test.d.ts.map +1 -0
  63. package/dist/src/__tests__/report-run.test.js +234 -0
  64. package/dist/src/__tests__/report-run.test.js.map +1 -0
  65. package/dist/src/browser/bridge.d.ts +119 -0
  66. package/dist/src/browser/bridge.d.ts.map +1 -0
  67. package/dist/src/browser/bridge.js +264 -0
  68. package/dist/src/browser/bridge.js.map +1 -0
  69. package/dist/src/browser/cdp.d.ts +11 -1
  70. package/dist/src/browser/cdp.d.ts.map +1 -1
  71. package/dist/src/browser/cdp.js +60 -56
  72. package/dist/src/browser/cdp.js.map +1 -1
  73. package/dist/src/browser/index.d.ts +3 -0
  74. package/dist/src/browser/index.d.ts.map +1 -1
  75. package/dist/src/browser/index.js +3 -0
  76. package/dist/src/browser/index.js.map +1 -1
  77. package/dist/src/browser/profiler.d.ts.map +1 -1
  78. package/dist/src/browser/profiler.js +19 -22
  79. package/dist/src/browser/profiler.js.map +1 -1
  80. package/dist/src/browser/transport.d.ts +62 -0
  81. package/dist/src/browser/transport.d.ts.map +1 -0
  82. package/dist/src/browser/transport.js +65 -0
  83. package/dist/src/browser/transport.js.map +1 -0
  84. package/dist/src/cli/commander-adapter.d.ts +16 -0
  85. package/dist/src/cli/commander-adapter.d.ts.map +1 -0
  86. package/dist/src/cli/commander-adapter.js +68 -0
  87. package/dist/src/cli/commander-adapter.js.map +1 -0
  88. package/dist/src/cli/commands-context.d.ts +14 -0
  89. package/dist/src/cli/commands-context.d.ts.map +1 -0
  90. package/dist/src/cli/commands-context.js +351 -0
  91. package/dist/src/cli/commands-context.js.map +1 -0
  92. package/dist/src/cli/commands-data.d.ts +12 -0
  93. package/dist/src/cli/commands-data.d.ts.map +1 -0
  94. package/dist/src/cli/commands-data.js +246 -0
  95. package/dist/src/cli/commands-data.js.map +1 -0
  96. package/dist/src/cli/commands-element.d.ts +15 -0
  97. package/dist/src/cli/commands-element.d.ts.map +1 -0
  98. package/dist/src/cli/commands-element.js +262 -0
  99. package/dist/src/cli/commands-element.js.map +1 -0
  100. package/dist/src/cli/commands-files.d.ts +9 -0
  101. package/dist/src/cli/commands-files.d.ts.map +1 -0
  102. package/dist/src/cli/commands-files.js +207 -0
  103. package/dist/src/cli/commands-files.js.map +1 -0
  104. package/dist/src/cli/commands-input.d.ts +26 -0
  105. package/dist/src/cli/commands-input.d.ts.map +1 -0
  106. package/dist/src/cli/commands-input.js +399 -0
  107. package/dist/src/cli/commands-input.js.map +1 -0
  108. package/dist/src/cli/commands-navigate.d.ts +12 -0
  109. package/dist/src/cli/commands-navigate.d.ts.map +1 -0
  110. package/dist/src/cli/commands-navigate.js +389 -0
  111. package/dist/src/cli/commands-navigate.js.map +1 -0
  112. package/dist/src/cli/commands-page.d.ts +25 -0
  113. package/dist/src/cli/commands-page.d.ts.map +1 -0
  114. package/dist/src/cli/commands-page.js +377 -0
  115. package/dist/src/cli/commands-page.js.map +1 -0
  116. package/dist/src/cli/commands-report.d.ts +8 -0
  117. package/dist/src/cli/commands-report.d.ts.map +1 -0
  118. package/dist/src/cli/commands-report.js +256 -0
  119. package/dist/src/cli/commands-report.js.map +1 -0
  120. package/dist/src/cli/commands-script.d.ts +43 -0
  121. package/dist/src/cli/commands-script.d.ts.map +1 -0
  122. package/dist/src/cli/commands-script.js +168 -0
  123. package/dist/src/cli/commands-script.js.map +1 -0
  124. package/dist/src/cli/commands-session.d.ts +15 -0
  125. package/dist/src/cli/commands-session.d.ts.map +1 -0
  126. package/dist/src/cli/commands-session.js +357 -0
  127. package/dist/src/cli/commands-session.js.map +1 -0
  128. package/dist/src/cli/commands-trace.d.ts +12 -0
  129. package/dist/src/cli/commands-trace.d.ts.map +1 -0
  130. package/dist/src/cli/commands-trace.js +347 -0
  131. package/dist/src/cli/commands-trace.js.map +1 -0
  132. package/dist/src/cli/commands.d.ts +22 -42
  133. package/dist/src/cli/commands.d.ts.map +1 -1
  134. package/dist/src/cli/commands.js +48 -3400
  135. package/dist/src/cli/commands.js.map +1 -1
  136. package/dist/src/cli/session.d.ts +47 -0
  137. package/dist/src/cli/session.d.ts.map +1 -0
  138. package/dist/src/cli/session.js +343 -0
  139. package/dist/src/cli/session.js.map +1 -0
  140. package/dist/src/report/a11y.d.ts +52 -0
  141. package/dist/src/report/a11y.d.ts.map +1 -0
  142. package/dist/src/report/a11y.js +231 -0
  143. package/dist/src/report/a11y.js.map +1 -0
  144. package/dist/src/report/analyze.d.ts +20 -0
  145. package/dist/src/report/analyze.d.ts.map +1 -0
  146. package/dist/src/report/analyze.js +136 -0
  147. package/dist/src/report/analyze.js.map +1 -0
  148. package/dist/src/report/budget.d.ts +27 -0
  149. package/dist/src/report/budget.d.ts.map +1 -0
  150. package/dist/src/report/budget.js +143 -0
  151. package/dist/src/report/budget.js.map +1 -0
  152. package/dist/src/report/collect-a11y.d.ts +25 -0
  153. package/dist/src/report/collect-a11y.d.ts.map +1 -0
  154. package/dist/src/report/collect-a11y.js +154 -0
  155. package/dist/src/report/collect-a11y.js.map +1 -0
  156. package/dist/src/report/collect.d.ts +45 -0
  157. package/dist/src/report/collect.d.ts.map +1 -0
  158. package/dist/src/report/collect.js +275 -0
  159. package/dist/src/report/collect.js.map +1 -0
  160. package/dist/src/report/evidence.d.ts +80 -0
  161. package/dist/src/report/evidence.d.ts.map +1 -0
  162. package/dist/src/report/evidence.js +279 -0
  163. package/dist/src/report/evidence.js.map +1 -0
  164. package/dist/src/report/flake.d.ts +88 -0
  165. package/dist/src/report/flake.d.ts.map +1 -0
  166. package/dist/src/report/flake.js +232 -0
  167. package/dist/src/report/flake.js.map +1 -0
  168. package/dist/src/report/history.d.ts +85 -0
  169. package/dist/src/report/history.d.ts.map +1 -0
  170. package/dist/src/report/history.js +176 -0
  171. package/dist/src/report/history.js.map +1 -0
  172. package/dist/src/report/index.d.ts +102 -0
  173. package/dist/src/report/index.d.ts.map +1 -0
  174. package/dist/src/report/index.js +216 -0
  175. package/dist/src/report/index.js.map +1 -0
  176. package/dist/src/report/pixel-diff.d.ts +30 -0
  177. package/dist/src/report/pixel-diff.d.ts.map +1 -0
  178. package/dist/src/report/pixel-diff.js +175 -0
  179. package/dist/src/report/pixel-diff.js.map +1 -0
  180. package/dist/src/report/png.d.ts +28 -0
  181. package/dist/src/report/png.d.ts.map +1 -0
  182. package/dist/src/report/png.js +259 -0
  183. package/dist/src/report/png.js.map +1 -0
  184. package/dist/src/report/render-evidence.d.ts +16 -0
  185. package/dist/src/report/render-evidence.d.ts.map +1 -0
  186. package/dist/src/report/render-evidence.js +174 -0
  187. package/dist/src/report/render-evidence.js.map +1 -0
  188. package/dist/src/report/render-trend.d.ts +16 -0
  189. package/dist/src/report/render-trend.d.ts.map +1 -0
  190. package/dist/src/report/render-trend.js +194 -0
  191. package/dist/src/report/render-trend.js.map +1 -0
  192. package/dist/src/report/render.d.ts +21 -0
  193. package/dist/src/report/render.d.ts.map +1 -0
  194. package/dist/src/report/render.js +286 -0
  195. package/dist/src/report/render.js.map +1 -0
  196. package/dist/src/report/run-diff.d.ts +18 -0
  197. package/dist/src/report/run-diff.d.ts.map +1 -0
  198. package/dist/src/report/run-diff.js +51 -0
  199. package/dist/src/report/run-diff.js.map +1 -0
  200. package/dist/src/report/structure.d.ts +41 -0
  201. package/dist/src/report/structure.d.ts.map +1 -0
  202. package/dist/src/report/structure.js +210 -0
  203. package/dist/src/report/structure.js.map +1 -0
  204. package/dist/src/report/trend.d.ts +26 -0
  205. package/dist/src/report/trend.d.ts.map +1 -0
  206. package/dist/src/report/trend.js +164 -0
  207. package/dist/src/report/trend.js.map +1 -0
  208. package/dist/src/report/types.d.ts +320 -0
  209. package/dist/src/report/types.d.ts.map +1 -0
  210. package/dist/src/report/types.js +11 -0
  211. package/dist/src/report/types.js.map +1 -0
  212. package/dist/src/report/util.d.ts +17 -0
  213. package/dist/src/report/util.d.ts.map +1 -0
  214. package/dist/src/report/util.js +45 -0
  215. package/dist/src/report/util.js.map +1 -0
  216. package/dist/tsconfig.tsbuildinfo +1 -1
  217. package/package.json +1 -1
  218. package/src/__tests__/bridge-instruments.test.ts +180 -0
  219. package/src/__tests__/bridge-transport.test.ts +365 -0
  220. package/src/__tests__/cdp-transport.test.ts +198 -0
  221. package/src/__tests__/profiler-heap.test.ts +191 -0
  222. package/src/__tests__/report-a11y.test.ts +180 -0
  223. package/src/__tests__/report-analyze.test.ts +202 -0
  224. package/src/__tests__/report-budget.test.ts +92 -0
  225. package/src/__tests__/report-diff.test.ts +272 -0
  226. package/src/__tests__/report-evidence.test.ts +276 -0
  227. package/src/__tests__/report-flake.test.ts +214 -0
  228. package/src/__tests__/report-history.test.ts +293 -0
  229. package/src/__tests__/report-output.test.ts +210 -0
  230. package/src/__tests__/report-png.test.ts +95 -0
  231. package/src/__tests__/report-render-extras.test.ts +315 -0
  232. package/src/__tests__/report-render.test.ts +175 -0
  233. package/src/__tests__/report-run.test.ts +264 -0
  234. package/src/browser/bridge.ts +354 -0
  235. package/src/browser/cdp.ts +57 -52
  236. package/src/browser/index.ts +3 -0
  237. package/src/browser/profiler.ts +28 -21
  238. package/src/browser/transport.ts +111 -0
  239. package/src/cli/commander-adapter.ts +65 -0
  240. package/src/cli/commands-context.ts +367 -0
  241. package/src/cli/commands-data.ts +248 -0
  242. package/src/cli/commands-element.ts +305 -0
  243. package/src/cli/commands-files.ts +248 -0
  244. package/src/cli/commands-input.ts +434 -0
  245. package/src/cli/commands-navigate.ts +442 -0
  246. package/src/cli/commands-page.ts +420 -0
  247. package/src/cli/commands-report.ts +275 -0
  248. package/src/cli/commands-script.ts +183 -0
  249. package/src/cli/commands-session.ts +376 -0
  250. package/src/cli/commands-trace.ts +349 -0
  251. package/src/cli/commands.ts +101 -3677
  252. package/src/cli/session.ts +412 -0
  253. package/src/report/a11y.ts +242 -0
  254. package/src/report/analyze.ts +181 -0
  255. package/src/report/budget.ts +155 -0
  256. package/src/report/collect-a11y.ts +200 -0
  257. package/src/report/collect.ts +408 -0
  258. package/src/report/evidence.ts +379 -0
  259. package/src/report/flake.ts +303 -0
  260. package/src/report/history.ts +219 -0
  261. package/src/report/index.ts +307 -0
  262. package/src/report/pixel-diff.ts +197 -0
  263. package/src/report/png.ts +290 -0
  264. package/src/report/render-evidence.ts +194 -0
  265. package/src/report/render-trend.ts +225 -0
  266. package/src/report/render.ts +314 -0
  267. package/src/report/run-diff.ts +63 -0
  268. package/src/report/structure.ts +212 -0
  269. package/src/report/trend.ts +189 -0
  270. package/src/report/types.ts +356 -0
  271. package/src/report/util.ts +48 -0
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Report sections for the trend (RIG-13) and the run-to-run diff (RIG-10).
3
+ *
4
+ * Kept separate from render.ts purely for size. Same rules apply: no external
5
+ * resources, so the sparklines are hand-written inline SVG rather than a
6
+ * charting library, and the diff image is a `data:` URI.
7
+ *
8
+ * The design goal for the trend is legibility at a glance — the point of the
9
+ * feature is catching decay nobody noticed, which only works if the reader
10
+ * sees it without going looking.
11
+ */
12
+
13
+ import { formatTrendDelta, formatTrendValue } from './trend.js';
14
+ import type { RunDiff, StructureDiff, StructureNode, TrendReport, TrendSeries } from './types.js';
15
+ import { escapeHtml } from './util.js';
16
+
17
+ /** Rows shown per diff category before the tail is summarised. */
18
+ const MAX_DIFF_ROWS = 20;
19
+
20
+ export const TREND_STYLES = `
21
+ .headlines{background:var(--warn-bg);border:1px solid var(--warn);color:var(--warn);
22
+ border-radius:10px;padding:12px 16px;margin:0 0 16px}
23
+ .headlines p{margin:0 0 6px;font-size:14px}
24
+ .headlines p:last-child{margin-bottom:0}
25
+ .spark{display:block}
26
+ .spark .line{fill:none;stroke:var(--muted);stroke-width:1.5}
27
+ .spark .dot{fill:var(--ink)}
28
+ .dir{font-weight:600}
29
+ .dir.regressed{color:var(--fail)}
30
+ .dir.improved{color:var(--pass)}
31
+ .dir.flat,.dir.new{color:var(--muted)}
32
+ .verdictstrip{display:flex;flex-wrap:wrap;gap:4px;margin:0 0 16px;padding:0;list-style:none}
33
+ .verdictstrip li{width:13px;height:13px;border-radius:3px;background:var(--pass)}
34
+ .verdictstrip li.fail{background:var(--fail)}
35
+ .difftag{display:inline-block;margin:0 8px 8px 0;padding:4px 10px;border-radius:999px;
36
+ font-size:12px;background:var(--panel);border:1px solid var(--line)}
37
+ .difftag b{font-variant-numeric:tabular-nums}
38
+ `.trim();
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Trend
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * A sparkline as inline SVG. Flat series are drawn down the middle rather
46
+ * than at y=0, which is what a naive min/max scale does when every value is
47
+ * identical and it reads as a cliff.
48
+ */
49
+ function sparkline(series: TrendSeries): string {
50
+ const values = series.points.map((p) => p.value).filter((v): v is number => v !== null);
51
+ if (values.length < 2) return '<span style="color:var(--muted)">—</span>';
52
+
53
+ const width = 120;
54
+ const height = 24;
55
+ const min = Math.min(...values);
56
+ const max = Math.max(...values);
57
+ const span = max - min;
58
+ const x = (i: number) => (i / (values.length - 1)) * (width - 4) + 2;
59
+ const y = (v: number) =>
60
+ span === 0 ? height / 2 : height - 2 - ((v - min) / span) * (height - 4);
61
+
62
+ const points = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
63
+ const lastX = x(values.length - 1).toFixed(1);
64
+ const lastY = y(values[values.length - 1]).toFixed(1);
65
+ const label = `${series.label} over ${values.length} runs, ${formatTrendValue(series.unit, min)} to ${formatTrendValue(series.unit, max)}`;
66
+ return `<svg class="spark" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(label)}">
67
+ <polyline class="line" points="${points}"/>
68
+ <circle class="dot" cx="${lastX}" cy="${lastY}" r="2.5"/>
69
+ </svg>`;
70
+ }
71
+
72
+ const DIRECTION_WORD: Record<TrendSeries['direction'], string> = {
73
+ regressed: 'worse',
74
+ improved: 'better',
75
+ flat: 'steady',
76
+ new: 'first',
77
+ };
78
+
79
+ export function renderTrend(trend: TrendReport | undefined): string {
80
+ if (!trend) return '';
81
+ if (trend.runs === 0) {
82
+ return `<h2>Trend</h2><p class="empty">First recorded run for this URL — from the next run on, this section compares against it.</p>`;
83
+ }
84
+
85
+ const headlines = trend.headlines.length
86
+ ? `<div class="headlines">${trend.headlines.map((h) => `<p>${escapeHtml(h)}</p>`).join('')}</div>`
87
+ : '';
88
+
89
+ const strip = `<ul class="verdictstrip" aria-label="Verdict per run, oldest first">${trend.verdictHistory
90
+ .map(
91
+ (v) =>
92
+ `<li class="${v.verdict}" title="${escapeHtml(`${v.capturedAt}: ${v.verdict}`)}"></li>`,
93
+ )
94
+ .join('')}</ul>`;
95
+
96
+ // A series nobody has ever measured is noise, not an absent trend.
97
+ const rows = trend.series
98
+ .filter((s) => s.points.some((p) => p.value !== null))
99
+ .map(
100
+ (s) => `<tr>
101
+ <td><b>${escapeHtml(s.label)}</b></td>
102
+ <td class="num mono">${escapeHtml(formatTrendValue(s.unit, s.previous))}</td>
103
+ <td class="num mono">${escapeHtml(formatTrendValue(s.unit, s.current))}</td>
104
+ <td class="num mono"><span class="dir ${s.direction}">${escapeHtml(formatTrendDelta(s.unit, s.deltaFromPrevious))}</span></td>
105
+ <td class="num mono">${escapeHtml(formatTrendDelta(s.unit, s.deltaFromOldest))}</td>
106
+ <td><span class="dir ${s.direction}">${DIRECTION_WORD[s.direction]}</span></td>
107
+ <td>${sparkline(s)}</td>
108
+ </tr>`,
109
+ )
110
+ .join('');
111
+
112
+ return `<h2>Trend — this run against the previous ${trend.runs}</h2>
113
+ ${headlines}
114
+ ${strip}
115
+ <table><thead><tr>
116
+ <th>Metric</th><th>Last run</th><th>This run</th><th>vs last</th>
117
+ <th>vs ${trend.runs} runs ago</th><th>Direction</th><th>History</th>
118
+ </tr></thead><tbody>${rows}</tbody></table>
119
+ <p class="note">Window starts ${escapeHtml(trend.windowFrom ?? 'unknown')}. Every metric here is lower-is-better, so a positive delta is a regression.</p>`;
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Diff
124
+ // ---------------------------------------------------------------------------
125
+
126
+ function nodeRows(nodes: StructureNode[], kind: string): string {
127
+ const shown = nodes.slice(0, MAX_DIFF_ROWS);
128
+ const rows = shown
129
+ .map(
130
+ (n) => `<tr>
131
+ <td>${escapeHtml(kind)}</td>
132
+ <td>${escapeHtml(n.role)}</td>
133
+ <td>${n.name ? escapeHtml(n.name) : '<span style="color:var(--muted)">(no accessible name)</span>'}</td>
134
+ <td class="wrap mono">${escapeHtml(n.path)}</td>
135
+ </tr>`,
136
+ )
137
+ .join('');
138
+ const rest = nodes.length - shown.length;
139
+ return rest > 0
140
+ ? `${rows}<tr><td colspan="4" style="color:var(--muted)">…and ${rest} more ${escapeHtml(kind)}</td></tr>`
141
+ : rows;
142
+ }
143
+
144
+ function renderStructure(diff: StructureDiff): string {
145
+ const tags = [
146
+ `<span class="difftag"><b>${diff.gained.length}</b> gained</span>`,
147
+ `<span class="difftag"><b>${diff.lost.length}</b> lost</span>`,
148
+ `<span class="difftag"><b>${diff.renamed.length}</b> renamed</span>`,
149
+ `<span class="difftag"><b>${diff.moved.length}</b> moved</span>`,
150
+ `<span class="difftag"><b>${diff.unchanged}</b> unchanged</span>`,
151
+ ].join('');
152
+
153
+ if (diff.changed === 0 && diff.moved.length === 0) {
154
+ return `${tags}<p class="empty">The accessibility tree is identical to the previous run.</p>`;
155
+ }
156
+
157
+ const renamedRows = diff.renamed
158
+ .slice(0, MAX_DIFF_ROWS)
159
+ .map(
160
+ (r) => `<tr>
161
+ <td>renamed</td>
162
+ <td>${escapeHtml(r.role)}</td>
163
+ <td>${escapeHtml(r.from ?? '(none)')} → <b>${escapeHtml(r.to ?? '(none)')}</b></td>
164
+ <td class="wrap mono">${escapeHtml(r.path)}</td>
165
+ </tr>`,
166
+ )
167
+ .join('');
168
+
169
+ const body = `${nodeRows(diff.gained, 'gained')}${nodeRows(diff.lost, 'lost')}${renamedRows}`;
170
+ return `${tags}
171
+ <table><thead><tr><th>Change</th><th>Role</th><th>Accessible name</th><th>Path</th></tr></thead>
172
+ <tbody>${body}</tbody></table>
173
+ <p class="note">Compared on the accessibility tree, not the DOM: a restyled class name is invisible here, while a control losing its label is not. Elements that only moved are counted but not listed.</p>`;
174
+ }
175
+
176
+ function renderPixels(diff: NonNullable<RunDiff['pixels']>): string {
177
+ if (!diff.comparable)
178
+ return `<p class="empty">${escapeHtml(diff.note ?? 'Pixel diff unavailable.')}</p>`;
179
+
180
+ const pct =
181
+ diff.changedPercent === 0
182
+ ? '0'
183
+ : diff.changedPercent < 0.01
184
+ ? '<0.01'
185
+ : diff.changedPercent.toFixed(2);
186
+ const size = diff.sizeChanged
187
+ ? `<span class="difftag">size <b>${diff.previousSize?.width}×${diff.previousSize?.height}</b> → <b>${diff.currentSize?.width}×${diff.currentSize?.height}</b></span>`
188
+ : '';
189
+ const tags = `<span class="difftag"><b>${pct}%</b> of pixels changed</span>
190
+ <span class="difftag"><b>${diff.changedPixels.toLocaleString('en-US')}</b> of ${diff.totalPixels.toLocaleString('en-US')} pixels</span>${size}`;
191
+
192
+ if (!diff.diffDataUrl) {
193
+ return `${tags}<p class="empty">The screenshots are pixel-identical.</p>`;
194
+ }
195
+ const scaled = diff.scale
196
+ ? ` Shown at 1/${diff.scale} scale; a change smaller than one output pixel is still marked.`
197
+ : '';
198
+ return `${tags}
199
+ <figure>
200
+ <figcaption>Changed pixels in magenta, over this run's screenshot.${escapeHtml(scaled)}</figcaption>
201
+ <img alt="Pixel differences against the previous run" src="${escapeHtml(diff.diffDataUrl)}">
202
+ </figure>
203
+ <p class="note">Per-channel tolerance is applied before counting, so anti-aliasing and font-hinting jitter do not register as change.</p>`;
204
+ }
205
+
206
+ export function renderDiff(diff: RunDiff | undefined): string {
207
+ if (!diff) return '';
208
+ const notes = diff.notes.length
209
+ ? `<p class="note">${diff.notes.map((n) => escapeHtml(n)).join(' ')}</p>`
210
+ : '';
211
+ const structure = diff.structure
212
+ ? `<h3 style="font-size:13px;color:var(--muted);margin:20px 0 8px">Structure</h3>${renderStructure(diff.structure)}`
213
+ : '';
214
+ const pixels = diff.pixels
215
+ ? `<h3 style="font-size:13px;color:var(--muted);margin:24px 0 8px">Pixels</h3>${renderPixels(diff.pixels)}`
216
+ : '';
217
+ if (!structure && !pixels) {
218
+ return `<h2>Changes since the previous run</h2>${notes || '<p class="empty">Nothing could be compared against the previous run.</p>'}`;
219
+ }
220
+ return `<h2>Changes since the previous run</h2>
221
+ <p class="note">Compared against the run of ${escapeHtml(diff.previousCapturedAt)}.</p>
222
+ ${structure}
223
+ ${pixels}
224
+ ${notes}`;
225
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Renders a Report as ONE self-contained HTML file: CSS inline, screenshots
3
+ * as `data:` URIs, no fonts, no CDN, no network access of any kind at view
4
+ * time. That is not a stylistic preference — it is what lets monomind's
5
+ * document pipeline ingest the file as an ordinary document, so test history
6
+ * becomes searchable, and what lets the report be mailed or archived and
7
+ * still render years later.
8
+ *
9
+ * Reading order matches how you triage: verdict, errors, failed requests,
10
+ * vitals, accessibility, screenshots.
11
+ */
12
+
13
+ import { budgetLabel } from './budget.js';
14
+ import type { FlakeReport } from './flake.js';
15
+ import { EVIDENCE_STYLES, renderEvidence, renderFlake } from './render-evidence.js';
16
+ import { renderDiff, renderTrend, TREND_STYLES } from './render-trend.js';
17
+ import type { A11yFinding, Report, RequestEntry, Screenshot } from './types.js';
18
+ import { escapeHtml } from './util.js';
19
+
20
+ export { escapeHtml };
21
+
22
+ function ms(value?: number): string {
23
+ return value === undefined || !Number.isFinite(value) ? 'not measured' : `${Math.round(value)}ms`;
24
+ }
25
+
26
+ function vitalRating(metric: string, v?: number): 'good' | 'mixed' | 'poor' | 'none' {
27
+ if (v === undefined || !Number.isFinite(v)) return 'none';
28
+ const scale: Record<string, [number, number]> = {
29
+ lcp: [2500, 4000],
30
+ fcp: [1800, 3000],
31
+ cls: [0.1, 0.25],
32
+ inp: [200, 500],
33
+ ttfb: [800, 1800],
34
+ };
35
+ const bounds = scale[metric];
36
+ if (!bounds) return 'none';
37
+ return v <= bounds[0] ? 'good' : v <= bounds[1] ? 'mixed' : 'poor';
38
+ }
39
+
40
+ const STYLES = `
41
+ :root{
42
+ --bg:#fbfbfd; --panel:#fff; --ink:#15171c; --muted:#5d6470; --line:#e3e6ec;
43
+ --pass:#0f7b4f; --pass-bg:#e7f6ee; --fail:#b3261e; --fail-bg:#fdeceb;
44
+ --warn:#8a5a00; --warn-bg:#fff5e0; --code:#f3f4f7;
45
+ }
46
+ @media (prefers-color-scheme: dark){
47
+ :root{
48
+ --bg:#101317; --panel:#171b21; --ink:#e7eaf0; --muted:#98a1b0; --line:#262c35;
49
+ --pass:#57d69c; --pass-bg:#11291f; --fail:#ff9a92; --fail-bg:#2c1614;
50
+ --warn:#f0c168; --warn-bg:#2b2113; --code:#1d222a;
51
+ }
52
+ }
53
+ *{box-sizing:border-box}
54
+ body{margin:0;padding:32px 16px 72px;background:var(--bg);color:var(--ink);
55
+ font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}
56
+ main{max-width:940px;margin:0 auto}
57
+ h1{font-size:22px;margin:0 0 4px;letter-spacing:-.01em}
58
+ h2{font-size:15px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);
59
+ margin:36px 0 12px;font-weight:600}
60
+ a{color:inherit}
61
+ .sub{color:var(--muted);font-size:13px;margin:0 0 24px;word-break:break-all}
62
+ .verdict{border-radius:12px;padding:18px 20px;margin-bottom:8px;border:1px solid transparent}
63
+ .verdict.pass{background:var(--pass-bg);border-color:var(--pass);color:var(--pass)}
64
+ .verdict.fail{background:var(--fail-bg);border-color:var(--fail);color:var(--fail)}
65
+ .verdict .tag{font-size:26px;font-weight:700;letter-spacing:.02em}
66
+ .verdict .line{color:var(--ink);margin-top:6px;font-size:14px}
67
+ .stats{display:flex;flex-wrap:wrap;gap:8px;margin:16px 0 0;padding:0;list-style:none}
68
+ .stats li{background:var(--panel);border:1px solid var(--line);border-radius:8px;
69
+ padding:8px 12px;font-size:13px;color:var(--muted)}
70
+ .stats li b{display:block;font-size:18px;color:var(--ink)}
71
+ .stats li.bad b{color:var(--fail)}
72
+ table{width:100%;border-collapse:collapse;background:var(--panel);
73
+ border:1px solid var(--line);border-radius:10px;overflow:hidden;font-size:13px}
74
+ th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);vertical-align:top}
75
+ th{color:var(--muted);font-weight:600;white-space:nowrap}
76
+ tr:last-child td{border-bottom:none}
77
+ td.num{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}
78
+ .wrap{word-break:break-all}
79
+ code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
80
+ code{background:var(--code);padding:1px 5px;border-radius:4px}
81
+ .pill{display:inline-block;padding:1px 7px;border-radius:999px;font-size:11px;
82
+ font-weight:600;text-transform:uppercase;letter-spacing:.04em}
83
+ .pill.error{background:var(--fail-bg);color:var(--fail)}
84
+ .pill.warning{background:var(--warn-bg);color:var(--warn)}
85
+ .pill.good{background:var(--pass-bg);color:var(--pass)}
86
+ .pill.mixed{background:var(--warn-bg);color:var(--warn)}
87
+ .pill.poor{background:var(--fail-bg);color:var(--fail)}
88
+ .pill.none{background:var(--code);color:var(--muted)}
89
+ .empty{color:var(--muted);font-size:13px;background:var(--panel);border:1px solid var(--line);
90
+ border-radius:10px;padding:12px}
91
+ .note{color:var(--muted);font-size:12px;margin-top:8px}
92
+ figure{margin:0 0 20px}
93
+ figcaption{color:var(--muted);font-size:12px;margin-bottom:6px}
94
+ img{max-width:100%;height:auto;display:block;border:1px solid var(--line);border-radius:10px}
95
+ footer{margin-top:40px;color:var(--muted);font-size:12px;border-top:1px solid var(--line);
96
+ padding-top:12px}
97
+ @media (max-width:640px){body{padding:20px 12px 48px}th,td{padding:8px}}
98
+ `.trim();
99
+
100
+ function emptyBlock(text: string): string {
101
+ return `<p class="empty">${escapeHtml(text)}</p>`;
102
+ }
103
+
104
+ function renderVerdict(report: Report): string {
105
+ const pass = report.verdict === 'pass';
106
+ const headline = pass
107
+ ? 'All budgets met.'
108
+ : `${report.failures.length} budget ${report.failures.length === 1 ? 'failure' : 'failures'}.`;
109
+ const rows = report.failures
110
+ .map(
111
+ (f) => `<tr>
112
+ <td>${escapeHtml(budgetLabel(f.budget))}</td>
113
+ <td class="num mono">${escapeHtml(f.expected)}</td>
114
+ <td class="num mono">${escapeHtml(f.actual)}</td>
115
+ <td class="wrap">${escapeHtml(f.detail ?? '')}</td>
116
+ </tr>`,
117
+ )
118
+ .join('');
119
+
120
+ const table = report.failures.length
121
+ ? `<table><thead><tr><th>Budget</th><th>Expected</th><th>Actual</th><th>Evidence</th></tr></thead>
122
+ <tbody>${rows}</tbody></table>`
123
+ : '';
124
+
125
+ const unmeasured = report.unmeasured.length
126
+ ? `<p class="note">Not measured on this run: ${escapeHtml(report.unmeasured.join(', '))} — the page never reported the metric, so its budget was neither met nor breached.</p>`
127
+ : '';
128
+
129
+ return `<section class="verdict ${pass ? 'pass' : 'fail'}">
130
+ <div class="tag">${pass ? 'PASS' : 'FAIL'}</div>
131
+ <div class="line">${escapeHtml(headline)}</div>
132
+ </section>
133
+ ${table}
134
+ ${unmeasured}`;
135
+ }
136
+
137
+ function renderStats(report: Report): string {
138
+ const c = report.counts;
139
+ const item = (label: string, value: number, bad: boolean) =>
140
+ `<li class="${bad ? 'bad' : ''}"><b>${value}</b>${escapeHtml(label)}</li>`;
141
+ return `<ul class="stats">
142
+ ${item('console errors', c.consoleErrors, c.consoleErrors > 0)}
143
+ ${item('page errors', c.pageErrors, c.pageErrors > 0)}
144
+ ${item('failed requests', c.failedRequests, c.failedRequests > 0)}
145
+ ${item('requests', c.requests, false)}
146
+ ${item('a11y errors', c.a11yErrors, c.a11yErrors > 0)}
147
+ ${item('a11y warnings', c.a11yWarnings, false)}
148
+ </ul>`;
149
+ }
150
+
151
+ function renderErrors(report: Report): string {
152
+ const rows: string[] = [];
153
+ for (const e of report.pageErrors) {
154
+ const where = e.url ? `${e.url}${e.lineNumber !== undefined ? `:${e.lineNumber}` : ''}` : '';
155
+ rows.push(`<tr>
156
+ <td><span class="pill error">uncaught</span></td>
157
+ <td class="wrap">${escapeHtml(e.text)}</td>
158
+ <td class="wrap mono">${escapeHtml(where)}</td>
159
+ </tr>`);
160
+ }
161
+ for (const m of report.console) {
162
+ if (m.type !== 'error' && m.type !== 'warn' && m.type !== 'warning') continue;
163
+ const pill = m.type === 'error' ? 'error' : 'warning';
164
+ const where = m.url ? `${m.url}${m.lineNumber !== undefined ? `:${m.lineNumber}` : ''}` : '';
165
+ rows.push(`<tr>
166
+ <td><span class="pill ${pill}">console.${escapeHtml(m.type)}</span></td>
167
+ <td class="wrap">${escapeHtml(m.text)}</td>
168
+ <td class="wrap mono">${escapeHtml(where)}</td>
169
+ </tr>`);
170
+ }
171
+ if (!rows.length) return emptyBlock('No console errors, warnings or uncaught exceptions.');
172
+ return `<table><thead><tr><th>Kind</th><th>Message</th><th>Source</th></tr></thead>
173
+ <tbody>${rows.join('')}</tbody></table>`;
174
+ }
175
+
176
+ function renderRequests(requests: RequestEntry[]): string {
177
+ const failed = requests.filter((r) => r.failed);
178
+ if (!failed.length) return emptyBlock('Every request returned a successful response.');
179
+ const rows = failed
180
+ .map(
181
+ (r) => `<tr>
182
+ <td class="mono">${escapeHtml(r.status ?? '—')}</td>
183
+ <td class="mono">${escapeHtml(r.method)}</td>
184
+ <td class="wrap mono">${escapeHtml(r.url)}</td>
185
+ <td class="wrap">${escapeHtml(r.errorText ?? '')}</td>
186
+ </tr>`,
187
+ )
188
+ .join('');
189
+ return `<table><thead><tr><th>Status</th><th>Method</th><th>URL</th><th>Error</th></tr></thead>
190
+ <tbody>${rows}</tbody></table>`;
191
+ }
192
+
193
+ function renderVitals(report: Report): string {
194
+ const v = report.vitals;
195
+ const defs: Array<[string, string, string]> = [
196
+ ['lcp', 'LCP', 'Largest Contentful Paint'],
197
+ ['fcp', 'FCP', 'First Contentful Paint'],
198
+ ['cls', 'CLS', 'Cumulative Layout Shift'],
199
+ ['inp', 'INP', 'Interaction to Next Paint'],
200
+ ['ttfb', 'TTFB', 'Time to First Byte'],
201
+ ];
202
+ const rows = defs
203
+ .map(([key, short, long]) => {
204
+ const raw = v[key as keyof typeof v] as number | undefined;
205
+ const rating = vitalRating(key, raw);
206
+ const shown = key === 'cls' ? (raw === undefined ? 'not measured' : raw.toFixed(3)) : ms(raw);
207
+ return `<tr>
208
+ <td><b>${short}</b> <span style="color:var(--muted)">${escapeHtml(long)}</span></td>
209
+ <td class="num mono">${escapeHtml(shown)}</td>
210
+ <td><span class="pill ${rating}">${rating === 'none' ? 'n/a' : rating}</span></td>
211
+ </tr>`;
212
+ })
213
+ .join('');
214
+ const load = `<tr><td>Load event</td><td class="num mono">${escapeHtml(ms(v.loadTime))}</td><td></td></tr>
215
+ <tr><td>DOM interactive</td><td class="num mono">${escapeHtml(ms(v.domInteractive))}</td><td></td></tr>
216
+ <tr><td>Resources</td><td class="num mono">${escapeHtml(v.resources ?? '—')}</td><td></td></tr>`;
217
+ return `<table><thead><tr><th>Metric</th><th>Value</th><th>Rating</th></tr></thead>
218
+ <tbody>${rows}${load}</tbody></table>`;
219
+ }
220
+
221
+ function renderA11y(findings: A11yFinding[]): string {
222
+ const disclaimer =
223
+ '<p class="note">Colour contrast is not evaluated: it cannot be derived from the accessibility tree, and guessing it would be worse than omitting it.</p>';
224
+ if (!findings.length) {
225
+ return `${emptyBlock('No accessibility findings from the AX tree.')}${disclaimer}`;
226
+ }
227
+ const order = { error: 0, warning: 1 } as const;
228
+ const rows = [...findings]
229
+ .sort((a, b) => order[a.impact] - order[b.impact])
230
+ .map(
231
+ (f) => `<tr>
232
+ <td><span class="pill ${f.impact}">${escapeHtml(f.impact)}</span></td>
233
+ <td class="mono">${escapeHtml(f.rule)}</td>
234
+ <td>${escapeHtml(f.role)}</td>
235
+ <td>${f.name ? escapeHtml(f.name) : '<span style="color:var(--muted)">(no accessible name)</span>'}</td>
236
+ <td class="wrap mono">${escapeHtml(f.locator)}</td>
237
+ <td class="wrap">${escapeHtml(f.detail)}</td>
238
+ </tr>`,
239
+ )
240
+ .join('');
241
+ return `<table><thead><tr><th>Impact</th><th>Rule</th><th>Role</th><th>Name</th><th>Locator</th><th>Detail</th></tr></thead>
242
+ <tbody>${rows}</tbody></table>${disclaimer}`;
243
+ }
244
+
245
+ function renderScreenshots(shots: Screenshot[]): string {
246
+ if (!shots.length) return emptyBlock('No screenshot was captured.');
247
+ return shots
248
+ .map(
249
+ (s) => `<figure>
250
+ <figcaption>${escapeHtml(s.label)} — ${s.width}×${s.height}</figcaption>
251
+ <img alt="${escapeHtml(`${s.label} screenshot`)}" src="${escapeHtml(s.dataUrl)}">
252
+ </figure>`,
253
+ )
254
+ .join('');
255
+ }
256
+
257
+ export interface RenderOptions {
258
+ /** Present when the report came from `--repeat` (RIG-14). */
259
+ flake?: FlakeReport;
260
+ }
261
+
262
+ export function renderHtml(report: Report, options: RenderOptions = {}): string {
263
+ const title = report.title || report.finalUrl || report.url;
264
+ const notes = report.notes.length
265
+ ? `<h2>Run notes</h2><ul class="empty">${report.notes.map((n) => `<li>${escapeHtml(n)}</li>`).join('')}</ul>`
266
+ : '';
267
+
268
+ return `<!doctype html>
269
+ <html lang="en">
270
+ <head>
271
+ <meta charset="utf-8">
272
+ <meta name="viewport" content="width=device-width, initial-scale=1">
273
+ <meta name="color-scheme" content="light dark">
274
+ <title>${escapeHtml(`Page report — ${title}`)}</title>
275
+ <style>${STYLES}
276
+ ${TREND_STYLES}
277
+ ${EVIDENCE_STYLES}</style>
278
+ </head>
279
+ <body>
280
+ <main>
281
+ <h1>${escapeHtml(title)}</h1>
282
+ <p class="sub">${escapeHtml(report.finalUrl || report.url)} · captured ${escapeHtml(report.capturedAt)} · ${Math.round(report.durationMs)}ms</p>
283
+
284
+ ${renderVerdict(report)}
285
+ ${renderFlake(options.flake)}
286
+ ${renderStats(report)}
287
+
288
+ ${renderTrend(report.trend)}
289
+ ${renderDiff(report.diff)}
290
+
291
+ <h2>Errors</h2>
292
+ ${renderErrors(report)}
293
+
294
+ ${renderEvidence(report.evidence)}
295
+
296
+ <h2>Failed requests</h2>
297
+ ${renderRequests(report.requests)}
298
+
299
+ <h2>Web vitals</h2>
300
+ ${renderVitals(report)}
301
+
302
+ <h2>Accessibility</h2>
303
+ ${renderA11y(report.a11y)}
304
+
305
+ <h2>Screenshots</h2>
306
+ ${renderScreenshots(report.screenshots)}
307
+
308
+ ${notes}
309
+ <footer>Generated by monobrowse report — self-contained, no external resources.</footer>
310
+ </main>
311
+ </body>
312
+ </html>
313
+ `;
314
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Assembles the run-to-run diff (RIG-10) from what the previous run left in
3
+ * the history directory: its structural signature (in the JSON record) and
4
+ * its screenshot (the sibling PNG).
5
+ *
6
+ * Every missing piece degrades into a note rather than an absent section, so
7
+ * a reader can tell "nothing changed" apart from "we could not check".
8
+ */
9
+
10
+ import { type HistoryRun, loadRunScreenshot } from './history.js';
11
+ import { type PixelDiffOptions, pixelDiff } from './pixel-diff.js';
12
+ import { pngFromDataUrl } from './png.js';
13
+ import { diffStructure } from './structure.js';
14
+ import type { Report, RunDiff } from './types.js';
15
+
16
+ export interface BuildRunDiffOptions {
17
+ pixels?: PixelDiffOptions;
18
+ /** Set false to skip the pixel half entirely. */
19
+ comparePixels?: boolean;
20
+ }
21
+
22
+ export async function buildRunDiff(
23
+ dir: string,
24
+ previous: HistoryRun | undefined,
25
+ current: Report,
26
+ options: BuildRunDiffOptions = {},
27
+ ): Promise<RunDiff | undefined> {
28
+ if (!previous) return undefined;
29
+
30
+ const notes: string[] = [];
31
+ const diff: RunDiff = {
32
+ previousRunId: previous.id,
33
+ previousCapturedAt: previous.capturedAt,
34
+ notes,
35
+ };
36
+
37
+ if (previous.structure?.length && current.structure?.length) {
38
+ diff.structure = diffStructure(previous.structure, current.structure);
39
+ } else if (!current.structure?.length) {
40
+ notes.push('No accessibility tree this run — structural diff skipped.');
41
+ } else {
42
+ notes.push('The previous run stored no structural signature — nothing to diff against.');
43
+ }
44
+
45
+ if (options.comparePixels !== false) {
46
+ const currentShot =
47
+ current.screenshots.find((s) => s.label === 'page') ?? current.screenshots[0];
48
+ const previousPng = await loadRunScreenshot(dir, previous);
49
+ if (!previousPng) {
50
+ notes.push('The previous run archived no screenshot — pixel diff skipped.');
51
+ } else if (!currentShot?.dataUrl) {
52
+ notes.push('No screenshot this run — pixel diff skipped.');
53
+ } else {
54
+ try {
55
+ diff.pixels = pixelDiff(previousPng, pngFromDataUrl(currentShot.dataUrl), options.pixels);
56
+ } catch (err) {
57
+ notes.push(`Pixel diff failed: ${(err as Error).message}`);
58
+ }
59
+ }
60
+ }
61
+
62
+ return diff;
63
+ }