@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,210 @@
1
+ /**
2
+ * Everything around the two files the command leaves behind: where they go,
3
+ * what the JSON carries, plus the two pure helpers from the collectors
4
+ * (request classification and locator upgrading) that a stub client can
5
+ * exercise without launching Chrome.
6
+ */
7
+ import { mkdtemp, readFile } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { describe, expect, it, vi } from 'vitest';
11
+ import type { CdpClient } from '../browser/cdp.js';
12
+ import { buildReport } from '../report/analyze.js';
13
+ import { DEFAULT_BUDGET } from '../report/budget.js';
14
+ import { toRequestEntries } from '../report/collect.js';
15
+ import { upgradeLocators } from '../report/collect-a11y.js';
16
+ import { resolveOutPath, toJsonReport, writeReportFiles } from '../report/index.js';
17
+ import type { A11yFinding, AxNode, CaptureData, Report } from '../report/types.js';
18
+
19
+ function capture(partial: Partial<CaptureData> = {}): CaptureData {
20
+ return {
21
+ url: 'https://shop.test/checkout',
22
+ finalUrl: 'https://shop.test/checkout',
23
+ title: 'Checkout',
24
+ capturedAt: '2026-09-21T10:00:00.000Z',
25
+ durationMs: 3200,
26
+ console: [],
27
+ pageErrors: [],
28
+ requests: [],
29
+ vitals: { lcp: 100, cls: 0, inp: 10 },
30
+ a11y: [],
31
+ screenshots: [],
32
+ notes: [],
33
+ ...partial,
34
+ };
35
+ }
36
+
37
+ const report = (partial: Partial<CaptureData> = {}): Report =>
38
+ buildReport(capture(partial), DEFAULT_BUDGET);
39
+
40
+ describe('resolveOutPath', () => {
41
+ const when = '2026-09-21T10:00:00.000Z';
42
+
43
+ it('derives a dated, host-and-path filename when --out is omitted', async () => {
44
+ const path = await resolveOutPath(undefined, 'https://shop.test/checkout', when, '/work');
45
+ expect(path).toBe('/work/monobrowse-report-shop.test-checkout-20260921T100000Z.html');
46
+ });
47
+
48
+ it('uses an explicit .html path as given', async () => {
49
+ expect(await resolveOutPath('/tmp/out.html', 'https://a.test/', when, '/work')).toBe(
50
+ '/tmp/out.html',
51
+ );
52
+ });
53
+
54
+ it('resolves a relative path against the working directory', async () => {
55
+ expect(await resolveOutPath('reports/run.html', 'https://a.test/', when, '/work')).toBe(
56
+ '/work/reports/run.html',
57
+ );
58
+ });
59
+
60
+ it('adds .html to a path that has no extension and is not a directory', async () => {
61
+ expect(await resolveOutPath('/tmp/run', 'https://a.test/', when, '/work')).toBe(
62
+ '/tmp/run.html',
63
+ );
64
+ });
65
+
66
+ it('treats a trailing-separator path as a directory to drop the report into', async () => {
67
+ const path = await resolveOutPath('reports/', 'https://a.test/', when, '/work');
68
+ expect(path).toBe('/work/reports/monobrowse-report-a.test-20260921T100000Z.html');
69
+ });
70
+
71
+ it('treats an existing directory as a directory', async () => {
72
+ const dir = await mkdtemp(join(tmpdir(), 'monobrowse-out-'));
73
+ const path = await resolveOutPath(dir, 'https://a.test/', when, '/work');
74
+ expect(path).toBe(join(dir, 'monobrowse-report-a.test-20260921T100000Z.html'));
75
+ });
76
+ });
77
+
78
+ describe('writeReportFiles', () => {
79
+ it('writes the HTML and a sibling JSON carrying the verdict and failures', async () => {
80
+ const dir = await mkdtemp(join(tmpdir(), 'monobrowse-write-'));
81
+ const r = report({ pageErrors: [{ text: 'boom', timestamp: 0 }] });
82
+ const paths = await writeReportFiles(r, join(dir, 'run.html'));
83
+
84
+ expect(paths.jsonPath).toBe(join(dir, 'run.json'));
85
+ const html = await readFile(paths.htmlPath, 'utf8');
86
+ expect(html).toContain('<!doctype html>');
87
+
88
+ const json = JSON.parse(await readFile(paths.jsonPath, 'utf8'));
89
+ expect(json.verdict).toBe('fail');
90
+ expect(json.failures[0]).toMatchObject({ budget: 'maxPageErrors', actual: '1' });
91
+ });
92
+
93
+ it('creates the output directory if it does not exist yet', async () => {
94
+ const dir = await mkdtemp(join(tmpdir(), 'monobrowse-write-'));
95
+ const paths = await writeReportFiles(report(), join(dir, 'nested', 'deep', 'run.html'));
96
+ await expect(readFile(paths.htmlPath, 'utf8')).resolves.toContain('PASS');
97
+ });
98
+ });
99
+
100
+ describe('toJsonReport', () => {
101
+ it('drops base64 image payloads but keeps the screenshot metadata', () => {
102
+ const json = toJsonReport(
103
+ report({
104
+ screenshots: [
105
+ {
106
+ label: 'page',
107
+ width: 1280,
108
+ height: 900,
109
+ dataUrl: 'data:image/png;base64,AAAA',
110
+ path: '/tmp/page.png',
111
+ },
112
+ ],
113
+ }),
114
+ );
115
+ const shots = json.screenshots as Array<Record<string, unknown>>;
116
+ expect(shots[0]).toEqual({ label: 'page', width: 1280, height: 900, path: '/tmp/page.png' });
117
+ expect(JSON.stringify(json)).not.toContain('base64');
118
+ });
119
+ });
120
+
121
+ describe('toRequestEntries', () => {
122
+ const base = { id: '1', url: 'https://a.test/x', method: 'GET', startTime: 100 };
123
+
124
+ it('marks a 4xx/5xx response as failed', () => {
125
+ const [entry] = toRequestEntries([{ ...base, status: 503, endTime: 400 }], new Map());
126
+ expect(entry.failed).toBe(true);
127
+ expect(entry.durationMs).toBe(300);
128
+ });
129
+
130
+ it('leaves a 3xx and a 2xx alone', () => {
131
+ const entries = toRequestEntries(
132
+ [
133
+ { ...base, status: 200 },
134
+ { ...base, id: '2', status: 302 },
135
+ ],
136
+ new Map(),
137
+ );
138
+ expect(entries.map((e) => e.failed)).toEqual([false, false]);
139
+ });
140
+
141
+ it('marks a transport failure as failed and carries its errorText', () => {
142
+ const failures = new Map([['1', { errorText: 'net::ERR_CONNECTION_REFUSED' }]]);
143
+ const [entry] = toRequestEntries([base], failures);
144
+ expect(entry).toMatchObject({ failed: true, errorText: 'net::ERR_CONNECTION_REFUSED' });
145
+ });
146
+
147
+ it('does not count a request the page itself cancelled', () => {
148
+ const failures = new Map([['1', { errorText: 'net::ERR_ABORTED', canceled: true }]]);
149
+ expect(toRequestEntries([base], failures)[0].failed).toBe(false);
150
+ });
151
+
152
+ it('still counts a blocked request even though CDP also marks it cancelled', () => {
153
+ const failures = new Map([['1', { canceled: true, blockedReason: 'csp' }]]);
154
+ const [entry] = toRequestEntries([base], failures);
155
+ expect(entry).toMatchObject({ failed: true, errorText: 'blocked: csp' });
156
+ });
157
+
158
+ it('does not treat a still-in-flight request as a failure', () => {
159
+ expect(toRequestEntries([base], new Map())[0].failed).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe('upgradeLocators', () => {
164
+ const finding = (locator: string): A11yFinding => ({
165
+ rule: 'unlabelled-control',
166
+ impact: 'error',
167
+ role: 'button',
168
+ name: null,
169
+ locator,
170
+ detail: 'no name',
171
+ });
172
+
173
+ function stubClient(selector: string | null) {
174
+ return {
175
+ send: vi.fn(async (method: string) => {
176
+ if (method === 'DOM.resolveNode') return { object: { objectId: 'obj-1' } };
177
+ if (method === 'Runtime.callFunctionOn') return { result: { value: selector } };
178
+ return {};
179
+ }),
180
+ } as unknown as CdpClient;
181
+ }
182
+
183
+ const nodes: AxNode[] = [{ nodeId: '7', backendDOMNodeId: 42 }];
184
+
185
+ it('replaces the ax-node placeholder with a resolved CSS selector', async () => {
186
+ const out = await upgradeLocators(stubClient('#submit'), 'sid', [finding('ax-node:7')], nodes);
187
+ expect(out[0].locator).toBe('#submit');
188
+ });
189
+
190
+ it('keeps the placeholder when the DOM cannot resolve the node', async () => {
191
+ const out = await upgradeLocators(stubClient(null), 'sid', [finding('ax-node:7')], nodes);
192
+ expect(out[0].locator).toBe('ax-node:7');
193
+ });
194
+
195
+ it('leaves DOM-derived locators untouched and costs no round-trip', async () => {
196
+ const client = stubClient('#other');
197
+ const out = await upgradeLocators(client, 'sid', [finding('#already-known')], nodes);
198
+ expect(out[0].locator).toBe('#already-known');
199
+ expect(client.send).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it('resolves each backend node once, however many findings point at it', async () => {
203
+ const client = stubClient('#submit');
204
+ await upgradeLocators(client, 'sid', [finding('ax-node:7'), finding('ax-node:7')], nodes);
205
+ const resolves = (client.send as ReturnType<typeof vi.fn>).mock.calls.filter(
206
+ (c) => c[0] === 'DOM.resolveNode',
207
+ );
208
+ expect(resolves).toHaveLength(1);
209
+ });
210
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The PNG codec underpins the pixel diff, so it is tested against images it
3
+ * did not produce as well as its own round trip — an encoder and decoder that
4
+ * are wrong in the same direction would agree with each other perfectly.
5
+ *
6
+ * `assets/mascot.png` and `assets/logo.png` are ordinary 8-bit RGBA PNGs
7
+ * checked into this repo by other tooling; decoding them exercises the real
8
+ * filter mix (Sub/Up/Average/Paeth) that a synthetic image never produces.
9
+ */
10
+ import { readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { describe, expect, it } from 'vitest';
13
+ import { crc32, decodePng, encodePng, pngFromDataUrl, pngToDataUrl } from '../report/png.js';
14
+
15
+ const ASSETS = join(import.meta.dirname, '..', '..', '..', '..', '..', 'assets');
16
+
17
+ function solid(width: number, height: number, rgba: [number, number, number, number]) {
18
+ const data = new Uint8Array(width * height * 4);
19
+ for (let i = 0; i < width * height; i++) data.set(rgba, i * 4);
20
+ return { width, height, data };
21
+ }
22
+
23
+ describe('crc32', () => {
24
+ it('matches the known CRC-32 of "123456789"', () => {
25
+ expect(crc32(new TextEncoder().encode('123456789'))).toBe(0xcbf43926);
26
+ });
27
+ });
28
+
29
+ describe('decodePng', () => {
30
+ it('decodes a real 8-bit RGBA PNG at its stated dimensions', () => {
31
+ const img = decodePng(readFileSync(join(ASSETS, 'mascot.png')));
32
+ expect({ width: img.width, height: img.height }).toEqual({ width: 650, height: 618 });
33
+ expect(img.data.length).toBe(650 * 618 * 4);
34
+ });
35
+
36
+ it('decodes a second real PNG, exercising a different filter mix', () => {
37
+ const img = decodePng(readFileSync(join(ASSETS, 'logo.png')));
38
+ expect(img.data.length).toBe(img.width * img.height * 4);
39
+ // A logo is not uniform: if unfiltering were broken every row would be
40
+ // identical or the image would be a single flat colour.
41
+ const unique = new Set<string>();
42
+ for (let i = 0; i < img.data.length; i += 4) {
43
+ unique.add(`${img.data[i]},${img.data[i + 1]},${img.data[i + 2]},${img.data[i + 3]}`);
44
+ if (unique.size > 5) break;
45
+ }
46
+ expect(unique.size).toBeGreaterThan(5);
47
+ });
48
+
49
+ it('rejects a file that is not a PNG', () => {
50
+ expect(() => decodePng(new TextEncoder().encode('GIF89a not a png'))).toThrow(/bad signature/);
51
+ });
52
+
53
+ it('rejects an interlaced PNG by name rather than decoding it wrongly', () => {
54
+ const png = encodePng(solid(2, 2, [1, 2, 3, 4]));
55
+ png[8 + 8 + 12] = 1; // IHDR interlace byte
56
+ expect(() => decodePng(png)).toThrow(/interlaced/);
57
+ });
58
+ });
59
+
60
+ describe('encodePng', () => {
61
+ it('round-trips an image byte-for-byte', () => {
62
+ const img = decodePng(readFileSync(join(ASSETS, 'mascot.png')));
63
+ const again = decodePng(encodePng(img));
64
+ expect(again.width).toBe(img.width);
65
+ expect(again.height).toBe(img.height);
66
+ expect(Buffer.from(again.data).equals(Buffer.from(img.data))).toBe(true);
67
+ });
68
+
69
+ it('writes the PNG signature and an IEND chunk', () => {
70
+ const png = encodePng(solid(3, 2, [10, 20, 30, 255]));
71
+ expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
72
+ expect(png.subarray(png.length - 8, png.length - 4).toString('ascii')).toBe('IEND');
73
+ });
74
+
75
+ it('refuses a pixel buffer whose length disagrees with the dimensions', () => {
76
+ expect(() => encodePng({ width: 4, height: 4, data: new Uint8Array(8) })).toThrow(
77
+ /expected 64 bytes/,
78
+ );
79
+ });
80
+ });
81
+
82
+ describe('data URLs', () => {
83
+ it('round-trips through a data: URL', () => {
84
+ const png = encodePng(solid(2, 2, [7, 8, 9, 255]));
85
+ const url = pngToDataUrl(png);
86
+ expect(url.startsWith('data:image/png;base64,')).toBe(true);
87
+ expect(Buffer.from(pngFromDataUrl(url)).equals(png)).toBe(true);
88
+ });
89
+
90
+ it('accepts a bare base64 payload, which is what CDP actually returns', () => {
91
+ const png = encodePng(solid(1, 1, [1, 1, 1, 255]));
92
+ const bare = Buffer.from(png).toString('base64');
93
+ expect(Buffer.from(pngFromDataUrl(bare)).equals(png)).toBe(true);
94
+ });
95
+ });
@@ -0,0 +1,315 @@
1
+ /**
2
+ * The Wave 2 report sections: trend (RIG-13), run diff (RIG-10), evidence
3
+ * timeline (RIG-11) and flake analysis (RIG-14).
4
+ *
5
+ * The report is a single self-contained file that gets mailed around and fed
6
+ * to the document pipeline, so two properties matter beyond "the section is
7
+ * there": everything interpolated is escaped, and the JSON sibling carries
8
+ * the measurements without the megabytes of base64.
9
+ */
10
+ import { describe, expect, it } from 'vitest';
11
+ import { buildReport } from '../report/analyze.js';
12
+ import { DEFAULT_BUDGET } from '../report/budget.js';
13
+ import { analyzeFlake } from '../report/flake.js';
14
+ import { toHistoryRun } from '../report/history.js';
15
+ import { toJsonReport } from '../report/index.js';
16
+ import { encodePng, pngToDataUrl } from '../report/png.js';
17
+ import { renderHtml } from '../report/render.js';
18
+ import { buildTrend } from '../report/trend.js';
19
+ import type { CaptureData, Evidence, Report, RunDiff } from '../report/types.js';
20
+
21
+ function capture(partial: Partial<CaptureData> = {}): CaptureData {
22
+ return {
23
+ url: 'https://shop.test/checkout',
24
+ finalUrl: 'https://shop.test/checkout',
25
+ title: 'Checkout',
26
+ capturedAt: '2026-09-21T10:00:00.000Z',
27
+ durationMs: 1500,
28
+ console: [],
29
+ pageErrors: [],
30
+ requests: [],
31
+ vitals: { lcp: 2400, cls: 0.02, inp: 60 },
32
+ a11y: [],
33
+ screenshots: [],
34
+ notes: [],
35
+ ...partial,
36
+ };
37
+ }
38
+
39
+ const report = (partial: Partial<CaptureData> = {}): Report =>
40
+ buildReport(capture(partial), DEFAULT_BUDGET);
41
+
42
+ function tinyPngDataUrl(shade: number) {
43
+ const data = new Uint8Array(4 * 4 * 4);
44
+ for (let i = 0; i < 16; i++) data.set([shade, shade, shade, 255], i * 4);
45
+ return pngToDataUrl(encodePng({ width: 4, height: 4, data }));
46
+ }
47
+
48
+ describe('trend section', () => {
49
+ it('says so plainly on a first run instead of drawing an empty chart', () => {
50
+ const r = report();
51
+ r.trend = buildTrend(r, []);
52
+ const html = renderHtml(r);
53
+ expect(html).toContain('First recorded run for this URL');
54
+ });
55
+
56
+ it('leads with the creep headline and draws a sparkline', () => {
57
+ const history = Array.from({ length: 5 }, (_, i) =>
58
+ toHistoryRun(
59
+ report({ capturedAt: `2026-09-1${i}T10:00:00.000Z`, vitals: { lcp: 2000 } }),
60
+ `id${i}`,
61
+ ),
62
+ );
63
+ const r = report({ vitals: { lcp: 2400 } });
64
+ r.trend = buildTrend(r, history);
65
+ const html = renderHtml(r);
66
+ expect(html).toContain('Trend — this run against the previous 5');
67
+ expect(html).toContain('LCP has crept up 400ms');
68
+ expect(html).toContain('<svg class="spark"');
69
+ expect(html).toContain('verdictstrip');
70
+ // Lower-is-better is stated, or a positive delta reads as good news.
71
+ expect(html).toContain('lower-is-better');
72
+ });
73
+
74
+ it('omits a metric no run ever measured', () => {
75
+ const history = [toHistoryRun(report({ vitals: { lcp: 2000 } }), 'id0')];
76
+ const r = report({ vitals: { lcp: 2400 } });
77
+ r.trend = buildTrend(r, history);
78
+ const html = renderHtml(r);
79
+ // Scoped to the trend table: the Wave 1 vitals table lists every metric
80
+ // whether or not it was measured, which is correct for that section.
81
+ const section = html.slice(html.indexOf('<h2>Trend'), html.indexOf('<h2>Errors'));
82
+ expect(section).toContain('<b>LCP</b>');
83
+ expect(section).not.toContain('<b>TTFB</b>');
84
+ });
85
+ });
86
+
87
+ describe('diff section', () => {
88
+ const diff: RunDiff = {
89
+ previousRunId: '20260920T100000Z-abcdabcd',
90
+ previousCapturedAt: '2026-09-20T10:00:00.000Z',
91
+ notes: [],
92
+ structure: {
93
+ gained: [{ path: '/main[1]/button[1]', role: 'button', name: 'Cancel', depth: 1 }],
94
+ lost: [{ path: '/main[1]/link[1]', role: 'link', name: 'Back', depth: 1 }],
95
+ renamed: [{ path: '/main[1]/button[2]', role: 'button', from: 'Pay now', to: 'Place order' }],
96
+ moved: [],
97
+ unchanged: 42,
98
+ changed: 3,
99
+ },
100
+ pixels: {
101
+ comparable: true,
102
+ changedPixels: 1234,
103
+ totalPixels: 100_000,
104
+ changedPercent: 1.234,
105
+ previousSize: { width: 400, height: 250 },
106
+ currentSize: { width: 400, height: 300 },
107
+ sizeChanged: true,
108
+ diffDataUrl: tinyPngDataUrl(120),
109
+ scale: 2,
110
+ },
111
+ };
112
+
113
+ it('lists what was gained, lost and renamed', () => {
114
+ const r = report();
115
+ r.diff = diff;
116
+ const html = renderHtml(r);
117
+ expect(html).toContain('Changes since the previous run');
118
+ expect(html).toContain('2026-09-20T10:00:00.000Z');
119
+ expect(html).toContain('Cancel');
120
+ expect(html).toContain('Back');
121
+ expect(html).toContain('Place order');
122
+ expect(html).toContain('42</b> unchanged');
123
+ });
124
+
125
+ it('reports the changed percentage, the size change and the highlight image', () => {
126
+ const r = report();
127
+ r.diff = diff;
128
+ const html = renderHtml(r);
129
+ expect(html).toContain('1.23%</b> of pixels changed');
130
+ expect(html).toContain('400×250');
131
+ expect(html).toContain('400×300');
132
+ expect(html).toContain('src="data:image/png;base64,');
133
+ expect(html).toContain('1/2 scale');
134
+ });
135
+
136
+ it('says the tree is identical rather than showing an empty table', () => {
137
+ const r = report();
138
+ r.diff = {
139
+ ...diff,
140
+ structure: { gained: [], lost: [], renamed: [], moved: [], unchanged: 40, changed: 0 },
141
+ pixels: undefined,
142
+ };
143
+ expect(renderHtml(r)).toContain('identical to the previous run');
144
+ });
145
+
146
+ it('escapes an accessible name that contains markup', () => {
147
+ const r = report();
148
+ r.diff = {
149
+ ...diff,
150
+ structure: {
151
+ gained: [
152
+ {
153
+ path: '/main[1]/button[1]',
154
+ role: 'button',
155
+ name: '<img src=x onerror="alert(1)">',
156
+ depth: 1,
157
+ },
158
+ ],
159
+ lost: [],
160
+ renamed: [],
161
+ moved: [],
162
+ unchanged: 0,
163
+ changed: 1,
164
+ },
165
+ };
166
+ const html = renderHtml(r);
167
+ expect(html).not.toContain('<img src=x');
168
+ expect(html).toContain('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
169
+ });
170
+ });
171
+
172
+ describe('evidence section', () => {
173
+ const evidence: Evidence = {
174
+ reason: 'budget-failure',
175
+ focusOffsetMs: 1200,
176
+ frames: [
177
+ { offsetMs: 1100, dataUrl: 'data:image/jpeg;base64,AAAA', bytes: 4 },
178
+ { offsetMs: 1200, dataUrl: 'data:image/jpeg;base64,BBBB', bytes: 4 },
179
+ ],
180
+ timeline: [
181
+ { offsetMs: 1100, kind: 'frame', label: 'frame', severity: 'info', frameIndex: 0 },
182
+ {
183
+ offsetMs: 1200,
184
+ kind: 'console',
185
+ label: 'console.error',
186
+ detail: 'Cannot read properties of undefined',
187
+ severity: 'error',
188
+ },
189
+ { offsetMs: 1200, kind: 'marker', label: 'first failure', severity: 'error' },
190
+ ],
191
+ droppedFrames: 18,
192
+ totalBytes: 8,
193
+ notes: ['18 recorded frame(s) not attached — the report keeps the 2 nearest the failure.'],
194
+ };
195
+
196
+ it('renders a filmstrip and a time-ordered timeline', () => {
197
+ const r = report();
198
+ r.evidence = evidence;
199
+ const html = renderHtml(r);
200
+ expect(html).toContain('<h2>Evidence</h2>');
201
+ expect(html).toContain('filmstrip');
202
+ expect(html).toContain('data:image/jpeg;base64,BBBB');
203
+ expect(html).toContain('Cannot read properties of undefined');
204
+ expect(html).toContain('first failure');
205
+ expect(html).toContain('+1.20s');
206
+ });
207
+
208
+ it('marks the frame nearest the failure', () => {
209
+ const r = report();
210
+ r.evidence = evidence;
211
+ const html = renderHtml(r);
212
+ expect(html).toContain('at failure');
213
+ expect(html).toContain('<figure class="focus">');
214
+ });
215
+
216
+ it('says why it recorded and surfaces its own bounds', () => {
217
+ const r = report();
218
+ r.evidence = evidence;
219
+ const html = renderHtml(r);
220
+ expect(html).toContain('breached its budget');
221
+ expect(html).toContain('not attached');
222
+ });
223
+
224
+ it('is absent entirely when nothing was recorded', () => {
225
+ expect(renderHtml(report())).not.toContain('<h2>Evidence</h2>');
226
+ });
227
+ });
228
+
229
+ describe('flake section', () => {
230
+ const err = (text: string) => ({ type: 'error', text, timestamp: 0 });
231
+
232
+ it('shows a FLAKY banner and the per-check rates', () => {
233
+ const flake = analyzeFlake([report(), report({ console: [err('boom')] }), report()]);
234
+ const html = renderHtml(report(), { flake });
235
+ expect(html).toContain('Repeatability — 3 runs');
236
+ expect(html).toContain('>FLAKY<');
237
+ expect(html).toContain('2 of 3 passed');
238
+ expect(html).toContain('Confidence: high.');
239
+ });
240
+
241
+ it('states what a clean window does not prove', () => {
242
+ const flake = analyzeFlake([report(), report()]);
243
+ const html = renderHtml(report(), { flake });
244
+ expect(html).toContain('>PASS<');
245
+ expect(html).toContain('95% confidence');
246
+ });
247
+
248
+ it('lists an intermittent error with the runs it appeared in', () => {
249
+ const flake = analyzeFlake([report(), report({ console: [err('flaky fetch')] }), report()]);
250
+ const html = renderHtml(report(), { flake });
251
+ expect(html).toContain('flaky fetch');
252
+ expect(html).toContain('1 of 3');
253
+ });
254
+
255
+ it('is absent for an ordinary single run', () => {
256
+ expect(renderHtml(report())).not.toContain('Repeatability');
257
+ });
258
+ });
259
+
260
+ describe('toJsonReport', () => {
261
+ it('keeps the pixel measurements but drops the highlight image', () => {
262
+ const r = report();
263
+ r.diff = {
264
+ previousRunId: 'x',
265
+ previousCapturedAt: '2026-09-20T10:00:00.000Z',
266
+ notes: [],
267
+ pixels: {
268
+ comparable: true,
269
+ changedPixels: 10,
270
+ totalPixels: 100,
271
+ changedPercent: 10,
272
+ previousSize: { width: 4, height: 4 },
273
+ currentSize: { width: 4, height: 4 },
274
+ sizeChanged: false,
275
+ diffDataUrl: tinyPngDataUrl(10),
276
+ },
277
+ };
278
+ const json = toJsonReport(r) as { diff: { pixels: Record<string, unknown> } };
279
+ expect(json.diff.pixels.changedPercent).toBe(10);
280
+ expect(json.diff.pixels.diffDataUrl).toBeUndefined();
281
+ });
282
+
283
+ it('keeps frame offsets but drops the frame images', () => {
284
+ const r = report();
285
+ r.evidence = {
286
+ reason: 'requested',
287
+ focusOffsetMs: null,
288
+ frames: [{ offsetMs: 500, dataUrl: 'data:image/jpeg;base64,AAAA', bytes: 4 }],
289
+ timeline: [],
290
+ droppedFrames: 0,
291
+ totalBytes: 4,
292
+ notes: [],
293
+ };
294
+ const json = toJsonReport(r) as { evidence: { frames: Array<Record<string, unknown>> } };
295
+ expect(json.evidence.frames).toEqual([{ offsetMs: 500, bytes: 4 }]);
296
+ });
297
+
298
+ it('never serializes the raw recording buffer', () => {
299
+ const r = report();
300
+ r.recording = {
301
+ startedAtMs: 1,
302
+ frames: [{ offsetMs: 0, data: 'x', bytes: 1 }],
303
+ droppedFrames: 0,
304
+ requested: true,
305
+ };
306
+ expect(JSON.stringify(toJsonReport(r))).not.toContain('"recording"');
307
+ });
308
+
309
+ it('carries the trend through unchanged', () => {
310
+ const r = report();
311
+ r.trend = buildTrend(r, []);
312
+ const json = toJsonReport(r) as { trend: { runs: number } };
313
+ expect(json.trend.runs).toBe(0);
314
+ });
315
+ });