@monoes/monobrowse 1.0.12 → 1.0.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/__tests__/bridge-instruments.test.d.ts +2 -0
- package/dist/src/__tests__/bridge-instruments.test.d.ts.map +1 -0
- package/dist/src/__tests__/bridge-instruments.test.js +132 -0
- package/dist/src/__tests__/bridge-instruments.test.js.map +1 -0
- package/dist/src/__tests__/bridge-transport.test.d.ts +2 -0
- package/dist/src/__tests__/bridge-transport.test.d.ts.map +1 -0
- package/dist/src/__tests__/bridge-transport.test.js +287 -0
- package/dist/src/__tests__/bridge-transport.test.js.map +1 -0
- package/dist/src/__tests__/cdp-transport.test.d.ts +2 -0
- package/dist/src/__tests__/cdp-transport.test.d.ts.map +1 -0
- package/dist/src/__tests__/cdp-transport.test.js +167 -0
- package/dist/src/__tests__/cdp-transport.test.js.map +1 -0
- package/dist/src/__tests__/profiler-heap.test.d.ts +2 -0
- package/dist/src/__tests__/profiler-heap.test.d.ts.map +1 -0
- package/dist/src/__tests__/profiler-heap.test.js +154 -0
- package/dist/src/__tests__/profiler-heap.test.js.map +1 -0
- package/dist/src/__tests__/report-a11y.test.d.ts +2 -0
- package/dist/src/__tests__/report-a11y.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-a11y.test.js +147 -0
- package/dist/src/__tests__/report-a11y.test.js.map +1 -0
- package/dist/src/__tests__/report-analyze.test.d.ts +2 -0
- package/dist/src/__tests__/report-analyze.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-analyze.test.js +157 -0
- package/dist/src/__tests__/report-analyze.test.js.map +1 -0
- package/dist/src/__tests__/report-budget.test.d.ts +2 -0
- package/dist/src/__tests__/report-budget.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-budget.test.js +80 -0
- package/dist/src/__tests__/report-budget.test.js.map +1 -0
- package/dist/src/__tests__/report-diff.test.d.ts +2 -0
- package/dist/src/__tests__/report-diff.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-diff.test.js +231 -0
- package/dist/src/__tests__/report-diff.test.js.map +1 -0
- package/dist/src/__tests__/report-evidence.test.d.ts +2 -0
- package/dist/src/__tests__/report-evidence.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-evidence.test.js +214 -0
- package/dist/src/__tests__/report-evidence.test.js.map +1 -0
- package/dist/src/__tests__/report-flake.test.d.ts +2 -0
- package/dist/src/__tests__/report-flake.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-flake.test.js +182 -0
- package/dist/src/__tests__/report-flake.test.js.map +1 -0
- package/dist/src/__tests__/report-history.test.d.ts +2 -0
- package/dist/src/__tests__/report-history.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-history.test.js +229 -0
- package/dist/src/__tests__/report-history.test.js.map +1 -0
- package/dist/src/__tests__/report-output.test.d.ts +2 -0
- package/dist/src/__tests__/report-output.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-output.test.js +169 -0
- package/dist/src/__tests__/report-output.test.js.map +1 -0
- package/dist/src/__tests__/report-png.test.d.ts +2 -0
- package/dist/src/__tests__/report-png.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-png.test.js +84 -0
- package/dist/src/__tests__/report-png.test.js.map +1 -0
- package/dist/src/__tests__/report-render-extras.test.d.ts +2 -0
- package/dist/src/__tests__/report-render-extras.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-render-extras.test.js +285 -0
- package/dist/src/__tests__/report-render-extras.test.js.map +1 -0
- package/dist/src/__tests__/report-render.test.d.ts +2 -0
- package/dist/src/__tests__/report-render.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-render.test.js +143 -0
- package/dist/src/__tests__/report-render.test.js.map +1 -0
- package/dist/src/__tests__/report-run.test.d.ts +2 -0
- package/dist/src/__tests__/report-run.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-run.test.js +234 -0
- package/dist/src/__tests__/report-run.test.js.map +1 -0
- package/dist/src/browser/bridge.d.ts +119 -0
- package/dist/src/browser/bridge.d.ts.map +1 -0
- package/dist/src/browser/bridge.js +264 -0
- package/dist/src/browser/bridge.js.map +1 -0
- package/dist/src/browser/cdp.d.ts +11 -1
- package/dist/src/browser/cdp.d.ts.map +1 -1
- package/dist/src/browser/cdp.js +60 -56
- package/dist/src/browser/cdp.js.map +1 -1
- package/dist/src/browser/index.d.ts +3 -0
- package/dist/src/browser/index.d.ts.map +1 -1
- package/dist/src/browser/index.js +3 -0
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/browser/profiler.d.ts.map +1 -1
- package/dist/src/browser/profiler.js +19 -22
- package/dist/src/browser/profiler.js.map +1 -1
- package/dist/src/browser/transport.d.ts +62 -0
- package/dist/src/browser/transport.d.ts.map +1 -0
- package/dist/src/browser/transport.js +65 -0
- package/dist/src/browser/transport.js.map +1 -0
- package/dist/src/cli/commander-adapter.d.ts +16 -0
- package/dist/src/cli/commander-adapter.d.ts.map +1 -0
- package/dist/src/cli/commander-adapter.js +68 -0
- package/dist/src/cli/commander-adapter.js.map +1 -0
- package/dist/src/cli/commands-context.d.ts +14 -0
- package/dist/src/cli/commands-context.d.ts.map +1 -0
- package/dist/src/cli/commands-context.js +351 -0
- package/dist/src/cli/commands-context.js.map +1 -0
- package/dist/src/cli/commands-data.d.ts +12 -0
- package/dist/src/cli/commands-data.d.ts.map +1 -0
- package/dist/src/cli/commands-data.js +246 -0
- package/dist/src/cli/commands-data.js.map +1 -0
- package/dist/src/cli/commands-element.d.ts +15 -0
- package/dist/src/cli/commands-element.d.ts.map +1 -0
- package/dist/src/cli/commands-element.js +262 -0
- package/dist/src/cli/commands-element.js.map +1 -0
- package/dist/src/cli/commands-files.d.ts +9 -0
- package/dist/src/cli/commands-files.d.ts.map +1 -0
- package/dist/src/cli/commands-files.js +207 -0
- package/dist/src/cli/commands-files.js.map +1 -0
- package/dist/src/cli/commands-input.d.ts +26 -0
- package/dist/src/cli/commands-input.d.ts.map +1 -0
- package/dist/src/cli/commands-input.js +399 -0
- package/dist/src/cli/commands-input.js.map +1 -0
- package/dist/src/cli/commands-navigate.d.ts +12 -0
- package/dist/src/cli/commands-navigate.d.ts.map +1 -0
- package/dist/src/cli/commands-navigate.js +389 -0
- package/dist/src/cli/commands-navigate.js.map +1 -0
- package/dist/src/cli/commands-page.d.ts +25 -0
- package/dist/src/cli/commands-page.d.ts.map +1 -0
- package/dist/src/cli/commands-page.js +377 -0
- package/dist/src/cli/commands-page.js.map +1 -0
- package/dist/src/cli/commands-report.d.ts +8 -0
- package/dist/src/cli/commands-report.d.ts.map +1 -0
- package/dist/src/cli/commands-report.js +256 -0
- package/dist/src/cli/commands-report.js.map +1 -0
- package/dist/src/cli/commands-script.d.ts +43 -0
- package/dist/src/cli/commands-script.d.ts.map +1 -0
- package/dist/src/cli/commands-script.js +168 -0
- package/dist/src/cli/commands-script.js.map +1 -0
- package/dist/src/cli/commands-session.d.ts +15 -0
- package/dist/src/cli/commands-session.d.ts.map +1 -0
- package/dist/src/cli/commands-session.js +357 -0
- package/dist/src/cli/commands-session.js.map +1 -0
- package/dist/src/cli/commands-trace.d.ts +12 -0
- package/dist/src/cli/commands-trace.d.ts.map +1 -0
- package/dist/src/cli/commands-trace.js +347 -0
- package/dist/src/cli/commands-trace.js.map +1 -0
- package/dist/src/cli/commands.d.ts +22 -42
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +48 -3400
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/cli/session.d.ts +47 -0
- package/dist/src/cli/session.d.ts.map +1 -0
- package/dist/src/cli/session.js +343 -0
- package/dist/src/cli/session.js.map +1 -0
- package/dist/src/report/a11y.d.ts +52 -0
- package/dist/src/report/a11y.d.ts.map +1 -0
- package/dist/src/report/a11y.js +231 -0
- package/dist/src/report/a11y.js.map +1 -0
- package/dist/src/report/analyze.d.ts +20 -0
- package/dist/src/report/analyze.d.ts.map +1 -0
- package/dist/src/report/analyze.js +136 -0
- package/dist/src/report/analyze.js.map +1 -0
- package/dist/src/report/budget.d.ts +27 -0
- package/dist/src/report/budget.d.ts.map +1 -0
- package/dist/src/report/budget.js +143 -0
- package/dist/src/report/budget.js.map +1 -0
- package/dist/src/report/collect-a11y.d.ts +25 -0
- package/dist/src/report/collect-a11y.d.ts.map +1 -0
- package/dist/src/report/collect-a11y.js +154 -0
- package/dist/src/report/collect-a11y.js.map +1 -0
- package/dist/src/report/collect.d.ts +45 -0
- package/dist/src/report/collect.d.ts.map +1 -0
- package/dist/src/report/collect.js +275 -0
- package/dist/src/report/collect.js.map +1 -0
- package/dist/src/report/evidence.d.ts +80 -0
- package/dist/src/report/evidence.d.ts.map +1 -0
- package/dist/src/report/evidence.js +279 -0
- package/dist/src/report/evidence.js.map +1 -0
- package/dist/src/report/flake.d.ts +88 -0
- package/dist/src/report/flake.d.ts.map +1 -0
- package/dist/src/report/flake.js +232 -0
- package/dist/src/report/flake.js.map +1 -0
- package/dist/src/report/history.d.ts +85 -0
- package/dist/src/report/history.d.ts.map +1 -0
- package/dist/src/report/history.js +176 -0
- package/dist/src/report/history.js.map +1 -0
- package/dist/src/report/index.d.ts +102 -0
- package/dist/src/report/index.d.ts.map +1 -0
- package/dist/src/report/index.js +216 -0
- package/dist/src/report/index.js.map +1 -0
- package/dist/src/report/pixel-diff.d.ts +30 -0
- package/dist/src/report/pixel-diff.d.ts.map +1 -0
- package/dist/src/report/pixel-diff.js +175 -0
- package/dist/src/report/pixel-diff.js.map +1 -0
- package/dist/src/report/png.d.ts +28 -0
- package/dist/src/report/png.d.ts.map +1 -0
- package/dist/src/report/png.js +259 -0
- package/dist/src/report/png.js.map +1 -0
- package/dist/src/report/render-evidence.d.ts +16 -0
- package/dist/src/report/render-evidence.d.ts.map +1 -0
- package/dist/src/report/render-evidence.js +174 -0
- package/dist/src/report/render-evidence.js.map +1 -0
- package/dist/src/report/render-trend.d.ts +16 -0
- package/dist/src/report/render-trend.d.ts.map +1 -0
- package/dist/src/report/render-trend.js +194 -0
- package/dist/src/report/render-trend.js.map +1 -0
- package/dist/src/report/render.d.ts +21 -0
- package/dist/src/report/render.d.ts.map +1 -0
- package/dist/src/report/render.js +286 -0
- package/dist/src/report/render.js.map +1 -0
- package/dist/src/report/run-diff.d.ts +18 -0
- package/dist/src/report/run-diff.d.ts.map +1 -0
- package/dist/src/report/run-diff.js +51 -0
- package/dist/src/report/run-diff.js.map +1 -0
- package/dist/src/report/structure.d.ts +41 -0
- package/dist/src/report/structure.d.ts.map +1 -0
- package/dist/src/report/structure.js +210 -0
- package/dist/src/report/structure.js.map +1 -0
- package/dist/src/report/trend.d.ts +26 -0
- package/dist/src/report/trend.d.ts.map +1 -0
- package/dist/src/report/trend.js +164 -0
- package/dist/src/report/trend.js.map +1 -0
- package/dist/src/report/types.d.ts +320 -0
- package/dist/src/report/types.d.ts.map +1 -0
- package/dist/src/report/types.js +11 -0
- package/dist/src/report/types.js.map +1 -0
- package/dist/src/report/util.d.ts +17 -0
- package/dist/src/report/util.d.ts.map +1 -0
- package/dist/src/report/util.js +45 -0
- package/dist/src/report/util.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/bridge-instruments.test.ts +180 -0
- package/src/__tests__/bridge-transport.test.ts +365 -0
- package/src/__tests__/cdp-transport.test.ts +198 -0
- package/src/__tests__/profiler-heap.test.ts +191 -0
- package/src/__tests__/report-a11y.test.ts +180 -0
- package/src/__tests__/report-analyze.test.ts +202 -0
- package/src/__tests__/report-budget.test.ts +92 -0
- package/src/__tests__/report-diff.test.ts +272 -0
- package/src/__tests__/report-evidence.test.ts +276 -0
- package/src/__tests__/report-flake.test.ts +214 -0
- package/src/__tests__/report-history.test.ts +293 -0
- package/src/__tests__/report-output.test.ts +210 -0
- package/src/__tests__/report-png.test.ts +95 -0
- package/src/__tests__/report-render-extras.test.ts +315 -0
- package/src/__tests__/report-render.test.ts +175 -0
- package/src/__tests__/report-run.test.ts +264 -0
- package/src/browser/bridge.ts +354 -0
- package/src/browser/cdp.ts +57 -52
- package/src/browser/index.ts +3 -0
- package/src/browser/profiler.ts +28 -21
- package/src/browser/transport.ts +111 -0
- package/src/cli/commander-adapter.ts +65 -0
- package/src/cli/commands-context.ts +367 -0
- package/src/cli/commands-data.ts +248 -0
- package/src/cli/commands-element.ts +305 -0
- package/src/cli/commands-files.ts +248 -0
- package/src/cli/commands-input.ts +434 -0
- package/src/cli/commands-navigate.ts +442 -0
- package/src/cli/commands-page.ts +420 -0
- package/src/cli/commands-report.ts +275 -0
- package/src/cli/commands-script.ts +183 -0
- package/src/cli/commands-session.ts +376 -0
- package/src/cli/commands-trace.ts +349 -0
- package/src/cli/commands.ts +101 -3677
- package/src/cli/session.ts +412 -0
- package/src/report/a11y.ts +242 -0
- package/src/report/analyze.ts +181 -0
- package/src/report/budget.ts +155 -0
- package/src/report/collect-a11y.ts +200 -0
- package/src/report/collect.ts +408 -0
- package/src/report/evidence.ts +379 -0
- package/src/report/flake.ts +303 -0
- package/src/report/history.ts +219 -0
- package/src/report/index.ts +307 -0
- package/src/report/pixel-diff.ts +197 -0
- package/src/report/png.ts +290 -0
- package/src/report/render-evidence.ts +194 -0
- package/src/report/render-trend.ts +225 -0
- package/src/report/render.ts +314 -0
- package/src/report/run-diff.ts +63 -0
- package/src/report/structure.ts +212 -0
- package/src/report/trend.ts +189 -0
- package/src/report/types.ts +356 -0
- package/src/report/util.ts +48 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RIG-13 — run history on disk and the trends computed from it.
|
|
3
|
+
*
|
|
4
|
+
* The filesystem half uses a scratch directory under the monomind data dir
|
|
5
|
+
* (never the shared /tmp) and points MONOBROWSE_HISTORY_DIR at it, so no test
|
|
6
|
+
* can touch a real history.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
12
|
+
import { buildReport } from '../report/analyze.js';
|
|
13
|
+
import { DEFAULT_BUDGET } from '../report/budget.js';
|
|
14
|
+
import {
|
|
15
|
+
type HistoryRun,
|
|
16
|
+
historyKey,
|
|
17
|
+
listRuns,
|
|
18
|
+
loadRunScreenshot,
|
|
19
|
+
pruneHistory,
|
|
20
|
+
saveRun,
|
|
21
|
+
toHistoryRun,
|
|
22
|
+
} from '../report/history.js';
|
|
23
|
+
import { encodePng, pngToDataUrl } from '../report/png.js';
|
|
24
|
+
import { buildTrend, formatTrendDelta, formatTrendValue } from '../report/trend.js';
|
|
25
|
+
import type { Budget, CaptureData, Report, VitalsData } from '../report/types.js';
|
|
26
|
+
|
|
27
|
+
function capture(partial: Partial<CaptureData> = {}): CaptureData {
|
|
28
|
+
return {
|
|
29
|
+
url: 'https://shop.test/checkout',
|
|
30
|
+
finalUrl: 'https://shop.test/checkout',
|
|
31
|
+
title: 'Checkout',
|
|
32
|
+
capturedAt: '2026-09-21T10:00:00.000Z',
|
|
33
|
+
durationMs: 1200,
|
|
34
|
+
console: [],
|
|
35
|
+
pageErrors: [],
|
|
36
|
+
requests: [],
|
|
37
|
+
vitals: { lcp: 2000, cls: 0.01, inp: 50 },
|
|
38
|
+
a11y: [],
|
|
39
|
+
screenshots: [],
|
|
40
|
+
notes: [],
|
|
41
|
+
...partial,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const report = (partial: Partial<CaptureData> = {}, budget: Budget = DEFAULT_BUDGET): Report =>
|
|
46
|
+
buildReport(capture(partial), budget);
|
|
47
|
+
|
|
48
|
+
/** A prior run, shaped exactly as it comes back off disk. */
|
|
49
|
+
function historyRun(
|
|
50
|
+
capturedAt: string,
|
|
51
|
+
vitals: VitalsData,
|
|
52
|
+
counts: Partial<Report['counts']> = {},
|
|
53
|
+
) {
|
|
54
|
+
const run = toHistoryRun(report({ capturedAt, vitals }), `id-${capturedAt}`);
|
|
55
|
+
run.counts = { ...run.counts, ...counts };
|
|
56
|
+
run.verdict = (counts.consoleErrors ?? 0) > 0 ? 'fail' : 'pass';
|
|
57
|
+
return run;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tinyPng(width = 4, height = 4, shade = 200) {
|
|
61
|
+
const data = new Uint8Array(width * height * 4);
|
|
62
|
+
for (let i = 0; i < width * height; i++) data.set([shade, shade, shade, 255], i * 4);
|
|
63
|
+
return encodePng({ width, height, data });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Storage
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
describe('historyKey', () => {
|
|
71
|
+
it('is readable and includes a hash of the full URL', () => {
|
|
72
|
+
expect(historyKey('https://shop.test/checkout')).toMatch(/^shop\.test-checkout-[0-9a-f]{8}$/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not collide across URLs that differ only in the query string', () => {
|
|
76
|
+
expect(historyKey('https://a.test/list?page=2')).not.toBe(
|
|
77
|
+
historyKey('https://a.test/list?page=3'),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('survives a string that is not a URL at all', () => {
|
|
82
|
+
expect(historyKey('not a url')).toMatch(/^not-a-url-[0-9a-f]{8}$/);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('run history on disk', () => {
|
|
87
|
+
let root: string;
|
|
88
|
+
|
|
89
|
+
beforeEach(async () => {
|
|
90
|
+
await mkdir(join(homedir(), '.monomind'), { recursive: true });
|
|
91
|
+
root = await mkdtemp(join(homedir(), '.monomind', 'monobrowse-history-test-'));
|
|
92
|
+
process.env.MONOBROWSE_HISTORY_DIR = root;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(async () => {
|
|
96
|
+
delete process.env.MONOBROWSE_HISTORY_DIR;
|
|
97
|
+
await rm(root, { recursive: true, force: true });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('writes one JSON record per run, keyed by URL', async () => {
|
|
101
|
+
const saved = await saveRun(report(), { root, id: '20260921T100000Z-aaaaaaaa' });
|
|
102
|
+
expect(saved.dir).toBe(join(root, historyKey('https://shop.test/checkout')));
|
|
103
|
+
const files = await readdir(saved.dir);
|
|
104
|
+
expect(files).toContain('20260921T100000Z-aaaaaaaa.json');
|
|
105
|
+
expect(files).toContain('url.txt');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('archives the main screenshot alongside so the next run can diff pixels', async () => {
|
|
109
|
+
const png = tinyPng();
|
|
110
|
+
const saved = await saveRun(
|
|
111
|
+
report({
|
|
112
|
+
screenshots: [{ label: 'page', width: 4, height: 4, dataUrl: pngToDataUrl(png) }],
|
|
113
|
+
}),
|
|
114
|
+
{ root, id: '20260921T100000Z-bbbbbbbb' },
|
|
115
|
+
);
|
|
116
|
+
expect(saved.run.screenshotFile).toBe('20260921T100000Z-bbbbbbbb.png');
|
|
117
|
+
const loaded = await loadRunScreenshot(saved.dir, saved.run);
|
|
118
|
+
expect(loaded && Buffer.from(loaded).equals(png)).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('returns null for a screenshot that was never archived', async () => {
|
|
122
|
+
const saved = await saveRun(report(), { root, id: '20260921T100000Z-cccccccc' });
|
|
123
|
+
expect(await loadRunScreenshot(saved.dir, saved.run)).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('lists runs oldest first', async () => {
|
|
127
|
+
for (const ts of ['2026-09-21T12:00:00.000Z', '2026-09-21T10:00:00.000Z']) {
|
|
128
|
+
await saveRun(report({ capturedAt: ts }), { root });
|
|
129
|
+
}
|
|
130
|
+
const dir = join(root, historyKey('https://shop.test/checkout'));
|
|
131
|
+
expect((await listRuns(dir)).map((r) => r.capturedAt)).toEqual([
|
|
132
|
+
'2026-09-21T10:00:00.000Z',
|
|
133
|
+
'2026-09-21T12:00:00.000Z',
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('prunes to the configured maximum, oldest first, taking the PNGs with them', async () => {
|
|
138
|
+
const dir = join(root, historyKey('https://shop.test/checkout'));
|
|
139
|
+
for (let i = 0; i < 5; i++) {
|
|
140
|
+
await saveRun(
|
|
141
|
+
report({
|
|
142
|
+
capturedAt: `2026-09-2${i + 1}T10:00:00.000Z`,
|
|
143
|
+
screenshots: [{ label: 'page', width: 4, height: 4, dataUrl: pngToDataUrl(tinyPng()) }],
|
|
144
|
+
}),
|
|
145
|
+
{ root, max: 3, id: `2026092${i + 1}T100000Z-0000000${i}` },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const runs = await listRuns(dir);
|
|
149
|
+
expect(runs.map((r) => r.capturedAt)).toEqual([
|
|
150
|
+
'2026-09-23T10:00:00.000Z',
|
|
151
|
+
'2026-09-24T10:00:00.000Z',
|
|
152
|
+
'2026-09-25T10:00:00.000Z',
|
|
153
|
+
]);
|
|
154
|
+
const files = await readdir(dir);
|
|
155
|
+
expect(files.filter((f) => f.endsWith('.png')).length).toBe(3);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('keeps everything when the history is shorter than the maximum', async () => {
|
|
159
|
+
const dir = join(root, historyKey('https://shop.test/checkout'));
|
|
160
|
+
await saveRun(report(), { root, max: 10 });
|
|
161
|
+
expect(await pruneHistory(dir, 10)).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('skips a corrupt record instead of failing the whole read', async () => {
|
|
165
|
+
const dir = join(root, historyKey('https://shop.test/checkout'));
|
|
166
|
+
await saveRun(report(), { root, id: '20260921T100000Z-dddddddd' });
|
|
167
|
+
await writeFile(join(dir, '20260922T100000Z-eeeeeeee.json'), '{ truncated', 'utf8');
|
|
168
|
+
const runs = await listRuns(dir);
|
|
169
|
+
expect(runs.map((r) => r.id)).toEqual(['20260921T100000Z-dddddddd']);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('returns an empty history for a URL never reported on', async () => {
|
|
173
|
+
expect(await listRuns(join(root, 'never-seen-00000000'))).toEqual([]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Trends
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
describe('buildTrend', () => {
|
|
182
|
+
it('reports no prior runs on a first run without inventing a direction', () => {
|
|
183
|
+
const trend = buildTrend(report(), []);
|
|
184
|
+
expect(trend.runs).toBe(0);
|
|
185
|
+
expect(trend.windowFrom).toBeNull();
|
|
186
|
+
expect(trend.series.every((s) => s.direction === 'new')).toBe(true);
|
|
187
|
+
expect(trend.headlines).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('names the slow decay the story is about', () => {
|
|
191
|
+
// LCP walks 2000 -> 2400ms across nine prior runs, never once crossing
|
|
192
|
+
// the 2500ms budget, so no individual run ever failed.
|
|
193
|
+
const history: HistoryRun[] = [];
|
|
194
|
+
for (let i = 0; i < 9; i++) {
|
|
195
|
+
history.push(historyRun(`2026-09-${10 + i}T10:00:00.000Z`, { lcp: 2000 + i * 50 }));
|
|
196
|
+
}
|
|
197
|
+
const trend = buildTrend(
|
|
198
|
+
report({ capturedAt: '2026-09-21T10:00:00.000Z', vitals: { lcp: 2400 } }),
|
|
199
|
+
history,
|
|
200
|
+
);
|
|
201
|
+
const lcp = trend.series.find((s) => s.key === 'lcp');
|
|
202
|
+
expect(lcp?.deltaFromOldest).toBe(400);
|
|
203
|
+
expect(lcp?.creep).toBe('LCP has crept up 400ms over 10 runs (2.00s → 2.40s).');
|
|
204
|
+
expect(trend.headlines[0]).toContain('crept up 400ms');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('does not call rasteriser-scale jitter a regression', () => {
|
|
208
|
+
const history = [historyRun('2026-09-20T10:00:00.000Z', { lcp: 2000 })];
|
|
209
|
+
const trend = buildTrend(report({ vitals: { lcp: 2010 } }), history);
|
|
210
|
+
expect(trend.series.find((s) => s.key === 'lcp')?.direction).toBe('flat');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('calls a real movement a regression, and the reverse an improvement', () => {
|
|
214
|
+
const history = [historyRun('2026-09-20T10:00:00.000Z', { lcp: 2000 })];
|
|
215
|
+
expect(
|
|
216
|
+
buildTrend(report({ vitals: { lcp: 2600 } }), history).series.find((s) => s.key === 'lcp')
|
|
217
|
+
?.direction,
|
|
218
|
+
).toBe('regressed');
|
|
219
|
+
expect(
|
|
220
|
+
buildTrend(report({ vitals: { lcp: 1400 } }), history).series.find((s) => s.key === 'lcp')
|
|
221
|
+
?.direction,
|
|
222
|
+
).toBe('improved');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('treats a single new console error as a regression', () => {
|
|
226
|
+
const history = [historyRun('2026-09-20T10:00:00.000Z', { lcp: 2000 })];
|
|
227
|
+
const current = report({
|
|
228
|
+
console: [{ type: 'error', text: 'boom', timestamp: 0 }],
|
|
229
|
+
});
|
|
230
|
+
const errors = buildTrend(current, history).series.find((s) => s.key === 'consoleErrors');
|
|
231
|
+
expect(errors).toMatchObject({
|
|
232
|
+
current: 1,
|
|
233
|
+
previous: 0,
|
|
234
|
+
deltaFromPrevious: 1,
|
|
235
|
+
direction: 'regressed',
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('leads with a first failure after a clean window', () => {
|
|
240
|
+
const history = [1, 2, 3].map((i) => historyRun(`2026-09-1${i}T10:00:00.000Z`, { lcp: 2000 }));
|
|
241
|
+
const failing = report({ console: [{ type: 'error', text: 'boom', timestamp: 0 }] });
|
|
242
|
+
expect(failing.verdict).toBe('fail');
|
|
243
|
+
expect(buildTrend(failing, history).headlines[0]).toBe(
|
|
244
|
+
'First failure in this window — the previous 3 run(s) all passed.',
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('includes this run as the last point of every series', () => {
|
|
249
|
+
const history = [historyRun('2026-09-20T10:00:00.000Z', { lcp: 2000 })];
|
|
250
|
+
const trend = buildTrend(
|
|
251
|
+
report({ capturedAt: '2026-09-21T10:00:00.000Z', vitals: { lcp: 2100 } }),
|
|
252
|
+
history,
|
|
253
|
+
);
|
|
254
|
+
const lcp = trend.series.find((s) => s.key === 'lcp');
|
|
255
|
+
expect(lcp?.points).toEqual([
|
|
256
|
+
{ capturedAt: '2026-09-20T10:00:00.000Z', value: 2000 },
|
|
257
|
+
{ capturedAt: '2026-09-21T10:00:00.000Z', value: 2100 },
|
|
258
|
+
]);
|
|
259
|
+
expect(trend.verdictHistory.at(-1)).toEqual({
|
|
260
|
+
capturedAt: '2026-09-21T10:00:00.000Z',
|
|
261
|
+
verdict: 'pass',
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('ignores runs that never measured the metric when picking a baseline', () => {
|
|
266
|
+
const history = [
|
|
267
|
+
historyRun('2026-09-18T10:00:00.000Z', { lcp: 2000 }),
|
|
268
|
+
historyRun('2026-09-19T10:00:00.000Z', {}),
|
|
269
|
+
];
|
|
270
|
+
const lcp = buildTrend(report({ vitals: { lcp: 2600 } }), history).series.find(
|
|
271
|
+
(s) => s.key === 'lcp',
|
|
272
|
+
);
|
|
273
|
+
expect(lcp?.previous).toBe(2000);
|
|
274
|
+
expect(lcp?.deltaFromPrevious).toBe(600);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('trend formatting', () => {
|
|
279
|
+
it('switches to seconds past a second so a reader can see the scale', () => {
|
|
280
|
+
expect(formatTrendValue('ms', 412)).toBe('412ms');
|
|
281
|
+
expect(formatTrendValue('ms', 2450)).toBe('2.45s');
|
|
282
|
+
expect(formatTrendValue('score', 0.1234)).toBe('0.123');
|
|
283
|
+
expect(formatTrendValue('count', 3)).toBe('3');
|
|
284
|
+
expect(formatTrendValue('ms', null)).toBe('—');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('signs deltas, and says so plainly when there is no change', () => {
|
|
288
|
+
expect(formatTrendDelta('ms', 400)).toBe('+400ms');
|
|
289
|
+
expect(formatTrendDelta('ms', -400)).toBe('−400ms');
|
|
290
|
+
expect(formatTrendDelta('count', 0)).toBe('no change');
|
|
291
|
+
expect(formatTrendDelta('count', null)).toBe('no change');
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -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
|
+
});
|