@percy/core 1.31.15-beta.0 → 1.32.0-beta.1

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/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/dist/percy.js CHANGED
@@ -20,6 +20,7 @@ import { gatherSnapshots, createSnapshotsQueue, validateSnapshotOptions } from '
20
20
  import { discoverSnapshotResources, createDiscoveryQueue, RESOURCE_CACHE_KEY, CACHE_STATS_KEY, DISK_SPILL_KEY } from './discovery.js';
21
21
  import Monitoring from '@percy/monitoring';
22
22
  import { WaitForJob } from './wait-for-job.js';
23
+ import { closeGrpcClientCache } from './maestro-hierarchy.js';
23
24
  const MAX_SUGGESTION_CALLS = 10;
24
25
 
25
26
  // If no activity is done for 5 mins, we will stop monitoring
@@ -121,6 +122,16 @@ export class Percy {
121
122
  this.monitoringCheckLastExecutedAt = null;
122
123
  this.sdkInfoDisplayed = false;
123
124
 
125
+ // Per-Percy gRPC client cache for the Android view-hierarchy resolver
126
+ // (D9 in 2026-05-07-002 plan). Owns transport state — channels hold open
127
+ // sockets, must be closed in stop(). Module-scoped state would leak
128
+ // between concurrent Percy instances and cause cross-instance shutdown
129
+ // races. The drift envelope (maestroHierarchyDrift in maestro-hierarchy.js)
130
+ // stays module-scoped because drift is observability state — surfaced
131
+ // process-wide on /percy/healthcheck. Two scopes, two reasons.
132
+ this.grpcClientCache = new Map();
133
+ this.grpcClientCache.shutdownInProgress = false;
134
+
124
135
  // Domain validation state for auto domain allow-listing
125
136
  this.domainValidation = {
126
137
  autoConfiguredHosts: new Set(),
@@ -429,6 +440,13 @@ export class Percy {
429
440
  await _classPrivateFieldGet(_discovery, this).end();
430
441
  await _classPrivateFieldGet(_snapshots, this).end();
431
442
 
443
+ // Close gRPC channels for the Android view-hierarchy resolver. Set the
444
+ // shutdown flag first so any in-flight runAndroidGrpcDump() that hits
445
+ // CANCELLED returns {kind:'unavailable', reason:'shutdown'} instead of
446
+ // triggering the fallback chain on a tearing-down process (R-7).
447
+ this.grpcClientCache.shutdownInProgress = true;
448
+ closeGrpcClientCache(this.grpcClientCache);
449
+
432
450
  // mark instance as stopped
433
451
  this.readyState = 3;
434
452
  } catch (err) {
@@ -0,0 +1,47 @@
1
+ # Vendored protobuf — `maestro_android.proto`
2
+
3
+ Direct copy of the protobuf schema served by `dev.mobile.maestro` on Android
4
+ devices, used by `@percy/core`'s element-region resolver to call
5
+ `maestro_android.MaestroDriver/viewHierarchy` directly over gRPC instead of
6
+ spawning the full `maestro` CLI (~9s JVM cold start per call → <100ms direct
7
+ gRPC call).
8
+
9
+ ## Source
10
+
11
+ - **Upstream file:** `maestro-proto/src/main/proto/maestro_android.proto`
12
+ - **Upstream repo:** [`mobile-dev-inc/Maestro`](https://github.com/mobile-dev-inc/Maestro)
13
+ - **Commit SHA at copy time:** `bc8bde1b5cb7f2d4076047c0a9db094ece47512f` (2025-05-26)
14
+ - **Closest CLI release:** `cli-2.5.1`
15
+ - **Copy date:** 2026-04-29
16
+
17
+ ## What we use
18
+
19
+ Only `MaestroDriver/viewHierarchy(ViewHierarchyRequest) returns (ViewHierarchyResponse)`
20
+ and the `string hierarchy = 1` field on the response. The rest of the proto
21
+ is included unchanged so future updates can be a clean upstream re-copy
22
+ without surgical edits.
23
+
24
+ ## Drift policy
25
+
26
+ - The proto **must** be re-vendored from upstream whenever the Maestro CLI
27
+ version deployed on BrowserStack hosts is bumped past the version recorded
28
+ above. PRs that update this file must paste the upstream SHA and CLI tag.
29
+ - The runtime parser (`@grpc/proto-loader`) silently drops unknown fields.
30
+ If `viewHierarchy`'s response field is renumbered, retyped, or replaced,
31
+ decode errors surface as `dump-error (grpc-decode)` and a
32
+ `maestroHierarchyDrift` flag appears on the `/percy/healthcheck` response.
33
+ See `docs/solutions/integration-issues/percy-labels-cli-schema-rejection-2026-04-23.md`
34
+ for context on why we monitor schema drift loudly.
35
+
36
+ ## How to refresh
37
+
38
+ ```sh
39
+ curl -fsSL "https://raw.githubusercontent.com/mobile-dev-inc/Maestro/main/maestro-proto/src/main/proto/maestro_android.proto" \
40
+ -o packages/core/src/proto/maestro_android.proto
41
+ # Update the SHA + CLI tag above; PR must show the diff and the new pin.
42
+ ```
43
+
44
+ The file is loaded at module init via `@grpc/proto-loader`'s `loadSync` from
45
+ `packages/core/src/maestro-hierarchy.js`. Babel CLI's `copyFiles: true`
46
+ (scripts/build.js:26) preserves the relative layout so it lands at
47
+ `dist/proto/maestro_android.proto` after `yarn build`.
@@ -0,0 +1,116 @@
1
+ syntax = "proto3";
2
+
3
+ package maestro_android;
4
+
5
+ service MaestroDriver {
6
+
7
+ rpc deviceInfo(DeviceInfoRequest) returns (DeviceInfo) {}
8
+
9
+ rpc viewHierarchy(ViewHierarchyRequest) returns (ViewHierarchyResponse) {}
10
+
11
+ rpc screenshot(ScreenshotRequest) returns (ScreenshotResponse) {}
12
+
13
+ rpc tap(TapRequest) returns (TapResponse) {}
14
+
15
+ rpc inputText(InputTextRequest) returns (InputTextResponse) {}
16
+
17
+ rpc eraseAllText(EraseAllTextRequest) returns (EraseAllTextResponse) {}
18
+
19
+ rpc setLocation(SetLocationRequest) returns (SetLocationResponse) {}
20
+
21
+ rpc isWindowUpdating(CheckWindowUpdatingRequest) returns (CheckWindowUpdatingResponse) {}
22
+
23
+ rpc launchApp(LaunchAppRequest) returns (LaunchAppResponse) {}
24
+
25
+ rpc addMedia(stream AddMediaRequest) returns (AddMediaResponse) {}
26
+
27
+ rpc enableMockLocationProviders(EmptyRequest) returns (EmptyResponse) {}
28
+
29
+ rpc disableLocationUpdates(EmptyRequest) returns (EmptyResponse) {}
30
+ }
31
+
32
+ message EmptyRequest {}
33
+ message EmptyResponse {}
34
+
35
+ message LaunchAppRequest {
36
+
37
+ string packageName = 1;
38
+ repeated ArgumentValue arguments = 2;
39
+ }
40
+
41
+ message ArgumentValue {
42
+ string key = 1;
43
+ string value = 2;
44
+ string type = 3;
45
+ }
46
+
47
+ message LaunchAppResponse {}
48
+
49
+ // Device info
50
+ message DeviceInfoRequest {}
51
+
52
+ message DeviceInfo {
53
+ uint32 widthPixels = 1;
54
+ uint32 heightPixels = 2;
55
+ }
56
+
57
+ message ScreenshotRequest {}
58
+
59
+ message ScreenshotResponse {
60
+ bytes bytes = 1;
61
+ }
62
+
63
+ // View hierarchy
64
+ message ViewHierarchyRequest {}
65
+
66
+ message ViewHierarchyResponse {
67
+ string hierarchy = 1;
68
+ }
69
+
70
+ // Interactions
71
+
72
+ message TapRequest {
73
+ uint32 x = 1;
74
+ uint32 y = 2;
75
+ }
76
+
77
+ message TapResponse {}
78
+
79
+ message InputTextRequest {
80
+ string text = 1;
81
+ }
82
+ message InputTextResponse {}
83
+
84
+
85
+ message EraseAllTextRequest {
86
+ uint32 charactersToErase = 1;
87
+ }
88
+
89
+ message EraseAllTextResponse {}
90
+
91
+ message SetLocationRequest {
92
+ double latitude = 1;
93
+ double longitude = 2;
94
+ }
95
+
96
+ message SetLocationResponse {}
97
+
98
+ message CheckWindowUpdatingRequest {
99
+ string appId = 1;
100
+ }
101
+
102
+ message CheckWindowUpdatingResponse {
103
+ bool isWindowUpdating = 1;
104
+ }
105
+
106
+ message AddMediaRequest {
107
+ Payload payload = 1;
108
+ string media_name = 2;
109
+ string media_ext = 3;
110
+ }
111
+
112
+ message AddMediaResponse { }
113
+
114
+ message Payload {
115
+ bytes data = 1;
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@percy/core",
3
- "version": "1.31.15-beta.0",
3
+ "version": "1.32.0-beta.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -44,16 +44,20 @@
44
44
  "test:types": "tsd"
45
45
  },
46
46
  "dependencies": {
47
- "@percy/client": "1.31.15-beta.0",
48
- "@percy/config": "1.31.15-beta.0",
49
- "@percy/dom": "1.31.15-beta.0",
50
- "@percy/logger": "1.31.15-beta.0",
51
- "@percy/monitoring": "1.31.15-beta.0",
52
- "@percy/webdriver-utils": "1.31.15-beta.0",
47
+ "@grpc/grpc-js": "^1.14.3",
48
+ "@grpc/proto-loader": "^0.8.0",
49
+ "@percy/client": "1.32.0-beta.1",
50
+ "@percy/config": "1.32.0-beta.1",
51
+ "@percy/dom": "1.32.0-beta.1",
52
+ "@percy/logger": "1.32.0-beta.1",
53
+ "@percy/monitoring": "1.32.0-beta.1",
54
+ "@percy/webdriver-utils": "1.32.0-beta.1",
55
+ "busboy": "^1.6.0",
53
56
  "content-disposition": "^0.5.4",
54
57
  "cross-spawn": "^7.0.3",
55
58
  "extract-zip": "^2.0.1",
56
59
  "fast-glob": "^3.2.11",
60
+ "fast-xml-parser": "^4.4.1",
57
61
  "micromatch": "^4.0.8",
58
62
  "mime-types": "^2.1.34",
59
63
  "pako": "^2.1.0",
@@ -63,7 +67,7 @@
63
67
  "yaml": "^2.4.1"
64
68
  },
65
69
  "optionalDependencies": {
66
- "@percy/cli-doctor": "1.31.15-beta.0"
70
+ "@percy/cli-doctor": "1.32.0-beta.1"
67
71
  },
68
- "gitHead": "b2012ce5dae37e5009dd3f4190454bb9d9d118e3"
72
+ "gitHead": "899783cc84e5fd1b68c701c251cd8cabcbe35fd1"
69
73
  }
@@ -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