@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,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { CaptureTimeoutError, UnsupportedFormatError } = require('../errors/index.js');
|
|
4
|
+
const { waitForStable } = require('./settle.js');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Capture a full page (or just the viewport) via a `PageAdapter`.
|
|
8
|
+
*
|
|
9
|
+
* Implements `spec.md` § 8.1.1. Lazy-loads `sharp` only when the requested
|
|
10
|
+
* output format is one the browser can't produce directly (today: webp;
|
|
11
|
+
* Chromium emits png/jpeg natively).
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
* @param {import('../types/index.d.ts').PageAdapter} adapter
|
|
15
|
+
* @param {import('../types/index.d.ts').CaptureOptions} [options]
|
|
16
|
+
* @returns {Promise<import('../types/index.d.ts').CaptureResult>}
|
|
17
|
+
*/
|
|
18
|
+
async function capturePage(adapter, options = {}) {
|
|
19
|
+
const {
|
|
20
|
+
format = 'png',
|
|
21
|
+
quality = 90,
|
|
22
|
+
fullPage = true,
|
|
23
|
+
omitBackground = false,
|
|
24
|
+
encoding = 'buffer',
|
|
25
|
+
timeout = 30_000,
|
|
26
|
+
waitForStable: settleOpts = false,
|
|
27
|
+
events = null,
|
|
28
|
+
} = options;
|
|
29
|
+
|
|
30
|
+
validateFormat(format);
|
|
31
|
+
|
|
32
|
+
const start = nowMs();
|
|
33
|
+
emit(events, 'capture:start', { format, fullPage });
|
|
34
|
+
|
|
35
|
+
let settleMs = 0;
|
|
36
|
+
const warnings = [];
|
|
37
|
+
if (settleOpts) {
|
|
38
|
+
const settleStart = nowMs();
|
|
39
|
+
const report = await waitForStable(adapter, settleOpts === true ? {} : settleOpts);
|
|
40
|
+
settleMs = nowMs() - settleStart;
|
|
41
|
+
if (report.fonts === 'timeout') {
|
|
42
|
+
warnings.push('fonts did not settle within maxWait');
|
|
43
|
+
}
|
|
44
|
+
if (report.animations === 'timeout') {
|
|
45
|
+
warnings.push('animations did not settle within maxWait');
|
|
46
|
+
}
|
|
47
|
+
if (report.images.failed > 0) {
|
|
48
|
+
warnings.push(`${report.images.failed} image(s) failed to load`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const metrics = await adapter.getMetrics();
|
|
53
|
+
|
|
54
|
+
// Decide whether to delegate the format to the browser or to re-encode.
|
|
55
|
+
const browserFormat = decideBrowserFormat(format);
|
|
56
|
+
const browserOpts = buildBrowserOpts({
|
|
57
|
+
format: browserFormat,
|
|
58
|
+
quality,
|
|
59
|
+
fullPage,
|
|
60
|
+
omitBackground,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const captureStart = nowMs();
|
|
64
|
+
const rawBuffer = await withTimeout(adapter.screenshot(browserOpts), timeout, {
|
|
65
|
+
operation: 'adapter.screenshot',
|
|
66
|
+
});
|
|
67
|
+
const captureMs = nowMs() - captureStart;
|
|
68
|
+
|
|
69
|
+
let bytes = rawBuffer;
|
|
70
|
+
let encodeMs = 0;
|
|
71
|
+
if (browserFormat !== format) {
|
|
72
|
+
const encodeStart = nowMs();
|
|
73
|
+
bytes = await reencode(rawBuffer, { format, quality });
|
|
74
|
+
encodeMs = nowMs() - encodeStart;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (encoding === 'stream') {
|
|
78
|
+
bytes = bufferToStream(bytes);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const totalMs = nowMs() - start;
|
|
82
|
+
|
|
83
|
+
const result = {
|
|
84
|
+
format,
|
|
85
|
+
bytes,
|
|
86
|
+
page: metrics,
|
|
87
|
+
timing: { settleMs, captureMs, encodeMs, totalMs },
|
|
88
|
+
warnings,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
emit(events, 'capture:complete', {
|
|
92
|
+
format,
|
|
93
|
+
bytes: typeof bytes === 'object' && Buffer.isBuffer(bytes) ? bytes.length : null,
|
|
94
|
+
totalMs,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Map the user's desired output format to the format the browser should
|
|
102
|
+
* produce. Returns either the same format (no re-encode) or 'png' (when
|
|
103
|
+
* re-encoding to a format the browser can't produce directly).
|
|
104
|
+
*
|
|
105
|
+
* @param {import('../types/index.d.ts').Format} format
|
|
106
|
+
* @returns {import('../types/index.d.ts').Format}
|
|
107
|
+
*/
|
|
108
|
+
function decideBrowserFormat(format) {
|
|
109
|
+
// Chromium produces png + jpeg natively; webp must be re-encoded by sharp.
|
|
110
|
+
return format === 'webp' ? 'png' : format;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Translate package options into the shape the adapter's `screenshot()`
|
|
115
|
+
* method expects (spec § 10 `ScreenshotOptions`).
|
|
116
|
+
*
|
|
117
|
+
* @param {{format: string, quality: number, fullPage: boolean, omitBackground: boolean}} opts
|
|
118
|
+
* @returns {import('../types/index.d.ts').ScreenshotOptions}
|
|
119
|
+
*/
|
|
120
|
+
function buildBrowserOpts({ format, quality, fullPage, omitBackground }) {
|
|
121
|
+
const out = { fullPage, omitBackground, type: format };
|
|
122
|
+
if (format === 'jpeg') {
|
|
123
|
+
out.quality = quality;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Re-encode a PNG buffer into the requested format using sharp.
|
|
130
|
+
* Lazy-loaded so consumers who never re-encode pay no startup cost.
|
|
131
|
+
*
|
|
132
|
+
* @param {Buffer} input
|
|
133
|
+
* @param {{format: import('../types/index.d.ts').Format, quality: number}} opts
|
|
134
|
+
* @returns {Promise<Buffer>}
|
|
135
|
+
*/
|
|
136
|
+
async function reencode(input, { format, quality }) {
|
|
137
|
+
const sharp = require('sharp');
|
|
138
|
+
let pipeline = sharp(input);
|
|
139
|
+
switch (format) {
|
|
140
|
+
case 'webp':
|
|
141
|
+
pipeline = pipeline.webp({ quality });
|
|
142
|
+
break;
|
|
143
|
+
case 'jpeg':
|
|
144
|
+
pipeline = pipeline.jpeg({ quality });
|
|
145
|
+
break;
|
|
146
|
+
case 'png':
|
|
147
|
+
pipeline = pipeline.png();
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
return pipeline.toBuffer();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Wrap a promise with a timeout that throws CaptureTimeoutError.
|
|
155
|
+
*
|
|
156
|
+
* @template T
|
|
157
|
+
* @param {Promise<T>} promise
|
|
158
|
+
* @param {number} ms
|
|
159
|
+
* @param {{operation: string}} ctx
|
|
160
|
+
* @returns {Promise<T>}
|
|
161
|
+
*/
|
|
162
|
+
function withTimeout(promise, ms, ctx) {
|
|
163
|
+
if (!Number.isFinite(ms) || ms <= 0) {
|
|
164
|
+
return promise;
|
|
165
|
+
}
|
|
166
|
+
let timer;
|
|
167
|
+
const timeoutPromise = new Promise((_resolve, reject) => {
|
|
168
|
+
timer = setTimeout(() => {
|
|
169
|
+
reject(
|
|
170
|
+
new CaptureTimeoutError(`${ctx.operation} exceeded ${ms}ms timeout`, {
|
|
171
|
+
context: { operation: ctx.operation, timeoutMs: ms },
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
}, ms);
|
|
175
|
+
// Don't keep the event loop alive if the test process is exiting.
|
|
176
|
+
if (typeof timer.unref === 'function') {
|
|
177
|
+
timer.unref();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function validateFormat(format) {
|
|
184
|
+
if (!['png', 'jpeg', 'webp'].includes(format)) {
|
|
185
|
+
throw new UnsupportedFormatError(
|
|
186
|
+
`format must be 'png', 'jpeg', or 'webp'; received ${JSON.stringify(format)}`,
|
|
187
|
+
{ context: { received: format } }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function emit(events, name, payload) {
|
|
193
|
+
if (events && typeof events.emit === 'function') {
|
|
194
|
+
events.emit(name, payload);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function nowMs() {
|
|
199
|
+
return Number(process.hrtime.bigint() / 1_000_000n);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function bufferToStream(buf) {
|
|
203
|
+
const { Readable } = require('node:stream');
|
|
204
|
+
return Readable.from(buf);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { capturePage };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { capturePage } = require('./page.js');
|
|
4
|
+
const { captureLongPage } = require('./long-page.js');
|
|
5
|
+
const { captureViewport } = require('./viewport.js');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Capture the same page at multiple viewport sizes in one orchestrated
|
|
9
|
+
* call. Useful for responsive accessibility audits.
|
|
10
|
+
*
|
|
11
|
+
* Implements `spec.md` § 8.1.5.
|
|
12
|
+
*
|
|
13
|
+
* - `parallelism: 1` (default) is the safe choice — driving viewport
|
|
14
|
+
* changes against a single page in parallel produces non-deterministic
|
|
15
|
+
* results. Consumers can bump it above 1 only if they hand over
|
|
16
|
+
* distinct adapters via the (Phase 4) per-context capture API.
|
|
17
|
+
* - `reloadBetween: true` (default) reloads the page between viewport
|
|
18
|
+
* changes so media queries and layout re-evaluate cleanly.
|
|
19
|
+
*
|
|
20
|
+
* @public
|
|
21
|
+
* @param {import('../types/index.d.ts').PageAdapter} adapter
|
|
22
|
+
* @param {import('../types/index.d.ts').ResponsiveCaptureOptions} options
|
|
23
|
+
* @returns {Promise<import('../types/index.d.ts').ResponsiveCaptureResult>}
|
|
24
|
+
*/
|
|
25
|
+
async function captureResponsive(adapter, options) {
|
|
26
|
+
if (!options || !Array.isArray(options.viewports) || options.viewports.length === 0) {
|
|
27
|
+
const { InvalidRectError } = require('../errors/index.js');
|
|
28
|
+
throw new InvalidRectError(
|
|
29
|
+
'captureResponsive requires options.viewports as a non-empty array',
|
|
30
|
+
{ context: { received: options?.viewports } }
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
// parallelism is accepted in the options shape (per spec § 8.1.5) but
|
|
34
|
+
// ignored here: running viewport changes in parallel against one
|
|
35
|
+
// adapter is unsafe (shared viewport state). The Phase 4 per-context
|
|
36
|
+
// API will honor it. Pull it off the options object before destructure
|
|
37
|
+
// so it doesn't end up in captureOptions.
|
|
38
|
+
const { viewports, reloadBetween = true, captureMode = 'fullPage', ...rest } = options;
|
|
39
|
+
delete rest.parallelism;
|
|
40
|
+
const captureOptions = rest;
|
|
41
|
+
|
|
42
|
+
const start = nowMs();
|
|
43
|
+
const captures = [];
|
|
44
|
+
|
|
45
|
+
// Parallelism > 1 against a single adapter is unsafe (viewport state
|
|
46
|
+
// is shared). For now run strictly serial; Phase 4 will introduce a
|
|
47
|
+
// per-adapter mode that takes a factory.
|
|
48
|
+
for (const viewport of viewports) {
|
|
49
|
+
if (!viewport || typeof viewport.name !== 'string') {
|
|
50
|
+
const { InvalidRectError } = require('../errors/index.js');
|
|
51
|
+
throw new InvalidRectError('Each viewport must have a string name', {
|
|
52
|
+
context: { viewport },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
await adapter.setViewport(viewport);
|
|
56
|
+
if (reloadBetween) {
|
|
57
|
+
await adapter.reload({ waitUntil: 'networkidle0' }).catch(() => adapter.reload());
|
|
58
|
+
}
|
|
59
|
+
const capture = await captureForMode(adapter, captureMode, captureOptions);
|
|
60
|
+
captures.push({ ...capture, viewportName: viewport.name });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
captures,
|
|
65
|
+
capturedAt: new Date().toISOString(),
|
|
66
|
+
totalMs: nowMs() - start,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Dispatch to the right capture function based on captureMode.
|
|
72
|
+
*
|
|
73
|
+
* @param {import('../types/index.d.ts').PageAdapter} adapter
|
|
74
|
+
* @param {'fullPage' | 'viewport' | 'longPage'} mode
|
|
75
|
+
* @param {import('../types/index.d.ts').CaptureOptions} captureOptions
|
|
76
|
+
* @returns {Promise<import('../types/index.d.ts').CaptureResult>}
|
|
77
|
+
*/
|
|
78
|
+
async function captureForMode(adapter, mode, captureOptions) {
|
|
79
|
+
switch (mode) {
|
|
80
|
+
case 'viewport':
|
|
81
|
+
return captureViewport(adapter, captureOptions);
|
|
82
|
+
case 'longPage':
|
|
83
|
+
return captureLongPage(adapter, captureOptions);
|
|
84
|
+
default:
|
|
85
|
+
// 'fullPage' or anything unknown: full-page capture.
|
|
86
|
+
return capturePage(adapter, { ...captureOptions, fullPage: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function nowMs() {
|
|
91
|
+
return Number(process.hrtime.bigint() / 1_000_000n);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { captureResponsive };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wait for the page to reach a render-stable state before capture.
|
|
5
|
+
*
|
|
6
|
+
* Implements `spec.md` § 8.1.6. The whole settle logic runs inside the
|
|
7
|
+
* page context via a single `adapter.evaluate()` call to avoid Node ↔
|
|
8
|
+
* browser round-trips for each sub-check.
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
* @param {import('../types/index.d.ts').PageAdapter} adapter
|
|
12
|
+
* @param {import('../types/index.d.ts').WaitForStableOptions} [options]
|
|
13
|
+
* @returns {Promise<import('../types/index.d.ts').StabilityReport>}
|
|
14
|
+
*/
|
|
15
|
+
async function waitForStable(adapter, options = {}) {
|
|
16
|
+
const {
|
|
17
|
+
fonts = true,
|
|
18
|
+
images = true,
|
|
19
|
+
animations = 'reduce',
|
|
20
|
+
maxWait = 5000,
|
|
21
|
+
idleMs = 200,
|
|
22
|
+
} = options;
|
|
23
|
+
|
|
24
|
+
const start = nowMs();
|
|
25
|
+
const report = await adapter.evaluate(runInPage, {
|
|
26
|
+
fonts,
|
|
27
|
+
images,
|
|
28
|
+
animations,
|
|
29
|
+
maxWait,
|
|
30
|
+
idleMs,
|
|
31
|
+
});
|
|
32
|
+
const totalMs = nowMs() - start;
|
|
33
|
+
return { ...report, totalMs };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Page-context settle implementation. Serialized + executed by the
|
|
38
|
+
* adapter via the browser's evaluate primitive. Must be self-contained
|
|
39
|
+
* (no closures over Node-side state) and only use `globalThis.*` so
|
|
40
|
+
* eslint's no-undef passes.
|
|
41
|
+
*
|
|
42
|
+
* @param {{fonts: boolean, images: boolean, animations: 'reduce'|'wait'|'ignore', maxWait: number, idleMs: number}} opts
|
|
43
|
+
* @returns {Promise<Omit<import('../types/index.d.ts').StabilityReport, 'totalMs'>>}
|
|
44
|
+
*/
|
|
45
|
+
/* v8 ignore start -- runs in the browser via adapter.evaluate(); not
|
|
46
|
+
instrumentable by Node-side coverage. Exercised by settle-integration.test.js. */
|
|
47
|
+
async function runInPage(opts) {
|
|
48
|
+
const doc = globalThis.document;
|
|
49
|
+
/** @type {Omit<import('../types/index.d.ts').StabilityReport, 'totalMs'>} */
|
|
50
|
+
const report = {
|
|
51
|
+
fonts: 'skipped',
|
|
52
|
+
images: { total: 0, loaded: 0, failed: 0 },
|
|
53
|
+
animations: 'skipped',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// All sub-checks are nested functions so the closure is one
|
|
57
|
+
// serializable unit when adapter.evaluate ships this to the browser.
|
|
58
|
+
|
|
59
|
+
const withTimeout = (promise, ms, label) =>
|
|
60
|
+
Promise.race([
|
|
61
|
+
promise,
|
|
62
|
+
new Promise((_resolve, reject) => {
|
|
63
|
+
setTimeout(() => reject(new Error(`${label} timeout`)), ms);
|
|
64
|
+
}),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const settleFonts = async () => {
|
|
68
|
+
if (!opts.fonts || !doc || !doc.fonts || !doc.fonts.ready) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
await withTimeout(doc.fonts.ready, opts.maxWait, 'fonts.ready');
|
|
73
|
+
report.fonts = 'ready';
|
|
74
|
+
} catch {
|
|
75
|
+
report.fonts = 'timeout';
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const settleOneImage = img =>
|
|
80
|
+
new Promise(resolve => {
|
|
81
|
+
if (img.complete) {
|
|
82
|
+
if (img.naturalHeight > 0) {
|
|
83
|
+
report.images.loaded += 1;
|
|
84
|
+
} else {
|
|
85
|
+
report.images.failed += 1;
|
|
86
|
+
}
|
|
87
|
+
resolve();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
img.addEventListener(
|
|
91
|
+
'load',
|
|
92
|
+
() => {
|
|
93
|
+
report.images.loaded += 1;
|
|
94
|
+
resolve();
|
|
95
|
+
},
|
|
96
|
+
{ once: true }
|
|
97
|
+
);
|
|
98
|
+
img.addEventListener(
|
|
99
|
+
'error',
|
|
100
|
+
() => {
|
|
101
|
+
report.images.failed += 1;
|
|
102
|
+
resolve();
|
|
103
|
+
},
|
|
104
|
+
{ once: true }
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const settleImages = async () => {
|
|
109
|
+
if (!opts.images || !doc) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const imgs = Array.from(doc.images || doc.querySelectorAll('img') || []);
|
|
113
|
+
report.images.total = imgs.length;
|
|
114
|
+
await Promise.all(imgs.map(settleOneImage));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const settleAnimations = async () => {
|
|
118
|
+
if (opts.animations === 'ignore' || !doc) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const reduced =
|
|
122
|
+
globalThis.matchMedia &&
|
|
123
|
+
globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
124
|
+
if (reduced) {
|
|
125
|
+
report.animations = 'reduced';
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const running = doc.getAnimations
|
|
129
|
+
? doc.getAnimations().filter(a => a.playState === 'running')
|
|
130
|
+
: [];
|
|
131
|
+
if (opts.animations === 'reduce') {
|
|
132
|
+
report.animations = running.length > 0 ? 'timeout' : 'none';
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (opts.animations === 'wait') {
|
|
136
|
+
if (running.length === 0) {
|
|
137
|
+
report.animations = 'none';
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
await withTimeout(
|
|
142
|
+
Promise.all(running.map(a => a.finished.catch(() => {}))),
|
|
143
|
+
opts.maxWait,
|
|
144
|
+
'animations'
|
|
145
|
+
);
|
|
146
|
+
report.animations = 'settled';
|
|
147
|
+
} catch {
|
|
148
|
+
report.animations = 'timeout';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const settleIdle = async () => {
|
|
154
|
+
if (opts.idleMs <= 0) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (typeof globalThis.requestIdleCallback === 'function') {
|
|
158
|
+
await new Promise(resolve => {
|
|
159
|
+
globalThis.requestIdleCallback(() => resolve(), {
|
|
160
|
+
timeout: opts.idleMs * 2,
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
await new Promise(resolve => {
|
|
165
|
+
setTimeout(resolve, opts.idleMs);
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
await settleFonts();
|
|
170
|
+
await settleImages();
|
|
171
|
+
await settleAnimations();
|
|
172
|
+
await settleIdle();
|
|
173
|
+
|
|
174
|
+
return report;
|
|
175
|
+
}
|
|
176
|
+
/* v8 ignore stop */
|
|
177
|
+
|
|
178
|
+
function nowMs() {
|
|
179
|
+
return Number(process.hrtime.bigint() / 1_000_000n);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { waitForStable };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { capturePage } = require('./page.js');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Capture only what is currently scrolled into view (no full-page scroll
|
|
7
|
+
* or stitching). Useful for AI evidence and above-the-fold checks.
|
|
8
|
+
*
|
|
9
|
+
* Implements `spec.md` § 8.1.2. Thin wrapper around `capturePage` that
|
|
10
|
+
* forces `fullPage: false` regardless of caller options.
|
|
11
|
+
*
|
|
12
|
+
* @public
|
|
13
|
+
* @param {import('../types/index.d.ts').PageAdapter} adapter
|
|
14
|
+
* @param {import('../types/index.d.ts').CaptureOptions} [options]
|
|
15
|
+
* @returns {Promise<import('../types/index.d.ts').CaptureResult>}
|
|
16
|
+
*/
|
|
17
|
+
async function captureViewport(adapter, options = {}) {
|
|
18
|
+
return capturePage(adapter, { ...options, fullPage: false });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { captureViewport };
|