@percy/core 1.31.14-beta.2 → 1.31.14-beta.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.
@@ -0,0 +1,194 @@
1
+ // Closed-shadow capture helper. CLI-side only.
2
+ //
3
+ // External Percy SDK plugins (puppeteer-percy, playwright-percy,
4
+ // cypress-percy, selenium-chrome-percy) will get their own copy when
5
+ // SDK-side closed-shadow capture is added — that work is intentionally
6
+ // scoped to a separate change so this PR stays focused on the CLI path.
7
+ //
8
+ // Discovers closed shadow roots in the live page and exposes them to
9
+ // PercyDOM.serialize() via per-document `__percyClosedShadowRoots`
10
+ // WeakMaps that clone-dom.js reads through shadow-utils.getRuntime().
11
+ //
12
+ // Closed shadow roots are inaccessible from JavaScript
13
+ // (`element.shadowRoot === null`), but Chrome DevTools Protocol's DOM domain
14
+ // can pierce them. We get the full DOM tree with `pierce: true` (which also
15
+ // traverses iframe boundaries — closed shadow hosts inside iframes are
16
+ // captured by the same walk), collect every closed-shadow host/root pair,
17
+ // resolve both to JS object references via `DOM.resolveNode`, then call
18
+ // `Runtime.callFunctionOn` to write the mapping. The function body installs
19
+ // the WeakMap on the host's *own* `ownerDocument.defaultView` — so a host
20
+ // inside an iframe writes into the iframe's realm, where shadow-utils will
21
+ // later read it.
22
+ //
23
+ // Works for any caller that has a CDP session-like object exposing
24
+ // `send(method, params) => Promise`:
25
+ // - Puppeteer: `await page.target().createCDPSession()`
26
+ // - Playwright: `await context.newCDPSession(page)`
27
+ // - Selenium: `await driver.getDevTools()` (Chromium only)
28
+ // - Percy CLI: Percy's own session.send wrapper
29
+ //
30
+ // Side effect: temporarily enables and then disables the CDP `DOM` domain
31
+ // on the supplied session. Don't run concurrently with another `DOM`-domain
32
+ // consumer on the same session — the helper installs an in-flight guard
33
+ // against itself, but can't see other consumers.
34
+ //
35
+ // Limitation: captures the closed shadow roots present at the time of the
36
+ // call. Custom elements that lazy-attach a closed shadow root after this
37
+ // returns (e.g. inside `requestIdleCallback` or `IntersectionObserver`)
38
+ // won't be captured. The caller is responsible for waiting until the page
39
+ // is settled before invoking.
40
+ //
41
+ // Returns the number of closed shadow roots successfully exposed (0 if none,
42
+ // -1 on top-level error). Per-pair errors are swallowed and surfaced via the
43
+ // optional `log` callback — closed-shadow capture is best-effort and must
44
+ // never break a snapshot run.
45
+
46
+ const DEFAULT_LOG = () => {};
47
+
48
+ // Mirrors HARD_MAX_IFRAME_DEPTH from serialize-frames so every recursive
49
+ // walk in the capture pipeline shares the same ceiling. Counted only across
50
+ // shadow / iframe boundary crossings — not plain children — otherwise a
51
+ // normal deep DOM (html → body → div → … → custom-element) would burn
52
+ // through the budget before reaching any shadow host.
53
+ const MAX_SHADOW_DEPTH = 10;
54
+
55
+ // Bound concurrent CDP messages so we don't flood a session with hundreds
56
+ // of in-flight resolveNode/callFunctionOn calls when a page has many
57
+ // closed shadow hosts. Phase 1 (resolve) issues 2 calls per pair, so peak
58
+ // in-flight there is 2 * CDP_BATCH_SIZE; phase 2 (stamp) is 1 per pair so
59
+ // peak is exactly CDP_BATCH_SIZE. 8 chosen as a conservative default that
60
+ // keeps both phases well under typical CDP message-queue depths.
61
+ const CDP_BATCH_SIZE = 8;
62
+
63
+ // The function body that installs the WeakMap and writes the host→shadow
64
+ // pair. Runs inside Runtime.callFunctionOn with the host as `this`, so
65
+ // `this.ownerDocument.defaultView` is the host's *own* realm — the iframe's
66
+ // window when the host is inside an iframe.
67
+ //
68
+ // IMPORTANT: this is a string (required by Runtime.callFunctionOn) AND it
69
+ // is intentionally ES5 — it executes in the page's realm, which may be any
70
+ // browser/JS target the page itself targets. Don't "modernize" with arrow
71
+ // functions, let/const, or optional chaining.
72
+ const STAMP_FUNCTION = 'function(shadowRoot) {' + ' var w = this.ownerDocument && this.ownerDocument.defaultView;' + ' if (!w) return;' + ' if (!w.__percyClosedShadowRoots) w.__percyClosedShadowRoots = new WeakMap();' + ' w.__percyClosedShadowRoots.set(this, shadowRoot);' + '}';
73
+
74
+ // Marker for the in-flight guard — prevents concurrent invocations on the
75
+ // same session from racing each other's DOM.enable / DOM.disable lifecycle.
76
+ // Module-local Symbol (not Symbol.for) so it can't collide with any other
77
+ // global registry entry.
78
+ const IN_FLIGHT = Symbol('percy.closedShadow.inFlight');
79
+ export async function exposeClosedShadowRoots(cdp, log = DEFAULT_LOG) {
80
+ if (!cdp || typeof cdp.send !== 'function') return -1;
81
+ if (cdp[IN_FLIGHT]) {
82
+ log('Skipping concurrent closed-shadow CDP discovery on the same session');
83
+ return -1;
84
+ }
85
+ cdp[IN_FLIGHT] = true;
86
+ let domEnabled = false;
87
+ try {
88
+ await cdp.send('DOM.enable');
89
+ domEnabled = true;
90
+ const {
91
+ root
92
+ } = await cdp.send('DOM.getDocument', {
93
+ depth: -1,
94
+ pierce: true
95
+ });
96
+ const closedPairs = [];
97
+ walkCDPNodes(root, closedPairs);
98
+ if (closedPairs.length === 0) {
99
+ return 0;
100
+ }
101
+ log(`Found ${closedPairs.length} closed shadow root(s), exposing via CDP`);
102
+
103
+ // Phase 1: resolve every backendNodeId → objectId in parallel batches.
104
+ const resolved = [];
105
+ for (let i = 0; i < closedPairs.length; i += CDP_BATCH_SIZE) {
106
+ const slice = closedPairs.slice(i, i + CDP_BATCH_SIZE);
107
+ const out = await Promise.all(slice.map(async pair => {
108
+ try {
109
+ const [hostRes, shadowRes] = await Promise.all([cdp.send('DOM.resolveNode', {
110
+ backendNodeId: pair.hostBackendNodeId
111
+ }), cdp.send('DOM.resolveNode', {
112
+ backendNodeId: pair.shadowBackendNodeId
113
+ })]);
114
+ return {
115
+ hostObj: hostRes.object,
116
+ shadowObj: shadowRes.object,
117
+ pair
118
+ };
119
+ } catch (err) {
120
+ const msg = err && err.message ? err.message : err;
121
+ log(`Skipping a closed shadow pair: ${msg} (host=${pair.hostBackendNodeId}, shadow=${pair.shadowBackendNodeId})`);
122
+ return null;
123
+ }
124
+ }));
125
+ for (const entry of out) if (entry) resolved.push(entry);
126
+ }
127
+
128
+ // Phase 2: stamp the WeakMap (per-realm), also batched. Track real
129
+ // successes — earlier shapes returned closedPairs.length and overstated
130
+ // success when stamps failed.
131
+ let stamped = 0;
132
+ for (let i = 0; i < resolved.length; i += CDP_BATCH_SIZE) {
133
+ const slice = resolved.slice(i, i + CDP_BATCH_SIZE);
134
+ const results = await Promise.all(slice.map(({
135
+ hostObj,
136
+ shadowObj,
137
+ pair
138
+ }) => cdp.send('Runtime.callFunctionOn', {
139
+ functionDeclaration: STAMP_FUNCTION,
140
+ objectId: hostObj.objectId,
141
+ arguments: [{
142
+ objectId: shadowObj.objectId
143
+ }]
144
+ }).then(() => true).catch(err => {
145
+ const msg = err && err.message ? err.message : err;
146
+ log(`Skipping a closed shadow pair: ${msg} (host=${pair.hostBackendNodeId}, shadow=${pair.shadowBackendNodeId})`);
147
+ return false;
148
+ })));
149
+ for (const ok of results) if (ok) stamped++;
150
+ }
151
+ return stamped;
152
+ } catch (err) {
153
+ log(`Could not expose closed shadow roots via CDP: ${err && err.message ? err.message : err}`);
154
+ return -1;
155
+ } finally {
156
+ if (domEnabled) {
157
+ await cdp.send('DOM.disable').catch(disableErr => {
158
+ log(`DOM.disable failed during closed-shadow cleanup: ${disableErr && disableErr.message ? disableErr.message : disableErr}`);
159
+ });
160
+ }
161
+ delete cdp[IN_FLIGHT];
162
+ }
163
+ }
164
+
165
+ // Walk a DOM.getDocument tree (with pierce: true) collecting every
166
+ // closed-shadow host/root pair we encounter. `pierce: true` traverses both
167
+ // shadow boundaries and iframe `contentDocument` boundaries, so a single
168
+ // walk reaches closed shadow hosts inside nested iframes. Recursion is
169
+ // bounded at MAX_SHADOW_DEPTH levels — counted only across shadow/iframe
170
+ // boundary crossings, not plain children — so a deep ordinary DOM doesn't
171
+ // exhaust the budget before reaching its shadow hosts. Exported for tests.
172
+ export function walkCDPNodes(node, pairs, depth = 0) {
173
+ if (!node || depth >= MAX_SHADOW_DEPTH) return;
174
+ if (node.shadowRoots) {
175
+ for (const sr of node.shadowRoots) {
176
+ if (sr.shadowRootType === 'closed') {
177
+ pairs.push({
178
+ hostBackendNodeId: node.backendNodeId,
179
+ shadowBackendNodeId: sr.backendNodeId
180
+ });
181
+ }
182
+ // crossing a shadow boundary — increment depth
183
+ walkCDPNodes(sr, pairs, depth + 1);
184
+ }
185
+ }
186
+ if (node.children) {
187
+ // plain children — same realm, same depth
188
+ for (const child of node.children) walkCDPNodes(child, pairs, depth);
189
+ }
190
+ // pierce: true surfaces iframe content documents on the iframe node;
191
+ // crossing into the iframe's realm — increment depth.
192
+ if (node.contentDocument) walkCDPNodes(node.contentDocument, pairs, depth + 1);
193
+ }
194
+ export default exposeClosedShadowRoots;
package/dist/config.js CHANGED
@@ -151,6 +151,91 @@ export const configSchema = {
151
151
  sync: {
152
152
  type: 'boolean'
153
153
  },
154
+ readiness: {
155
+ type: 'object',
156
+ additionalProperties: false,
157
+ properties: {
158
+ preset: {
159
+ type: 'string',
160
+ enum: ['balanced', 'strict', 'fast', 'disabled']
161
+ },
162
+ stabilityWindowMs: {
163
+ type: 'integer',
164
+ minimum: 50,
165
+ maximum: 30000
166
+ },
167
+ jsIdleWindowMs: {
168
+ type: 'integer',
169
+ minimum: 50,
170
+ maximum: 30000
171
+ },
172
+ networkIdleWindowMs: {
173
+ type: 'integer',
174
+ minimum: 50,
175
+ maximum: 10000
176
+ },
177
+ timeoutMs: {
178
+ type: 'integer',
179
+ minimum: 1000,
180
+ maximum: 60000
181
+ },
182
+ domStability: {
183
+ type: 'boolean'
184
+ },
185
+ imageReady: {
186
+ type: 'boolean'
187
+ },
188
+ fontReady: {
189
+ type: 'boolean'
190
+ },
191
+ jsIdle: {
192
+ type: 'boolean'
193
+ },
194
+ readySelectors: {
195
+ type: 'array',
196
+ items: {
197
+ oneOf: [{
198
+ type: 'string'
199
+ }, {
200
+ type: 'object',
201
+ additionalProperties: false,
202
+ properties: {
203
+ css: {
204
+ type: 'string'
205
+ },
206
+ xpath: {
207
+ type: 'string'
208
+ }
209
+ }
210
+ }]
211
+ }
212
+ },
213
+ notPresentSelectors: {
214
+ type: 'array',
215
+ items: {
216
+ oneOf: [{
217
+ type: 'string'
218
+ }, {
219
+ type: 'object',
220
+ additionalProperties: false,
221
+ properties: {
222
+ css: {
223
+ type: 'string'
224
+ },
225
+ xpath: {
226
+ type: 'string'
227
+ }
228
+ }
229
+ }]
230
+ }
231
+ },
232
+ maxTimeoutMs: {
233
+ type: 'integer',
234
+ minimum: 1000,
235
+ maximum: 60000
236
+ }
237
+ }
238
+ },
154
239
  responsiveSnapshotCapture: {
155
240
  type: 'boolean',
156
241
  default: false
@@ -332,6 +417,14 @@ export const configSchema = {
332
417
  type: 'boolean',
333
418
  default: false
334
419
  },
420
+ ignoreIframeSelectors: {
421
+ type: 'array',
422
+ default: [],
423
+ items: {
424
+ type: 'string',
425
+ minLength: 1
426
+ }
427
+ },
335
428
  pseudoClassEnabledElements: {
336
429
  type: 'object',
337
430
  additionalProperties: false,
@@ -604,6 +697,9 @@ export const snapshotSchema = {
604
697
  sync: {
605
698
  $ref: '/config/snapshot#/properties/sync'
606
699
  },
700
+ readiness: {
701
+ $ref: '/config/snapshot#/properties/readiness'
702
+ },
607
703
  responsiveSnapshotCapture: {
608
704
  $ref: '/config/snapshot#/properties/responsiveSnapshotCapture'
609
705
  },
@@ -640,6 +736,9 @@ export const snapshotSchema = {
640
736
  ignoreStyleSheetSerializationErrors: {
641
737
  $ref: '/config/snapshot#/properties/ignoreStyleSheetSerializationErrors'
642
738
  },
739
+ ignoreIframeSelectors: {
740
+ $ref: '/config/snapshot#/properties/ignoreIframeSelectors'
741
+ },
643
742
  pseudoClassEnabledElements: {
644
743
  $ref: '/config/snapshot#/properties/pseudoClassEnabledElements'
645
744
  },
@@ -934,6 +1033,11 @@ export const snapshotSchema = {
934
1033
  type: 'string'
935
1034
  }
936
1035
  },
1036
+ readiness_diagnostics: {
1037
+ type: 'object',
1038
+ normalize: false,
1039
+ description: 'Diagnostics from readiness checks run before serialization. ' + 'normalize: false preserves the snake_case wire format the SDKs send (timed_out, ' + 'total_duration_ms, etc.) instead of camelCasing inner keys at validate time.'
1040
+ },
937
1041
  corsIframes: {
938
1042
  type: 'array',
939
1043
  items: {
package/dist/page.js CHANGED
@@ -1,8 +1,55 @@
1
1
  import fs from 'fs';
2
2
  import logger from '@percy/logger';
3
3
  import Network from './network.js';
4
+ import { exposeClosedShadowRoots } from './closed-shadow.js';
4
5
  import { PERCY_DOM } from './api.js';
5
6
  import { hostname, waitFor, waitForTimeout as sleep, serializeFunction } from './utils.js';
7
+
8
+ // Internal ceiling on the customElements wait. Set tight (500ms) so a
9
+ // page with a never-registering custom element — third-party widget whose
10
+ // loader is blocked, typo'd tag name, etc. — doesn't add a full 1500ms to
11
+ // every snapshot. Real cascades of legitimate lazy-defined elements
12
+ // complete well within this budget; the loop also exits early as soon as
13
+ // `:not(:defined)` clears.
14
+ export const DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 500;
15
+
16
+ // Body of the customElements wait. Runs in the browser via
17
+ // Runtime.callFunctionOn. Re-polls each tick so lazy-defined element
18
+ // cascades are awaited up to the deadline.
19
+ //
20
+ // IMPORTANT: this body is intentionally ES5 — it is evaluated in the
21
+ // page's realm and must work in any browser the page targets. Don't
22
+ // "modernize" with arrow functions, let/const, or optional chaining.
23
+ export const WAIT_FOR_CUSTOM_ELEMENTS_BODY = `
24
+ var deadline = Date.now() + (arguments[0] || 500);
25
+ return new Promise(function(resolve) {
26
+ function tick() {
27
+ var undef = document.querySelectorAll(":not(:defined)");
28
+ if (!undef.length) return resolve();
29
+ if (Date.now() >= deadline) return resolve();
30
+ var names = {};
31
+ for (var i = 0; i < undef.length; i++) names[undef[i].localName] = true;
32
+ var promises = Object.keys(names).map(function(n) {
33
+ return window.customElements.whenDefined(n).catch(function(){});
34
+ });
35
+ Promise.race([
36
+ Promise.all(promises),
37
+ new Promise(function(r) { setTimeout(r, 100); })
38
+ ]).then(tick);
39
+ }
40
+ tick();
41
+ });
42
+ `;
43
+
44
+ /* istanbul ignore next: runs in the page realm via Runtime.callFunctionOn,
45
+ not in the test process — there is no way to instrument it from here */
46
+ function serializeDomCapture(_, options) {
47
+ /* eslint-disable-next-line no-undef */
48
+ return {
49
+ domSnapshot: PercyDOM.serialize(options),
50
+ url: document.URL
51
+ };
52
+ }
6
53
  export class Page {
7
54
  static TIMEOUT = undefined;
8
55
  log = logger('core:page');
@@ -185,6 +232,7 @@ export class Page {
185
232
  execute,
186
233
  ...snapshot
187
234
  }) {
235
+ var _this$browser;
188
236
  let {
189
237
  name,
190
238
  width,
@@ -195,6 +243,7 @@ export class Page {
195
243
  reshuffleInvalidTags,
196
244
  ignoreCanvasSerializationErrors,
197
245
  ignoreStyleSheetSerializationErrors,
246
+ ignoreIframeSelectors,
198
247
  pseudoClassEnabledElements
199
248
  } = snapshot;
200
249
  this.log.debug(`Taking snapshot: ${name}${width ? ` @${width}px` : ''}`, this.meta);
@@ -219,17 +268,49 @@ export class Page {
219
268
 
220
269
  // wait for any final network activity before capturing the dom snapshot
221
270
  await this.network.idle();
222
- await this.insertPercyDom();
223
271
 
224
- // serialize and capture a DOM snapshot
225
- this.log.debug('Serialize DOM', this.meta);
272
+ // Pre-snapshot best-effort steps: waiting for lazy custom elements and
273
+ // discovering closed shadow roots via CDP. Both target a fully-loaded
274
+ // page; if the session has already terminated, skip them so the proper
275
+ // crash/close error surfaces from the downstream insertPercyDom +
276
+ // serialize evals (which gate on the same session).
277
+ //
278
+ // Ordering is load-bearing: closed-shadow capture must run AFTER the
279
+ // customElements wait so we catch shadows attached inside upgrade /
280
+ // connectedCallback hooks. Don't reorder or parallelise these.
281
+ if (!this.session.closedReason) {
282
+ // Best-effort: a flaky page should not break the snapshot.
283
+ try {
284
+ await this.eval(WAIT_FOR_CUSTOM_ELEMENTS_BODY, DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT);
285
+ } catch (err) {
286
+ /* istanbul ignore next: best-effort log; defensive against non-Error throws */
287
+ this.log.debug(`Custom elements wait failed: ${err.message ?? err}`, this.meta);
288
+ }
289
+ if (!disableShadowDOM) {
290
+ await exposeClosedShadowRoots(this.session, this._logShadowDebug.bind(this));
291
+ }
292
+ }
293
+ await this.insertPercyDom();
226
294
 
227
- /* istanbul ignore next: no instrumenting injected code */
228
- let capture = await this.eval((_, options) => ({
229
- /* eslint-disable-next-line no-undef */
230
- domSnapshot: PercyDOM.serialize(options),
231
- url: document.URL
232
- }), {
295
+ // Run readiness checks before serializing wait for page stability
296
+ let readiness = snapshot.readiness || ((_this$browser = this.browser) === null || _this$browser === void 0 || (_this$browser = _this$browser.percy) === null || _this$browser === void 0 || (_this$browser = _this$browser.config) === null || _this$browser === void 0 || (_this$browser = _this$browser.snapshot) === null || _this$browser === void 0 ? void 0 : _this$browser.readiness);
297
+ let readinessDiagnostics;
298
+ if (readiness && readiness.preset !== 'disabled') {
299
+ var _readinessDiagnostics;
300
+ this.log.debug('Waiting for readiness', this.meta);
301
+ readinessDiagnostics = await this.eval(/* istanbul ignore next: no instrumenting injected code */
302
+ async (_, config) => {
303
+ var _PercyDOM;
304
+ // eslint-disable-next-line no-undef
305
+ if (typeof ((_PercyDOM = PercyDOM) === null || _PercyDOM === void 0 ? void 0 : _PercyDOM.waitForReady) === 'function') return PercyDOM.waitForReady(config);
306
+ }, readiness).catch(e => {
307
+ this.log.debug(`Readiness check failed: ${e}`, this.meta);
308
+ });
309
+ if ((_readinessDiagnostics = readinessDiagnostics) !== null && _readinessDiagnostics !== void 0 && _readinessDiagnostics.timed_out) {
310
+ this.log.debug('Readiness timed out, capturing anyway', this.meta);
311
+ }
312
+ }
313
+ let capture = await this.eval(serializeDomCapture, {
233
314
  enableJavaScript,
234
315
  disableShadowDOM,
235
316
  forceShadowAsLightDOM,
@@ -237,14 +318,31 @@ export class Page {
237
318
  reshuffleInvalidTags,
238
319
  ignoreCanvasSerializationErrors,
239
320
  ignoreStyleSheetSerializationErrors,
321
+ ignoreIframeSelectors,
240
322
  pseudoClassEnabledElements
241
323
  });
324
+
325
+ // Attach readiness diagnostics onto the captured DOM snapshot so the backend/UI can surface
326
+ // readiness metrics. Only valid when domSnapshot is the structured object form — the legacy
327
+ // string form (HTML only) has no place to embed diagnostics.
328
+ if (readinessDiagnostics && capture !== null && capture !== void 0 && capture.domSnapshot && typeof capture.domSnapshot === 'object') {
329
+ capture.domSnapshot.readiness_diagnostics = readinessDiagnostics;
330
+ }
242
331
  return {
243
332
  ...snapshot,
244
333
  ...capture
245
334
  };
246
335
  }
247
336
 
337
+ // Logger for the closed-shadow CDP helper. Defined on the prototype (not
338
+ // a class-field arrow) so it's reachable from a unit test that constructs
339
+ // a Page via Object.create without invoking the constructor — gives us a
340
+ // direct way to cover the callback without simulating a closed shadow
341
+ // discovery flow at the integration level.
342
+ _logShadowDebug(msg) {
343
+ this.log.debug(msg, this.meta);
344
+ }
345
+
248
346
  // Initialize newly attached pages and iframes with page options
249
347
  _handleAttachedToTarget = event => {
250
348
  let session = !event ? this.session : this.session.children.get(event.sessionId);
package/dist/snapshot.js CHANGED
@@ -231,6 +231,27 @@ export function validateSnapshotOptions(options) {
231
231
  log.warn('Encountered snapshot serialization warnings:');
232
232
  for (let w of domWarnings) log.warn(`- ${w}`);
233
233
  }
234
+
235
+ // log readiness diagnostics when present.
236
+ // domSnapshot is a union of `string` (legacy SDK payload — HTML only) and `object`
237
+ // ({ html, warnings, readiness_diagnostics, ... }). Diagnostics only exist on the object form;
238
+ // gate explicitly on typeof so the intent is obvious to readers.
239
+ // The schema marks readiness_diagnostics with normalize: false to preserve the snake_case wire
240
+ // format. The dual-read fallback below is defensive — it keeps the log working even if a future
241
+ // SDK sends camelCase keys, or if a path in PercyConfig.migrate skips the normalize: false hint.
242
+ let domSnapshotObj = migrated.domSnapshot && typeof migrated.domSnapshot === 'object' ? migrated.domSnapshot : null;
243
+ let readinessDiag = (domSnapshotObj === null || domSnapshotObj === void 0 ? void 0 : domSnapshotObj.readiness_diagnostics) ?? (domSnapshotObj === null || domSnapshotObj === void 0 ? void 0 : domSnapshotObj.readinessDiagnostics);
244
+ if (readinessDiag) {
245
+ let timedOut = readinessDiag.timed_out ?? readinessDiag.timedOut;
246
+ let durationMs = readinessDiag.total_duration_ms ?? readinessDiag.totalDurationMs;
247
+ let presetName = readinessDiag.preset || 'custom';
248
+ if (timedOut) {
249
+ log.warn(`Readiness timed out after ${durationMs}ms (preset: ${presetName})`);
250
+ } else {
251
+ log.debug(`Readiness passed in ${durationMs}ms (preset: ${presetName})`);
252
+ }
253
+ }
254
+
234
255
  // warn on validation errors
235
256
  let errors = PercyConfig.validate(migrated, schema);
236
257
  if ((errors === null || errors === void 0 ? void 0 : errors.length) > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/core",
3
- "version": "1.31.14-beta.2",
3
+ "version": "1.31.14-beta.4",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -44,12 +44,12 @@
44
44
  "test:types": "tsd"
45
45
  },
46
46
  "dependencies": {
47
- "@percy/client": "1.31.14-beta.2",
48
- "@percy/config": "1.31.14-beta.2",
49
- "@percy/dom": "1.31.14-beta.2",
50
- "@percy/logger": "1.31.14-beta.2",
51
- "@percy/monitoring": "1.31.14-beta.2",
52
- "@percy/webdriver-utils": "1.31.14-beta.2",
47
+ "@percy/client": "1.31.14-beta.4",
48
+ "@percy/config": "1.31.14-beta.4",
49
+ "@percy/dom": "1.31.14-beta.4",
50
+ "@percy/logger": "1.31.14-beta.4",
51
+ "@percy/monitoring": "1.31.14-beta.4",
52
+ "@percy/webdriver-utils": "1.31.14-beta.4",
53
53
  "content-disposition": "^0.5.4",
54
54
  "cross-spawn": "^7.0.3",
55
55
  "extract-zip": "^2.0.1",
@@ -63,7 +63,7 @@
63
63
  "yaml": "^2.4.1"
64
64
  },
65
65
  "optionalDependencies": {
66
- "@percy/cli-doctor": "1.31.14-beta.2"
66
+ "@percy/cli-doctor": "1.31.14-beta.4"
67
67
  },
68
- "gitHead": "e4fce73023453b77cdef50aac1a9bd5eb70cd01a"
68
+ "gitHead": "b52f1d2fb6272c0b3694e1e9ff584c5622a118c7"
69
69
  }