@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,272 @@
1
+ /**
2
+ * RIG-10 — the two halves of the run-to-run diff.
3
+ *
4
+ * The structural half is fed hand-written AX trees; the pixel half is fed
5
+ * PNGs this test encodes itself, so both run without Chrome.
6
+ */
7
+ import { describe, expect, it } from 'vitest';
8
+ import { pixelDiff, summarizePixelDiff } from '../report/pixel-diff.js';
9
+ import { encodePng, type RgbaImage } from '../report/png.js';
10
+ import { buildStructure, diffStructure, summarizeStructureDiff } from '../report/structure.js';
11
+ import type { AxNode, StructureNode } from '../report/types.js';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Structure
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const node = (
18
+ id: number,
19
+ role: string,
20
+ name: string | null,
21
+ childIds: number[] = [],
22
+ extra: Partial<AxNode> = {},
23
+ ): AxNode => ({
24
+ nodeId: id,
25
+ role: { value: role },
26
+ ...(name === null ? {} : { name: { value: name } }),
27
+ childIds,
28
+ ...extra,
29
+ });
30
+
31
+ /** A small page: a form with a labelled field and two buttons. */
32
+ function page(overrides: { submitName?: string; extraButton?: boolean } = {}): AxNode[] {
33
+ const formChildren = [3, 4];
34
+ if (overrides.extraButton) formChildren.push(5);
35
+ return [
36
+ node(1, 'RootWebArea', 'Checkout', [2]),
37
+ node(2, 'form', 'Payment', formChildren.length ? [3, 4] : []),
38
+ node(3, 'textbox', 'Card number'),
39
+ node(4, 'button', overrides.submitName ?? 'Pay now'),
40
+ ...(overrides.extraButton ? [node(5, 'button', 'Cancel')] : []),
41
+ ].map((n) => (n.nodeId === 2 ? { ...n, childIds: formChildren } : n));
42
+ }
43
+
44
+ describe('buildStructure', () => {
45
+ it('paths each element by role and sibling index', () => {
46
+ const structure = buildStructure(page());
47
+ expect(structure.map((s) => s.path)).toEqual([
48
+ '/rootwebarea[1]',
49
+ '/rootwebarea[1]/form[1]',
50
+ '/rootwebarea[1]/form[1]/textbox[1]',
51
+ '/rootwebarea[1]/form[1]/button[1]',
52
+ ]);
53
+ expect(structure[3]).toMatchObject({ role: 'button', name: 'Pay now', depth: 2 });
54
+ });
55
+
56
+ it('splices transparent wrappers away so a styling div cannot shift a path', () => {
57
+ const wrapped = [
58
+ node(1, 'RootWebArea', 'Checkout', [9]),
59
+ node(9, 'generic', null, [2]),
60
+ node(2, 'button', 'Pay now'),
61
+ ];
62
+ expect(buildStructure(wrapped).map((s) => s.path)).toEqual([
63
+ '/rootwebarea[1]',
64
+ '/rootwebarea[1]/button[1]',
65
+ ]);
66
+ });
67
+
68
+ it('skips ignored nodes but keeps their children', () => {
69
+ const tree = [
70
+ node(1, 'RootWebArea', 'Page', [2]),
71
+ node(2, 'section', 'Hidden wrapper', [3], { ignored: true }),
72
+ node(3, 'link', 'Continue'),
73
+ ];
74
+ expect(buildStructure(tree).map((s) => s.role)).toEqual(['rootwebarea', 'link']);
75
+ });
76
+
77
+ it('collapses whitespace in accessible names so reflow is not a rename', () => {
78
+ const tree = [node(1, 'button', ' Pay \n now ')];
79
+ expect(buildStructure(tree)[0].name).toBe('Pay now');
80
+ });
81
+
82
+ it('survives a cyclic tree instead of looping forever', () => {
83
+ const cyclic = [node(1, 'RootWebArea', 'Loop', [2]), node(2, 'group', 'Child', [1])];
84
+ const structure = buildStructure(cyclic);
85
+ expect(structure.length).toBe(2);
86
+ });
87
+
88
+ it('caps a pathological tree rather than emitting a row per node', () => {
89
+ const many: AxNode[] = [node(1, 'RootWebArea', 'Big', [])];
90
+ const childIds: number[] = [];
91
+ for (let i = 2; i < 3000; i++) {
92
+ childIds.push(i);
93
+ many.push(node(i, 'listitem', `Item ${i}`));
94
+ }
95
+ many[0] = node(1, 'RootWebArea', 'Big', childIds);
96
+ expect(buildStructure(many).length).toBeLessThanOrEqual(1500);
97
+ });
98
+ });
99
+
100
+ describe('diffStructure', () => {
101
+ it('reports an identical page as unchanged', () => {
102
+ const before = buildStructure(page());
103
+ const after = buildStructure(page());
104
+ const diff = diffStructure(before, after);
105
+ expect(diff).toMatchObject({ changed: 0, gained: [], lost: [], renamed: [] });
106
+ expect(diff.unchanged).toBe(4);
107
+ expect(summarizeStructureDiff(diff)).toBe('structurally identical');
108
+ });
109
+
110
+ it('reports a changed accessible name as a rename, not an add and a delete', () => {
111
+ const diff = diffStructure(
112
+ buildStructure(page()),
113
+ buildStructure(page({ submitName: 'Place order' })),
114
+ );
115
+ expect(diff.gained).toEqual([]);
116
+ expect(diff.lost).toEqual([]);
117
+ expect(diff.renamed).toEqual([
118
+ {
119
+ path: '/rootwebarea[1]/form[1]/button[1]',
120
+ role: 'button',
121
+ from: 'Pay now',
122
+ to: 'Place order',
123
+ },
124
+ ]);
125
+ expect(summarizeStructureDiff(diff)).toBe('1 renamed');
126
+ });
127
+
128
+ it('reports a new element as gained', () => {
129
+ const diff = diffStructure(buildStructure(page()), buildStructure(page({ extraButton: true })));
130
+ expect(diff.gained.map((n) => n.name)).toEqual(['Cancel']);
131
+ expect(diff.lost).toEqual([]);
132
+ });
133
+
134
+ it('reports a removed element as lost', () => {
135
+ const diff = diffStructure(buildStructure(page({ extraButton: true })), buildStructure(page()));
136
+ expect(diff.lost.map((n) => n.name)).toEqual(['Cancel']);
137
+ expect(diff.gained).toEqual([]);
138
+ });
139
+
140
+ it('calls a reordered element moved, not gained-and-lost', () => {
141
+ // Inserting at the front renumbers every later sibling; a path-only diff
142
+ // would call that four changes.
143
+ const before: StructureNode[] = [
144
+ { path: '/list[1]/listitem[1]', role: 'listitem', name: 'Alpha', depth: 1 },
145
+ { path: '/list[1]/listitem[2]', role: 'listitem', name: 'Beta', depth: 1 },
146
+ ];
147
+ const after: StructureNode[] = [
148
+ { path: '/list[1]/listitem[1]', role: 'listitem', name: 'Beta', depth: 1 },
149
+ { path: '/list[1]/listitem[2]', role: 'listitem', name: 'Alpha', depth: 1 },
150
+ ];
151
+ const diff = diffStructure(before, after);
152
+ expect(diff.gained).toEqual([]);
153
+ expect(diff.lost).toEqual([]);
154
+ expect(diff.renamed.length).toBe(2);
155
+ });
156
+
157
+ it('recovers an element that moved to a different parent', () => {
158
+ const before: StructureNode[] = [
159
+ { path: '/main[1]/button[1]', role: 'button', name: 'Help', depth: 1 },
160
+ ];
161
+ const after: StructureNode[] = [
162
+ { path: '/footer[1]/button[1]', role: 'button', name: 'Help', depth: 1 },
163
+ ];
164
+ const diff = diffStructure(before, after);
165
+ expect(diff.gained).toEqual([]);
166
+ expect(diff.lost).toEqual([]);
167
+ expect(diff.moved).toEqual([
168
+ { role: 'button', name: 'Help', from: '/main[1]/button[1]', to: '/footer[1]/button[1]' },
169
+ ]);
170
+ expect(diff.changed).toBe(0);
171
+ });
172
+
173
+ it('treats a role change in the same slot as a replacement', () => {
174
+ const before: StructureNode[] = [{ path: '/x[1]', role: 'button', name: 'Go', depth: 0 }];
175
+ const after: StructureNode[] = [{ path: '/x[1]', role: 'link', name: 'Go', depth: 0 }];
176
+ const diff = diffStructure(before, after);
177
+ expect(diff.renamed).toEqual([]);
178
+ expect(diff.gained.map((n) => n.role)).toEqual(['link']);
179
+ expect(diff.lost.map((n) => n.role)).toEqual(['button']);
180
+ });
181
+ });
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Pixels
185
+ // ---------------------------------------------------------------------------
186
+
187
+ function canvas(width: number, height: number, fill: [number, number, number]): RgbaImage {
188
+ const data = new Uint8Array(width * height * 4);
189
+ for (let i = 0; i < width * height; i++) data.set([...fill, 255], i * 4);
190
+ return { width, height, data };
191
+ }
192
+
193
+ function withBlock(
194
+ base: RgbaImage,
195
+ x0: number,
196
+ y0: number,
197
+ w: number,
198
+ h: number,
199
+ fill: [number, number, number],
200
+ ): RgbaImage {
201
+ const data = new Uint8Array(base.data);
202
+ for (let y = y0; y < y0 + h; y++) {
203
+ for (let x = x0; x < x0 + w; x++) {
204
+ data.set([...fill, 255], (y * base.width + x) * 4);
205
+ }
206
+ }
207
+ return { ...base, data };
208
+ }
209
+
210
+ describe('pixelDiff', () => {
211
+ const base = canvas(40, 40, [255, 255, 255]);
212
+
213
+ it('reports zero change for identical screenshots and renders no image', () => {
214
+ const png = encodePng(base);
215
+ const diff = pixelDiff(png, png);
216
+ expect(diff).toMatchObject({ comparable: true, changedPixels: 0, changedPercent: 0 });
217
+ expect(diff.diffDataUrl).toBeUndefined();
218
+ expect(summarizePixelDiff(diff)).toBe('0% of pixels changed');
219
+ });
220
+
221
+ it('counts exactly the pixels that changed', () => {
222
+ const changed = withBlock(base, 5, 5, 10, 10, [0, 0, 0]);
223
+ const diff = pixelDiff(encodePng(base), encodePng(changed));
224
+ expect(diff.changedPixels).toBe(100);
225
+ expect(diff.totalPixels).toBe(1600);
226
+ expect(diff.changedPercent).toBeCloseTo(6.25, 5);
227
+ expect(diff.diffDataUrl?.startsWith('data:image/png;base64,')).toBe(true);
228
+ });
229
+
230
+ it('ignores sub-threshold noise, which is what rasteriser jitter looks like', () => {
231
+ const jittered = withBlock(base, 0, 0, 40, 40, [250, 250, 250]);
232
+ expect(pixelDiff(encodePng(base), encodePng(jittered)).changedPixels).toBe(0);
233
+ // ...but the same pixels at a real difference are counted.
234
+ const real = withBlock(base, 0, 0, 40, 40, [200, 200, 200]);
235
+ expect(pixelDiff(encodePng(base), encodePng(real)).changedPixels).toBe(1600);
236
+ });
237
+
238
+ it('handles a page that grew taller, counting the new region as changed', () => {
239
+ const taller = canvas(40, 60, [255, 255, 255]);
240
+ const diff = pixelDiff(encodePng(base), encodePng(taller));
241
+ expect(diff.sizeChanged).toBe(true);
242
+ expect(diff.previousSize).toEqual({ width: 40, height: 40 });
243
+ expect(diff.currentSize).toEqual({ width: 40, height: 60 });
244
+ // The 800 new pixels, measured against the larger of the two areas.
245
+ expect(diff.changedPixels).toBe(800);
246
+ expect(diff.totalPixels).toBe(2400);
247
+ expect(summarizePixelDiff(diff)).toContain('size 40x40 -> 40x60');
248
+ });
249
+
250
+ it('counts pixels the previous shot had and this one does not', () => {
251
+ const shorter = canvas(40, 20, [255, 255, 255]);
252
+ const diff = pixelDiff(encodePng(base), encodePng(shorter));
253
+ expect(diff.changedPixels).toBe(800);
254
+ expect(diff.totalPixels).toBe(1600);
255
+ });
256
+
257
+ it('downscales the highlight image instead of inlining a giant PNG', () => {
258
+ const big = canvas(1600, 1200, [255, 255, 255]);
259
+ const diff = pixelDiff(encodePng(big), encodePng(withBlock(big, 0, 0, 40, 40, [0, 0, 0])), {
260
+ maxPixels: 40_000,
261
+ });
262
+ expect(diff.scale).toBeGreaterThan(1);
263
+ expect(diff.changedPixels).toBe(1600);
264
+ });
265
+
266
+ it('degrades to a note rather than throwing when a screenshot will not decode', () => {
267
+ const diff = pixelDiff(new TextEncoder().encode('not a png'), encodePng(base));
268
+ expect(diff.comparable).toBe(false);
269
+ expect(diff.note).toMatch(/Pixel diff skipped/);
270
+ expect(summarizePixelDiff(diff)).toMatch(/Pixel diff skipped/);
271
+ });
272
+ });
@@ -0,0 +1,276 @@
1
+ /**
2
+ * RIG-11 — recorded evidence around a failure.
3
+ *
4
+ * Only the pure half is tested here (selection and timeline assembly); the
5
+ * recorder itself is exercised with a stub CDP client, so nothing launches
6
+ * Chrome. The bounds are the point: a report must never grow without limit,
7
+ * however long the run or however noisy the page.
8
+ */
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import type { CdpClient } from '../browser/cdp.js';
11
+ import {
12
+ type BuildEvidenceInput,
13
+ buildEvidence,
14
+ findFocusOffset,
15
+ type RawFrame,
16
+ selectFrames,
17
+ startEvidenceRecorder,
18
+ } from '../report/evidence.js';
19
+
20
+ const START = 1_000_000;
21
+
22
+ function frames(count: number, spacingMs = 100, bytes = 1000): RawFrame[] {
23
+ return Array.from({ length: count }, (_, i) => ({
24
+ offsetMs: i * spacingMs,
25
+ data: 'x'.repeat(bytes),
26
+ bytes,
27
+ }));
28
+ }
29
+
30
+ function input(partial: Partial<BuildEvidenceInput> = {}): BuildEvidenceInput {
31
+ return {
32
+ reason: 'budget-failure',
33
+ startedAtMs: START,
34
+ frames: frames(20),
35
+ console: [],
36
+ pageErrors: [],
37
+ requests: [],
38
+ failures: [],
39
+ ...partial,
40
+ };
41
+ }
42
+
43
+ describe('findFocusOffset', () => {
44
+ it('picks the earliest hard error as the moment to explain', () => {
45
+ const offset = findFocusOffset(
46
+ input({
47
+ console: [
48
+ { type: 'error', text: 'late', timestamp: START + 1800 },
49
+ { type: 'error', text: 'early', timestamp: START + 600 },
50
+ ],
51
+ pageErrors: [{ text: 'uncaught', timestamp: START + 900 }],
52
+ }),
53
+ );
54
+ expect(offset).toBe(600);
55
+ });
56
+
57
+ it('ignores console warnings — a warning is not the failure', () => {
58
+ expect(
59
+ findFocusOffset(input({ console: [{ type: 'warn', text: 'meh', timestamp: START + 100 }] })),
60
+ ).toBeNull();
61
+ });
62
+
63
+ it('uses a failed request when it carries a clock reading', () => {
64
+ expect(
65
+ findFocusOffset(
66
+ input({
67
+ requests: [
68
+ { url: 'https://a.test/x', method: 'GET', failed: true, startedAtMs: START + 450 },
69
+ ],
70
+ }),
71
+ ),
72
+ ).toBe(450);
73
+ });
74
+
75
+ it('skips a failed request with no clock reading rather than guessing', () => {
76
+ expect(
77
+ findFocusOffset(
78
+ input({ requests: [{ url: 'https://a.test/x', method: 'GET', failed: true }] }),
79
+ ),
80
+ ).toBeNull();
81
+ });
82
+
83
+ it('has no focus when the breach is a load-wide metric', () => {
84
+ expect(
85
+ findFocusOffset(
86
+ input({ failures: [{ budget: 'lcpMs', expected: '<= 2500ms', actual: '4100ms' }] }),
87
+ ),
88
+ ).toBeNull();
89
+ });
90
+ });
91
+
92
+ describe('selectFrames', () => {
93
+ it('keeps the frames nearest the failure', () => {
94
+ const picked = selectFrames(frames(40), 2000, { maxFrames: 5 });
95
+ expect(picked.frames.map((f) => f.offsetMs)).toEqual([1800, 1900, 2000, 2100, 2200]);
96
+ expect(picked.dropped).toBe(35);
97
+ });
98
+
99
+ it('samples the whole run when there is no single moment to centre on', () => {
100
+ const picked = selectFrames(frames(21), null, { maxFrames: 5 });
101
+ expect(picked.frames.map((f) => f.offsetMs)).toEqual([0, 500, 1000, 1500, 2000]);
102
+ });
103
+
104
+ it('returns the frames in time order even though it ranks them by distance', () => {
105
+ const picked = selectFrames(frames(40), 2000, { maxFrames: 7 });
106
+ const offsets = picked.frames.map((f) => f.offsetMs);
107
+ expect([...offsets].sort((a, b) => a - b)).toEqual(offsets);
108
+ });
109
+
110
+ it('falls back to the nearest frames when none fall inside the window', () => {
111
+ const picked = selectFrames(frames(10), 60_000, { maxFrames: 3, windowMs: 500 });
112
+ expect(picked.frames.map((f) => f.offsetMs)).toEqual([700, 800, 900]);
113
+ });
114
+
115
+ it('enforces the byte budget by evicting the least relevant frame first', () => {
116
+ const picked = selectFrames(frames(40, 100, 1000), 2000, { maxFrames: 10, maxBytes: 3000 });
117
+ expect(picked.frames.length).toBe(3);
118
+ expect(picked.frames.map((f) => f.offsetMs)).toEqual([1900, 2000, 2100]);
119
+ });
120
+
121
+ it('handles an empty recording without throwing', () => {
122
+ expect(selectFrames([], 100)).toEqual({ frames: [], dropped: 0 });
123
+ });
124
+ });
125
+
126
+ describe('buildEvidence', () => {
127
+ it('attaches frames around the failure and lines the events up against them', () => {
128
+ const evidence = buildEvidence(
129
+ input({
130
+ frames: frames(40),
131
+ console: [{ type: 'error', text: 'Cannot read x of undefined', timestamp: START + 2000 }],
132
+ requests: [
133
+ {
134
+ url: 'https://api.test/cart',
135
+ method: 'GET',
136
+ status: 503,
137
+ failed: true,
138
+ startedAtMs: START + 2100,
139
+ },
140
+ ],
141
+ failures: [{ budget: 'maxConsoleErrors', expected: '<= 0', actual: '1' }],
142
+ select: { maxFrames: 5 },
143
+ }),
144
+ );
145
+
146
+ expect(evidence.focusOffsetMs).toBe(2000);
147
+ expect(evidence.frames.length).toBe(5);
148
+ expect(evidence.frames[0].dataUrl.startsWith('data:image/jpeg;base64,')).toBe(true);
149
+
150
+ const kinds = evidence.timeline.map((e) => e.kind);
151
+ expect(kinds).toContain('console');
152
+ expect(kinds).toContain('request');
153
+ expect(kinds).toContain('marker');
154
+ // Everything is in time order, which is what makes it readable.
155
+ const offsets = evidence.timeline.map((e) => e.offsetMs);
156
+ expect([...offsets].sort((a, b) => a - b)).toEqual(offsets);
157
+ });
158
+
159
+ it('indexes each frame row back to the attached frame it shows', () => {
160
+ const evidence = buildEvidence(input({ frames: frames(6), select: { maxFrames: 3 } }));
161
+ const frameRows = evidence.timeline.filter((e) => e.kind === 'frame');
162
+ expect(frameRows.map((e) => e.frameIndex)).toEqual([0, 1, 2]);
163
+ for (const row of frameRows) {
164
+ expect(evidence.frames[row.frameIndex as number].offsetMs).toBe(row.offsetMs);
165
+ }
166
+ });
167
+
168
+ it('bounds the total attached bytes', () => {
169
+ const evidence = buildEvidence(
170
+ input({
171
+ frames: frames(200, 50, 100_000),
172
+ console: [{ type: 'error', text: 'boom', timestamp: START + 5000 }],
173
+ select: { maxFrames: 12, maxBytes: 500_000 },
174
+ }),
175
+ );
176
+ expect(evidence.totalBytes).toBeLessThanOrEqual(500_000);
177
+ expect(evidence.droppedFrames).toBeGreaterThan(0);
178
+ expect(evidence.notes.join(' ')).toMatch(/not attached/);
179
+ });
180
+
181
+ it('trims a noisy timeline worst-first so the real error survives', () => {
182
+ const warnings = Array.from({ length: 200 }, (_, i) => ({
183
+ type: 'warn',
184
+ text: `deprecation ${i}`,
185
+ timestamp: START + i,
186
+ }));
187
+ const evidence = buildEvidence(
188
+ input({
189
+ frames: frames(4),
190
+ console: [...warnings, { type: 'error', text: 'the real one', timestamp: START + 9999 }],
191
+ maxEvents: 10,
192
+ select: { maxFrames: 4 },
193
+ }),
194
+ );
195
+ const details = evidence.timeline.map((e) => e.detail ?? '');
196
+ expect(details).toContain('the real one');
197
+ expect(evidence.timeline.filter((e) => e.kind !== 'frame').length).toBe(10);
198
+ expect(evidence.notes.join(' ')).toMatch(/Timeline trimmed/);
199
+ });
200
+
201
+ it('explains itself when the breach has no single moment', () => {
202
+ const evidence = buildEvidence(
203
+ input({ failures: [{ budget: 'lcpMs', expected: '<= 2500ms', actual: '4100ms' }] }),
204
+ );
205
+ expect(evidence.focusOffsetMs).toBeNull();
206
+ expect(evidence.notes.join(' ')).toMatch(/load-wide metric/);
207
+ });
208
+
209
+ it('says so plainly when nothing was recorded', () => {
210
+ const evidence = buildEvidence(input({ frames: [] }));
211
+ expect(evidence.frames).toEqual([]);
212
+ expect(evidence.notes.join(' ')).toMatch(/No screencast frames/);
213
+ });
214
+
215
+ it('carries the dropped-frame count from the recorder into the total', () => {
216
+ const evidence = buildEvidence(
217
+ input({ frames: frames(4), bufferDropped: 30, select: { maxFrames: 4 } }),
218
+ );
219
+ expect(evidence.droppedFrames).toBe(30);
220
+ });
221
+ });
222
+
223
+ describe('startEvidenceRecorder', () => {
224
+ function stubClient() {
225
+ const handlers = new Map<string, (params: unknown, sid: string) => void>();
226
+ const send = vi.fn(async () => ({}));
227
+ const client = {
228
+ send,
229
+ on: (event: string, handler: (params: unknown, sid: string) => void) => {
230
+ handlers.set(event, handler);
231
+ return () => handlers.delete(event);
232
+ },
233
+ } as unknown as CdpClient;
234
+ return { client, handlers, send };
235
+ }
236
+
237
+ it('starts a screencast and collects timestamped frames', async () => {
238
+ const { client, handlers, send } = stubClient();
239
+ const recorder = await startEvidenceRecorder(client, 'S1', Date.now());
240
+ expect(send).toHaveBeenCalledWith('Page.startScreencast', expect.anything(), 'S1');
241
+
242
+ handlers.get('Page.screencastFrame')?.({ data: 'abc', sessionId: 1 }, 'S1');
243
+ expect(recorder.frames.length).toBe(1);
244
+ expect(recorder.frames[0].bytes).toBe(3);
245
+ // Failing to ack stalls the screencast, so it must happen every frame.
246
+ expect(send).toHaveBeenCalledWith('Page.screencastFrameAck', { sessionId: 1 }, 'S1');
247
+ });
248
+
249
+ it('ignores frames from another session', async () => {
250
+ const { client, handlers } = stubClient();
251
+ const recorder = await startEvidenceRecorder(client, 'S1', Date.now());
252
+ handlers.get('Page.screencastFrame')?.({ data: 'abc', sessionId: 1 }, 'S2');
253
+ expect(recorder.frames.length).toBe(0);
254
+ });
255
+
256
+ it('decimates rather than dropping the head, so early frames survive a long run', async () => {
257
+ const { client, handlers } = stubClient();
258
+ const recorder = await startEvidenceRecorder(client, 'S1', Date.now(), { maxFrames: 8 });
259
+ const frame = handlers.get('Page.screencastFrame');
260
+ for (let i = 0; i < 20; i++) frame?.({ data: `f${i}`, sessionId: i }, 'S1');
261
+
262
+ expect(recorder.frames.length).toBeLessThanOrEqual(8);
263
+ expect(recorder.droppedFrames).toBeGreaterThan(0);
264
+ // The very first frame is still there — a ring buffer would have lost it.
265
+ expect(recorder.frames[0].data).toBe('f0');
266
+ });
267
+
268
+ it('unsubscribes and stops the screencast on stop', async () => {
269
+ const { client, handlers, send } = stubClient();
270
+ const recorder = await startEvidenceRecorder(client, 'S1', Date.now());
271
+ await recorder.stop();
272
+ expect(send).toHaveBeenCalledWith('Page.stopScreencast', {}, 'S1');
273
+ handlers.get('Page.screencastFrame')?.({ data: 'abc', sessionId: 1 }, 'S1');
274
+ expect(recorder.frames.length).toBe(0);
275
+ });
276
+ });