@browserless/screenshot 13.6.2 → 13.6.4

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@browserless/screenshot",
3
3
  "description": "Capture high-quality screenshots of websites with overlay support, device emulation, and automated image optimization.",
4
4
  "homepage": "https://browserless.js.org/#/?id=screenshoturl-options",
5
- "version": "13.6.2",
5
+ "version": "13.6.4",
6
6
  "main": "src/index.js",
7
7
  "author": {
8
8
  "email": "hello@microlink.io",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "@browserless/errors": "13.1.7",
36
- "@browserless/goto": "13.6.2",
36
+ "@browserless/goto": "13.6.4",
37
37
  "@kikobeats/content-type": "~1.0.4",
38
38
  "@kikobeats/time-span": "~1.0.12",
39
39
  "automad-prism-themes": "~0.3.7",
@@ -52,7 +52,7 @@
52
52
  "svg-gradient": "~1.0.4"
53
53
  },
54
54
  "devDependencies": {
55
- "@browserless/test": "13.6.2",
55
+ "@browserless/test": "13.6.4",
56
56
  "ava": "5"
57
57
  },
58
58
  "engines": {
@@ -71,5 +71,5 @@
71
71
  "timeout": "2m",
72
72
  "workerThreads": false
73
73
  },
74
- "gitHead": "7c7c6fee9e82050887d5499ed9dc3bac16c88c42"
74
+ "gitHead": "c9d9abe1af82ce0ddf4ce1b6e8fe500c1b58532a"
75
75
  }
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ const waitForPrism = require('./pretty')
10
10
  const prettyTimeSpan = require('./time-span')
11
11
  const overlay = require('./overlay')
12
12
  const { waitForDomStability, resolveWaitForDom, DEFAULT_WAIT_FOR_DOM } = require('./wait-for-dom')
13
+ const { waitForReady } = require('./wait-for-ready')
13
14
 
14
15
  const timeSpan = require('@kikobeats/time-span')()
15
16
 
@@ -277,4 +278,5 @@ module.exports.captureWithNavigationRetry = captureWithNavigationRetry
277
278
  module.exports.isWhiteScreenshot = isWhiteScreenshot
278
279
  module.exports.waitForDomStability = waitForDomStability
279
280
  module.exports.resolveWaitForDom = resolveWaitForDom
281
+ module.exports.waitForReady = waitForReady
280
282
  module.exports.SCREENSHOT_DEFAULT_OPTS = SCREENSHOT_DEFAULT_OPTS
@@ -0,0 +1,276 @@
1
+ 'use strict'
2
+
3
+ // Navigation-tolerant readiness gate. Resolves once the page has been visually
4
+ // quiet — document height stable, every image decoded, `readyState` complete —
5
+ // for `quietMs`, bounded by `timeout`. Unlike a screenshot poll it takes no
6
+ // screenshots (which are expensive under software rendering, e.g. the GPU-less
7
+ // fleet's llvmpipe) and it survives a client-side navigation: when the
8
+ // execution context is destroyed mid-check the quiet window resets and polling
9
+ // continues instead of throwing (SPAs re-commit their own URL after load).
10
+
11
+ const { setTimeout: sleep } = require('node:timers/promises')
12
+ const { isContextDestroyed } = require('@browserless/errors')
13
+
14
+ // Evaluated in-page: a cheap snapshot of the paint/settle signals. `images`
15
+ // counts only images expected to settle: a lazy image well below the viewport
16
+ // sits outside Chromium's lazy-load fetch threshold and never starts loading
17
+ // without a scroll, so counting it could only stall the gate into its timeout.
18
+ // `decoded` counts every counted image that finished loading — a broken `<img>`
19
+ // counts too, so it can't stall the settle gate — while `painted` counts only
20
+ // images a screenshot would actually see: successfully decoded, rendered at a
21
+ // visible size (a 16×16 box or larger, so a tracking pixel doesn't count),
22
+ // inside the viewport, and not hidden via CSS — so a blank shell can't pass for
23
+ // content. `text` is the equivalent paint signal for imageless pages: visible
24
+ // characters inside the viewport, counted up to 200 (the threshold consumers
25
+ // rely on) and skipping text whose color matches its effective background
26
+ // (invisible on capture), and `fonts` reports whether webfonts finished
27
+ // loading — during a `font-display: block` period text renders invisible,
28
+ // exactly when a capture would be white. `covered` reports whether the counted
29
+ // content hides behind an opaque viewport-covering layer (a fixed white
30
+ // loading overlay passes every DOM signal while a capture stays white):
31
+ // hit-testing catches interactive overlays, and a root-level scan catches
32
+ // `pointer-events: none` layers hit-testing can't see. `viewport` is the
33
+ // in-page viewport height, so consumers can compare it against `height`
34
+ // without relying on `page.viewport()`, which is null under
35
+ // `defaultViewport: null`.
36
+ const snapshot = () => {
37
+ const vw = window.innerWidth || document.documentElement.clientWidth
38
+ const vh = window.innerHeight || document.documentElement.clientHeight
39
+
40
+ // Computed colors resolve to `rgb()`/`rgba()`; anything else is unknown.
41
+ const parseColor = value => {
42
+ const match = /rgba?\(([^)]+)\)/.exec(value || '')
43
+ if (!match) return null
44
+ const parts = match[1].split(',').map(parseFloat)
45
+ return { r: parts[0], g: parts[1], b: parts[2], a: parts.length === 4 ? parts[3] : 1 }
46
+ }
47
+
48
+ // First opaque background up the ancestor chain; the canvas default (white)
49
+ // when every ancestor is transparent.
50
+ const effectiveBackground = el => {
51
+ for (let node = el; node; node = node.parentElement) {
52
+ const color = parseColor(window.getComputedStyle(node).backgroundColor)
53
+ if (color && color.a >= 0.99) return color
54
+ }
55
+ return { r: 255, g: 255, b: 255, a: 1 }
56
+ }
57
+
58
+ const sameColor = (a, b) =>
59
+ Math.abs(a.r - b.r) < 10 && Math.abs(a.g - b.g) < 10 && Math.abs(a.b - b.b) < 10
60
+
61
+ // A layer only blanks a capture when it is itself painted solid: visible,
62
+ // full opacity, and an opaque background color or a background image.
63
+ const isOpaqueLayer = el => {
64
+ const style = window.getComputedStyle(el)
65
+ if (style.visibility === 'hidden' || parseFloat(style.opacity) < 0.99) return false
66
+ const background = parseColor(style.backgroundColor)
67
+ return (background && background.a >= 0.99) || style.backgroundImage !== 'none'
68
+ }
69
+
70
+ const coversViewport = el => {
71
+ const rect = el.getBoundingClientRect()
72
+ const width = Math.min(rect.right, vw) - Math.max(rect.left, 0)
73
+ const height = Math.min(rect.bottom, vh) - Math.max(rect.top, 0)
74
+ return width > 0 && height > 0 && width * height >= vw * vh * 0.9
75
+ }
76
+
77
+ // Up to a handful of counted content samples — enough to hit-test without
78
+ // turning the snapshot into a layout storm. The clamp keeps each probe point
79
+ // inside the viewport; the rect checks below guarantee it stays inside the
80
+ // sampled content's own box.
81
+ const contentPoints = []
82
+ const samplePoint = (el, rect) => {
83
+ if (contentPoints.length >= 4) return
84
+ contentPoints.push({
85
+ el,
86
+ x: Math.max(0, Math.min(vw - 1, rect.left + rect.width / 2)),
87
+ y: Math.max(0, Math.min(vh - 1, rect.top + rect.height / 2))
88
+ })
89
+ }
90
+
91
+ const imgs = document.images
92
+ let images = 0
93
+ let decoded = 0
94
+ let painted = 0
95
+ for (let i = 0; i < imgs.length; i++) {
96
+ const img = imgs[i]
97
+ if (!img.complete) {
98
+ // Two viewports of headroom stays inside the smallest fetch threshold
99
+ // Chromium uses for lazy images (~1250px on fast connections), so an
100
+ // undecoded lazy image within it is loading and worth waiting for;
101
+ // beyond it, the fetch never starts.
102
+ if (img.loading === 'lazy' && img.getBoundingClientRect().top > vh * 2) continue
103
+ images++
104
+ continue
105
+ }
106
+ images++
107
+ decoded++
108
+ if (img.naturalWidth === 0) continue
109
+ const rect = img.getBoundingClientRect()
110
+ if (rect.width * rect.height < 256) continue
111
+ if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= vh || rect.left >= vw) continue
112
+ if (
113
+ typeof img.checkVisibility === 'function' &&
114
+ !img.checkVisibility({ visibilityProperty: true, opacityProperty: true })
115
+ ) {
116
+ continue
117
+ }
118
+ painted++
119
+ samplePoint(img, rect)
120
+ }
121
+ // Counting stops at 200 chars, so a text-heavy page costs a handful of nodes,
122
+ // not a full DOM walk; only a near-blank shell walks every text node.
123
+ let text = 0
124
+ const walker = document.createTreeWalker(
125
+ document.body || document.documentElement,
126
+ window.NodeFilter.SHOW_TEXT
127
+ )
128
+ while (text < 200) {
129
+ const node = walker.nextNode()
130
+ if (!node) break
131
+ const value = node.nodeValue.trim()
132
+ if (!value) continue
133
+ const el = node.parentElement
134
+ if (!el) continue
135
+ const tag = el.tagName
136
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') continue
137
+ if (
138
+ typeof el.checkVisibility === 'function' &&
139
+ !el.checkVisibility({ visibilityProperty: true, opacityProperty: true })
140
+ ) {
141
+ continue
142
+ }
143
+ const range = document.createRange()
144
+ range.selectNodeContents(node)
145
+ const rect = range.getBoundingClientRect()
146
+ if (rect.width === 0 || rect.height === 0) continue
147
+ if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= vh || rect.left >= vw) continue
148
+ // White-on-white (or any color-on-same-color) text passes every geometry
149
+ // check while a capture shows nothing: don't count it as painted text.
150
+ const color = parseColor(window.getComputedStyle(el).color)
151
+ if (color && (color.a < 0.05 || sameColor(color, effectiveBackground(el)))) continue
152
+ text += value.length
153
+ samplePoint(el, rect)
154
+ }
155
+
156
+ // Is this content sample's paint hidden behind an unrelated opaque,
157
+ // viewport-covering layer? Walk up from the hit-tested element: anything
158
+ // related to the sample (itself, an ancestor, a descendant) paints with it,
159
+ // and the walk stops at the first common ancestor — an ancestor's background
160
+ // always paints below its own descendants.
161
+ const coveredAt = ({ el, x, y }) => {
162
+ const top = document.elementFromPoint(x, y)
163
+ if (!top) return false
164
+ for (let node = top; node && node !== document.documentElement; node = node.parentElement) {
165
+ if (node === el || node.contains(el) || el.contains(node)) return false
166
+ if (coversViewport(node) && isOpaqueLayer(node)) return true
167
+ }
168
+ return false
169
+ }
170
+
171
+ // Only pages the fast path could trust get the (layout-forcing) cover check.
172
+ let covered = false
173
+ if (painted > 0 || text >= 200) {
174
+ covered = contentPoints.some(coveredAt)
175
+ // `elementFromPoint` skips `pointer-events: none` elements, exactly how
176
+ // fading loading overlays are styled — scan root-level layers for one.
177
+ // Overlays mount as direct children of <body>; a deeper scan would cost a
178
+ // full styled DOM walk on every poll for a marginal case.
179
+ if (!covered && document.body) {
180
+ const layers = document.body.children
181
+ for (let i = 0; i < layers.length && !covered; i++) {
182
+ const layer = layers[i]
183
+ const style = window.getComputedStyle(layer)
184
+ if (style.pointerEvents !== 'none') continue
185
+ if (
186
+ style.position !== 'fixed' &&
187
+ style.position !== 'absolute' &&
188
+ style.position !== 'sticky'
189
+ ) {
190
+ continue
191
+ }
192
+ if (contentPoints.some(point => layer.contains(point.el))) continue
193
+ if (coversViewport(layer) && isOpaqueLayer(layer)) covered = true
194
+ }
195
+ }
196
+ }
197
+
198
+ return {
199
+ height: document.documentElement.scrollHeight,
200
+ viewport: vh,
201
+ images,
202
+ decoded,
203
+ painted,
204
+ text,
205
+ covered,
206
+ fonts: !document.fonts || document.fonts.status === 'loaded',
207
+ complete: document.readyState === 'complete'
208
+ }
209
+ }
210
+
211
+ // 300ms of held quiet plus one 150ms poll to prove height stability puts the
212
+ // gate's floor at ~450ms. The hold guards against lulls (a page momentarily
213
+ // stable between async chunks); height stability + all images decoded +
214
+ // `readyState === 'complete'` carry most of the settle signal, so a longer
215
+ // hold buys little — below ~300ms it would start trusting coincidences.
216
+ const waitForReady = async (page, { timeout, quietMs = 300, poll = 150 } = {}) => {
217
+ if (!Number.isFinite(timeout)) throw new TypeError('timeout must be a finite number')
218
+ // The quiet window must fit within the budget with room to observe it, or the
219
+ // gate could never satisfy its own requirement and would always time out.
220
+ quietMs = Math.min(quietMs, Math.floor(timeout / 2))
221
+ const deadline = Date.now() + timeout
222
+ // Never sleep past the deadline: with a poll larger than the remaining
223
+ // budget, a full-length sleep would overshoot the timeout and steal time
224
+ // from whatever shares the caller's budget (the blank-SPA screenshot poll).
225
+ const nextPoll = () => sleep(Math.min(poll, Math.max(0, deadline - Date.now())))
226
+ let lastHeight = -1
227
+ let quietSince = 0
228
+ let resets = 0
229
+ let last = {
230
+ height: 0,
231
+ viewport: 0,
232
+ images: 0,
233
+ decoded: 0,
234
+ painted: 0,
235
+ text: 0,
236
+ covered: false,
237
+ fonts: false,
238
+ complete: false
239
+ }
240
+
241
+ while (Date.now() < deadline) {
242
+ let snap
243
+ try {
244
+ snap = await page.evaluate(snapshot)
245
+ } catch (error) {
246
+ // Only a client-side navigation tearing down the execution context is
247
+ // absorbed: reset the quiet window and keep polling. Any other evaluate
248
+ // failure is a real error and surfaces instead of masquerading as a
249
+ // timeout.
250
+ if (!isContextDestroyed(error)) throw error
251
+ resets++
252
+ lastHeight = -1
253
+ quietSince = 0
254
+ await nextPoll()
255
+ continue
256
+ }
257
+
258
+ last = snap
259
+ const imagesDecoded = snap.images === 0 || snap.decoded >= snap.images
260
+ const quiet = snap.complete && imagesDecoded && snap.height === lastHeight
261
+
262
+ if (quiet) {
263
+ if (quietSince === 0) quietSince = Date.now()
264
+ if (Date.now() - quietSince >= quietMs) return { ...snap, resets, timedOut: false }
265
+ } else {
266
+ quietSince = 0
267
+ lastHeight = snap.height
268
+ }
269
+
270
+ await nextPoll()
271
+ }
272
+
273
+ return { ...last, resets, timedOut: true }
274
+ }
275
+
276
+ module.exports = { waitForReady }