@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,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Budget evaluation decides the exit code, so these cover the boundaries
|
|
3
|
+
* (at the limit passes, one over fails) and the cases where a metric simply
|
|
4
|
+
* was not measured — which must not silently grade as a pass.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
buildReport,
|
|
9
|
+
evaluateBudget,
|
|
10
|
+
failedRequests,
|
|
11
|
+
summarizeVerdict,
|
|
12
|
+
} from '../report/analyze.js';
|
|
13
|
+
import { DEFAULT_BUDGET, parseBudget } from '../report/budget.js';
|
|
14
|
+
import type { A11yFinding, CaptureData, ConsoleEntry, RequestEntry } from '../report/types.js';
|
|
15
|
+
|
|
16
|
+
function capture(partial: Partial<CaptureData> = {}): CaptureData {
|
|
17
|
+
return {
|
|
18
|
+
url: 'https://example.test/',
|
|
19
|
+
finalUrl: 'https://example.test/',
|
|
20
|
+
title: 'Example',
|
|
21
|
+
capturedAt: '2026-09-21T10:00:00.000Z',
|
|
22
|
+
durationMs: 1234,
|
|
23
|
+
console: [],
|
|
24
|
+
pageErrors: [],
|
|
25
|
+
requests: [],
|
|
26
|
+
vitals: {},
|
|
27
|
+
a11y: [],
|
|
28
|
+
screenshots: [],
|
|
29
|
+
notes: [],
|
|
30
|
+
...partial,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const consoleEntry = (type: string, text: string): ConsoleEntry => ({
|
|
35
|
+
type,
|
|
36
|
+
text,
|
|
37
|
+
timestamp: 0,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const request = (partial: Partial<RequestEntry> = {}): RequestEntry => ({
|
|
41
|
+
url: 'https://example.test/api',
|
|
42
|
+
method: 'GET',
|
|
43
|
+
failed: false,
|
|
44
|
+
...partial,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const a11yError = (rule: A11yFinding['rule']): A11yFinding => ({
|
|
48
|
+
rule,
|
|
49
|
+
impact: 'error',
|
|
50
|
+
role: 'button',
|
|
51
|
+
name: null,
|
|
52
|
+
locator: '#x',
|
|
53
|
+
detail: 'no name',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('evaluateBudget', () => {
|
|
57
|
+
it('passes a clean page against the defaults', () => {
|
|
58
|
+
const result = evaluateBudget(
|
|
59
|
+
capture({ vitals: { lcp: 900, cls: 0.02, inp: 80 } }),
|
|
60
|
+
DEFAULT_BUDGET,
|
|
61
|
+
);
|
|
62
|
+
expect(result.verdict).toBe('pass');
|
|
63
|
+
expect(result.failures).toEqual([]);
|
|
64
|
+
expect(result.unmeasured).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('fails on a console error and names expected vs actual', () => {
|
|
68
|
+
const result = evaluateBudget(
|
|
69
|
+
capture({ console: [consoleEntry('error', 'Uncaught TypeError: x is not a function')] }),
|
|
70
|
+
parseBudget({ lcp: null, cls: null, inp: null }),
|
|
71
|
+
);
|
|
72
|
+
expect(result.verdict).toBe('fail');
|
|
73
|
+
expect(result.failures).toHaveLength(1);
|
|
74
|
+
expect(result.failures[0]).toMatchObject({
|
|
75
|
+
budget: 'maxConsoleErrors',
|
|
76
|
+
expected: '<= 0',
|
|
77
|
+
actual: '1',
|
|
78
|
+
});
|
|
79
|
+
expect(result.failures[0].detail).toContain('Uncaught TypeError');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('does not count console warnings as errors', () => {
|
|
83
|
+
const result = evaluateBudget(
|
|
84
|
+
capture({ console: [consoleEntry('warn', 'deprecated')] }),
|
|
85
|
+
parseBudget({ lcp: null, cls: null, inp: null }),
|
|
86
|
+
);
|
|
87
|
+
expect(result.verdict).toBe('pass');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('fails on failed requests and quotes the first few', () => {
|
|
91
|
+
const requests = [
|
|
92
|
+
request({ failed: true, status: 500, url: 'https://example.test/a' }),
|
|
93
|
+
request({ failed: true, errorText: 'net::ERR_NAME_NOT_RESOLVED', url: 'https://x.test/b' }),
|
|
94
|
+
request({ status: 200 }),
|
|
95
|
+
];
|
|
96
|
+
const result = evaluateBudget(capture({ requests }), DEFAULT_BUDGET);
|
|
97
|
+
const failure = result.failures.find((f) => f.budget === 'maxFailedRequests');
|
|
98
|
+
expect(failure).toBeDefined();
|
|
99
|
+
expect(failure?.actual).toBe('2');
|
|
100
|
+
expect(failure?.detail).toContain('500 GET https://example.test/a');
|
|
101
|
+
expect(failure?.detail).toContain('net::ERR_NAME_NOT_RESOLVED');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('treats a metric exactly at the limit as passing, and one over as failing', () => {
|
|
105
|
+
const budget = parseBudget({ lcp: 2500, cls: null, inp: null });
|
|
106
|
+
expect(evaluateBudget(capture({ vitals: { lcp: 2500 } }), budget).verdict).toBe('pass');
|
|
107
|
+
const over = evaluateBudget(capture({ vitals: { lcp: 2501 } }), budget);
|
|
108
|
+
expect(over.verdict).toBe('fail');
|
|
109
|
+
expect(over.failures[0]).toMatchObject({
|
|
110
|
+
budget: 'lcpMs',
|
|
111
|
+
expected: '<= 2500ms',
|
|
112
|
+
actual: '2501ms',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('records an unmeasured metric instead of passing it', () => {
|
|
117
|
+
const result = evaluateBudget(
|
|
118
|
+
capture({ vitals: {} }),
|
|
119
|
+
parseBudget({ lcp: 2500, cls: null, inp: null }),
|
|
120
|
+
);
|
|
121
|
+
expect(result.verdict).toBe('pass');
|
|
122
|
+
expect(result.unmeasured).toEqual(['LCP (Largest Contentful Paint)']);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('skips a budget set to null entirely', () => {
|
|
126
|
+
const result = evaluateBudget(
|
|
127
|
+
capture({ vitals: { lcp: 99_000 } }),
|
|
128
|
+
parseBudget({ lcp: null, cls: null, inp: null }),
|
|
129
|
+
);
|
|
130
|
+
expect(result.verdict).toBe('pass');
|
|
131
|
+
expect(result.unmeasured).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('counts a11y errors but not a11y warnings against the a11y budget', () => {
|
|
135
|
+
const a11y: A11yFinding[] = [
|
|
136
|
+
a11yError('unlabelled-control'),
|
|
137
|
+
{ ...a11yError('heading-order-jump'), impact: 'warning' },
|
|
138
|
+
];
|
|
139
|
+
const result = evaluateBudget(capture({ a11y }), DEFAULT_BUDGET);
|
|
140
|
+
const failure = result.failures.find((f) => f.budget === 'maxA11yErrors');
|
|
141
|
+
expect(failure?.actual).toBe('1');
|
|
142
|
+
expect(failure?.detail).toContain('unlabelled-control at #x');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('reports a CLS breach as a score, not milliseconds', () => {
|
|
146
|
+
const result = evaluateBudget(
|
|
147
|
+
capture({ vitals: { cls: 0.42 } }),
|
|
148
|
+
parseBudget({ lcp: null, inp: null }),
|
|
149
|
+
);
|
|
150
|
+
expect(result.failures[0]).toMatchObject({
|
|
151
|
+
budget: 'clsScore',
|
|
152
|
+
expected: '<= 0.1',
|
|
153
|
+
actual: '0.42',
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('failedRequests', () => {
|
|
159
|
+
it('selects only entries marked failed', () => {
|
|
160
|
+
expect(failedRequests([request({ failed: true }), request()])).toHaveLength(1);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('buildReport', () => {
|
|
165
|
+
it('carries the verdict, the failure list and the headline counts', () => {
|
|
166
|
+
const report = buildReport(
|
|
167
|
+
capture({
|
|
168
|
+
console: [consoleEntry('error', 'boom'), consoleEntry('warn', 'meh')],
|
|
169
|
+
requests: [request({ failed: true, status: 404 }), request({ status: 200 })],
|
|
170
|
+
a11y: [a11yError('image-missing-alt')],
|
|
171
|
+
vitals: { lcp: 1000, cls: 0, inp: 10 },
|
|
172
|
+
}),
|
|
173
|
+
DEFAULT_BUDGET,
|
|
174
|
+
);
|
|
175
|
+
expect(report.verdict).toBe('fail');
|
|
176
|
+
expect(report.counts).toEqual({
|
|
177
|
+
consoleErrors: 1,
|
|
178
|
+
consoleWarnings: 1,
|
|
179
|
+
pageErrors: 0,
|
|
180
|
+
requests: 2,
|
|
181
|
+
failedRequests: 1,
|
|
182
|
+
a11yErrors: 1,
|
|
183
|
+
a11yWarnings: 0,
|
|
184
|
+
});
|
|
185
|
+
expect(report.failures.map((f) => f.budget).sort()).toEqual([
|
|
186
|
+
'maxA11yErrors',
|
|
187
|
+
'maxConsoleErrors',
|
|
188
|
+
'maxFailedRequests',
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('summarizes a pass and a fail in one line each', () => {
|
|
193
|
+
const pass = buildReport(capture({ vitals: { lcp: 10, cls: 0, inp: 1 } }), DEFAULT_BUDGET);
|
|
194
|
+
expect(summarizeVerdict(pass)).toContain('PASS');
|
|
195
|
+
const fail = buildReport(
|
|
196
|
+
capture({ pageErrors: [{ text: 'boom', timestamp: 0 }] }),
|
|
197
|
+
DEFAULT_BUDGET,
|
|
198
|
+
);
|
|
199
|
+
expect(summarizeVerdict(fail)).toContain('FAIL');
|
|
200
|
+
expect(summarizeVerdict(fail)).toContain('Uncaught page errors');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Budgets are the CI gate, so the parser's job is to be strict: a typo'd key
|
|
3
|
+
* must fail loudly rather than quietly disabling a check.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { DEFAULT_BUDGET, formatThreshold, loadBudget, parseBudget } from '../report/budget.js';
|
|
10
|
+
|
|
11
|
+
describe('DEFAULT_BUDGET', () => {
|
|
12
|
+
it('gates on zero errors and the Core Web Vitals "good" boundaries', () => {
|
|
13
|
+
expect(DEFAULT_BUDGET).toMatchObject({
|
|
14
|
+
maxConsoleErrors: 0,
|
|
15
|
+
maxPageErrors: 0,
|
|
16
|
+
maxFailedRequests: 0,
|
|
17
|
+
maxA11yErrors: 0,
|
|
18
|
+
lcpMs: 2500,
|
|
19
|
+
clsScore: 0.1,
|
|
20
|
+
inpMs: 200,
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('parseBudget', () => {
|
|
26
|
+
it('merges over the defaults rather than replacing them', () => {
|
|
27
|
+
const budget = parseBudget({ lcpMs: 4000 });
|
|
28
|
+
expect(budget.lcpMs).toBe(4000);
|
|
29
|
+
expect(budget.maxConsoleErrors).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('accepts short and snake_case spellings', () => {
|
|
33
|
+
const budget = parseBudget({ lcp: 3000, cls: 0.25, max_console_errors: 5, 'a11y-errors': 2 });
|
|
34
|
+
expect(budget).toMatchObject({
|
|
35
|
+
lcpMs: 3000,
|
|
36
|
+
clsScore: 0.25,
|
|
37
|
+
maxConsoleErrors: 5,
|
|
38
|
+
maxA11yErrors: 2,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('treats null as "do not enforce"', () => {
|
|
43
|
+
expect(parseBudget({ lcp: null }).lcpMs).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects an unknown key instead of silently ignoring it', () => {
|
|
47
|
+
expect(() => parseBudget({ maxConsoleError: 0 })).toThrow(/Unknown budget key/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('rejects non-numeric and negative values', () => {
|
|
51
|
+
expect(() => parseBudget({ lcp: 'fast' })).toThrow(/must be a number/);
|
|
52
|
+
expect(() => parseBudget({ lcp: -1 })).toThrow(/negative/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('rejects a non-object budget', () => {
|
|
56
|
+
expect(() => parseBudget([1, 2])).toThrow(/expected a JSON object/);
|
|
57
|
+
expect(() => parseBudget(null)).toThrow(/expected a JSON object/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('loadBudget', () => {
|
|
62
|
+
it('returns the defaults when nothing is passed', async () => {
|
|
63
|
+
await expect(loadBudget()).resolves.toEqual(DEFAULT_BUDGET);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('parses inline JSON', async () => {
|
|
67
|
+
await expect(loadBudget('{"lcp": 1000}')).resolves.toMatchObject({ lcpMs: 1000 });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('reads a budget file', async () => {
|
|
71
|
+
const dir = await mkdtemp(join(tmpdir(), 'monobrowse-budget-'));
|
|
72
|
+
const file = join(dir, 'budget.json');
|
|
73
|
+
await writeFile(file, JSON.stringify({ maxFailedRequests: 3 }), 'utf8');
|
|
74
|
+
await expect(loadBudget(file)).resolves.toMatchObject({ maxFailedRequests: 3 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('names the file it could not read', async () => {
|
|
78
|
+
await expect(loadBudget('/nope/does-not-exist.json')).rejects.toThrow(/Budget file not found/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('reports malformed inline JSON as such', async () => {
|
|
82
|
+
await expect(loadBudget('{oops}')).rejects.toThrow(/not valid JSON/);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('formatThreshold', () => {
|
|
87
|
+
it('units millisecond budgets and leaves counts and scores bare', () => {
|
|
88
|
+
expect(formatThreshold('lcpMs', 2500)).toBe('<= 2500ms');
|
|
89
|
+
expect(formatThreshold('clsScore', 0.1)).toBe('<= 0.1');
|
|
90
|
+
expect(formatThreshold('maxConsoleErrors', 0)).toBe('<= 0');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -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
|
+
});
|