@percy/core 1.31.14-beta.4 → 1.31.15-alpha.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 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. ${(error === null || error === void 0 ? void 0 : error.message) ?? ''}\n${stderr}'\n\n`)));
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
- // default chromium revisions corresponds to v126.0.6478.184
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: '1300309',
168
- win64: '1300297',
169
- win32: '1300295',
170
- darwin: '1300293',
171
- darwinArm: '1300314'
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
- // by default, emulate a non-headless browser
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
- // Make a new request with Node based on a network request
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$authorizatio;
615
- const {
616
- cookies
617
- } = await session.send('Network.getCookies', {
618
- urls: [request.url]
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="123", "Google Chrome";v="123", "Not?A_Brand";v="99"',
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$authorizatio = network.authorization) !== null && _network$authorizatio !== void 0 && _network$authorizatio.username) {
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
- // Checking for content length more than 100MB, to prevent websocket error which is governed by
667
- // maxPayload option of websocket defaulted to 100MB.
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
- body = await makeDirectRequest(network, request, session);
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.31.14-beta.4",
3
+ "version": "1.31.15-alpha.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "publishConfig": {
11
11
  "access": "public",
12
- "tag": "beta"
12
+ "tag": "alpha"
13
13
  },
14
14
  "engines": {
15
15
  "node": ">=14"
@@ -44,12 +44,12 @@
44
44
  "test:types": "tsd"
45
45
  },
46
46
  "dependencies": {
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",
47
+ "@percy/client": "1.31.15-alpha.0",
48
+ "@percy/config": "1.31.15-alpha.0",
49
+ "@percy/dom": "1.31.15-alpha.0",
50
+ "@percy/logger": "1.31.15-alpha.0",
51
+ "@percy/monitoring": "1.31.15-alpha.0",
52
+ "@percy/webdriver-utils": "1.31.15-alpha.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.31.14-beta.4"
66
+ "@percy/cli-doctor": "1.31.15-alpha.0"
67
67
  },
68
- "gitHead": "b52f1d2fb6272c0b3694e1e9ff584c5622a118c7"
68
+ "gitHead": "726ab78fcd37479a0904996a88400a0b6626daac"
69
69
  }
@@ -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