@afixt/screenshot-utils 1.0.0 → 1.1.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.
@@ -1 +1 @@
1
- {"version":3,"file":"element.d.ts","sourceRoot":"","sources":["../../../src/capture/element.js"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;GAiBG;AACH,wCAJW,OAAO,qBAAqB,EAAE,WAAW,YACzC,OAAO,qBAAqB,EAAE,qBAAqB,GACjD,OAAO,CAAC,OAAO,qBAAqB,EAAE,oBAAoB,CAAC,CA+GvE"}
1
+ {"version":3,"file":"element.d.ts","sourceRoot":"","sources":["../../../src/capture/element.js"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;GAiBG;AACH,wCAJW,OAAO,qBAAqB,EAAE,WAAW,YACzC,OAAO,qBAAqB,EAAE,qBAAqB,GACjD,OAAO,CAAC,OAAO,qBAAqB,EAAE,oBAAoB,CAAC,CA4HvE"}
@@ -1 +1 @@
1
- {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../../../src/capture/page.js"],"names":[],"mappings":"AAKA;;;;;;;;;;;GAWG;AACH,qCAJW,OAAO,qBAAqB,EAAE,WAAW,YACzC,OAAO,qBAAqB,EAAE,cAAc,GAC1C,OAAO,CAAC,OAAO,qBAAqB,EAAE,aAAa,CAAC,CAkFhE"}
1
+ {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../../../src/capture/page.js"],"names":[],"mappings":"AAKA;;;;;;;;;;;GAWG;AACH,qCAJW,OAAO,qBAAqB,EAAE,WAAW,YACzC,OAAO,qBAAqB,EAAE,cAAc,GAC1C,OAAO,CAAC,OAAO,qBAAqB,EAAE,aAAa,CAAC,CA0EhE"}
@@ -1 +1 @@
1
- {"version":3,"file":"settle.d.ts","sourceRoot":"","sources":["../../../src/capture/settle.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AACH,uCAJW,OAAO,qBAAqB,EAAE,WAAW,YACzC,OAAO,qBAAqB,EAAE,oBAAoB,GAChD,OAAO,CAAC,OAAO,qBAAqB,EAAE,eAAe,CAAC,CAqBlE"}
1
+ {"version":3,"file":"settle.d.ts","sourceRoot":"","sources":["../../../src/capture/settle.js"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AACH,uCAJW,OAAO,qBAAqB,EAAE,WAAW,YACzC,OAAO,qBAAqB,EAAE,oBAAoB,GAChD,OAAO,CAAC,OAAO,qBAAqB,EAAE,eAAe,CAAC,CA2BlE"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afixt/screenshot-utils",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Screenshot capture, transform, compose, tile, and redact utilities for the AFixt/Revenant accessibility platform",
5
5
  "type": "commonjs",
6
6
  "main": "./dist/index.js",
@@ -83,7 +83,7 @@
83
83
  "access": "restricted"
84
84
  },
85
85
  "engines": {
86
- "node": ">=20.0.0"
86
+ "node": ">=20.9.0"
87
87
  },
88
88
  "keywords": [
89
89
  "screenshot",
@@ -101,7 +101,7 @@
101
101
  "revenant"
102
102
  ],
103
103
  "dependencies": {
104
- "sharp": "0.34.5"
104
+ "sharp": "^0.35.3"
105
105
  },
106
106
  "peerDependencies": {
107
107
  "@napi-rs/canvas": "^0.1.0",
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { CaptureTimeoutError } = require('../errors/index.js');
4
+ const { waitForStable } = require('./settle.js');
4
5
 
5
6
  /**
6
7
  * Capture a single element by selector or xpath.
@@ -31,6 +32,7 @@ async function captureElement(adapter, options = {}) {
31
32
  timeout = 5000,
32
33
  format = 'png',
33
34
  quality = 90,
35
+ waitForStable: settleOpts = false,
34
36
  events = null,
35
37
  } = options;
36
38
 
@@ -77,6 +79,7 @@ async function captureElement(adapter, options = {}) {
77
79
  }
78
80
 
79
81
  let viewportRect = lookup.viewportRect;
82
+ let documentRect = lookup.documentRect;
80
83
  if (scrollIntoView) {
81
84
  await adapter.scrollTo({
82
85
  x: 0,
@@ -93,6 +96,17 @@ async function captureElement(adapter, options = {}) {
93
96
  }
94
97
  }
95
98
 
99
+ // Optional render-stability wait. Critically, we re-read the element rect
100
+ // *after* settling: late reflow (lazy images, web fonts, JS layout) is the
101
+ // cause of blank/wrong-region element crops, so coordinates captured before
102
+ // settle cannot be trusted for the clip.
103
+ const settled = await settleAndReread(adapter, { selector, xpath, timeout, settleOpts });
104
+ const settleMs = settled.settleMs;
105
+ if (settled.rect) {
106
+ viewportRect = settled.rect.viewportRect;
107
+ documentRect = settled.rect.documentRect;
108
+ }
109
+
96
110
  const clip = {
97
111
  x: Math.max(0, Math.round(viewportRect.x - padding)),
98
112
  y: Math.max(0, Math.round(viewportRect.y - padding)),
@@ -117,12 +131,12 @@ async function captureElement(adapter, options = {}) {
117
131
  format,
118
132
  bytes,
119
133
  page: metrics,
120
- timing: { settleMs: 0, captureMs, encodeMs: 0, totalMs },
134
+ timing: { settleMs, captureMs, encodeMs: 0, totalMs },
121
135
  warnings: [],
122
136
  element: {
123
137
  selector: selector ?? '',
124
138
  xpath: xpath ?? '',
125
- documentRect: lookup.documentRect,
139
+ documentRect,
126
140
  viewportRect,
127
141
  verified: Boolean(snippet),
128
142
  },
@@ -131,6 +145,35 @@ async function captureElement(adapter, options = {}) {
131
145
  return result;
132
146
  }
133
147
 
148
+ /**
149
+ * Run waitForStable (when requested) and re-read the element rect afterwards.
150
+ * Kept out of `captureElement` so the main flow stays flat.
151
+ *
152
+ * @param {import('../types/index.d.ts').PageAdapter} adapter
153
+ * @param {{selector?: string, xpath?: string, timeout: number, settleOpts: import('../types/index.d.ts').WaitForStableOptions|boolean}} ctx
154
+ * @returns {Promise<{settleMs: number, rect: {viewportRect: import('../types/index.d.ts').Rect, documentRect: import('../types/index.d.ts').Rect}|null}>}
155
+ */
156
+ async function settleAndReread(adapter, { selector, xpath, timeout, settleOpts }) {
157
+ if (!settleOpts) {
158
+ return { settleMs: 0, rect: null };
159
+ }
160
+ const settleStart = nowMs();
161
+ await waitForStable(adapter, settleOpts === true ? {} : settleOpts);
162
+ const settleMs = nowMs() - settleStart;
163
+ const reread = await withTimeout(
164
+ adapter.evaluate(findElementInPage, { selector, xpath }),
165
+ timeout,
166
+ { operation: 'adapter.evaluate(findElement after settle)' }
167
+ );
168
+ if (reread && reread.found) {
169
+ return {
170
+ settleMs,
171
+ rect: { viewportRect: reread.viewportRect, documentRect: reread.documentRect },
172
+ };
173
+ }
174
+ return { settleMs, rect: null };
175
+ }
176
+
134
177
  /**
135
178
  * Page-context element lookup. Shipped to the browser via adapter.evaluate.
136
179
  * Coverage suppressed for the same reason as `settle.js#runInPage` —
@@ -33,20 +33,12 @@ async function capturePage(adapter, options = {}) {
33
33
  emit(events, 'capture:start', { format, fullPage });
34
34
 
35
35
  let settleMs = 0;
36
- const warnings = [];
36
+ let warnings = [];
37
37
  if (settleOpts) {
38
38
  const settleStart = nowMs();
39
39
  const report = await waitForStable(adapter, settleOpts === true ? {} : settleOpts);
40
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
- }
41
+ warnings = settleWarnings(report);
50
42
  }
51
43
 
52
44
  const metrics = await adapter.getMetrics();
@@ -97,6 +89,35 @@ async function capturePage(adapter, options = {}) {
97
89
  return result;
98
90
  }
99
91
 
92
+ /**
93
+ * Translate a StabilityReport into non-fatal warning strings.
94
+ *
95
+ * @param {import('../types/index.d.ts').StabilityReport} report
96
+ * @returns {string[]}
97
+ */
98
+ function settleWarnings(report) {
99
+ const warnings = [];
100
+ if (report.fonts === 'timeout') {
101
+ warnings.push('fonts did not settle within maxWait');
102
+ }
103
+ if (report.animations === 'timeout') {
104
+ warnings.push('animations did not settle within maxWait');
105
+ }
106
+ if (report.images.failed > 0) {
107
+ warnings.push(`${report.images.failed} image(s) failed to load`);
108
+ }
109
+ if (report.backgroundImages && report.backgroundImages.failed > 0) {
110
+ warnings.push(`${report.backgroundImages.failed} background image(s) failed to load`);
111
+ }
112
+ if (report.network === 'timeout') {
113
+ warnings.push('network did not reach idle within maxWait');
114
+ }
115
+ if (report.layoutStable === 'timeout') {
116
+ warnings.push('layout did not stabilize within maxWait');
117
+ }
118
+ return warnings;
119
+ }
120
+
100
121
  /**
101
122
  * Map the user's desired output format to the format the browser should
102
123
  * produce. Returns either the same format (no re-encode) or 'png' (when
@@ -16,7 +16,10 @@ async function waitForStable(adapter, options = {}) {
16
16
  const {
17
17
  fonts = true,
18
18
  images = true,
19
+ backgroundImages = true,
19
20
  animations = 'reduce',
21
+ network = 'idle',
22
+ layoutStableMs = 250,
20
23
  maxWait = 5000,
21
24
  idleMs = 200,
22
25
  } = options;
@@ -25,7 +28,10 @@ async function waitForStable(adapter, options = {}) {
25
28
  const report = await adapter.evaluate(runInPage, {
26
29
  fonts,
27
30
  images,
31
+ backgroundImages,
28
32
  animations,
33
+ network,
34
+ layoutStableMs,
29
35
  maxWait,
30
36
  idleMs,
31
37
  });
@@ -39,7 +45,7 @@ async function waitForStable(adapter, options = {}) {
39
45
  * (no closures over Node-side state) and only use `globalThis.*` so
40
46
  * eslint's no-undef passes.
41
47
  *
42
- * @param {{fonts: boolean, images: boolean, animations: 'reduce'|'wait'|'ignore', maxWait: number, idleMs: number}} opts
48
+ * @param {{fonts: boolean, images: boolean, backgroundImages: boolean, animations: 'reduce'|'wait'|'ignore', network: 'idle'|'ignore', layoutStableMs: number, maxWait: number, idleMs: number}} opts
43
49
  * @returns {Promise<Omit<import('../types/index.d.ts').StabilityReport, 'totalMs'>>}
44
50
  */
45
51
  /* istanbul ignore next -- runs in the browser via adapter.evaluate(); must NOT
@@ -51,6 +57,9 @@ async function runInPage(opts) {
51
57
  const report = {
52
58
  fonts: 'skipped',
53
59
  images: { total: 0, loaded: 0, failed: 0 },
60
+ backgroundImages: 'skipped',
61
+ network: 'skipped',
62
+ layoutStable: 'skipped',
54
63
  animations: 'skipped',
55
64
  };
56
65
 
@@ -115,6 +124,129 @@ async function runInPage(opts) {
115
124
  await Promise.all(imgs.map(settleOneImage));
116
125
  };
117
126
 
127
+ // Collect unique, non-data CSS background-image URLs, bounded for perf.
128
+ const collectBackgroundUrls = () => {
129
+ const CAP = 200; // large DOMs otherwise scan tens of thousands of nodes
130
+ const urls = new Set();
131
+ const els = doc.querySelectorAll('*');
132
+ for (let i = 0; i < els.length && urls.size < CAP; i += 1) {
133
+ const bg = globalThis.getComputedStyle(els[i]).backgroundImage;
134
+ if (!bg || bg === 'none') {
135
+ continue;
136
+ }
137
+ for (const match of bg.matchAll(/url\((['"]?)([^'")]+)\1\)/g)) {
138
+ const url = match[2];
139
+ if (url && !url.startsWith('data:')) {
140
+ urls.add(url);
141
+ }
142
+ }
143
+ }
144
+ return Array.from(urls).slice(0, CAP);
145
+ };
146
+
147
+ // Load one URL and tally it. Listeners are attached before `src` is set, so
148
+ // even a cached resource fires 'load' on a freshly-created Image — no
149
+ // synchronous complete-branch is needed (which would risk a double count).
150
+ const settleOneBackgroundUrl = url =>
151
+ new Promise(resolve => {
152
+ const img = new globalThis.Image();
153
+ const onload = () => {
154
+ report.backgroundImages.loaded += 1;
155
+ resolve();
156
+ };
157
+ const onerror = () => {
158
+ report.backgroundImages.failed += 1;
159
+ resolve();
160
+ };
161
+ img.addEventListener('load', onload, { once: true });
162
+ img.addEventListener('error', onerror, { once: true });
163
+ img.src = url;
164
+ });
165
+
166
+ // Heroes and section backgrounds are commonly CSS background-images, not
167
+ // <img>. A capture taken before they paint reads as blank even though
168
+ // fonts.ready has resolved — this check waits for those URLs to load.
169
+ const settleBackgroundImages = async () => {
170
+ if (!opts.backgroundImages || !doc) {
171
+ return;
172
+ }
173
+ const list = collectBackgroundUrls();
174
+ report.backgroundImages = { total: list.length, loaded: 0, failed: 0 };
175
+ if (list.length === 0) {
176
+ return;
177
+ }
178
+ await withTimeout(
179
+ Promise.all(list.map(settleOneBackgroundUrl)),
180
+ opts.maxWait,
181
+ 'backgroundImages'
182
+ ).catch(() => {
183
+ // timeout: leave loaded/failed at whatever completed so far
184
+ });
185
+ };
186
+
187
+ // Approximate a networkidle gate without owning navigation: wait until the
188
+ // document is 'complete' and no new resource-timing entries appear for a
189
+ // short quiet window. Bounded by maxWait.
190
+ //
191
+ // Known limitation: getEntriesByType('resource') is capped by the resource
192
+ // timing buffer (default 250 entries). On a page with >250 resources the
193
+ // count plateaus and this can report 'idle' early. It errs toward idle
194
+ // (settle is less thorough, never a hang), and the image/background/layout
195
+ // checks still run, so the practical impact is small.
196
+ const settleNetwork = async () => {
197
+ if (opts.network !== 'idle' || !doc) {
198
+ return;
199
+ }
200
+ const perf = globalThis.performance;
201
+ const countResources = () =>
202
+ perf && perf.getEntriesByType ? perf.getEntriesByType('resource').length : 0;
203
+ const quietMs = 200;
204
+ const deadline = Date.now() + opts.maxWait;
205
+ let lastCount = countResources();
206
+ let lastChange = Date.now();
207
+ while (Date.now() < deadline) {
208
+ const count = countResources();
209
+ if (count !== lastCount) {
210
+ lastCount = count;
211
+ lastChange = Date.now();
212
+ }
213
+ if (doc.readyState === 'complete' && Date.now() - lastChange >= quietMs) {
214
+ report.network = 'idle';
215
+ return;
216
+ }
217
+ await new Promise(resolve => {
218
+ setTimeout(resolve, 50);
219
+ });
220
+ }
221
+ report.network = 'timeout';
222
+ };
223
+
224
+ // Late reflow is what leaves element crops on stale coordinates. Require the
225
+ // document height to hold steady for layoutStableMs before declaring stable.
226
+ const settleLayout = async () => {
227
+ if (!opts.layoutStableMs || opts.layoutStableMs <= 0 || !doc) {
228
+ return;
229
+ }
230
+ const el = doc.documentElement;
231
+ const deadline = Date.now() + opts.maxWait;
232
+ let lastHeight = el.scrollHeight;
233
+ let stableSince = Date.now();
234
+ while (Date.now() < deadline) {
235
+ await new Promise(resolve => {
236
+ setTimeout(resolve, 50);
237
+ });
238
+ const height = el.scrollHeight;
239
+ if (height !== lastHeight) {
240
+ lastHeight = height;
241
+ stableSince = Date.now();
242
+ } else if (Date.now() - stableSince >= opts.layoutStableMs) {
243
+ report.layoutStable = 'stable';
244
+ return;
245
+ }
246
+ }
247
+ report.layoutStable = 'timeout';
248
+ };
249
+
118
250
  const settleAnimations = async () => {
119
251
  if (opts.animations === 'ignore' || !doc) {
120
252
  return;
@@ -167,9 +299,12 @@ async function runInPage(opts) {
167
299
  });
168
300
  };
169
301
 
302
+ await settleNetwork();
170
303
  await settleFonts();
171
304
  await settleImages();
305
+ await settleBackgroundImages();
172
306
  await settleAnimations();
307
+ await settleLayout();
173
308
  await settleIdle();
174
309
 
175
310
  return report;
package/src/index.js CHANGED
@@ -41,7 +41,10 @@ const defaults = Object.freeze({
41
41
  waitForStable: Object.freeze({
42
42
  fonts: true,
43
43
  images: true,
44
+ backgroundImages: true,
44
45
  animations: 'reduce',
46
+ network: 'idle',
47
+ layoutStableMs: 250,
45
48
  maxWait: 5000,
46
49
  idleMs: 200,
47
50
  }),
@@ -53,11 +53,18 @@ export type Severity = 'critical' | 'serious' | 'moderate' | 'minor';
53
53
 
54
54
  export type Encoding = 'buffer' | 'stream';
55
55
  export type AnimationsPolicy = 'reduce' | 'wait' | 'ignore';
56
+ export type NetworkPolicy = 'idle' | 'ignore';
56
57
 
57
58
  export interface WaitForStableOptions {
58
59
  fonts?: boolean;
59
60
  images?: boolean;
61
+ /** Wait for CSS background-image URLs to load; default true. */
62
+ backgroundImages?: boolean;
60
63
  animations?: AnimationsPolicy;
64
+ /** Resource-timing quiescence gate; default 'idle'. */
65
+ network?: NetworkPolicy;
66
+ /** Require scrollHeight/target-rect steady for this long; 0 disables. Default 250. */
67
+ layoutStableMs?: number;
61
68
  /** ms; default 5000. */
62
69
  maxWait?: number;
63
70
  /** ms; default 200. */
@@ -150,6 +157,9 @@ export interface ResponsiveCaptureResult {
150
157
  export interface StabilityReport {
151
158
  fonts: 'ready' | 'timeout' | 'skipped';
152
159
  images: { total: number; loaded: number; failed: number };
160
+ backgroundImages: { total: number; loaded: number; failed: number } | 'skipped';
161
+ network: 'idle' | 'timeout' | 'skipped';
162
+ layoutStable: 'stable' | 'timeout' | 'skipped';
153
163
  animations: 'none' | 'reduced' | 'settled' | 'timeout' | 'skipped';
154
164
  totalMs: number;
155
165
  }