@percy/core 1.31.15-beta.0 → 1.32.0-beta.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.
- package/dist/browser.js +17 -5
- package/dist/install.js +7 -6
- package/dist/network.js +261 -33
- package/package.json +9 -9
- package/test/helpers/server.js +7 -1
package/dist/browser.js
CHANGED
|
@@ -10,6 +10,20 @@ import logger from '@percy/logger';
|
|
|
10
10
|
import install from './install.js';
|
|
11
11
|
import Session from './session.js';
|
|
12
12
|
import Page from './page.js';
|
|
13
|
+
|
|
14
|
+
// Chrome features Percy disables for v143 new-headless asset discovery.
|
|
15
|
+
const DISABLED_FEATURES = ['Translate',
|
|
16
|
+
// suppress translate prompt overlay
|
|
17
|
+
'OptimizationGuideModelDownloading',
|
|
18
|
+
// suppress background model fetches
|
|
19
|
+
'IsolateOrigins',
|
|
20
|
+
// [headless-only] keep cross-origin sub-resources on the page session for CDP capture
|
|
21
|
+
'site-per-process',
|
|
22
|
+
// companion to IsolateOrigins
|
|
23
|
+
'HttpsFirstBalancedModeAutoEnable',
|
|
24
|
+
// allow HTTP customer URLs (CI / local dev / staging)
|
|
25
|
+
'LocalNetworkAccessChecks' // allow loopback/RFC1918 sub-resources (Chrome 143 LNA gating)
|
|
26
|
+
];
|
|
13
27
|
export class Browser extends EventEmitter {
|
|
14
28
|
log = logger('core:browser');
|
|
15
29
|
sessions = new Map();
|
|
@@ -17,9 +31,7 @@ export class Browser extends EventEmitter {
|
|
|
17
31
|
closed = false;
|
|
18
32
|
#callbacks = new Map();
|
|
19
33
|
#lastid = 0;
|
|
20
|
-
args = [
|
|
21
|
-
// disable the translate popup and optimization downloads
|
|
22
|
-
'--disable-features=Translate,OptimizationGuideModelDownloading',
|
|
34
|
+
args = [`--disable-features=${DISABLED_FEATURES.join(',')}`,
|
|
23
35
|
// disable several subsystems which run network requests in the background
|
|
24
36
|
'--disable-background-networking',
|
|
25
37
|
// disable task throttling of timer tasks from background pages
|
|
@@ -318,8 +330,8 @@ export class Browser extends EventEmitter {
|
|
|
318
330
|
let match = chunk.match(/^DevTools listening on (ws:\/\/.*)$/m);
|
|
319
331
|
if (match) cleanup(() => resolve(match[1]));
|
|
320
332
|
};
|
|
321
|
-
let handleExitClose = () => handleError();
|
|
322
|
-
let handleError = error => cleanup(() => reject(new Error(`Failed to launch browser. ${
|
|
333
|
+
let handleExitClose = () => handleError(new Error('Browser exited before devtools address'));
|
|
334
|
+
let handleError = error => cleanup(() => reject(new Error(`Failed to launch browser. ${error.message}\n${stderr}'\n\n`)));
|
|
323
335
|
let cleanup = callback => {
|
|
324
336
|
clearTimeout(timeoutId);
|
|
325
337
|
this.process.stderr.off('data', handleData);
|
package/dist/install.js
CHANGED
|
@@ -162,13 +162,14 @@ export function chromium({
|
|
|
162
162
|
});
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
//
|
|
165
|
+
// Chrome 143.0.7499.169 (base position 1536371) — closest per-platform
|
|
166
|
+
// revision from https://commondatastorage.googleapis.com/chromium-browser-snapshots/index.html
|
|
166
167
|
chromium.revisions = {
|
|
167
|
-
linux: '
|
|
168
|
-
win64: '
|
|
169
|
-
win32: '
|
|
170
|
-
darwin: '
|
|
171
|
-
darwinArm: '
|
|
168
|
+
linux: '1536366',
|
|
169
|
+
win64: '1536376',
|
|
170
|
+
win32: '1536377',
|
|
171
|
+
darwin: '1536380',
|
|
172
|
+
darwinArm: '1536376'
|
|
172
173
|
};
|
|
173
174
|
|
|
174
175
|
// export the namespace by default
|
package/dist/network.js
CHANGED
|
@@ -6,6 +6,12 @@ const MAX_RESOURCE_SIZE = 25 * 1024 ** 2 * 0.63; // 25MB, 0.63 factor for accoun
|
|
|
6
6
|
const ALLOWED_STATUSES = [200, 201, 301, 302, 304, 307, 308];
|
|
7
7
|
const ALLOWED_RESOURCES = ['Document', 'Stylesheet', 'Image', 'Media', 'Font', 'Other'];
|
|
8
8
|
const ABORTED_MESSAGE = 'Request was aborted by browser';
|
|
9
|
+
// Chrome 143 omits Network.responseReceived for worker scripts; cap the wait
|
|
10
|
+
// so loadingFinished can clean up. Per-request — N timeouts accumulate to N*2s;
|
|
11
|
+
// PERCY_NETWORK_IDLE_WAIT_TIMEOUT (default 30s) caps cumulative impact.
|
|
12
|
+
const RESPONSE_RECEIVED_TIMEOUT = 2000;
|
|
13
|
+
// Cap idle() impact when a host accepts the TCP connection then stalls during a direct fetch.
|
|
14
|
+
const DIRECT_FETCH_TIMEOUT = 5000;
|
|
9
15
|
|
|
10
16
|
// Stable, machine-readable codes for abort errors thrown from this module.
|
|
11
17
|
// Consumers should prefer `error.code` over string matching on `error.message`.
|
|
@@ -61,14 +67,14 @@ export class Network {
|
|
|
61
67
|
#aborted = new Set();
|
|
62
68
|
#finishedUrls = new Set();
|
|
63
69
|
constructor(page, options) {
|
|
70
|
+
var _page$session$browser;
|
|
64
71
|
this.page = page;
|
|
65
72
|
this.timeout = options.networkIdleTimeout ?? 100;
|
|
66
73
|
this.authorization = options.authorization;
|
|
67
74
|
this.requestHeaders = options.requestHeaders ?? {};
|
|
68
75
|
this.captureMockedServiceWorker = options.captureMockedServiceWorker ?? false;
|
|
69
|
-
this.userAgent = options.userAgent ??
|
|
70
|
-
|
|
71
|
-
page.session.browser.version.userAgent.replace('Headless', '');
|
|
76
|
+
this.userAgent = options.userAgent ?? (// by default, emulate a non-headless browser
|
|
77
|
+
(_page$session$browser = page.session.browser) === null || _page$session$browser === void 0 || (_page$session$browser = _page$session$browser.version) === null || _page$session$browser === void 0 || (_page$session$browser = _page$session$browser.userAgent) === null || _page$session$browser === void 0 ? void 0 : _page$session$browser.replace('Headless', ''));
|
|
72
78
|
this.fontDomains = options.fontDomains || [];
|
|
73
79
|
this.intercept = options.intercept;
|
|
74
80
|
this.meta = options.meta;
|
|
@@ -246,6 +252,13 @@ export class Network {
|
|
|
246
252
|
resourceType
|
|
247
253
|
} = event;
|
|
248
254
|
|
|
255
|
+
// Response-stage events arrive here when Fetch.continueRequest was called
|
|
256
|
+
// with interceptResponse:true (see sendResponseResource).
|
|
257
|
+
if (event.responseStatusCode != null || event.responseErrorReason != null) {
|
|
258
|
+
await this._handleResponsePaused(session, event);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
249
262
|
// wait for request to be sent
|
|
250
263
|
await this.#requestsLifeCycleHandler.get(requestId).requestWillBeSent;
|
|
251
264
|
let pending = this.#pending.get(requestId);
|
|
@@ -260,6 +273,88 @@ export class Network {
|
|
|
260
273
|
}));
|
|
261
274
|
};
|
|
262
275
|
|
|
276
|
+
// Response-stage interception is kept ONLY to detect oversized/malformed
|
|
277
|
+
// Content-Length and abort the request before Chrome streams a body it
|
|
278
|
+
// would never terminate (Chrome 143 quirk). For everything else we just
|
|
279
|
+
// continue — body capture happens later via Network.loadingFinished →
|
|
280
|
+
// Network.getResponseBody (the v126 path). Reading the body at this stage
|
|
281
|
+
// hangs worker-initiated fetches, so we don't.
|
|
282
|
+
_handleResponsePaused = async (session, event) => {
|
|
283
|
+
var _event$request;
|
|
284
|
+
let {
|
|
285
|
+
networkId: requestId,
|
|
286
|
+
requestId: interceptId,
|
|
287
|
+
responseHeaders,
|
|
288
|
+
responseStatusCode
|
|
289
|
+
} = event;
|
|
290
|
+
// request may be undefined when a response-stage pause arrives for a request
|
|
291
|
+
// whose request-stage tracking we never installed (service-worker-fulfilled,
|
|
292
|
+
// or a cleanup race). We still need to unpause Chrome regardless.
|
|
293
|
+
let request = this.#requests.get(requestId);
|
|
294
|
+
let url = request ? originURL(request) : ((_event$request = event.request) === null || _event$request === void 0 ? void 0 : _event$request.url) && normalizeURL(event.request.url);
|
|
295
|
+
let headersObj = headersArrayToObject(responseHeaders);
|
|
296
|
+
let {
|
|
297
|
+
tooLarge,
|
|
298
|
+
malformed,
|
|
299
|
+
rawValue
|
|
300
|
+
} = inspectContentLength(headersObj);
|
|
301
|
+
if (tooLarge || malformed) {
|
|
302
|
+
let meta = {
|
|
303
|
+
...this.meta,
|
|
304
|
+
url,
|
|
305
|
+
responseStatus: responseStatusCode
|
|
306
|
+
};
|
|
307
|
+
logAssetInstrumentation(this.log, 'asset_not_uploaded', 'resource_too_large', {
|
|
308
|
+
url,
|
|
309
|
+
size: rawValue,
|
|
310
|
+
snapshot: meta.snapshot
|
|
311
|
+
});
|
|
312
|
+
this.log.debug('- Skipping resource larger than 25MB', meta);
|
|
313
|
+
|
|
314
|
+
// Disposition first, then forget the request — so we never leave Chrome's
|
|
315
|
+
// Fetch state paused while Percy thinks the request is already done.
|
|
316
|
+
try {
|
|
317
|
+
await this.send(session, 'Fetch.failRequest', {
|
|
318
|
+
requestId: interceptId,
|
|
319
|
+
errorReason: 'Aborted'
|
|
320
|
+
});
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error.message === ABORTED_MESSAGE || error.message.includes('Invalid InterceptionId')) {
|
|
323
|
+
// benign race — request was already aborted upstream; nothing to un-pause
|
|
324
|
+
} else {
|
|
325
|
+
this.log.debug(`Failed to abort oversized response for ${url}: ${error.message}`);
|
|
326
|
+
// Last-resort: un-pause Chrome's Fetch so it doesn't leak the response.
|
|
327
|
+
try {
|
|
328
|
+
await this.send(session, 'Fetch.continueResponse', {
|
|
329
|
+
requestId: interceptId
|
|
330
|
+
});
|
|
331
|
+
} catch (continueError) {
|
|
332
|
+
this.log.debug(`Last-resort continueResponse also failed for ${url}: ${continueError.message}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (request) {
|
|
337
|
+
this._forgetRequest(request);
|
|
338
|
+
this.#requestsLifeCycleHandler.get(requestId).resolveResponseReceived();
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
return this._continueResponse(session, interceptId, url);
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// Tell the browser to continue the paused response, swallowing expected
|
|
346
|
+
// races (request already aborted, interception ID no longer valid).
|
|
347
|
+
_continueResponse = async (session, interceptId, url) => {
|
|
348
|
+
try {
|
|
349
|
+
await this.send(session, 'Fetch.continueResponse', {
|
|
350
|
+
requestId: interceptId
|
|
351
|
+
});
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (error.message === ABORTED_MESSAGE || error.message.includes('Invalid InterceptionId')) return;
|
|
354
|
+
this.log.debug(`Failed to continue response for ${url}: ${error.message}`);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
263
358
|
// Called when a request will be sent. If the request has already been intercepted, handle it;
|
|
264
359
|
// otherwise set it to be pending until it is paused.
|
|
265
360
|
_handleRequestWillBeSent = async event => {
|
|
@@ -371,11 +466,36 @@ export class Network {
|
|
|
371
466
|
let {
|
|
372
467
|
requestId
|
|
373
468
|
} = event;
|
|
374
|
-
// wait for upto 2 seconds or check if response has been sent
|
|
375
|
-
await this.#requestsLifeCycleHandler.get(requestId).responseReceived;
|
|
376
469
|
let request = this.#requests.get(requestId);
|
|
377
470
|
/* istanbul ignore if: race condition paranoia */
|
|
378
471
|
if (!request) return;
|
|
472
|
+
if (!request.response) {
|
|
473
|
+
let timerId;
|
|
474
|
+
await Promise.race([this.#requestsLifeCycleHandler.get(requestId).responseReceived, new Promise(resolve => {
|
|
475
|
+
timerId = setTimeout(resolve, RESPONSE_RECEIVED_TIMEOUT);
|
|
476
|
+
})]);
|
|
477
|
+
clearTimeout(timerId);
|
|
478
|
+
}
|
|
479
|
+
if (!request.response) {
|
|
480
|
+
this.log.debug(`Skipping resource: responseReceived not received within ${RESPONSE_RECEIVED_TIMEOUT}ms - ${request.url}`);
|
|
481
|
+
// Chrome 143+ PlzDedicatedWorker: dedicated worker scripts fetch in the browser
|
|
482
|
+
// process and never surface a CDP response. resourceType varies ('Other' on v143,
|
|
483
|
+
// 'Script' on older Chrome) so we gate on hostname rather than type, and mirror
|
|
484
|
+
// sendResponseResource's disallowedHostnames-before-allowedHostnames precedence.
|
|
485
|
+
let url = originURL(request);
|
|
486
|
+
/* istanbul ignore else: the else only fires for PlzDedicatedWorker requests
|
|
487
|
+
whose worker-script fetch bypasses Fetch.requestPaused. Cross-origin assets
|
|
488
|
+
loaded via the document session still go through sendResponseResource
|
|
489
|
+
(which performs its own disallowedHostnames check), so the test harness
|
|
490
|
+
can't reliably reach this skip branch via integration tests. */
|
|
491
|
+
if (!hostnameMatches(this.intercept.disallowedHostnames, url) && hostnameMatches(this.intercept.allowedHostnames, url)) {
|
|
492
|
+
await captureResourceDirectly(this, request, session);
|
|
493
|
+
} else {
|
|
494
|
+
this.log.debug(`- Skipping direct-fetch fallback for ${url}: hostname not allowed`, this.meta);
|
|
495
|
+
}
|
|
496
|
+
this._forgetRequest(request);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
379
499
|
await saveResponseResource(this, request, session);
|
|
380
500
|
this._forgetRequest(request);
|
|
381
501
|
};
|
|
@@ -454,7 +574,7 @@ export class Network {
|
|
|
454
574
|
_initializeNetworkIdleWaitTimeout() {
|
|
455
575
|
// Per-instance timeout so concurrent pages with different env values
|
|
456
576
|
// (or env values changed mid-run by tests) don't stomp each other.
|
|
457
|
-
this.networkIdleWaitTimeout = parseInt(process.env.PERCY_NETWORK_IDLE_WAIT_TIMEOUT) || 30000;
|
|
577
|
+
this.networkIdleWaitTimeout = parseInt(process.env.PERCY_NETWORK_IDLE_WAIT_TIMEOUT, 10) || 30000;
|
|
458
578
|
if (this.networkIdleWaitTimeout > 60000) {
|
|
459
579
|
this.log.warn('Setting PERCY_NETWORK_IDLE_WAIT_TIMEOUT over 60000ms is not recommended. ' + 'If your page needs more than 60000ms to idle due to CPU/Network load, ' + 'its recommended to increase CI resources where this cli is running.');
|
|
460
580
|
}
|
|
@@ -493,6 +613,31 @@ function originURL(request) {
|
|
|
493
613
|
return normalizeURL((request.redirectChain[0] || request).url);
|
|
494
614
|
}
|
|
495
615
|
|
|
616
|
+
// Convert Fetch event responseHeaders ([{name, value}, …]) to a header object.
|
|
617
|
+
function headersArrayToObject(arr) {
|
|
618
|
+
let out = {};
|
|
619
|
+
if (!Array.isArray(arr)) return out;
|
|
620
|
+
for (let {
|
|
621
|
+
name,
|
|
622
|
+
value
|
|
623
|
+
} of arr) out[name] = value;
|
|
624
|
+
return out;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Returns { tooLarge, malformed, rawValue } for Content-Length classification.
|
|
628
|
+
function inspectContentLength(headers) {
|
|
629
|
+
let key = headers && Object.keys(headers).find(k => k.toLowerCase() === 'content-length');
|
|
630
|
+
let rawValue = key ? headers[key] : undefined;
|
|
631
|
+
let parsed = parseInt(rawValue, 10);
|
|
632
|
+
let tooLarge = Number.isFinite(parsed) && parsed > MAX_RESOURCE_SIZE;
|
|
633
|
+
let malformed = rawValue !== undefined && rawValue !== null && String(rawValue).length > 0 && !Number.isFinite(parsed);
|
|
634
|
+
return {
|
|
635
|
+
tooLarge,
|
|
636
|
+
malformed,
|
|
637
|
+
rawValue
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
496
641
|
// Validate domain for auto-allowlisting feature
|
|
497
642
|
// Only validates domains that returned 200 status
|
|
498
643
|
async function validateDomainForAllowlist(network, hostname, url, statusCode) {
|
|
@@ -574,8 +719,10 @@ async function sendResponseResource(network, request, session) {
|
|
|
574
719
|
}))
|
|
575
720
|
});
|
|
576
721
|
} else {
|
|
722
|
+
// interceptResponse:true triggers a second pause at the response stage. See _handleResponsePaused.
|
|
577
723
|
await send('Fetch.continueRequest', {
|
|
578
|
-
requestId: request.interceptId
|
|
724
|
+
requestId: request.interceptId,
|
|
725
|
+
interceptResponse: true
|
|
579
726
|
});
|
|
580
727
|
}
|
|
581
728
|
} catch (error) {
|
|
@@ -609,21 +756,68 @@ async function sendResponseResource(network, request, session) {
|
|
|
609
756
|
}
|
|
610
757
|
}
|
|
611
758
|
|
|
612
|
-
//
|
|
759
|
+
// Pick the CDP session for Network.getCookies. Worker/auxiliary sessions
|
|
760
|
+
// expose a partial Network domain where Network.getCookies throws
|
|
761
|
+
// "Internal error", so prefer the page's session whenever available and
|
|
762
|
+
// fall back to the request's own session otherwise.
|
|
763
|
+
export function pickCookieSession(network, session) {
|
|
764
|
+
var _network$page;
|
|
765
|
+
return ((_network$page = network.page) === null || _network$page === void 0 ? void 0 : _network$page.session) ?? session;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Decide whether to attach a Basic auth header to the Node-side direct fetch.
|
|
769
|
+
// The browser's URLLoader origin-scopes Basic auth; this fallback runs in
|
|
770
|
+
// Node, so we re-enforce the same-origin rule explicitly to avoid leaking
|
|
771
|
+
// credentials cross-origin. Malformed URLs fall through to `false` defensively.
|
|
772
|
+
export function shouldAttachAuth(authorization, requestUrl, snapshotUrl) {
|
|
773
|
+
if (!(authorization !== null && authorization !== void 0 && authorization.username)) return false;
|
|
774
|
+
try {
|
|
775
|
+
return new URL(requestUrl).origin === new URL(snapshotUrl).origin;
|
|
776
|
+
} catch {
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Race a promise against a timeout. Resolves with the promise's value if it
|
|
782
|
+
// settles within `ms`, otherwise rejects with `new Error(message)`. The
|
|
783
|
+
// internal timer is always cleared so the event loop can exit cleanly.
|
|
784
|
+
export function raceWithTimeout(promise, ms, message) {
|
|
785
|
+
let timerId;
|
|
786
|
+
return Promise.race([promise, new Promise((_, reject) => {
|
|
787
|
+
timerId = setTimeout(() => reject(new Error(message)), ms);
|
|
788
|
+
})]).finally(() => clearTimeout(timerId));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Server Content-Type wins; URL-extension mime is the fallback; binary default last.
|
|
792
|
+
export function resolveDirectFetchMime(responseHeaders, urlForLookup) {
|
|
793
|
+
var _responseHeaders$cont;
|
|
794
|
+
let serverMime = responseHeaders === null || responseHeaders === void 0 || (_responseHeaders$cont = responseHeaders['content-type']) === null || _responseHeaders$cont === void 0 ? void 0 : _responseHeaders$cont.split(';')[0].trim();
|
|
795
|
+
return serverMime || mime.lookup(urlForLookup) || 'application/octet-stream';
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Make a new request with Node based on a network request. Cookies are read
|
|
799
|
+
// from the page session because worker/auxiliary sessions have a partial
|
|
800
|
+
// Network domain where Network.getCookies throws "Internal error".
|
|
613
801
|
async function makeDirectRequest(network, request, session) {
|
|
614
|
-
var _network$
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
802
|
+
var _network$meta;
|
|
803
|
+
let cookies = [];
|
|
804
|
+
let cookieSession = pickCookieSession(network, session);
|
|
805
|
+
try {
|
|
806
|
+
({
|
|
807
|
+
cookies
|
|
808
|
+
} = await cookieSession.send('Network.getCookies', {
|
|
809
|
+
urls: [request.url]
|
|
810
|
+
}));
|
|
811
|
+
} catch (error) {
|
|
812
|
+
network.log.debug(`Network.getCookies unavailable for ${request.url}: ${error.message}`);
|
|
813
|
+
}
|
|
620
814
|
let headers = {
|
|
621
815
|
// add default browser
|
|
622
816
|
accept: '*/*',
|
|
623
817
|
'sec-fetch-site': 'same-origin',
|
|
624
818
|
'sec-fetch-mode': 'cors',
|
|
625
819
|
'sec-fetch-dest': 'font',
|
|
626
|
-
'sec-ch-ua': '"Chromium";v="
|
|
820
|
+
'sec-ch-ua': '"Chromium";v="143", "Google Chrome";v="143", "Not?A_Brand";v="99"',
|
|
627
821
|
'sec-ch-ua-mobile': '?0',
|
|
628
822
|
'sec-ch-ua-platform': '"macOS"',
|
|
629
823
|
'sec-fetch-user': '?1',
|
|
@@ -632,8 +826,7 @@ async function makeDirectRequest(network, request, session) {
|
|
|
632
826
|
// add applicable cookies
|
|
633
827
|
cookie: cookies.map(cookie => `${cookie.name}=${cookie.value}`).join('; ')
|
|
634
828
|
};
|
|
635
|
-
if ((_network$
|
|
636
|
-
// include basic authorization username and password
|
|
829
|
+
if (shouldAttachAuth(network.authorization, request.url, (_network$meta = network.meta) === null || _network$meta === void 0 ? void 0 : _network$meta.snapshotURL)) {
|
|
637
830
|
let {
|
|
638
831
|
username,
|
|
639
832
|
password
|
|
@@ -644,12 +837,56 @@ async function makeDirectRequest(network, request, session) {
|
|
|
644
837
|
return makeRequest(request.url, {
|
|
645
838
|
buffer: true,
|
|
646
839
|
headers
|
|
647
|
-
})
|
|
840
|
+
}, (body, res) => ({
|
|
841
|
+
body,
|
|
842
|
+
status: res.statusCode,
|
|
843
|
+
headers: res.headers
|
|
844
|
+
}));
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Capture a resource via direct HTTP fetch when the browser-side response
|
|
848
|
+
// never surfaces — Chrome 143+ fetches dedicated worker scripts in the browser
|
|
849
|
+
// process (PlzDedicatedWorker) so loadingFinished fires without a body on CDP.
|
|
850
|
+
async function captureResourceDirectly(network, request, session) {
|
|
851
|
+
let log = network.log;
|
|
852
|
+
let url = originURL(request);
|
|
853
|
+
let meta = {
|
|
854
|
+
...network.meta,
|
|
855
|
+
url
|
|
856
|
+
};
|
|
857
|
+
try {
|
|
858
|
+
log.debug('- Requesting resource directly (responseReceived timeout fallback)', meta);
|
|
859
|
+
let {
|
|
860
|
+
body,
|
|
861
|
+
status,
|
|
862
|
+
headers: responseHeaders
|
|
863
|
+
} = await raceWithTimeout(makeDirectRequest(network, request, session), DIRECT_FETCH_TIMEOUT, `Direct fetch timed out after ${DIRECT_FETCH_TIMEOUT}ms`);
|
|
864
|
+
if (body.length > MAX_RESOURCE_SIZE) {
|
|
865
|
+
logAssetInstrumentation(log, 'asset_not_uploaded', 'resource_too_large', {
|
|
866
|
+
url,
|
|
867
|
+
size: body.length,
|
|
868
|
+
snapshot: meta.snapshot
|
|
869
|
+
});
|
|
870
|
+
log.debug('- Skipping resource larger than 25MB', meta);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
let urlObj = new URL(url);
|
|
874
|
+
let mimeType = resolveDirectFetchMime(responseHeaders, urlObj.origin + urlObj.pathname);
|
|
875
|
+
let resource = createResource(url, body, mimeType, {
|
|
876
|
+
status,
|
|
877
|
+
headers: {
|
|
878
|
+
'content-type': [mimeType]
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
log.debug(`- Saving direct-fetched resource sha=${resource.sha} mimetype=${mimeType}`, meta);
|
|
882
|
+
network.intercept.saveResource(resource);
|
|
883
|
+
} catch (error) {
|
|
884
|
+
log.debug(`Direct fetch failed for ${url} - ${error.message}`, meta);
|
|
885
|
+
}
|
|
648
886
|
}
|
|
649
887
|
|
|
650
888
|
// Save a resource from a request, skipping it if specific parameters are not met
|
|
651
889
|
async function saveResponseResource(network, request, session) {
|
|
652
|
-
var _response$headers;
|
|
653
890
|
let {
|
|
654
891
|
disableCache,
|
|
655
892
|
allowedHostnames,
|
|
@@ -663,19 +900,8 @@ async function saveResponseResource(network, request, session) {
|
|
|
663
900
|
url,
|
|
664
901
|
responseStatus: response === null || response === void 0 ? void 0 : response.status
|
|
665
902
|
};
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
// If content-length is more than our allowed 25MB, no need to process that resouce we can return log.
|
|
669
|
-
let contentLength = (_response$headers = response.headers) === null || _response$headers === void 0 ? void 0 : _response$headers[Object.keys(response.headers).find(key => key.toLowerCase() === 'content-length')];
|
|
670
|
-
contentLength = parseInt(contentLength);
|
|
671
|
-
if (contentLength > MAX_RESOURCE_SIZE) {
|
|
672
|
-
logAssetInstrumentation(log, 'asset_not_uploaded', 'resource_too_large', {
|
|
673
|
-
url,
|
|
674
|
-
size: contentLength,
|
|
675
|
-
snapshot: meta.snapshot
|
|
676
|
-
});
|
|
677
|
-
return log.debug('- Skipping resource larger than 25MB', meta);
|
|
678
|
-
}
|
|
903
|
+
// Oversized/malformed Content-Length is rejected earlier in _handleResponsePaused;
|
|
904
|
+
// the body.length check below still guards cached responses where headers may lie.
|
|
679
905
|
let resource = network.intercept.getResource(url);
|
|
680
906
|
if (!resource || !resource.root && !resource.provided && disableCache) {
|
|
681
907
|
try {
|
|
@@ -767,7 +993,9 @@ async function saveResponseResource(network, request, session) {
|
|
|
767
993
|
// so request them directly.
|
|
768
994
|
if ((_mimeType = mimeType) !== null && _mimeType !== void 0 && _mimeType.includes('font') || detectedMime && detectedMime.includes('font')) {
|
|
769
995
|
log.debug('- Requesting asset directly', meta);
|
|
770
|
-
|
|
996
|
+
({
|
|
997
|
+
body
|
|
998
|
+
} = await makeDirectRequest(network, request, session));
|
|
771
999
|
log.debug('- Got direct response', meta);
|
|
772
1000
|
}
|
|
773
1001
|
resource = createResource(url, body, mimeType, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0-beta.0",
|
|
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.
|
|
48
|
-
"@percy/config": "1.
|
|
49
|
-
"@percy/dom": "1.
|
|
50
|
-
"@percy/logger": "1.
|
|
51
|
-
"@percy/monitoring": "1.
|
|
52
|
-
"@percy/webdriver-utils": "1.
|
|
47
|
+
"@percy/client": "1.32.0-beta.0",
|
|
48
|
+
"@percy/config": "1.32.0-beta.0",
|
|
49
|
+
"@percy/dom": "1.32.0-beta.0",
|
|
50
|
+
"@percy/logger": "1.32.0-beta.0",
|
|
51
|
+
"@percy/monitoring": "1.32.0-beta.0",
|
|
52
|
+
"@percy/webdriver-utils": "1.32.0-beta.0",
|
|
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.
|
|
66
|
+
"@percy/cli-doctor": "1.32.0-beta.0"
|
|
67
67
|
},
|
|
68
|
-
"gitHead": "
|
|
68
|
+
"gitHead": "36c0d4a8f23b5e3ab6bdf5db5e181c1febd4b767"
|
|
69
69
|
}
|
package/test/helpers/server.js
CHANGED
|
@@ -28,8 +28,14 @@ export function createTestServer({ default: defaultReply, ...replies }, port = 8
|
|
|
28
28
|
server.route(async (req, res, next) => {
|
|
29
29
|
let pathname = req.url.pathname;
|
|
30
30
|
if (req.url.search) pathname += req.url.search;
|
|
31
|
-
server.requests.push(req.body ? [pathname, req.body, req.headers] : [pathname, req.headers]);
|
|
32
31
|
let reply = replies[pathname] || defaultReply;
|
|
32
|
+
// Chrome >=128 auto-fetches /favicon.ico on every navigation; reply 204
|
|
33
|
+
// by default so it doesn't pollute snapshot resources. Tests can still
|
|
34
|
+
// override via `server.reply('/favicon.ico', ...)`.
|
|
35
|
+
if (req.url.pathname === '/favicon.ico' && !replies['/favicon.ico']) {
|
|
36
|
+
return res.writeHead(204).end();
|
|
37
|
+
}
|
|
38
|
+
server.requests.push(req.body ? [pathname, req.body, req.headers] : [pathname, req.headers]);
|
|
33
39
|
return reply ? await reply(req, res) : next();
|
|
34
40
|
});
|
|
35
41
|
|