@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,197 @@
1
+ /**
2
+ * Pixel diff between two screenshots (RIG-10), with no image dependency —
3
+ * png.ts decodes and re-encodes using node's own zlib.
4
+ *
5
+ * Two design choices worth stating:
6
+ *
7
+ * - A per-channel tolerance, not exact equality. Chrome's rasteriser is not
8
+ * bit-deterministic across runs (font hinting, GPU vs. software paths), so
9
+ * an exact compare reports 4% of every screenshot as "changed" and the
10
+ * number becomes noise nobody reads.
11
+ * - Differing dimensions are a result, not an error. A page that grew 200px
12
+ * taller is exactly the regression this is for, so we compare the
13
+ * overlapping region, count the rest as changed, and say so.
14
+ */
15
+
16
+ import { decodePng, encodePng, pngToDataUrl, type RgbaImage } from './png.js';
17
+ import type { PixelDiff } from './types.js';
18
+
19
+ /** Per-channel delta below which two pixels are "the same". */
20
+ const DEFAULT_THRESHOLD = 16;
21
+ /**
22
+ * Cap on the highlight image, in pixels. A full-page shot of a long article
23
+ * can be 1440x20000; inlining that as a data URI would add ~20MB to a report
24
+ * that is supposed to stay openable.
25
+ */
26
+ const DEFAULT_MAX_PIXELS = 1_400_000;
27
+
28
+ export interface PixelDiffOptions {
29
+ threshold?: number;
30
+ maxPixels?: number;
31
+ /** Set false to skip building the highlight image (counts only). */
32
+ renderImage?: boolean;
33
+ }
34
+
35
+ function changedAt(a: Uint8Array, ai: number, b: Uint8Array, bi: number, threshold: number) {
36
+ return (
37
+ Math.abs(a[ai] - b[bi]) > threshold ||
38
+ Math.abs(a[ai + 1] - b[bi + 1]) > threshold ||
39
+ Math.abs(a[ai + 2] - b[bi + 2]) > threshold ||
40
+ Math.abs(a[ai + 3] - b[bi + 3]) > threshold
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Build the highlight image: the current screenshot faded towards white so it
46
+ * reads as context, with changed pixels painted a saturated magenta. Scaled
47
+ * down by an integer factor when the shot is large, ORing the change mask so
48
+ * a one-pixel-wide change survives the downscale instead of being averaged
49
+ * into invisibility.
50
+ */
51
+ function renderHighlight(
52
+ current: RgbaImage,
53
+ mask: Uint8Array,
54
+ maxPixels: number,
55
+ ): { image: RgbaImage; scale: number } {
56
+ const { width, height, data } = current;
57
+ const scale = Math.max(1, Math.ceil(Math.sqrt((width * height) / maxPixels)));
58
+ const outW = Math.max(1, Math.ceil(width / scale));
59
+ const outH = Math.max(1, Math.ceil(height / scale));
60
+ const out = new Uint8Array(outW * outH * 4);
61
+
62
+ for (let oy = 0; oy < outH; oy++) {
63
+ for (let ox = 0; ox < outW; ox++) {
64
+ let r = 0;
65
+ let g = 0;
66
+ let b = 0;
67
+ let n = 0;
68
+ let hit = false;
69
+ for (let dy = 0; dy < scale; dy++) {
70
+ const y = oy * scale + dy;
71
+ if (y >= height) break;
72
+ for (let dx = 0; dx < scale; dx++) {
73
+ const x = ox * scale + dx;
74
+ if (x >= width) break;
75
+ const i = (y * width + x) * 4;
76
+ r += data[i];
77
+ g += data[i + 1];
78
+ b += data[i + 2];
79
+ n++;
80
+ if (mask[y * width + x]) hit = true;
81
+ }
82
+ }
83
+ const o = (oy * outW + ox) * 4;
84
+ if (!n) {
85
+ out[o + 3] = 255;
86
+ continue;
87
+ }
88
+ if (hit) {
89
+ out[o] = 214;
90
+ out[o + 1] = 31;
91
+ out[o + 2] = 105;
92
+ } else {
93
+ // Fade to white: keeps the layout legible without competing with the
94
+ // magenta, and compresses far better than the original.
95
+ out[o] = Math.round(255 - (255 - r / n) * 0.28);
96
+ out[o + 1] = Math.round(255 - (255 - g / n) * 0.28);
97
+ out[o + 2] = Math.round(255 - (255 - b / n) * 0.28);
98
+ }
99
+ out[o + 3] = 255;
100
+ }
101
+ }
102
+ return { image: { width: outW, height: outH, data: out }, scale };
103
+ }
104
+
105
+ /**
106
+ * Compare two PNGs. Never throws for image-shaped reasons — an undecodable
107
+ * screenshot produces a result carrying `note` and no counts, because a
108
+ * report that renders minus one section beats a report that fails to build.
109
+ */
110
+ export function pixelDiff(
111
+ previousPng: Uint8Array,
112
+ currentPng: Uint8Array,
113
+ options: PixelDiffOptions = {},
114
+ ): PixelDiff {
115
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
116
+ const maxPixels = options.maxPixels ?? DEFAULT_MAX_PIXELS;
117
+
118
+ let prev: RgbaImage;
119
+ let curr: RgbaImage;
120
+ try {
121
+ prev = decodePng(previousPng);
122
+ curr = decodePng(currentPng);
123
+ } catch (err) {
124
+ return {
125
+ comparable: false,
126
+ note: `Pixel diff skipped: ${(err as Error).message}`,
127
+ changedPixels: 0,
128
+ totalPixels: 0,
129
+ changedPercent: 0,
130
+ previousSize: null,
131
+ currentSize: null,
132
+ sizeChanged: false,
133
+ };
134
+ }
135
+
136
+ const overlapW = Math.min(prev.width, curr.width);
137
+ const overlapH = Math.min(prev.height, curr.height);
138
+ const sizeChanged = prev.width !== curr.width || prev.height !== curr.height;
139
+
140
+ // Denominator is the union, not the overlap: if the page doubled in height,
141
+ // "0.1% of pixels changed" would be a lie told by a small denominator.
142
+ const totalPixels = Math.max(prev.width * prev.height, curr.width * curr.height);
143
+ const mask = new Uint8Array(curr.width * curr.height);
144
+ let changed = 0;
145
+
146
+ for (let y = 0; y < curr.height; y++) {
147
+ for (let x = 0; x < curr.width; x++) {
148
+ const ci = (y * curr.width + x) * 4;
149
+ if (x >= overlapW || y >= overlapH) {
150
+ // Outside the overlap there is nothing to compare against: the pixel
151
+ // is new by definition.
152
+ mask[y * curr.width + x] = 1;
153
+ changed++;
154
+ continue;
155
+ }
156
+ const pi = (y * prev.width + x) * 4;
157
+ if (changedAt(prev.data, pi, curr.data, ci, threshold)) {
158
+ mask[y * curr.width + x] = 1;
159
+ changed++;
160
+ }
161
+ }
162
+ }
163
+ // Pixels the previous shot had and this one does not are changes too.
164
+ changed += Math.max(0, prev.width * prev.height - overlapW * overlapH);
165
+
166
+ const result: PixelDiff = {
167
+ comparable: true,
168
+ changedPixels: changed,
169
+ totalPixels,
170
+ changedPercent: totalPixels ? (changed / totalPixels) * 100 : 0,
171
+ previousSize: { width: prev.width, height: prev.height },
172
+ currentSize: { width: curr.width, height: curr.height },
173
+ sizeChanged,
174
+ };
175
+
176
+ if (options.renderImage !== false && changed > 0) {
177
+ try {
178
+ const { image, scale } = renderHighlight(curr, mask, maxPixels);
179
+ result.diffDataUrl = pngToDataUrl(encodePng(image));
180
+ if (scale > 1) result.scale = scale;
181
+ } catch (err) {
182
+ result.note = `Highlight image not rendered: ${(err as Error).message}`;
183
+ }
184
+ }
185
+ return result;
186
+ }
187
+
188
+ /** One line for a CI log. */
189
+ export function summarizePixelDiff(diff: PixelDiff): string {
190
+ if (!diff.comparable) return diff.note ?? 'pixel diff unavailable';
191
+ const pct = diff.changedPercent;
192
+ const shown = pct === 0 ? '0' : pct < 0.01 ? '<0.01' : pct.toFixed(2);
193
+ const size = diff.sizeChanged
194
+ ? ` (size ${diff.previousSize?.width}x${diff.previousSize?.height} -> ${diff.currentSize?.width}x${diff.currentSize?.height})`
195
+ : '';
196
+ return `${shown}% of pixels changed${size}`;
197
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * A minimal PNG decoder and encoder built on node's own `zlib`.
3
+ *
4
+ * This exists so the pixel diff (RIG-10) can compare two screenshots without
5
+ * adding an image dependency to a package whose whole point is to be a small
6
+ * self-contained CDP client. PNG is simple enough to justify it: the format
7
+ * is a chunk list wrapping one zlib stream, and node ships the zlib.
8
+ *
9
+ * Scope is deliberately narrow — what `Page.captureScreenshot` actually
10
+ * emits. Non-interlaced, bit depth 8 (16 is accepted by taking the high
11
+ * byte), colour types 0/2/3/4/6. Anything else throws a message that names
12
+ * what it saw, so the caller can degrade to "pixel diff unavailable" instead
13
+ * of rendering a wrong picture.
14
+ */
15
+
16
+ import { deflateSync, inflateSync } from 'node:zlib';
17
+
18
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
19
+
20
+ export interface RgbaImage {
21
+ width: number;
22
+ height: number;
23
+ /** Row-major RGBA, 4 bytes per pixel, length === width * height * 4. */
24
+ data: Uint8Array;
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // CRC32 — PNG chunks carry their own checksum, and zlib does not expose one.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const CRC_TABLE = (() => {
32
+ const table = new Uint32Array(256);
33
+ for (let n = 0; n < 256; n++) {
34
+ let c = n;
35
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
36
+ table[n] = c >>> 0;
37
+ }
38
+ return table;
39
+ })();
40
+
41
+ export function crc32(buf: Uint8Array): number {
42
+ let c = 0xffffffff;
43
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
44
+ return (c ^ 0xffffffff) >>> 0;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Decode
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
52
+
53
+ function paeth(a: number, b: number, c: number): number {
54
+ const p = a + b - c;
55
+ const pa = Math.abs(p - a);
56
+ const pb = Math.abs(p - b);
57
+ const pc = Math.abs(p - c);
58
+ if (pa <= pb && pa <= pc) return a;
59
+ return pb <= pc ? b : c;
60
+ }
61
+
62
+ /** Undo the per-scanline filters in place, returning the raw sample bytes. */
63
+ function unfilter(raw: Buffer, width: number, height: number, bpp: number, stride: number): Buffer {
64
+ const out = Buffer.alloc(height * stride);
65
+ let pos = 0;
66
+ for (let y = 0; y < height; y++) {
67
+ const filter = raw[pos++];
68
+ const line = raw.subarray(pos, pos + stride);
69
+ pos += stride;
70
+ const cur = out.subarray(y * stride, (y + 1) * stride);
71
+ const prev = y > 0 ? out.subarray((y - 1) * stride, y * stride) : null;
72
+ for (let x = 0; x < stride; x++) {
73
+ const a = x >= bpp ? cur[x - bpp] : 0;
74
+ const b = prev ? prev[x] : 0;
75
+ const c = prev && x >= bpp ? prev[x - bpp] : 0;
76
+ const v = line[x];
77
+ switch (filter) {
78
+ case 0:
79
+ cur[x] = v;
80
+ break;
81
+ case 1:
82
+ cur[x] = (v + a) & 0xff;
83
+ break;
84
+ case 2:
85
+ cur[x] = (v + b) & 0xff;
86
+ break;
87
+ case 3:
88
+ cur[x] = (v + ((a + b) >> 1)) & 0xff;
89
+ break;
90
+ case 4:
91
+ cur[x] = (v + paeth(a, b, c)) & 0xff;
92
+ break;
93
+ default:
94
+ throw new Error(`PNG: unknown scanline filter ${filter} on row ${y}`);
95
+ }
96
+ }
97
+ }
98
+ void width;
99
+ return out;
100
+ }
101
+
102
+ interface Header {
103
+ width: number;
104
+ height: number;
105
+ bitDepth: number;
106
+ colorType: number;
107
+ interlace: number;
108
+ }
109
+
110
+ function readHeader(buf: Buffer, offset: number): Header {
111
+ return {
112
+ width: buf.readUInt32BE(offset),
113
+ height: buf.readUInt32BE(offset + 4),
114
+ bitDepth: buf[offset + 8],
115
+ colorType: buf[offset + 9],
116
+ interlace: buf[offset + 12],
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Expand one row of decoded samples into RGBA. `step` is the byte stride of
122
+ * one sample (1 for bit depth 8, 2 for 16 — where we keep the high byte).
123
+ */
124
+ function toRgba(
125
+ samples: Buffer,
126
+ header: Header,
127
+ palette: Buffer | null,
128
+ transparency: Buffer | null,
129
+ ): Uint8Array {
130
+ const { width, height, bitDepth, colorType } = header;
131
+ const channels = CHANNELS[colorType];
132
+ const step = bitDepth === 16 ? 2 : 1;
133
+ const stride = width * channels * step;
134
+ const out = new Uint8Array(width * height * 4);
135
+
136
+ for (let y = 0; y < height; y++) {
137
+ for (let x = 0; x < width; x++) {
138
+ const s = y * stride + x * channels * step;
139
+ const d = (y * width + x) * 4;
140
+ if (colorType === 3) {
141
+ const index = samples[s];
142
+ if (!palette || index * 3 + 2 >= palette.length) {
143
+ throw new Error(`PNG: palette index ${index} out of range`);
144
+ }
145
+ out[d] = palette[index * 3];
146
+ out[d + 1] = palette[index * 3 + 1];
147
+ out[d + 2] = palette[index * 3 + 2];
148
+ out[d + 3] = transparency && index < transparency.length ? transparency[index] : 255;
149
+ } else if (colorType === 0) {
150
+ const g = samples[s];
151
+ out[d] = g;
152
+ out[d + 1] = g;
153
+ out[d + 2] = g;
154
+ out[d + 3] = 255;
155
+ } else if (colorType === 4) {
156
+ const g = samples[s];
157
+ out[d] = g;
158
+ out[d + 1] = g;
159
+ out[d + 2] = g;
160
+ out[d + 3] = samples[s + step];
161
+ } else if (colorType === 2) {
162
+ out[d] = samples[s];
163
+ out[d + 1] = samples[s + step];
164
+ out[d + 2] = samples[s + 2 * step];
165
+ out[d + 3] = 255;
166
+ } else {
167
+ out[d] = samples[s];
168
+ out[d + 1] = samples[s + step];
169
+ out[d + 2] = samples[s + 2 * step];
170
+ out[d + 3] = samples[s + 3 * step];
171
+ }
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+
177
+ export function decodePng(input: Uint8Array): RgbaImage {
178
+ const buf = Buffer.from(input.buffer, input.byteOffset, input.byteLength);
179
+ if (buf.length < 8 || !buf.subarray(0, 8).equals(SIGNATURE)) {
180
+ throw new Error('PNG: not a PNG (bad signature)');
181
+ }
182
+
183
+ let header: Header | null = null;
184
+ let palette: Buffer | null = null;
185
+ let transparency: Buffer | null = null;
186
+ const idat: Buffer[] = [];
187
+
188
+ let pos = 8;
189
+ while (pos + 8 <= buf.length) {
190
+ const length = buf.readUInt32BE(pos);
191
+ const type = buf.toString('ascii', pos + 4, pos + 8);
192
+ const dataStart = pos + 8;
193
+ if (dataStart + length > buf.length) throw new Error(`PNG: truncated ${type} chunk`);
194
+ if (type === 'IHDR') header = readHeader(buf, dataStart);
195
+ else if (type === 'PLTE') palette = buf.subarray(dataStart, dataStart + length);
196
+ else if (type === 'tRNS') transparency = buf.subarray(dataStart, dataStart + length);
197
+ else if (type === 'IDAT') idat.push(buf.subarray(dataStart, dataStart + length));
198
+ else if (type === 'IEND') break;
199
+ pos = dataStart + length + 4;
200
+ }
201
+
202
+ if (!header) throw new Error('PNG: no IHDR chunk');
203
+ if (header.interlace !== 0) {
204
+ throw new Error('PNG: interlaced (Adam7) images are not supported');
205
+ }
206
+ if (header.bitDepth !== 8 && header.bitDepth !== 16) {
207
+ throw new Error(`PNG: unsupported bit depth ${header.bitDepth} (need 8 or 16)`);
208
+ }
209
+ const channels = CHANNELS[header.colorType];
210
+ if (channels === undefined) {
211
+ throw new Error(`PNG: unsupported colour type ${header.colorType}`);
212
+ }
213
+ if (header.colorType === 3 && header.bitDepth !== 8) {
214
+ throw new Error(`PNG: palette images below bit depth 8 are not supported`);
215
+ }
216
+ if (!idat.length) throw new Error('PNG: no IDAT data');
217
+
218
+ const sampleBytes = header.bitDepth === 16 ? 2 : 1;
219
+ const stride = header.width * channels * sampleBytes;
220
+ const bpp = Math.max(1, channels * sampleBytes);
221
+ const inflated = inflateSync(Buffer.concat(idat));
222
+ const expected = header.height * (stride + 1);
223
+ if (inflated.length < expected) {
224
+ throw new Error(`PNG: pixel data short by ${expected - inflated.length} bytes`);
225
+ }
226
+ const samples = unfilter(inflated, header.width, header.height, bpp, stride);
227
+
228
+ return {
229
+ width: header.width,
230
+ height: header.height,
231
+ data: toRgba(samples, header, palette, transparency),
232
+ };
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Encode
237
+ // ---------------------------------------------------------------------------
238
+
239
+ function chunk(type: string, data: Uint8Array): Buffer {
240
+ const head = Buffer.alloc(8);
241
+ head.writeUInt32BE(data.length, 0);
242
+ head.write(type, 4, 'ascii');
243
+ const body = Buffer.concat([head.subarray(4), Buffer.from(data)]);
244
+ const crc = Buffer.alloc(4);
245
+ crc.writeUInt32BE(crc32(body), 0);
246
+ return Buffer.concat([head.subarray(0, 4), body, crc]);
247
+ }
248
+
249
+ /** Encode RGBA bytes as a non-interlaced, 8-bit truecolour-with-alpha PNG. */
250
+ export function encodePng(image: RgbaImage): Buffer {
251
+ const { width, height, data } = image;
252
+ if (data.length !== width * height * 4) {
253
+ throw new Error(
254
+ `PNG: encode expected ${width * height * 4} bytes for ${width}x${height}, got ${data.length}`,
255
+ );
256
+ }
257
+ const stride = width * 4;
258
+ const raw = Buffer.alloc(height * (stride + 1));
259
+ for (let y = 0; y < height; y++) {
260
+ raw[y * (stride + 1)] = 0; // filter: None. Simplest, and deflate still wins.
261
+ Buffer.from(data.buffer, data.byteOffset + y * stride, stride).copy(raw, y * (stride + 1) + 1);
262
+ }
263
+
264
+ const ihdr = Buffer.alloc(13);
265
+ ihdr.writeUInt32BE(width, 0);
266
+ ihdr.writeUInt32BE(height, 4);
267
+ ihdr[8] = 8; // bit depth
268
+ ihdr[9] = 6; // colour type: RGBA
269
+ ihdr[10] = 0; // compression
270
+ ihdr[11] = 0; // filter
271
+ ihdr[12] = 0; // interlace
272
+
273
+ return Buffer.concat([
274
+ SIGNATURE,
275
+ chunk('IHDR', ihdr),
276
+ chunk('IDAT', deflateSync(raw, { level: 6 })),
277
+ chunk('IEND', new Uint8Array(0)),
278
+ ]);
279
+ }
280
+
281
+ /** `data:image/png;base64,...` -> bytes. Accepts a bare base64 payload too. */
282
+ export function pngFromDataUrl(dataUrl: string): Uint8Array {
283
+ const comma = dataUrl.indexOf(',');
284
+ const payload = dataUrl.startsWith('data:') && comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
285
+ return new Uint8Array(Buffer.from(payload, 'base64'));
286
+ }
287
+
288
+ export function pngToDataUrl(png: Uint8Array): string {
289
+ return `data:image/png;base64,${Buffer.from(png).toString('base64')}`;
290
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Report sections for the recorded evidence timeline (RIG-11) and the flake
3
+ * analysis (RIG-14).
4
+ *
5
+ * The evidence section is written to be read the way someone triages a bug:
6
+ * a filmstrip of what the page looked like either side of the failure, then
7
+ * one time-ordered list mixing frames, console output and network failures,
8
+ * so "the error fired, then the screen went blank" is visible rather than
9
+ * inferred.
10
+ */
11
+
12
+ import type { FlakeCheck, FlakeReport } from './flake.js';
13
+ import type { Evidence, TimelineEntry } from './types.js';
14
+ import { escapeHtml } from './util.js';
15
+
16
+ export const EVIDENCE_STYLES = `
17
+ .filmstrip{display:flex;gap:10px;overflow-x:auto;padding:4px 0 10px;margin:0 0 8px}
18
+ .filmstrip figure{flex:0 0 auto;width:180px;margin:0}
19
+ .filmstrip img{width:180px;height:auto;border-radius:6px}
20
+ .filmstrip figcaption{font-variant-numeric:tabular-nums}
21
+ .filmstrip figure.focus img{border-color:var(--fail);border-width:2px}
22
+ .timeline{list-style:none;margin:0;padding:0;border-left:2px solid var(--line);
23
+ margin-left:8px}
24
+ .timeline li{position:relative;padding:6px 0 6px 18px;font-size:13px}
25
+ .timeline li::before{content:"";position:absolute;left:-5px;top:12px;width:8px;height:8px;
26
+ border-radius:50%;background:var(--muted)}
27
+ .timeline li.error::before{background:var(--fail)}
28
+ .timeline li.warning::before{background:var(--warn)}
29
+ .timeline li.marker{font-weight:600;color:var(--fail)}
30
+ .timeline .at{display:inline-block;min-width:66px;color:var(--muted);
31
+ font-variant-numeric:tabular-nums;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
32
+ .verdict.flaky{background:var(--warn-bg);border-color:var(--warn);color:var(--warn)}
33
+ .rate{display:inline-block;min-width:72px}
34
+ .bar{display:inline-block;width:80px;height:8px;border-radius:4px;background:var(--pass);
35
+ overflow:hidden;vertical-align:middle}
36
+ .bar i{display:block;height:100%;background:var(--fail)}
37
+ `.trim();
38
+
39
+ function at(offsetMs: number): string {
40
+ const sign = offsetMs < 0 ? '−' : '+';
41
+ const abs = Math.abs(offsetMs);
42
+ return abs >= 1000 ? `${sign}${(abs / 1000).toFixed(2)}s` : `${sign}${Math.round(abs)}ms`;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Evidence
47
+ // ---------------------------------------------------------------------------
48
+
49
+ function renderFilmstrip(evidence: Evidence): string {
50
+ if (!evidence.frames.length) return '';
51
+ // Mark the frame closest to the failure so the eye lands on it first.
52
+ let focusIndex = -1;
53
+ if (evidence.focusOffsetMs !== null) {
54
+ let best = Infinity;
55
+ evidence.frames.forEach((f, i) => {
56
+ const d = Math.abs(f.offsetMs - (evidence.focusOffsetMs as number));
57
+ if (d < best) {
58
+ best = d;
59
+ focusIndex = i;
60
+ }
61
+ });
62
+ }
63
+ const figures = evidence.frames
64
+ .map(
65
+ (f, i) => `<figure class="${i === focusIndex ? 'focus' : ''}">
66
+ <figcaption>${escapeHtml(at(f.offsetMs))}${i === focusIndex ? ' · at failure' : ''}</figcaption>
67
+ <img alt="${escapeHtml(`Frame at ${at(f.offsetMs)}`)}" src="${escapeHtml(f.dataUrl)}">
68
+ </figure>`,
69
+ )
70
+ .join('');
71
+ return `<div class="filmstrip">${figures}</div>`;
72
+ }
73
+
74
+ function renderTimelineRows(timeline: TimelineEntry[]): string {
75
+ if (!timeline.length) return '<p class="empty">Nothing was recorded for this run.</p>';
76
+ const rows = timeline
77
+ .map((e) => {
78
+ const detail = e.detail ? ` — ${escapeHtml(e.detail)}` : '';
79
+ const frame =
80
+ e.kind === 'frame' && e.frameIndex !== undefined
81
+ ? ` <span style="color:var(--muted)">#${e.frameIndex + 1}</span>`
82
+ : '';
83
+ return `<li class="${e.severity}${e.kind === 'marker' ? ' marker' : ''}">
84
+ <span class="at">${escapeHtml(at(e.offsetMs))}</span>${escapeHtml(e.label)}${frame}${detail}
85
+ </li>`;
86
+ })
87
+ .join('');
88
+ return `<ul class="timeline">${rows}</ul>`;
89
+ }
90
+
91
+ export function renderEvidence(evidence: Evidence | undefined): string {
92
+ if (!evidence) return '';
93
+ const why =
94
+ evidence.reason === 'budget-failure'
95
+ ? 'Recorded because this run breached its budget.'
96
+ : 'Recorded because --record was passed.';
97
+ const focus =
98
+ evidence.focusOffsetMs !== null
99
+ ? ` The timeline is centred on the first failure, at ${at(evidence.focusOffsetMs)} into the run.`
100
+ : '';
101
+ const notes = evidence.notes.length
102
+ ? `<p class="note">${evidence.notes.map((n) => escapeHtml(n)).join(' ')}</p>`
103
+ : '';
104
+
105
+ return `<h2>Evidence</h2>
106
+ <p class="note">${escapeHtml(why)}${escapeHtml(focus)} Offsets are measured from the start of the run.</p>
107
+ ${renderFilmstrip(evidence)}
108
+ ${renderTimelineRows(evidence.timeline)}
109
+ ${notes}`;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Flake
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function rateBar(check: FlakeCheck): string {
117
+ const pct = Math.round(check.flakeRate * 100);
118
+ return `<span class="bar" role="img" aria-label="${escapeHtml(`failed ${pct}% of runs`)}"><i style="width:${pct}%"></i></span>`;
119
+ }
120
+
121
+ function renderChecks(flake: FlakeReport): string {
122
+ const rows = flake.checks
123
+ // Unstable checks first: they are the reason this section exists.
124
+ .sort((a, b) => Number(a.stable) - Number(b.stable) || b.flakeRate - a.flakeRate)
125
+ .map(
126
+ (c) => `<tr>
127
+ <td>${escapeHtml(c.label)}</td>
128
+ <td class="num mono"><span class="rate">${c.passed} of ${c.runs} passed</span></td>
129
+ <td>${rateBar(c)}</td>
130
+ <td>${c.stable ? (c.failed ? '<span class="pill error">always fails</span>' : '<span class="pill good">stable</span>') : '<span class="pill warning">flaky</span>'}</td>
131
+ <td class="wrap mono">${escapeHtml(c.actuals.join(', '))}</td>
132
+ </tr>`,
133
+ )
134
+ .join('');
135
+ return `<table><thead><tr><th>Check</th><th>Outcome</th><th>Failure rate</th><th></th><th>Measured each run</th></tr></thead>
136
+ <tbody>${rows}</tbody></table>`;
137
+ }
138
+
139
+ function renderSignals(flake: FlakeReport): string {
140
+ if (!flake.signals.length) {
141
+ return '<p class="empty">No error or failed request came and went between runs.</p>';
142
+ }
143
+ const rows = flake.signals
144
+ .slice(0, 30)
145
+ .map(
146
+ (s) => `<tr>
147
+ <td>${escapeHtml(s.kind)}</td>
148
+ <td class="wrap">${escapeHtml(s.signature)}</td>
149
+ <td class="num mono">${s.runs} of ${s.total}</td>
150
+ </tr>`,
151
+ )
152
+ .join('');
153
+ return `<table><thead><tr><th>Kind</th><th>Signal</th><th>Appeared in</th></tr></thead><tbody>${rows}</tbody></table>
154
+ <p class="note">Ids, hashes and long numbers are normalised before grouping, so one intermittent error is one row rather than N.</p>`;
155
+ }
156
+
157
+ function renderMetricSpread(flake: FlakeReport): string {
158
+ if (!flake.metrics.length) return '';
159
+ const rows = flake.metrics
160
+ .map((m) => {
161
+ const fmt = (v: number) =>
162
+ m.unit === 'score'
163
+ ? v.toFixed(3)
164
+ : v >= 1000
165
+ ? `${(v / 1000).toFixed(2)}s`
166
+ : `${Math.round(v)}ms`;
167
+ return `<tr>
168
+ <td><b>${escapeHtml(m.label)}</b></td>
169
+ <td class="num mono">${escapeHtml(fmt(m.min))} – ${escapeHtml(fmt(m.max))}</td>
170
+ <td class="num mono">${escapeHtml(fmt(m.median))}</td>
171
+ <td class="num mono">${escapeHtml(fmt(m.spread))}</td>
172
+ <td>${m.unstable ? '<span class="pill warning">varies widely</span>' : '<span class="pill good">consistent</span>'}</td>
173
+ <td class="num mono">${m.measured} of ${m.total}</td>
174
+ </tr>`;
175
+ })
176
+ .join('');
177
+ return `<h3 style="font-size:13px;color:var(--muted);margin:24px 0 8px">Metric spread</h3>
178
+ <table><thead><tr><th>Metric</th><th>Range</th><th>Median</th><th>Spread</th><th></th><th>Measured</th></tr></thead>
179
+ <tbody>${rows}</tbody></table>`;
180
+ }
181
+
182
+ export function renderFlake(flake: FlakeReport | undefined): string {
183
+ if (!flake) return '';
184
+ return `<h2>Repeatability — ${flake.runs} runs</h2>
185
+ <section class="verdict ${flake.verdict === 'pass' ? 'pass' : flake.verdict === 'fail' ? 'fail' : 'flaky'}">
186
+ <div class="tag">${flake.verdict.toUpperCase()}</div>
187
+ <div class="line">${escapeHtml(flake.headline)}</div>
188
+ </section>
189
+ <p class="note"><b>Confidence: ${escapeHtml(flake.confidence)}.</b> ${escapeHtml(flake.confidenceNote)}</p>
190
+ ${renderChecks(flake)}
191
+ <h3 style="font-size:13px;color:var(--muted);margin:24px 0 8px">Intermittent errors and requests</h3>
192
+ ${renderSignals(flake)}
193
+ ${renderMetricSpread(flake)}`;
194
+ }