@afixt/screenshot-utils 0.9.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +20 -20
  3. package/dist/index.js +172 -16
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +172 -16
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/types/adapters/cdp.d.ts +28 -0
  8. package/dist/types/adapters/cdp.d.ts.map +1 -0
  9. package/dist/types/adapters/index.d.ts +5 -0
  10. package/dist/types/adapters/index.d.ts.map +1 -0
  11. package/dist/types/adapters/playwright.d.ts +28 -0
  12. package/dist/types/adapters/playwright.d.ts.map +1 -0
  13. package/dist/types/adapters/puppeteer.d.ts +20 -0
  14. package/dist/types/adapters/puppeteer.d.ts.map +1 -0
  15. package/dist/types/capture/element.d.ts +20 -0
  16. package/dist/types/capture/element.d.ts.map +1 -0
  17. package/dist/types/capture/index.d.ts +8 -0
  18. package/dist/types/capture/index.d.ts.map +1 -0
  19. package/dist/types/capture/long-page.d.ts +16 -0
  20. package/dist/types/capture/long-page.d.ts.map +1 -0
  21. package/dist/types/capture/page.d.ts +14 -0
  22. package/dist/types/capture/page.d.ts.map +1 -0
  23. package/dist/types/capture/responsive.d.ts +20 -0
  24. package/dist/types/capture/responsive.d.ts.map +1 -0
  25. package/dist/types/capture/settle.d.ts +14 -0
  26. package/dist/types/capture/settle.d.ts.map +1 -0
  27. package/dist/types/capture/viewport.d.ts +14 -0
  28. package/dist/types/capture/viewport.d.ts.map +1 -0
  29. package/dist/types/cli/index.d.ts +10 -0
  30. package/dist/types/cli/index.d.ts.map +1 -0
  31. package/dist/types/compose/annotate.d.ts +16 -0
  32. package/dist/types/compose/annotate.d.ts.map +1 -0
  33. package/dist/types/compose/diff.d.ts +15 -0
  34. package/dist/types/compose/diff.d.ts.map +1 -0
  35. package/dist/types/compose/heatmap.d.ts +17 -0
  36. package/dist/types/compose/heatmap.d.ts.map +1 -0
  37. package/dist/types/compose/index.d.ts +6 -0
  38. package/dist/types/compose/index.d.ts.map +1 -0
  39. package/dist/types/compose/theme.d.ts +17 -0
  40. package/dist/types/compose/theme.d.ts.map +1 -0
  41. package/dist/types/errors/adapter.d.ts +33 -0
  42. package/dist/types/errors/adapter.d.ts.map +1 -0
  43. package/dist/types/errors/base.d.ts +37 -0
  44. package/dist/types/errors/base.d.ts.map +1 -0
  45. package/dist/types/errors/capture.d.ts +109 -0
  46. package/dist/types/errors/capture.d.ts.map +1 -0
  47. package/dist/types/errors/compose.d.ts +35 -0
  48. package/dist/types/errors/compose.d.ts.map +1 -0
  49. package/dist/types/errors/index.d.ts +29 -0
  50. package/dist/types/errors/index.d.ts.map +1 -0
  51. package/dist/types/errors/optional.d.ts +23 -0
  52. package/dist/types/errors/optional.d.ts.map +1 -0
  53. package/dist/types/errors/redaction.d.ts +21 -0
  54. package/dist/types/errors/redaction.d.ts.map +1 -0
  55. package/dist/types/errors/tile.d.ts +29 -0
  56. package/dist/types/errors/tile.d.ts.map +1 -0
  57. package/dist/types/errors/transform.d.ts +35 -0
  58. package/dist/types/errors/transform.d.ts.map +1 -0
  59. package/dist/types/index.d.ts +58 -0
  60. package/dist/types/index.d.ts.map +1 -0
  61. package/dist/types/redact/index.d.ts +5 -0
  62. package/dist/types/redact/index.d.ts.map +1 -0
  63. package/dist/types/redact/policy.d.ts +29 -0
  64. package/dist/types/redact/policy.d.ts.map +1 -0
  65. package/dist/types/redact/redact.d.ts +29 -0
  66. package/dist/types/redact/redact.d.ts.map +1 -0
  67. package/dist/types/tile/dzi.d.ts +18 -0
  68. package/dist/types/tile/dzi.d.ts.map +1 -0
  69. package/dist/types/tile/index.d.ts +3 -0
  70. package/dist/types/tile/index.d.ts.map +1 -0
  71. package/dist/types/transform/convert.d.ts +13 -0
  72. package/dist/types/transform/convert.d.ts.map +1 -0
  73. package/dist/types/transform/crop.d.ts +33 -0
  74. package/dist/types/transform/crop.d.ts.map +1 -0
  75. package/dist/types/transform/index.d.ts +7 -0
  76. package/dist/types/transform/index.d.ts.map +1 -0
  77. package/dist/types/transform/normalize.d.ts +14 -0
  78. package/dist/types/transform/normalize.d.ts.map +1 -0
  79. package/dist/types/transform/resize.d.ts +14 -0
  80. package/dist/types/transform/resize.d.ts.map +1 -0
  81. package/package.json +5 -4
  82. package/src/capture/element.js +48 -6
  83. package/src/capture/page.js +31 -10
  84. package/src/capture/settle.js +139 -4
  85. package/src/index.js +3 -0
  86. package/src/types/index.d.ts +10 -0
@@ -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,17 +45,21 @@ 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
- /* v8 ignore start -- runs in the browser via adapter.evaluate(); not
46
- instrumentable by Node-side coverage. Exercised by settle-integration.test.js. */
51
+ /* istanbul ignore next -- runs in the browser via adapter.evaluate(); must NOT
52
+ be instrumented (injected counters don't exist in the page context) and is
53
+ exercised by settle-integration.test.js. */
47
54
  async function runInPage(opts) {
48
55
  const doc = globalThis.document;
49
56
  /** @type {Omit<import('../types/index.d.ts').StabilityReport, 'totalMs'>} */
50
57
  const report = {
51
58
  fonts: 'skipped',
52
59
  images: { total: 0, loaded: 0, failed: 0 },
60
+ backgroundImages: 'skipped',
61
+ network: 'skipped',
62
+ layoutStable: 'skipped',
53
63
  animations: 'skipped',
54
64
  };
55
65
 
@@ -114,6 +124,129 @@ async function runInPage(opts) {
114
124
  await Promise.all(imgs.map(settleOneImage));
115
125
  };
116
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
+
117
250
  const settleAnimations = async () => {
118
251
  if (opts.animations === 'ignore' || !doc) {
119
252
  return;
@@ -166,14 +299,16 @@ async function runInPage(opts) {
166
299
  });
167
300
  };
168
301
 
302
+ await settleNetwork();
169
303
  await settleFonts();
170
304
  await settleImages();
305
+ await settleBackgroundImages();
171
306
  await settleAnimations();
307
+ await settleLayout();
172
308
  await settleIdle();
173
309
 
174
310
  return report;
175
311
  }
176
- /* v8 ignore stop */
177
312
 
178
313
  function nowMs() {
179
314
  return Number(process.hrtime.bigint() / 1_000_000n);
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
  }