@afixt/screenshot-utils 0.9.0
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/CHANGELOG.md +39 -0
- package/LICENSE +13 -0
- package/README.md +132 -0
- package/bin/afixt-screenshot +14 -0
- package/dist/index.js +2797 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2795 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +164 -0
- package/src/adapters/cdp.js +116 -0
- package/src/adapters/index.js +17 -0
- package/src/adapters/playwright.js +150 -0
- package/src/adapters/puppeteer.js +128 -0
- package/src/capture/element.js +257 -0
- package/src/capture/index.js +17 -0
- package/src/capture/long-page.js +267 -0
- package/src/capture/page.js +207 -0
- package/src/capture/responsive.js +94 -0
- package/src/capture/settle.js +182 -0
- package/src/capture/viewport.js +21 -0
- package/src/cli/index.js +266 -0
- package/src/compose/annotate.js +283 -0
- package/src/compose/diff.js +173 -0
- package/src/compose/heatmap.js +159 -0
- package/src/compose/index.js +8 -0
- package/src/compose/theme.js +45 -0
- package/src/errors/adapter.js +65 -0
- package/src/errors/base.js +48 -0
- package/src/errors/capture.js +175 -0
- package/src/errors/compose.js +78 -0
- package/src/errors/index.js +75 -0
- package/src/errors/optional.js +37 -0
- package/src/errors/redaction.js +42 -0
- package/src/errors/tile.js +61 -0
- package/src/errors/transform.js +78 -0
- package/src/index.js +69 -0
- package/src/redact/index.js +6 -0
- package/src/redact/policy.js +67 -0
- package/src/redact/redact.js +268 -0
- package/src/tile/dzi.js +268 -0
- package/src/tile/index.js +5 -0
- package/src/transform/convert.js +54 -0
- package/src/transform/crop.js +251 -0
- package/src/transform/index.js +8 -0
- package/src/transform/normalize.js +83 -0
- package/src/transform/resize.js +99 -0
- package/src/types/index.d.ts +432 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
BufferCorruptError,
|
|
5
|
+
DimensionMismatchError,
|
|
6
|
+
OptionalDependencyMissingError,
|
|
7
|
+
} = require('../errors/index.js');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Perceptual visual diff between two image buffers.
|
|
11
|
+
*
|
|
12
|
+
* Implements `spec.md` § 8.3.4. Requires the optional peer
|
|
13
|
+
* `pixelmatch` (≥ v7); throws `OptionalDependencyMissingError` if it
|
|
14
|
+
* isn't installed.
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
* @param {Buffer} bufferA
|
|
18
|
+
* @param {Buffer} bufferB
|
|
19
|
+
* @param {import('../types/index.d.ts').DiffOptions} [options]
|
|
20
|
+
* @returns {Promise<import('../types/index.d.ts').DiffResult>}
|
|
21
|
+
*/
|
|
22
|
+
async function diff(bufferA, bufferB, options = {}) {
|
|
23
|
+
assertBuffer(bufferA, 'bufferA');
|
|
24
|
+
assertBuffer(bufferB, 'bufferB');
|
|
25
|
+
const {
|
|
26
|
+
threshold = 0.1,
|
|
27
|
+
includeAA = false,
|
|
28
|
+
alpha = 0.5,
|
|
29
|
+
diffColor = [255, 0, 0],
|
|
30
|
+
output = 'overlay',
|
|
31
|
+
} = options;
|
|
32
|
+
if (!['overlay', 'side-by-side', 'mask-only'].includes(output)) {
|
|
33
|
+
throw new DimensionMismatchError(
|
|
34
|
+
`diff output must be 'overlay', 'side-by-side', or 'mask-only'; received ${output}`,
|
|
35
|
+
{ context: { output } }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pixelmatch = await loadPixelmatch();
|
|
40
|
+
const sharp = require('sharp');
|
|
41
|
+
|
|
42
|
+
const a = await rawRgba(sharp, bufferA);
|
|
43
|
+
const b = await rawRgba(sharp, bufferB);
|
|
44
|
+
if (a.width !== b.width || a.height !== b.height) {
|
|
45
|
+
throw new DimensionMismatchError(
|
|
46
|
+
`diff requires equal dimensions: bufferA is ${a.width}x${a.height}, bufferB is ${b.width}x${b.height}`,
|
|
47
|
+
{
|
|
48
|
+
context: {
|
|
49
|
+
a: { width: a.width, height: a.height },
|
|
50
|
+
b: { width: b.width, height: b.height },
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const totalPixels = a.width * a.height;
|
|
56
|
+
|
|
57
|
+
const diffData = Buffer.alloc(a.data.length);
|
|
58
|
+
const changedPixels = pixelmatch(a.data, b.data, diffData, a.width, a.height, {
|
|
59
|
+
threshold,
|
|
60
|
+
includeAA,
|
|
61
|
+
alpha,
|
|
62
|
+
diffColor,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const diffBuffer = await renderOutput(sharp, output, {
|
|
66
|
+
a,
|
|
67
|
+
b,
|
|
68
|
+
diffData,
|
|
69
|
+
width: a.width,
|
|
70
|
+
height: a.height,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
diffBuffer,
|
|
75
|
+
changedPixels,
|
|
76
|
+
totalPixels,
|
|
77
|
+
percentChanged: totalPixels === 0 ? 0 : (changedPixels / totalPixels) * 100,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Dynamic-import pixelmatch so the package can be installed without it.
|
|
83
|
+
*
|
|
84
|
+
* @returns {Promise<Function>}
|
|
85
|
+
*/
|
|
86
|
+
async function loadPixelmatch() {
|
|
87
|
+
try {
|
|
88
|
+
// pixelmatch v7 is ESM-only. dynamic import returns the module
|
|
89
|
+
// namespace; default export is the function.
|
|
90
|
+
const mod = await import('pixelmatch');
|
|
91
|
+
return mod.default ?? mod;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new OptionalDependencyMissingError(
|
|
94
|
+
'diff() requires the optional peer dependency `pixelmatch`. Install it before calling diff().',
|
|
95
|
+
{ packageName: 'pixelmatch', cause: error }
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Decode a buffer to raw RGBA pixel data via sharp.
|
|
102
|
+
*
|
|
103
|
+
* @param {ReturnType<typeof require>} sharp
|
|
104
|
+
* @param {Buffer} buffer
|
|
105
|
+
* @returns {Promise<{data: Buffer, width: number, height: number}>}
|
|
106
|
+
*/
|
|
107
|
+
async function rawRgba(sharp, buffer) {
|
|
108
|
+
try {
|
|
109
|
+
const { data, info } = await sharp(buffer)
|
|
110
|
+
.ensureAlpha()
|
|
111
|
+
.raw()
|
|
112
|
+
.toBuffer({ resolveWithObject: true });
|
|
113
|
+
return { data, width: info.width, height: info.height };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new BufferCorruptError('sharp could not decode diff input buffer', {
|
|
116
|
+
cause: error,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Render the requested diff output mode.
|
|
123
|
+
*
|
|
124
|
+
* @param {ReturnType<typeof require>} sharp
|
|
125
|
+
* @param {string} mode 'overlay' | 'side-by-side' | 'mask-only'
|
|
126
|
+
* @param {{a: {data: Buffer, width: number, height: number}, b: {data: Buffer, width: number, height: number}, diffData: Buffer, width: number, height: number}} ctx
|
|
127
|
+
* @returns {Promise<Buffer>}
|
|
128
|
+
*/
|
|
129
|
+
async function renderOutput(sharp, mode, ctx) {
|
|
130
|
+
const diffPng = await sharp(ctx.diffData, {
|
|
131
|
+
raw: { width: ctx.width, height: ctx.height, channels: 4 },
|
|
132
|
+
})
|
|
133
|
+
.png()
|
|
134
|
+
.toBuffer();
|
|
135
|
+
|
|
136
|
+
if (mode === 'mask-only') {
|
|
137
|
+
return diffPng;
|
|
138
|
+
}
|
|
139
|
+
if (mode === 'side-by-side') {
|
|
140
|
+
const bImage = await sharp(ctx.b.data, {
|
|
141
|
+
raw: { width: ctx.b.width, height: ctx.b.height, channels: 4 },
|
|
142
|
+
})
|
|
143
|
+
.png()
|
|
144
|
+
.toBuffer();
|
|
145
|
+
return sharp({
|
|
146
|
+
create: {
|
|
147
|
+
width: ctx.width * 2,
|
|
148
|
+
height: ctx.height,
|
|
149
|
+
channels: 4,
|
|
150
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
.composite([
|
|
154
|
+
{ input: bImage, top: 0, left: 0 },
|
|
155
|
+
{ input: diffPng, top: 0, left: ctx.width },
|
|
156
|
+
])
|
|
157
|
+
.png()
|
|
158
|
+
.toBuffer();
|
|
159
|
+
}
|
|
160
|
+
// 'overlay' — the diff image already encodes the overlay because
|
|
161
|
+
// pixelmatch writes the b image with changed pixels highlighted.
|
|
162
|
+
return diffPng;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function assertBuffer(buffer, name) {
|
|
166
|
+
if (!Buffer.isBuffer(buffer)) {
|
|
167
|
+
throw new BufferCorruptError(`${name} must be a Buffer`, {
|
|
168
|
+
context: { received: typeof buffer, name },
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = { diff };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { BufferCorruptError, ComposeError, HeatmapPointError } = require('../errors/index.js');
|
|
4
|
+
|
|
5
|
+
const MAX_POINTS = 100_000;
|
|
6
|
+
const VALID_BLEND_MODES = new Set(['overlay', 'screen', 'multiply']);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Render a kernel-density heatmap over a screenshot.
|
|
10
|
+
*
|
|
11
|
+
* Implements `spec.md` § 8.3.3 with a simple SVG-circles + blur approach.
|
|
12
|
+
* Each point gets a circle whose radius scales with weight; the
|
|
13
|
+
* collection is then blurred to produce a smooth heatmap surface and
|
|
14
|
+
* composited onto the source using the requested blend mode.
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
* @param {Buffer} input
|
|
18
|
+
* @param {Array<import('../types/index.d.ts').HeatmapPoint>} points
|
|
19
|
+
* @param {import('../types/index.d.ts').HeatmapOptions} [options]
|
|
20
|
+
* @returns {Promise<Buffer>}
|
|
21
|
+
*/
|
|
22
|
+
async function heatmap(input, points, options = {}) {
|
|
23
|
+
assertBuffer(input);
|
|
24
|
+
if (!Array.isArray(points)) {
|
|
25
|
+
throw new HeatmapPointError('points must be an array', {
|
|
26
|
+
context: { received: typeof points },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (points.length > MAX_POINTS) {
|
|
30
|
+
throw new ComposeError(
|
|
31
|
+
`Cannot render a heatmap with more than ${MAX_POINTS} points in a single call`,
|
|
32
|
+
{ context: { received: points.length, max: MAX_POINTS } }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const { radius = 40, intensity = 0.7, colorScale = 'viridis', blendMode = 'overlay' } = options;
|
|
36
|
+
if (!VALID_BLEND_MODES.has(blendMode)) {
|
|
37
|
+
throw new ComposeError(
|
|
38
|
+
`blendMode must be one of ${[...VALID_BLEND_MODES].join(', ')}; received ${blendMode}`,
|
|
39
|
+
{ context: { received: blendMode } }
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
if (!Number.isFinite(radius) || radius <= 0) {
|
|
43
|
+
throw new HeatmapPointError('radius must be a positive number', {
|
|
44
|
+
context: { received: radius },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const [i, point] of points.entries()) {
|
|
49
|
+
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
|
50
|
+
throw new HeatmapPointError(`points[${i}] must have finite x and y`, {
|
|
51
|
+
context: { index: i, point },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if (point.weight !== undefined && !Number.isFinite(point.weight)) {
|
|
55
|
+
throw new HeatmapPointError(`points[${i}].weight must be a finite number`, {
|
|
56
|
+
context: { index: i, weight: point.weight },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sharp = require('sharp');
|
|
62
|
+
const meta = await readMeta(sharp, input);
|
|
63
|
+
|
|
64
|
+
if (points.length === 0) {
|
|
65
|
+
return input;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const color = resolveColor(colorScale);
|
|
69
|
+
const svg = buildSvg(points, radius, intensity, color, meta);
|
|
70
|
+
const blurred = await sharp(Buffer.from(svg, 'utf8'))
|
|
71
|
+
.resize(meta.width, meta.height, { fit: 'fill' })
|
|
72
|
+
.blur(Math.min(50, radius / 2))
|
|
73
|
+
.toBuffer();
|
|
74
|
+
|
|
75
|
+
return sharp(input)
|
|
76
|
+
.composite([{ input: blurred, blend: blendMode }])
|
|
77
|
+
.toBuffer();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the SVG layer with one circle per point.
|
|
82
|
+
*
|
|
83
|
+
* @param {Array<import('../types/index.d.ts').HeatmapPoint>} points
|
|
84
|
+
* @param {number} radius
|
|
85
|
+
* @param {number} intensity
|
|
86
|
+
* @param {string} color CSS color for the dot fill.
|
|
87
|
+
* @param {{width: number, height: number}} meta
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
function buildSvg(points, radius, intensity, color, meta) {
|
|
91
|
+
const opacity = Math.max(0, Math.min(1, intensity));
|
|
92
|
+
const circles = points
|
|
93
|
+
.map(p => {
|
|
94
|
+
const w = Math.max(0.1, Number(p.weight ?? 1));
|
|
95
|
+
const r = radius * Math.sqrt(w);
|
|
96
|
+
return `<circle cx="${p.x}" cy="${p.y}" r="${r}" fill="${color}" fill-opacity="${opacity}"/>`;
|
|
97
|
+
})
|
|
98
|
+
.join('');
|
|
99
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${meta.width}" height="${meta.height}"><g>${circles}</g></svg>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the colorScale option into a single CSS color used for every
|
|
104
|
+
* point. Full multi-stop color scales are deferred to v1.1; for v1.0
|
|
105
|
+
* we pick a representative warm color per built-in scale.
|
|
106
|
+
*
|
|
107
|
+
* @param {string | Function} colorScale
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
function resolveColor(colorScale) {
|
|
111
|
+
if (typeof colorScale === 'function') {
|
|
112
|
+
// Sample at the high-intensity end (t=1).
|
|
113
|
+
try {
|
|
114
|
+
const result = colorScale(1);
|
|
115
|
+
if (Array.isArray(result) && result.length >= 3) {
|
|
116
|
+
return `rgb(${result[0]}, ${result[1]}, ${result[2]})`;
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// fall through to default
|
|
120
|
+
}
|
|
121
|
+
return '#FF0000';
|
|
122
|
+
}
|
|
123
|
+
switch (colorScale) {
|
|
124
|
+
case 'magma':
|
|
125
|
+
return '#FB8861';
|
|
126
|
+
case 'severity':
|
|
127
|
+
return '#D32F2F';
|
|
128
|
+
default:
|
|
129
|
+
// 'viridis' (default) or any unknown scale.
|
|
130
|
+
return '#FDE725';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function assertBuffer(input) {
|
|
135
|
+
if (!Buffer.isBuffer(input)) {
|
|
136
|
+
throw new BufferCorruptError('input must be a Buffer', {
|
|
137
|
+
context: { received: typeof input },
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function readMeta(sharp, input) {
|
|
143
|
+
try {
|
|
144
|
+
const meta = await sharp(input).metadata();
|
|
145
|
+
if (!meta.width || !meta.height) {
|
|
146
|
+
throw new BufferCorruptError('Image has no detectable dimensions');
|
|
147
|
+
}
|
|
148
|
+
return { width: meta.width, height: meta.height };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (error instanceof BufferCorruptError) {
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
throw new BufferCorruptError('sharp could not read heatmap input buffer', {
|
|
154
|
+
cause: error,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { heatmap, MAX_POINTS };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { annotate } = require('./annotate.js');
|
|
4
|
+
const { defaultTheme } = require('./theme.js');
|
|
5
|
+
const { diff } = require('./diff.js');
|
|
6
|
+
const { heatmap } = require('./heatmap.js');
|
|
7
|
+
|
|
8
|
+
module.exports = { annotate, defaultTheme, diff, heatmap };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default annotation theme shipped with the package.
|
|
5
|
+
*
|
|
6
|
+
* Implements `spec.md` § 8.3.2. The four severity keys cover the standard
|
|
7
|
+
* accessibility-issue grading. Consumers can replace any subset by passing
|
|
8
|
+
* their own theme object to `annotate()`; the function shallow-merges over
|
|
9
|
+
* this default.
|
|
10
|
+
*
|
|
11
|
+
* Color choices target WCAG 2.2 AA when rendered against typical light or
|
|
12
|
+
* dark page backgrounds. Stroke widths are in CSS px and scale with the
|
|
13
|
+
* captured image's pixel space.
|
|
14
|
+
*
|
|
15
|
+
* @public
|
|
16
|
+
* @type {Readonly<import('../types/index.d.ts').AnnotationTheme>}
|
|
17
|
+
*/
|
|
18
|
+
const defaultTheme = Object.freeze({
|
|
19
|
+
'severity-critical': Object.freeze({
|
|
20
|
+
stroke: '#D32F2F',
|
|
21
|
+
strokeWidth: 3,
|
|
22
|
+
fill: '#D32F2F22',
|
|
23
|
+
badge: Object.freeze({ bg: '#D32F2F', fg: '#fff' }),
|
|
24
|
+
}),
|
|
25
|
+
'severity-serious': Object.freeze({
|
|
26
|
+
stroke: '#F57C00',
|
|
27
|
+
strokeWidth: 3,
|
|
28
|
+
fill: '#F57C0022',
|
|
29
|
+
badge: Object.freeze({ bg: '#F57C00', fg: '#fff' }),
|
|
30
|
+
}),
|
|
31
|
+
'severity-moderate': Object.freeze({
|
|
32
|
+
stroke: '#FBC02D',
|
|
33
|
+
strokeWidth: 2,
|
|
34
|
+
fill: '#FBC02D22',
|
|
35
|
+
badge: Object.freeze({ bg: '#FBC02D', fg: '#000' }),
|
|
36
|
+
}),
|
|
37
|
+
'severity-minor': Object.freeze({
|
|
38
|
+
stroke: '#1976D2',
|
|
39
|
+
strokeWidth: 2,
|
|
40
|
+
fill: '#1976D222',
|
|
41
|
+
badge: Object.freeze({ bg: '#1976D2', fg: '#fff' }),
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
module.exports = { defaultTheme };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { ScreenshotUtilsError } = require('./base.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Base for errors arising at the `PageAdapter` boundary
|
|
7
|
+
* (third-party browser object integration).
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
class AdapterError extends ScreenshotUtilsError {
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} message
|
|
14
|
+
* @param {object} [options]
|
|
15
|
+
*/
|
|
16
|
+
constructor(message, options) {
|
|
17
|
+
super(message, options);
|
|
18
|
+
this.code = 'ADAPTER_ERROR';
|
|
19
|
+
this.retriable = false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The wrapped third-party page object is missing a method required by the
|
|
25
|
+
* `PageAdapter` contract (spec § 10). Deterministic — the caller passed the
|
|
26
|
+
* wrong kind of object.
|
|
27
|
+
*
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
class AdapterContractError extends AdapterError {
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} message
|
|
33
|
+
* @param {object} [options]
|
|
34
|
+
*/
|
|
35
|
+
constructor(message, options) {
|
|
36
|
+
super(message, options);
|
|
37
|
+
this.code = 'ADAPTER_CONTRACT';
|
|
38
|
+
this.retriable = false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The underlying puppeteer / playwright / CDP call threw. Wrapped so consumers
|
|
44
|
+
* can rely on the package's error shape without sniffing browser-library
|
|
45
|
+
* internals. Retriable because most browser-API errors are transient.
|
|
46
|
+
*
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
class BrowserApiError extends AdapterError {
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} message
|
|
52
|
+
* @param {object} [options]
|
|
53
|
+
*/
|
|
54
|
+
constructor(message, options) {
|
|
55
|
+
super(message, options);
|
|
56
|
+
this.code = 'BROWSER_API';
|
|
57
|
+
this.retriable = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = {
|
|
62
|
+
AdapterError,
|
|
63
|
+
AdapterContractError,
|
|
64
|
+
BrowserApiError,
|
|
65
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Abstract base class for all errors thrown by `@afixt/screenshot-utils`.
|
|
5
|
+
*
|
|
6
|
+
* Concrete subclasses must:
|
|
7
|
+
* - set a stable `code` string (machine-readable; never localized)
|
|
8
|
+
* - set `retriable: true` only when a consumer-side retry has a chance of succeeding
|
|
9
|
+
* without operator intervention (transient browser timeouts, transport blips, etc.)
|
|
10
|
+
* - never include PII in `context`. The platform privacy rules in
|
|
11
|
+
* the parent CLAUDE.md forbid logging user data; the same applies to
|
|
12
|
+
* error payloads since they are commonly logged verbatim.
|
|
13
|
+
*
|
|
14
|
+
* See `spec.md` § 14 for the full error model.
|
|
15
|
+
*
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
class ScreenshotUtilsError extends Error {
|
|
19
|
+
/**
|
|
20
|
+
* Construct an error. Subclasses set `code` and `retriable`; callers may
|
|
21
|
+
* supply `reason`, `cause`, and `context`.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} message Human-readable summary. Should not include PII.
|
|
24
|
+
* @param {object} [options]
|
|
25
|
+
* @param {string} [options.reason] Sub-classification (enum string) — see spec § 14.3.
|
|
26
|
+
* @param {Error} [options.cause] Underlying error being wrapped, preserved for stack traces.
|
|
27
|
+
* @param {Record<string, unknown>} [options.context] Structured debug data. NO PII.
|
|
28
|
+
*/
|
|
29
|
+
constructor(message, { reason, cause, context } = {}) {
|
|
30
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
31
|
+
this.name = new.target.name;
|
|
32
|
+
// Subclasses overwrite `code` and `retriable` after super().
|
|
33
|
+
// Defaults here keep base instances valid for testing the shape.
|
|
34
|
+
this.code = 'SCREENSHOT_UTILS_ERROR';
|
|
35
|
+
this.retriable = false;
|
|
36
|
+
if (reason !== undefined) {
|
|
37
|
+
this.reason = reason;
|
|
38
|
+
}
|
|
39
|
+
if (context !== undefined) {
|
|
40
|
+
this.context = context;
|
|
41
|
+
}
|
|
42
|
+
// Note: modern V8 automatically excludes the Error constructor
|
|
43
|
+
// from the stack trace when subclassing, so an explicit
|
|
44
|
+
// Error.captureStackTrace(this, new.target) is unnecessary.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { ScreenshotUtilsError };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { ScreenshotUtilsError } = require('./base.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Base for any error arising during the capture phase
|
|
7
|
+
* (browser interaction, scroll, settle, screenshot).
|
|
8
|
+
*
|
|
9
|
+
* @public
|
|
10
|
+
*/
|
|
11
|
+
class CaptureError extends ScreenshotUtilsError {
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} message
|
|
14
|
+
* @param {object} [options]
|
|
15
|
+
* @param {string} [options.reason]
|
|
16
|
+
* @param {Error} [options.cause]
|
|
17
|
+
* @param {Record<string, unknown>} [options.context]
|
|
18
|
+
*/
|
|
19
|
+
constructor(message, options) {
|
|
20
|
+
super(message, options);
|
|
21
|
+
this.code = 'CAPTURE_ERROR';
|
|
22
|
+
this.retriable = false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The browser context is unreachable (process crashed, pool exhausted, etc.).
|
|
28
|
+
* Retriable because the consumer may obtain a fresh browser context.
|
|
29
|
+
*
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
class BrowserUnavailableError extends CaptureError {
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} message
|
|
35
|
+
* @param {object} [options]
|
|
36
|
+
*/
|
|
37
|
+
constructor(message, options) {
|
|
38
|
+
super(message, options);
|
|
39
|
+
this.code = 'BROWSER_UNAVAILABLE';
|
|
40
|
+
this.retriable = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Document scrollHeight exceeds the configured `maxHeight` ceiling.
|
|
46
|
+
*
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
class PageTooTallError extends CaptureError {
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} message
|
|
52
|
+
* @param {object} [options]
|
|
53
|
+
*/
|
|
54
|
+
constructor(message, options) {
|
|
55
|
+
super(message, options);
|
|
56
|
+
this.code = 'PAGE_TOO_TALL';
|
|
57
|
+
this.retriable = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The requested selector / xpath returned no DOM node.
|
|
63
|
+
*
|
|
64
|
+
* @public
|
|
65
|
+
*/
|
|
66
|
+
class ElementNotFoundError extends CaptureError {
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} message
|
|
69
|
+
* @param {object} [options]
|
|
70
|
+
*/
|
|
71
|
+
constructor(message, options) {
|
|
72
|
+
super(message, options);
|
|
73
|
+
this.code = 'ELEMENT_NOT_FOUND';
|
|
74
|
+
this.retriable = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The element exists but has zero size or is hidden from layout.
|
|
80
|
+
*
|
|
81
|
+
* @public
|
|
82
|
+
*/
|
|
83
|
+
class ElementNotVisibleError extends CaptureError {
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} message
|
|
86
|
+
* @param {object} [options]
|
|
87
|
+
*/
|
|
88
|
+
constructor(message, options) {
|
|
89
|
+
super(message, options);
|
|
90
|
+
this.code = 'ELEMENT_NOT_VISIBLE';
|
|
91
|
+
this.retriable = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A verification snippet was provided but the live DOM did not match.
|
|
97
|
+
*
|
|
98
|
+
* @public
|
|
99
|
+
*/
|
|
100
|
+
class ElementMismatchError extends CaptureError {
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} message
|
|
103
|
+
* @param {object} [options]
|
|
104
|
+
*/
|
|
105
|
+
constructor(message, options) {
|
|
106
|
+
super(message, options);
|
|
107
|
+
this.code = 'ELEMENT_MISMATCH';
|
|
108
|
+
this.retriable = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Element dimensions exceed `maxDimensions` (default 4000×4000 CSS px).
|
|
114
|
+
*
|
|
115
|
+
* @public
|
|
116
|
+
*/
|
|
117
|
+
class ElementTooLargeError extends CaptureError {
|
|
118
|
+
/**
|
|
119
|
+
* @param {string} message
|
|
120
|
+
* @param {object} [options]
|
|
121
|
+
*/
|
|
122
|
+
constructor(message, options) {
|
|
123
|
+
super(message, options);
|
|
124
|
+
this.code = 'ELEMENT_TOO_LARGE';
|
|
125
|
+
this.retriable = false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `waitForStable` exceeded its `maxWait`. The page was still settling.
|
|
131
|
+
* Retriable because subsequent calls may catch a stable frame.
|
|
132
|
+
*
|
|
133
|
+
* @public
|
|
134
|
+
*/
|
|
135
|
+
class StabilityTimeoutError extends CaptureError {
|
|
136
|
+
/**
|
|
137
|
+
* @param {string} message
|
|
138
|
+
* @param {object} [options]
|
|
139
|
+
*/
|
|
140
|
+
constructor(message, options) {
|
|
141
|
+
super(message, options);
|
|
142
|
+
this.code = 'STABILITY_TIMEOUT';
|
|
143
|
+
this.retriable = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The underlying browser screenshot call exceeded the configured `timeout`.
|
|
149
|
+
* Retriable for the same reasons as StabilityTimeoutError.
|
|
150
|
+
*
|
|
151
|
+
* @public
|
|
152
|
+
*/
|
|
153
|
+
class CaptureTimeoutError extends CaptureError {
|
|
154
|
+
/**
|
|
155
|
+
* @param {string} message
|
|
156
|
+
* @param {object} [options]
|
|
157
|
+
*/
|
|
158
|
+
constructor(message, options) {
|
|
159
|
+
super(message, options);
|
|
160
|
+
this.code = 'CAPTURE_TIMEOUT';
|
|
161
|
+
this.retriable = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
CaptureError,
|
|
167
|
+
BrowserUnavailableError,
|
|
168
|
+
PageTooTallError,
|
|
169
|
+
ElementNotFoundError,
|
|
170
|
+
ElementNotVisibleError,
|
|
171
|
+
ElementMismatchError,
|
|
172
|
+
ElementTooLargeError,
|
|
173
|
+
StabilityTimeoutError,
|
|
174
|
+
CaptureTimeoutError,
|
|
175
|
+
};
|