@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +13 -0
  3. package/README.md +132 -0
  4. package/bin/afixt-screenshot +14 -0
  5. package/dist/index.js +2797 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +2795 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +164 -0
  10. package/src/adapters/cdp.js +116 -0
  11. package/src/adapters/index.js +17 -0
  12. package/src/adapters/playwright.js +150 -0
  13. package/src/adapters/puppeteer.js +128 -0
  14. package/src/capture/element.js +257 -0
  15. package/src/capture/index.js +17 -0
  16. package/src/capture/long-page.js +267 -0
  17. package/src/capture/page.js +207 -0
  18. package/src/capture/responsive.js +94 -0
  19. package/src/capture/settle.js +182 -0
  20. package/src/capture/viewport.js +21 -0
  21. package/src/cli/index.js +266 -0
  22. package/src/compose/annotate.js +283 -0
  23. package/src/compose/diff.js +173 -0
  24. package/src/compose/heatmap.js +159 -0
  25. package/src/compose/index.js +8 -0
  26. package/src/compose/theme.js +45 -0
  27. package/src/errors/adapter.js +65 -0
  28. package/src/errors/base.js +48 -0
  29. package/src/errors/capture.js +175 -0
  30. package/src/errors/compose.js +78 -0
  31. package/src/errors/index.js +75 -0
  32. package/src/errors/optional.js +37 -0
  33. package/src/errors/redaction.js +42 -0
  34. package/src/errors/tile.js +61 -0
  35. package/src/errors/transform.js +78 -0
  36. package/src/index.js +69 -0
  37. package/src/redact/index.js +6 -0
  38. package/src/redact/policy.js +67 -0
  39. package/src/redact/redact.js +268 -0
  40. package/src/tile/dzi.js +268 -0
  41. package/src/tile/index.js +5 -0
  42. package/src/transform/convert.js +54 -0
  43. package/src/transform/crop.js +251 -0
  44. package/src/transform/index.js +8 -0
  45. package/src/transform/normalize.js +83 -0
  46. package/src/transform/resize.js +99 -0
  47. 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 };