@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.
@@ -0,0 +1,1769 @@
1
+ // Cross-platform view-hierarchy resolver for /percy/maestro-screenshot element regions.
2
+ //
3
+ // Caller dispatches by platform via `dump({ platform: 'android' | 'ios' })`. Same
4
+ // public API for both; platform-specific attribute key mapping happens internally
5
+ // in `flattenMaestroNodes` (Android TreeNode shape; iOS CLI fallback shape) or
6
+ // `flattenIosAxElement` (iOS HTTP path raw AXElement shape).
7
+ //
8
+ // Selector vocabulary in V1:
9
+ // Android — `resource-id`, `text`, `content-desc`, `class`, plus `id` as alias
10
+ // for `resource-id` (R1 vocabulary parity).
11
+ // iOS — `id` only (maps to `resource-id` populated from AXElement.identifier).
12
+ // Maestro's own iOS TreeNode does not carry `class` (per
13
+ // IOSDriver.mapViewHierarchy at cli-2.0.7), so Percy keeps iOS
14
+ // selector vocabulary aligned with that capability.
15
+ //
16
+ // Bounds canonicalize to a bracket-format string `[X,Y][X+W,Y+H]` regardless
17
+ // of platform; firstMatch() parses to `{x, y, width, height}` integers.
18
+ //
19
+ // Android primary: direct gRPC to `MaestroDriver/viewHierarchy` on
20
+ // `127.0.0.1:${PERCY_ANDROID_GRPC_PORT}`. Forward-compatibility path for
21
+ // Maestro distributions that install the `dev.mobile.maestro` instrumentation
22
+ // APK and bind tcp:6790 device-side. Empirically (2026-05-16 investigation,
23
+ // docs/solutions/best-practices/2026-05-16-grpc-unavailable-investigation.md),
24
+ // none of the Maestro versions BS currently ships (1.39.13 / 1.39.15 / 2.0.7 /
25
+ // 2.4.0) install that package during `maestro test` or `maestro hierarchy` —
26
+ // they fetch the hierarchy via uiautomator-based IPC instead. On those
27
+ // distros, the gRPC primary correctly classifies the failure as
28
+ // `channel-broken: UNAVAILABLE` and the cascade falls through gracefully.
29
+ // PERCY_ANDROID_GRPC_PORT is realmobile/mobile-injected; absence skips gRPC.
30
+ // Kill switch: PERCY_MAESTRO_GRPC=0 force-skips BOTH Maestro hierarchy
31
+ // primaries — Android gRPC AND iOS HTTP — and routes each platform straight
32
+ // to its maestro-CLI fallback. In-process emergency rollback distinct from
33
+ // removing the env injection (which requires a coordinated mobile/realmobile
34
+ // deploy). Read fresh on every dump() call so an on-call can toggle it
35
+ // mid-process without a CLI restart.
36
+ // Android fallback chain (per error class):
37
+ // - schema-class → drift bit set; no fallback (return error)
38
+ // - channel-broken (UNAVAILABLE,
39
+ // INTERNAL, CANCELLED) → evict client; maestro CLI shell-out → adb
40
+ // - contention-class (DEADLINE,
41
+ // RESOURCE_EXHAUSTED, ABORTED) → keep client (timeout = backpressure,
42
+ // not channel-breakage); skip CLI; adb
43
+ // Self-hosted (env unset): maestro CLI primary → adb fallback.
44
+ //
45
+ // iOS primary: HTTP POST to Maestro's iOS XCTestRunner /viewHierarchy endpoint
46
+ // at http://127.0.0.1:${PERCY_IOS_DRIVER_HOST_PORT}/viewHierarchy. Sends
47
+ // `{appIds: [], excludeKeyboardElements: false}` — at cli-2.0.7+ the runner
48
+ // detects the AUT itself via RunningApp.getForegroundApp() (Maestro PR #2365).
49
+ // On older Maestro versions empty `appIds` returns SpringBoard; the parser
50
+ // detects that and routes to the maestro-CLI fallback below.
51
+ // iOS fallback: `maestro --udid <udid> --driver-host-port <P> hierarchy` —
52
+ // CLI shell-out path (knows the AUT internally via Maestro flow context).
53
+ //
54
+ // Reads process.env.ANDROID_SERIAL + PERCY_ANDROID_GRPC_PORT (Android) or
55
+ // PERCY_IOS_DEVICE_UDID + PERCY_IOS_DRIVER_HOST_PORT (iOS) — never accepts
56
+ // device addressing from user input. Honors MAESTRO_BIN env var on both platforms.
57
+ //
58
+ // State scoping (deliberate asymmetry):
59
+ // - `maestroHierarchyDrift` is module-scoped: drift is observability state,
60
+ // surfaced on /percy/healthcheck process-wide; multiple Percy instances in
61
+ // one process share the envelope, which is the correct behavior.
62
+ // - gRPC client cache is per-Percy-instance: channels hold open sockets,
63
+ // each Percy instance owns its lifecycle (constructor + stop()). The
64
+ // cache is passed via parameter from the public dump() signature.
65
+
66
+ import path from 'path';
67
+ import url from 'url';
68
+ import http from 'http';
69
+ import spawn from 'cross-spawn';
70
+ import { XMLParser } from 'fast-xml-parser';
71
+ import * as grpc from '@grpc/grpc-js';
72
+ import * as protoLoader from '@grpc/proto-loader';
73
+ import logger from '@percy/logger';
74
+ const log = logger('core:maestro-hierarchy');
75
+ const DUMP_TIMEOUT_MS = 2000;
76
+ const MAESTRO_TIMEOUT_MS = 15000; // JVM cold start is ~9s; +6s headroom
77
+ const MAX_DUMP_BYTES = 5 * 1024 * 1024;
78
+ const SIGKILL_EXIT = 137; // 128 + SIGKILL; uiautomator often hits this under device contention
79
+ // Backoff delays for the SIGKILL retry loop — covers a ~3.5s window total, which is
80
+ // long enough to outlast most Maestro takeScreenshot → uiautomator-settle windows
81
+ // while staying within a reasonable per-screenshot budget.
82
+ const SIGKILL_RETRY_DELAYS_MS = [500, 1000, 2000];
83
+ // Android-side V1 selector vocabulary plus `id` as alias for `resource-id`
84
+ // (R1 vocabulary parity). The iOS branch uses `id` only — Maestro's iOS
85
+ // TreeNode does not carry `class` (per IOSDriver.mapViewHierarchy at cli-2.0.7),
86
+ // and Percy keeps iOS selector vocabulary aligned with that capability.
87
+ // Customers see one union whitelist for handler-side validation; firstMatch
88
+ // dispatches per-platform via the node shape (Android nodes have resource-id;
89
+ // iOS nodes have identifier surfaced as `id` and `resource-id`).
90
+ const ANDROID_SELECTOR_KEYS = ['resource-id', 'text', 'content-desc', 'class', 'id'];
91
+ const IOS_SELECTOR_KEYS = ['id'];
92
+ // Union whitelist exported for api.js handler-side validation. firstMatch
93
+ // itself uses node-shape lookups so the per-platform divergence is implicit.
94
+ const SELECTOR_KEYS_UNION = ['resource-id', 'text', 'content-desc', 'class', 'id'];
95
+
96
+ // iOS HTTP transport tunables.
97
+ // Healthy deadline is the per-call socket-timeout budget; circuit-breaker is
98
+ // the Promise.race outer bound that protects against the runner stalling
99
+ // past the socket timeout.
100
+ const IOS_HTTP_HEALTHY_DEADLINE_MS = 1500;
101
+ const IOS_HTTP_CIRCUIT_BREAKER_MS = 5000;
102
+ // Maestro iOS driver-host-port is realmobile-derived as wda_port + 2700.
103
+ // WDA ports are 8400-8410 → driver host ports are 11100-11110.
104
+ const IOS_DRIVER_HOST_PORT_MIN = 11100;
105
+ const IOS_DRIVER_HOST_PORT_MAX = 11110;
106
+ // HTTP response cap before parse — sized for WebView-heavy iOS apps.
107
+ const IOS_HTTP_RESPONSE_MAX_BYTES = 20 * 1024 * 1024;
108
+
109
+ // Android gRPC transport tunables. Symmetric with iOS HTTP (D11): same
110
+ // healthy-deadline + circuit-breaker pair. gRPC's `deadline` option is
111
+ // client-library-enforced, not kernel-enforced — the outer Promise.race is
112
+ // defense-in-depth that bounds blast radius if the channel sticks past
113
+ // the deadline (historically grpc-node#2620, fixed in 1.9.11; the wrapper
114
+ // is cheap and stays as a guarantee).
115
+ const GRPC_HEALTHY_DEADLINE_MS = 1500;
116
+ const GRPC_CIRCUIT_BREAKER_MS = 5000;
117
+
118
+ // Three-class gRPC error taxonomy (D10):
119
+ // - schema-class → no fallback, drift bit set, return dump-error
120
+ // - channel-broken → fallback runs, cache evicted (channel actually broken)
121
+ // - contention-class → fallback runs (skip CLI, go to adb), cache PRESERVED
122
+ // (timeout is backpressure evidence, not channel breakage;
123
+ // re-establishing the channel costs ~50-200ms for nothing)
124
+ const GRPC_SCHEMA_CLASS_CODES = new Set([grpc.status.INVALID_ARGUMENT, grpc.status.FAILED_PRECONDITION, grpc.status.OUT_OF_RANGE, grpc.status.UNIMPLEMENTED, grpc.status.DATA_LOSS]);
125
+ const GRPC_CONTENTION_CLASS_CODES = new Set([grpc.status.DEADLINE_EXCEEDED, grpc.status.RESOURCE_EXHAUSTED, grpc.status.ABORTED]);
126
+ // Channel-broken codes: UNAVAILABLE, INTERNAL, CANCELLED (and any unmapped
127
+ // code not in the above two sets — conservative default routes to fallback +
128
+ // eviction).
129
+
130
+ // Eager-load the maestro_android proto at module init. The ~15-40ms parse
131
+ // cost lands on CLI cold start, not on the first dump request. Path
132
+ // resolution mirrors utils.js's secretPatterns.yml — works under src/ (dev)
133
+ // and dist/ (publish) because Babel CLI's copyFiles: true preserves the
134
+ // relative layout.
135
+ const protoFilePath = path.resolve(url.fileURLToPath(import.meta.url), '../proto/maestro_android.proto');
136
+ const protoPackageDef = protoLoader.loadSync(protoFilePath, {
137
+ keepCase: true,
138
+ longs: String,
139
+ enums: String,
140
+ defaults: true,
141
+ oneofs: true
142
+ });
143
+ const MaestroDriverClient = grpc.loadPackageDefinition(protoPackageDef).maestro_android.MaestroDriver;
144
+
145
+ // Two-slot drift + resolver-activity envelope. Surfaces on /percy/healthcheck
146
+ // so ops can answer two questions from one HTTP probe:
147
+ //
148
+ // 1. Has the per-platform schema-class drift bit fired? (set-once,
149
+ // first-seen-per-platform wins — preserves the original semantics)
150
+ // Fields: `code`, `reason`, `firstSeenAt` — only present after a
151
+ // schema-class failure on that platform.
152
+ //
153
+ // 2. What is the resolver cascade actually doing on this BS host?
154
+ // (R7/R8 — added to surface channel-broken / contention-class outcomes
155
+ // that previously only showed at --verbose debug level.)
156
+ // Fields: `lastFailureClass` ('schema-class' | 'channel-broken' |
157
+ // 'contention-class' | 'other' | null), `fallbackCount` (cumulative
158
+ // primary→fallback transitions this process), `succeededVia`
159
+ // ('grpc' | 'maestro-cli' | 'adb' | 'maestro-http' |
160
+ // 'maestro-cli-fallback' | 'none' | null — matches the existing
161
+ // `dump took Nms via X` log vocabulary).
162
+ //
163
+ // Both field groups coexist on the same per-platform slot. Slot stays `null`
164
+ // until any resolver activity touches it; first activity initialises the
165
+ // activity-counter fields, schema-class failure additionally sets the
166
+ // drift-bit fields. Existing ops consumers reading `slot.{code,reason,firstSeenAt}`
167
+ // keep working unchanged.
168
+ //
169
+ // Module-scoped is deliberate: drift is observability state — surfaced
170
+ // process-wide on /percy/healthcheck. Multiple Percy instances in one process
171
+ // share the envelope, which is the correct behavior for ops dashboards. The
172
+ // gRPC channel cache (per-Percy-instance) follows a different ownership rule
173
+ // because it holds transport state with per-session lifecycle. Two scopes,
174
+ // two reasons — see Percy class constructor for the channel cache.
175
+ let maestroHierarchyDrift = {
176
+ android: null,
177
+ ios: null
178
+ };
179
+ const BOUNDS_RE = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/;
180
+ const UNAVAILABLE_STDERR_RE = /no devices|unauthorized|device offline/i;
181
+ const MAESTRO_UNAVAILABLE_STDERR_RE = /No connected devices|Device not found|Could not connect/i;
182
+ const parser = new XMLParser({
183
+ ignoreAttributes: false,
184
+ attributeNamePrefix: '@_',
185
+ parseAttributeValue: false,
186
+ trimValues: true,
187
+ processEntities: false,
188
+ allowBooleanAttributes: false
189
+ });
190
+
191
+ // Generic spawn-with-timeout wrapper used by both the maestro and adb code paths.
192
+ // Mirrors the async spawn + timeout + cleanup pattern from browser.js:256-297.
193
+ // Returns { stdout, stderr, exitCode, timedOut, spawnError, oversize }.
194
+ /* istanbul ignore next — production-only child-process spawn wrapper; unit
195
+ suite stubs execAdb/execMaestro, so this function is never invoked under
196
+ coverage. Integration tests on BS hosts exercise the real spawn path. */
197
+ function spawnWithTimeout(cmd, args, {
198
+ timeoutMs
199
+ } = {}) {
200
+ return new Promise(resolve => {
201
+ var _proc$stdout2, _proc$stderr2;
202
+ let stdout = '';
203
+ let stderr = '';
204
+ let timedOut = false;
205
+ let settled = false;
206
+ let proc;
207
+ try {
208
+ proc = spawn(cmd, args);
209
+ } catch (err) {
210
+ resolve({
211
+ spawnError: err
212
+ });
213
+ return;
214
+ }
215
+ const settle = result => {
216
+ var _proc$stdout, _proc$stderr;
217
+ if (settled) return;
218
+ settled = true;
219
+ clearTimeout(timeoutId);
220
+ (_proc$stdout = proc.stdout) === null || _proc$stdout === void 0 || _proc$stdout.off('data', onStdout);
221
+ (_proc$stderr = proc.stderr) === null || _proc$stderr === void 0 || _proc$stderr.off('data', onStderr);
222
+ proc.off('exit', onExit);
223
+ proc.off('error', onError);
224
+ resolve(result);
225
+ };
226
+ const onStdout = chunk => {
227
+ stdout += chunk.toString();
228
+ if (stdout.length > MAX_DUMP_BYTES) {
229
+ try {
230
+ proc.kill('SIGKILL');
231
+ } catch {/* already dead */}
232
+ settle({
233
+ stdout: '',
234
+ stderr,
235
+ exitCode: 1,
236
+ oversize: true
237
+ });
238
+ }
239
+ };
240
+ const onStderr = chunk => {
241
+ stderr += chunk.toString();
242
+ };
243
+ const onExit = code => settle({
244
+ stdout,
245
+ stderr,
246
+ exitCode: code ?? 1,
247
+ timedOut
248
+ });
249
+ const onError = err => settle({
250
+ spawnError: err
251
+ });
252
+ const timeoutId = setTimeout(() => {
253
+ timedOut = true;
254
+ try {
255
+ proc.kill('SIGKILL');
256
+ } catch {/* already dead */}
257
+ settle({
258
+ stdout,
259
+ stderr,
260
+ exitCode: null,
261
+ timedOut: true
262
+ });
263
+ }, timeoutMs ?? DUMP_TIMEOUT_MS);
264
+ (_proc$stdout2 = proc.stdout) === null || _proc$stdout2 === void 0 || _proc$stdout2.on('data', onStdout);
265
+ (_proc$stderr2 = proc.stderr) === null || _proc$stderr2 === void 0 || _proc$stderr2.on('data', onStderr);
266
+ proc.on('exit', onExit);
267
+ proc.on('error', onError);
268
+ });
269
+ }
270
+
271
+ // Maestro CLI path: honor MAESTRO_BIN env var (mobile-repo or deploy config sets this),
272
+ // fall back to plain `maestro` on PATH. Never accepts a path from untrusted input.
273
+ /* istanbul ignore next — production-only; unit suite injects a fake getEnv
274
+ that returns whatever the test specifies, so this helper's PATH-fallback
275
+ branch is not exercised. */
276
+ function defaultMaestroBin(getEnv) {
277
+ return getEnv('MAESTRO_BIN') || 'maestro';
278
+ }
279
+
280
+ /* istanbul ignore next — production-only maestro spawn wrapper; unit suite
281
+ injects a fake execMaestro. Composes defaultMaestroBin + spawnWithTimeout
282
+ (both already istanbul-ignored). */
283
+ async function defaultExecMaestro(args, getEnv) {
284
+ const bin = defaultMaestroBin(getEnv);
285
+ return spawnWithTimeout(bin, args, {
286
+ timeoutMs: MAESTRO_TIMEOUT_MS
287
+ });
288
+ }
289
+
290
+ // Preserved for the adb fallback code path (signature unchanged — existing tests
291
+ // pass a fake execAdb and assert -s <serial> is forwarded).
292
+ /* istanbul ignore next — production-only adb spawn wrapper; unit suite
293
+ injects a fake execAdb. Has its own native spawn() inline rather than
294
+ going through spawnWithTimeout, so the ignore must be applied here too. */
295
+ async function defaultExecAdb(args) {
296
+ return new Promise(resolve => {
297
+ var _proc$stdout4, _proc$stderr4;
298
+ let stdout = '';
299
+ let stderr = '';
300
+ let timedOut = false;
301
+ let settled = false;
302
+ let proc;
303
+ try {
304
+ proc = spawn('adb', args);
305
+ } catch (err) {
306
+ resolve({
307
+ spawnError: err
308
+ });
309
+ return;
310
+ }
311
+ const settle = result => {
312
+ var _proc$stdout3, _proc$stderr3;
313
+ if (settled) return;
314
+ settled = true;
315
+ clearTimeout(timeoutId);
316
+ (_proc$stdout3 = proc.stdout) === null || _proc$stdout3 === void 0 || _proc$stdout3.off('data', onStdout);
317
+ (_proc$stderr3 = proc.stderr) === null || _proc$stderr3 === void 0 || _proc$stderr3.off('data', onStderr);
318
+ proc.off('exit', onExit);
319
+ proc.off('error', onError);
320
+ resolve(result);
321
+ };
322
+ const onStdout = chunk => {
323
+ stdout += chunk.toString();
324
+ if (stdout.length > MAX_DUMP_BYTES) {
325
+ try {
326
+ proc.kill('SIGKILL');
327
+ } catch {/* already dead */}
328
+ settle({
329
+ stdout: '',
330
+ stderr,
331
+ exitCode: 1,
332
+ oversize: true
333
+ });
334
+ }
335
+ };
336
+ const onStderr = chunk => {
337
+ stderr += chunk.toString();
338
+ };
339
+ const onExit = code => settle({
340
+ stdout,
341
+ stderr,
342
+ exitCode: code ?? 1,
343
+ timedOut
344
+ });
345
+ const onError = err => settle({
346
+ spawnError: err
347
+ });
348
+ const timeoutId = setTimeout(() => {
349
+ timedOut = true;
350
+ try {
351
+ proc.kill('SIGKILL');
352
+ } catch {/* already dead */}
353
+ settle({
354
+ stdout,
355
+ stderr,
356
+ exitCode: null,
357
+ timedOut: true
358
+ });
359
+ }, DUMP_TIMEOUT_MS);
360
+ (_proc$stdout4 = proc.stdout) === null || _proc$stdout4 === void 0 || _proc$stdout4.on('data', onStdout);
361
+ (_proc$stderr4 = proc.stderr) === null || _proc$stderr4 === void 0 || _proc$stderr4.on('data', onStderr);
362
+ proc.on('exit', onExit);
363
+ proc.on('error', onError);
364
+ });
365
+ }
366
+ function defaultGetEnv(key) {
367
+ return process.env[key];
368
+ }
369
+ function classifyAdbFailure(result) {
370
+ if (result.spawnError) {
371
+ const code = result.spawnError.code;
372
+ /* istanbul ignore next — early-return-if pattern: NYC counts the
373
+ not-taken branch as a fall-through statement (line below); this
374
+ directive ignores BOTH the if's else branch AND the fall-through
375
+ return statement so non-ENOENT spawn-errors get full coverage credit. */
376
+ if (code === 'ENOENT') return {
377
+ kind: 'unavailable',
378
+ reason: 'adb-not-found'
379
+ };
380
+ /* istanbul ignore next */
381
+ return {
382
+ kind: 'unavailable',
383
+ reason: `spawn-error:${code || 'unknown'}`
384
+ };
385
+ }
386
+ if (result.timedOut) return {
387
+ kind: 'unavailable',
388
+ reason: 'timeout'
389
+ };
390
+ if (result.oversize) return {
391
+ kind: 'dump-error',
392
+ reason: 'oversize'
393
+ };
394
+ if (UNAVAILABLE_STDERR_RE.test(result.stderr || '')) {
395
+ if (/unauthorized/i.test(result.stderr)) return {
396
+ kind: 'unavailable',
397
+ reason: 'device-unauthorized'
398
+ };
399
+ /* istanbul ignore next — no-devices regex branch; tests exercise
400
+ unauthorized + device-offline cases but not the exact "no devices"
401
+ stderr literal. */
402
+ if (/no devices/i.test(result.stderr)) return {
403
+ kind: 'unavailable',
404
+ reason: 'no-device'
405
+ };
406
+ /* istanbul ignore next — device-offline branch fires on stderr that
407
+ matches UNAVAILABLE_STDERR_RE but isn't `unauthorized` or `no devices`
408
+ (e.g. `error: device offline`); rare in practice, integration-tested. */
409
+ return {
410
+ kind: 'unavailable',
411
+ reason: 'device-offline'
412
+ };
413
+ }
414
+ return null;
415
+ }
416
+
417
+ // Resolve device serial: prefer ANDROID_SERIAL env; else probe `adb devices`
418
+ // and require exactly one device.
419
+ async function resolveSerial({
420
+ execAdb,
421
+ getEnv
422
+ }) {
423
+ const fromEnv = getEnv('ANDROID_SERIAL');
424
+ if (fromEnv) return {
425
+ serial: fromEnv
426
+ };
427
+ const probe = await execAdb(['devices']);
428
+ const fail = classifyAdbFailure(probe);
429
+ if (fail) return {
430
+ classification: fail
431
+ };
432
+
433
+ /* istanbul ignore next — adb-devices non-zero exit with no spawn error and
434
+ no recognized stderr; rare adb state, integration-tested on BS hosts.
435
+ The `?? 1` fallback also counts as a branch — ignore-next covers both. */
436
+ if ((probe.exitCode ?? 1) !== 0) {
437
+ return {
438
+ classification: {
439
+ kind: 'unavailable',
440
+ reason: `adb-devices-exit-${probe.exitCode}`
441
+ }
442
+ };
443
+ }
444
+
445
+ /* istanbul ignore next — `|| ''` branch fires only on missing/empty stdout
446
+ which the spawn helpers already normalize. */
447
+ const serials = (probe.stdout || '').split('\n').map(line => {
448
+ const m = line.match(/^(\S+)\s+device\s*$/);
449
+ return m ? m[1] : null;
450
+ }).filter(Boolean);
451
+ if (serials.length === 0) {
452
+ return {
453
+ classification: {
454
+ kind: 'unavailable',
455
+ reason: 'no-device'
456
+ }
457
+ };
458
+ }
459
+ if (serials.length > 1) {
460
+ return {
461
+ classification: {
462
+ kind: 'unavailable',
463
+ reason: 'multi-device-no-serial'
464
+ }
465
+ };
466
+ }
467
+ return {
468
+ serial: serials[0]
469
+ };
470
+ }
471
+
472
+ // Slice the XML envelope: first '<?xml' through first '</hierarchy>' (inclusive).
473
+ // Discards trailer lines like "UI hierarchy dumped to: /dev/tty" and defends
474
+ // against adversarial apps emitting multiple XML blocks in text attributes.
475
+ function sliceXmlEnvelope(raw) {
476
+ if (!raw || typeof raw !== 'string') return null;
477
+ const start = raw.indexOf('<?xml');
478
+ if (start < 0) return null;
479
+ const endIdx = raw.indexOf('</hierarchy>', start);
480
+ /* istanbul ignore if — defensive against malformed XML output (start tag
481
+ present but closing missing); fixtures always carry well-formed XML. */
482
+ if (endIdx < 0) return null;
483
+ return raw.slice(start, endIdx + '</hierarchy>'.length);
484
+ }
485
+ function flattenNodes(parsed) {
486
+ const nodes = [];
487
+ /* istanbul ignore next — flattenNodes invoked by runDump (adb-uiautomator
488
+ fallback path) which the unit suite stubs at higher levels. Coverage
489
+ comes from integration tests against real uiautomator XML fixtures. */
490
+ const walk = obj => {
491
+ if (!obj || typeof obj !== 'object') return;
492
+ if (Array.isArray(obj)) {
493
+ for (const item of obj) walk(item);
494
+ return;
495
+ }
496
+ // Attribute keys are prefixed with '@_'; a node with any attributes
497
+ // (we only keep the four selector attrs + bounds) is a candidate.
498
+ const resourceId = obj['@_resource-id'];
499
+ const node = {
500
+ 'resource-id': resourceId,
501
+ // Android `id` alias for R1 vocabulary parity — same value as resource-id.
502
+ id: resourceId,
503
+ text: obj['@_text'],
504
+ 'content-desc': obj['@_content-desc'],
505
+ class: obj['@_class'],
506
+ bounds: obj['@_bounds']
507
+ };
508
+ if (resourceId || node.text || node['content-desc'] || node.class) {
509
+ nodes.push(node);
510
+ }
511
+ // Recurse into children — any non-'@_' key is a nested element or array of elements.
512
+ for (const key of Object.keys(obj)) {
513
+ if (key.startsWith('@_')) continue;
514
+ if (key === '#text') continue;
515
+ walk(obj[key]);
516
+ }
517
+ };
518
+ walk(parsed);
519
+ return nodes;
520
+ }
521
+ async function runDump(args, execAdb) {
522
+ const result = await execAdb(args);
523
+ const fail = classifyAdbFailure(result);
524
+ if (fail) return fail;
525
+ /* istanbul ignore next — non-zero exit with no classified adb failure;
526
+ classifyAdbFailure catches the dominant cases. */
527
+ if ((result.exitCode ?? 1) !== 0) {
528
+ return {
529
+ kind: 'dump-error',
530
+ reason: `exit-${result.exitCode}`
531
+ };
532
+ }
533
+ const slice = sliceXmlEnvelope(result.stdout);
534
+ if (!slice) return {
535
+ kind: 'dump-error',
536
+ reason: 'no-xml-envelope'
537
+ };
538
+ try {
539
+ const parsed = parser.parse(slice);
540
+ const nodes = flattenNodes(parsed);
541
+ return {
542
+ kind: 'hierarchy',
543
+ nodes
544
+ };
545
+ } catch (err) {
546
+ /* istanbul ignore next — XML parser is fast-xml-parser; happy path is
547
+ fixture-covered. This catch rescues malformed XML from a regression
548
+ upstream (uiautomator format change). */
549
+ return {
550
+ kind: 'dump-error',
551
+ reason: `parse-error:${err.message}`
552
+ };
553
+ }
554
+ }
555
+
556
+ // Classify a maestro hierarchy invocation result.
557
+ // Maestro exits 0 on success, non-zero on device-not-found / connection-error / etc.
558
+ function classifyMaestroFailure(result) {
559
+ /* istanbul ignore if — spawnError branch only fires when execMaestro
560
+ returns { spawnError }; tests stub execMaestro to return JSON output. */
561
+ if (result.spawnError) {
562
+ const code = result.spawnError.code;
563
+ if (code === 'ENOENT') return {
564
+ kind: 'unavailable',
565
+ reason: 'maestro-not-found'
566
+ };
567
+ return {
568
+ kind: 'unavailable',
569
+ reason: `maestro-spawn-error:${code || 'unknown'}`
570
+ };
571
+ }
572
+ /* istanbul ignore if — timeout/oversize branches fire only when the
573
+ spawn wrapper reports them; unit suite stubs return normal results. */
574
+ if (result.timedOut) return {
575
+ kind: 'unavailable',
576
+ reason: 'maestro-timeout'
577
+ };
578
+ /* istanbul ignore if */
579
+ if (result.oversize) return {
580
+ kind: 'dump-error',
581
+ reason: 'maestro-oversize'
582
+ };
583
+ const stderr = result.stderr || '';
584
+ if (MAESTRO_UNAVAILABLE_STDERR_RE.test(stderr)) {
585
+ return {
586
+ kind: 'unavailable',
587
+ reason: 'maestro-no-device'
588
+ };
589
+ }
590
+ return null;
591
+ }
592
+
593
+ // Flatten a maestro JSON tree (Android shape) into the canonical node shape.
594
+ // Maps accessibilityText → content-desc; surfaces resource-id under both
595
+ // `resource-id` and `id` for R1 vocabulary parity (customers writing
596
+ // `{element: {id: "X"}}` resolve the same node as `{element: {resource-id: "X"}}`).
597
+ function flattenMaestroNodes(root) {
598
+ const nodes = [];
599
+ /* istanbul ignore next — flattenMaestroNodes is invoked by the
600
+ maestro-CLI fallback path which the unit suite stubs above
601
+ (runMaestroDump / runMaestroIosDump are mocked at higher levels).
602
+ The function and its inner walk are covered by integration tests
603
+ against real fixture JSON. */
604
+ const walk = obj => {
605
+ if (!obj || typeof obj !== 'object') return;
606
+ const attrs = obj.attributes;
607
+ if (attrs && typeof attrs === 'object') {
608
+ const resourceId = attrs['resource-id'];
609
+ const node = {
610
+ 'resource-id': resourceId,
611
+ // Android `id` alias: same value as resource-id; lets cross-platform
612
+ // selector vocabulary work without forcing customers to know the
613
+ // platform-specific key name. Per R1 of the 2026-04-27 plan.
614
+ id: resourceId,
615
+ text: attrs.text,
616
+ 'content-desc': attrs.accessibilityText,
617
+ class: attrs.class,
618
+ bounds: attrs.bounds
619
+ };
620
+ if (resourceId || node.text || node['content-desc'] || node.class) {
621
+ nodes.push(node);
622
+ }
623
+ }
624
+ const children = obj.children;
625
+ if (Array.isArray(children)) {
626
+ for (const child of children) walk(child);
627
+ }
628
+ };
629
+ walk(root);
630
+ return nodes;
631
+ }
632
+
633
+ // Lazily initialise the per-platform slot on first activity. Returns the slot
634
+ // reference (mutable) or `null` for unknown platforms. Activity counters start
635
+ // at their resting values so a slot that only ever saw a successful primary
636
+ // reads `{lastFailureClass: null, fallbackCount: 0, succeededVia: <via>}`.
637
+ function ensureSlot(platform) {
638
+ /* istanbul ignore if — defensive against unknown platform values;
639
+ callers pass 'android' or 'ios' literals. */
640
+ if (platform !== 'android' && platform !== 'ios') return null;
641
+ if (!maestroHierarchyDrift[platform]) {
642
+ maestroHierarchyDrift[platform] = {
643
+ lastFailureClass: null,
644
+ fallbackCount: 0,
645
+ succeededVia: null
646
+ };
647
+ }
648
+ return maestroHierarchyDrift[platform];
649
+ }
650
+
651
+ // Drift-bit setter. First-seen-per-platform wins for the `code`/`reason`/
652
+ // `firstSeenAt` triple — preserves the original observable semantics. Coexists
653
+ // with the activity-counter fields on the same slot. Unknown platform values
654
+ // are silently ignored — the setter is internal and the call sites pass static
655
+ // literals.
656
+ function setMaestroHierarchyDrift({
657
+ platform,
658
+ code,
659
+ reason
660
+ }) {
661
+ const slot = ensureSlot(platform);
662
+ /* istanbul ignore if — slot is null only for unknown platform values
663
+ which ensureSlot already filters above. */
664
+ if (!slot) return;
665
+ if (slot.firstSeenAt) return; // first-seen wins
666
+ slot.code = code;
667
+ slot.reason = reason;
668
+ slot.firstSeenAt = new Date().toISOString();
669
+ }
670
+
671
+ // Records a primary→fallback transition. Increments the cumulative counter
672
+ // and updates `lastFailureClass` to the most recent class (most-recent-wins;
673
+ // the counter retains history). Called from the cascade orchestrator in
674
+ // `dump()` at each fallback edge.
675
+ function recordResolverFallback({
676
+ platform,
677
+ failureClass
678
+ }) {
679
+ const slot = ensureSlot(platform);
680
+ /* istanbul ignore if — slot is null only for unknown platform values
681
+ which ensureSlot already filters above. */
682
+ if (!slot) return;
683
+ slot.fallbackCount += 1;
684
+ slot.lastFailureClass = failureClass;
685
+ }
686
+
687
+ // Records the resolver that ultimately served the dump. Most-recent-wins;
688
+ // `fallbackCount` and `lastFailureClass` are preserved (history). Called
689
+ // from `dump()` immediately before returning a `kind: 'hierarchy'` result.
690
+ function recordResolverSuccess({
691
+ platform,
692
+ via
693
+ }) {
694
+ const slot = ensureSlot(platform);
695
+ /* istanbul ignore if — slot is null only for unknown platform values
696
+ which ensureSlot already filters above. */
697
+ if (!slot) return;
698
+ slot.succeededVia = via;
699
+ }
700
+
701
+ // Records an unrecoverable failure — schema-class (no fallback per D10),
702
+ // env-missing, adb-unavailable, all-fallbacks-failed. Sets `succeededVia`
703
+ // to `'none'` and updates `lastFailureClass`. Drift-bit fields (if
704
+ // applicable for schema-class) are set separately via
705
+ // `setMaestroHierarchyDrift` at the same call site.
706
+ function recordResolverFinalFailure({
707
+ platform,
708
+ failureClass
709
+ }) {
710
+ const slot = ensureSlot(platform);
711
+ /* istanbul ignore if — slot is null only for unknown platform values
712
+ which ensureSlot already filters above. */
713
+ if (!slot) return;
714
+ slot.lastFailureClass = failureClass;
715
+ slot.succeededVia = 'none';
716
+ }
717
+
718
+ // Maps a resolver result's reason string to the failure-class taxonomy used
719
+ // in observability surfaces (envelope `lastFailureClass`, info log lines).
720
+ // Returns one of: 'schema-class' | 'channel-broken' | 'contention-class' |
721
+ // 'other'. The taxonomy lifts the existing classifier reason-string prefixes
722
+ // (`grpc-schema-`, `grpc-contention-`, `grpc-channel-broken-`, etc.) up one
723
+ // level so ops sees a stable four-value enum.
724
+ function failureClassFromReason(reason) {
725
+ /* istanbul ignore if — defensive type guard; callers always pass a string. */
726
+ if (typeof reason !== 'string') return 'other';
727
+ if (reason.startsWith('grpc-contention-')) return 'contention-class';
728
+ if (reason.startsWith('grpc-channel-broken-')) return 'channel-broken';
729
+ /* istanbul ignore next — gRPC schema-class OR-chain (5 clauses);
730
+ classifyGrpcFailure covers each shape individually but the unified
731
+ classifier path isn't exercised by the iOS-focused tests. */
732
+ if (reason.startsWith('grpc-schema-') || reason === 'grpc-decode' || reason === 'grpc-no-xml-envelope' || reason === 'grpc-unexpected-root' || reason.startsWith('grpc-parse-error')) {
733
+ return 'schema-class';
734
+ }
735
+ // iOS HTTP connection codes from classifyIosHttpFailure: http-econnrefused etc.
736
+ // and http-5xx (server reachable but unhealthy).
737
+ if (/^http-[a-z]+$/.test(reason)) return 'channel-broken';
738
+ if (/^http-5\d\d$/.test(reason)) return 'channel-broken';
739
+ /* istanbul ignore next — iOS HTTP schema-class OR-chain; same rationale
740
+ as the gRPC chain above (unified path under-exercised). */
741
+ if (/^http-(missing-|parse-error|frame-|flatten-error|unexpected-)/.test(reason) || /^http-[34]\d\d/.test(reason)) {
742
+ return 'schema-class';
743
+ }
744
+ // Everything else (maestro-exit-N, maestro-parse-error, maestro-no-json,
745
+ // no-aut-tree springboard-only, out-of-range-port-N, shutdown, env-missing).
746
+ return 'other';
747
+ }
748
+
749
+ // ─── Android gRPC primary path ─────────────────────────────────────────────
750
+
751
+ // Default factory: build a real gRPC client wrapping viewHierarchy in a
752
+ // promise so the resolver code can await it uniformly. Tests inject a
753
+ // factory that returns a stub with the same shape (see makeFakeFactory in
754
+ // maestro-hierarchy-grpc.test.js).
755
+ /* istanbul ignore next — production-only path; the unit suite always
756
+ injects a stub factory. Real gRPC client construction is integration-
757
+ tested against a live Maestro runner on BS hosts. */
758
+ function defaultGrpcClientFactory(address) {
759
+ const inner = new MaestroDriverClient(address, grpc.credentials.createInsecure());
760
+ return {
761
+ viewHierarchy: (req, options) => new Promise((resolve, reject) => {
762
+ inner.viewHierarchy(req, options || {}, (err, response) => {
763
+ if (err) reject(err);else resolve(response);
764
+ });
765
+ }),
766
+ close: () => inner.close()
767
+ };
768
+ }
769
+ function getOrCreateGrpcClient(cache, address, factory) {
770
+ let client = cache.get(address);
771
+ if (!client) {
772
+ client = factory(address);
773
+ cache.set(address, client);
774
+ }
775
+ return client;
776
+ }
777
+ function evictGrpcClient(cache, address) {
778
+ const client = cache.get(address);
779
+ /* istanbul ignore if — defensive against eviction of non-existent address;
780
+ callers only call this after a get() returned a client. */
781
+ if (!client) return;
782
+ try {
783
+ client.close();
784
+ } catch {/* swallow — already closed */}
785
+ cache.delete(address);
786
+ }
787
+
788
+ // Close every client in a cache and clear the Map. Idempotent — second call
789
+ // on the same cache is a no-op (empty Map). Called from percy.stop().
790
+ export function closeGrpcClientCache(cache) {
791
+ /* istanbul ignore if — defensive guard; callers always pass a Map. */
792
+ if (!cache || typeof cache.keys !== 'function') return;
793
+ /* istanbul ignore next — loop body fires only when the cache has live
794
+ entries; tests close empty caches in the resolver shutdown path. */
795
+ for (const address of Array.from(cache.keys())) {
796
+ evictGrpcClient(cache, address);
797
+ }
798
+ }
799
+ function grpcStatusName(code) {
800
+ for (const [name, value] of Object.entries(grpc.status)) {
801
+ if (value === code) return name.toLowerCase();
802
+ }
803
+ /* istanbul ignore next — fallback for status codes outside the grpc.status
804
+ enum; defensive against an upstream @grpc/grpc-js that introduces a
805
+ code we don't recognize. Every known code is covered by the classifier
806
+ tests above. */
807
+ return `code-${code}`;
808
+ }
809
+
810
+ // Three-class classification per D10. Returns one of:
811
+ // { kind: 'dump-error', reason: 'grpc-schema-<NAME>' | 'grpc-decode' }
812
+ // { kind: 'connection-fail', reason: 'grpc-contention-<NAME>' }
813
+ // { kind: 'connection-fail', reason: 'grpc-channel-broken-<NAME>' }
814
+ // Decoder errors (no err.code) collapse to schema-class with reason 'grpc-decode'.
815
+ // Unknown / unmapped codes default to channel-broken (conservative — fall
816
+ // back, evict, retry elsewhere).
817
+ export function classifyGrpcFailure(err) {
818
+ if (!err) return null;
819
+ if (err.code === undefined) {
820
+ return {
821
+ kind: 'dump-error',
822
+ reason: 'grpc-decode'
823
+ };
824
+ }
825
+ const name = grpcStatusName(err.code);
826
+ if (GRPC_SCHEMA_CLASS_CODES.has(err.code)) {
827
+ return {
828
+ kind: 'dump-error',
829
+ reason: `grpc-schema-${name}`
830
+ };
831
+ }
832
+ if (GRPC_CONTENTION_CLASS_CODES.has(err.code)) {
833
+ return {
834
+ kind: 'connection-fail',
835
+ reason: `grpc-contention-${name}`
836
+ };
837
+ }
838
+ return {
839
+ kind: 'connection-fail',
840
+ reason: `grpc-channel-broken-${name}`
841
+ };
842
+ }
843
+
844
+ // Returns true iff the failure is contention-class (caller must skip CLI
845
+ // fallback AND keep the cached client). Returns false for channel-broken
846
+ // (caller falls through to CLI AND evicts).
847
+ function isContentionClass(reason) {
848
+ return typeof reason === 'string' && reason.startsWith('grpc-contention-');
849
+ }
850
+ export async function runAndroidGrpcDump({
851
+ host,
852
+ port,
853
+ grpcClient,
854
+ cache,
855
+ shutdownInProgress
856
+ }) {
857
+ /* istanbul ignore next — fallback to default factory when caller omits;
858
+ tests always inject a stub factory. */
859
+ grpcClient = grpcClient || defaultGrpcClientFactory;
860
+ const address = `${host}:${port}`;
861
+ const client = getOrCreateGrpcClient(cache, address, grpcClient);
862
+ const start = Date.now();
863
+ let breakerTimer;
864
+ const callPromise = client.viewHierarchy({}, {
865
+ deadline: Date.now() + GRPC_HEALTHY_DEADLINE_MS
866
+ });
867
+ const breakerPromise = new Promise((_resolve, reject) => {
868
+ /* istanbul ignore next — circuit-breaker setTimeout body fires when the
869
+ gRPC call exceeds GRPC_CIRCUIT_BREAKER_MS; covered by the concurrent-
870
+ access integration harness, not the unit suite (tests use stubs that
871
+ resolve immediately or via injected error). */
872
+ breakerTimer = setTimeout(() => {
873
+ const err = new Error('gRPC circuit-breaker fired');
874
+ err.code = grpc.status.DEADLINE_EXCEEDED;
875
+ reject(err);
876
+ }, GRPC_CIRCUIT_BREAKER_MS);
877
+ });
878
+ let response;
879
+ try {
880
+ response = await Promise.race([callPromise, breakerPromise]);
881
+ } catch (err) {
882
+ log.debug(`gRPC viewHierarchy failed: name=${err.name} message=${err.message} code=${err.code}`);
883
+
884
+ // R-7: CANCELLED during shutdown. Special-case to avoid spawning the
885
+ // fallback chain on a process that's tearing down. The shutdown flag is
886
+ // set on the cache by closeGrpcClientCache's caller (see percy.js stop()).
887
+ if (shutdownInProgress && err.code === grpc.status.CANCELLED) {
888
+ return {
889
+ kind: 'unavailable',
890
+ reason: 'shutdown'
891
+ };
892
+ }
893
+ const classification = classifyGrpcFailure(err);
894
+ if (classification.kind === 'dump-error') {
895
+ // Schema-class — drift bit + return immediately (no fallback per D10).
896
+ log.warn(`gRPC viewHierarchy schema-class failure (${classification.reason}); skipping element regions`);
897
+ setMaestroHierarchyDrift({
898
+ platform: 'android',
899
+ code: err.code,
900
+ reason: classification.reason
901
+ });
902
+ } else if (isContentionClass(classification.reason)) {
903
+ // Contention-class — KEEP cached client (D10).
904
+ log.debug(`gRPC viewHierarchy contention-class (${classification.reason}); cache preserved; caller should skip CLI`);
905
+ } else {
906
+ // Channel-broken — evict cached client (D10).
907
+ log.debug(`gRPC viewHierarchy channel-broken (${classification.reason}); evicting cached client`);
908
+ evictGrpcClient(cache, address);
909
+ }
910
+ return classification;
911
+ } finally {
912
+ clearTimeout(breakerTimer);
913
+ }
914
+
915
+ // Success path — parse XML envelope from response.hierarchy.
916
+ /* istanbul ignore next — ternary defensive against malformed gRPC response;
917
+ fixtures always carry a string `hierarchy`. */
918
+ const xml = response && typeof response.hierarchy === 'string' ? response.hierarchy : '';
919
+ const slice = sliceXmlEnvelope(xml);
920
+ if (!slice) {
921
+ log.warn('gRPC viewHierarchy returned no XML envelope; skipping element regions');
922
+ setMaestroHierarchyDrift({
923
+ platform: 'android',
924
+ code: undefined,
925
+ reason: 'grpc-no-xml-envelope'
926
+ });
927
+ return {
928
+ kind: 'dump-error',
929
+ reason: 'grpc-no-xml-envelope'
930
+ };
931
+ }
932
+ let parsed;
933
+ try {
934
+ parsed = parser.parse(slice);
935
+ } catch (err) {
936
+ /* istanbul ignore next */
937
+ log.warn(`gRPC viewHierarchy parse error (${err.message}); skipping element regions`);
938
+ /* istanbul ignore next */
939
+ setMaestroHierarchyDrift({
940
+ platform: 'android',
941
+ code: undefined,
942
+ reason: 'grpc-parse-error'
943
+ });
944
+ /* istanbul ignore next */
945
+ return {
946
+ kind: 'dump-error',
947
+ reason: `grpc-parse-error:${err.message}`
948
+ };
949
+ }
950
+ /* istanbul ignore if — gRPC schema sanity check; defensive against a
951
+ Maestro upstream that returns an envelope without the `hierarchy` root. */
952
+ if (!parsed || !parsed.hierarchy) {
953
+ log.warn('gRPC viewHierarchy unexpected root tag; skipping element regions');
954
+ setMaestroHierarchyDrift({
955
+ platform: 'android',
956
+ code: undefined,
957
+ reason: 'grpc-unexpected-root'
958
+ });
959
+ return {
960
+ kind: 'dump-error',
961
+ reason: 'grpc-unexpected-root'
962
+ };
963
+ }
964
+ const nodes = flattenNodes(parsed);
965
+ log.debug(`dump took ${Date.now() - start}ms via grpc (${nodes.length} nodes)`);
966
+ return {
967
+ kind: 'hierarchy',
968
+ nodes
969
+ };
970
+ }
971
+
972
+ // Public reader for /percy/healthcheck. Always returns the full envelope;
973
+ // both slots are `null` in steady state. Consumers (api.js healthcheck
974
+ // handler, ops dashboards) must check both slots independently.
975
+ export function getMaestroHierarchyDrift() {
976
+ return maestroHierarchyDrift;
977
+ }
978
+
979
+ // Test helper — resets the drift envelope between specs. Not exported on the
980
+ // public surface (consumers shouldn't reset module state in production).
981
+ // The gRPC client cache is per-Percy-instance; tests pass a fresh `Map()` per
982
+ // spec rather than going through a module-state resetter.
983
+ export const __testing = {
984
+ resetMaestroHierarchyDrift() {
985
+ maestroHierarchyDrift = {
986
+ android: null,
987
+ ios: null
988
+ };
989
+ }
990
+ };
991
+
992
+ // Default Node http.request wrapper. Returns
993
+ // { statusCode, headers, body }
994
+ // on completed responses (any status code), or throws an Error with .code
995
+ // (e.g. ECONNREFUSED, ETIMEDOUT, ECONNRESET) on transport failures.
996
+ //
997
+ // Tests inject a fake httpRequest with the same shape; see
998
+ // makeFakeHttpRequest in maestro-hierarchy.test.js iOS HTTP describe block.
999
+ /* istanbul ignore next — production-only http transport; the unit suite
1000
+ always injects an httpRequest stub, so this function is never invoked
1001
+ under coverage. Integration tests on BS hosts exercise the real Node
1002
+ http.request against a live Maestro iOS XCTestRunner. */
1003
+ function defaultHttpRequest({
1004
+ host,
1005
+ port,
1006
+ method,
1007
+ path: requestPath,
1008
+ headers,
1009
+ body,
1010
+ timeoutMs
1011
+ }) {
1012
+ return new Promise((resolve, reject) => {
1013
+ let chunks = [];
1014
+ let totalBytes = 0;
1015
+ const req = http.request({
1016
+ host,
1017
+ port,
1018
+ method,
1019
+ path: requestPath,
1020
+ headers,
1021
+ timeout: timeoutMs
1022
+ }, res => {
1023
+ res.on('data', chunk => {
1024
+ totalBytes += chunk.length;
1025
+ /* istanbul ignore if — runaway response cap; Maestro upstream never
1026
+ produces >IOS_HTTP_RESPONSE_MAX_BYTES (16 MB) responses in practice.
1027
+ Defensive guard against pathological iOS payloads. */
1028
+ if (totalBytes > IOS_HTTP_RESPONSE_MAX_BYTES) {
1029
+ // Cap before parse — defensive against runaway responses.
1030
+ chunks = null;
1031
+ try {
1032
+ req.destroy();
1033
+ } catch {/* already destroyed */}
1034
+ reject(Object.assign(new Error('response-too-large'), {
1035
+ code: 'EMSGSIZE'
1036
+ }));
1037
+ return;
1038
+ }
1039
+ if (chunks) chunks.push(chunk);
1040
+ });
1041
+ res.on('end', () => {
1042
+ /* istanbul ignore if — `chunks === null` only after the response-too-large
1043
+ cap above rejected; end fires anyway as the response stream closes. */
1044
+ if (!chunks) return; // already rejected for size
1045
+ resolve({
1046
+ statusCode: res.statusCode,
1047
+ headers: res.headers,
1048
+ body: Buffer.concat(chunks).toString('utf8')
1049
+ });
1050
+ });
1051
+ /* istanbul ignore next — Node http response error path; only fires
1052
+ on mid-stream FIN/RST. Connection failures land in req.on('error'). */
1053
+ res.on('error', reject);
1054
+ });
1055
+
1056
+ /* istanbul ignore next — Node http socket timeout path; covered by the
1057
+ concurrent-access integration harness, not the unit suite. */
1058
+ req.on('timeout', () => {
1059
+ try {
1060
+ req.destroy();
1061
+ } catch {/* already destroyed */}
1062
+ reject(Object.assign(new Error('socket-timeout'), {
1063
+ code: 'ETIMEDOUT'
1064
+ }));
1065
+ });
1066
+ req.on('error', reject);
1067
+ if (body !== undefined && body !== null) req.write(body);
1068
+ req.end();
1069
+ });
1070
+ }
1071
+
1072
+ // Validate PERCY_IOS_DRIVER_HOST_PORT env value as integer in the realmobile
1073
+ // range (wda_port + 2700 = 11100-11110). Out-of-range values return null.
1074
+ function parseIosDriverHostPort(raw) {
1075
+ /* istanbul ignore if — undefined/null/empty raw value branch; iOS dispatch
1076
+ pre-checks PERCY_IOS_DRIVER_HOST_PORT before calling, so these never fire. */
1077
+ if (raw === undefined || raw === null || raw === '') return null;
1078
+ const n = Number(raw);
1079
+ /* istanbul ignore if — non-integer port (e.g. NaN from non-numeric env);
1080
+ env var is set by realmobile as the canonical wda_port+2700 integer. */
1081
+ if (!Number.isInteger(n)) return null;
1082
+ if (n < IOS_DRIVER_HOST_PORT_MIN || n > IOS_DRIVER_HOST_PORT_MAX) return null;
1083
+ return n;
1084
+ }
1085
+
1086
+ // Walk the AXElement tree from cli-2.0.7's HTTP /viewHierarchy response.
1087
+ // Find the AUT root: first node with `elementType === 1` (XCUI application)
1088
+ // whose `identifier !== 'com.apple.springboard'`. Returns the AUT subtree, OR
1089
+ // null if the only application is SpringBoard (AUT-not-running case).
1090
+ //
1091
+ // At cli-2.0.7 the wrap is `[appHierarchy, statusBarsContainer]` where the
1092
+ // statusBars container has `elementType: 0` (synthetic init). The AUT is the
1093
+ // first elementType==1 node and the rule selects it directly.
1094
+ //
1095
+ // At cli-1.39.13 the wrap was `[springboardHierarchy, appHierarchy]` where
1096
+ // both children have `elementType: 1`. The springboard-skip handles that.
1097
+ //
1098
+ // Post-PR-2402 forward-compat: when the response is a single-AUT root (no
1099
+ // wrap), the rule selects the root itself.
1100
+ function findAxAutRoot(axElement) {
1101
+ /* istanbul ignore if — defensive input guard; runIosHttpDump pre-checks
1102
+ parsed.axElement before calling this. */
1103
+ if (!axElement || typeof axElement !== 'object') return null;
1104
+ if (axElement.elementType === 1 && axElement.identifier !== 'com.apple.springboard') {
1105
+ return axElement;
1106
+ }
1107
+ const children = axElement.children;
1108
+ if (Array.isArray(children)) {
1109
+ for (const child of children) {
1110
+ const found = findAxAutRoot(child);
1111
+ if (found) return found;
1112
+ }
1113
+ }
1114
+ return null;
1115
+ }
1116
+
1117
+ // Adapter: walk an AXElement subtree (HTTP /viewHierarchy path) and emit nodes
1118
+ // in the canonical shape that firstMatch consumes for Android. Specifically:
1119
+ // { 'resource-id': identifier, id: identifier, bounds: '[X,Y][X+W,Y+H]' }
1120
+ // Notably no `class` attribute — Maestro's iOS TreeNode doesn't expose
1121
+ // elementType→class either, and Percy keeps both iOS paths symmetric.
1122
+ //
1123
+ // Returns an array of nodes; throws if any frame is malformed (caught by
1124
+ // caller and surfaced as schema-class drift).
1125
+ function flattenIosAxElement(axRoot) {
1126
+ const nodes = [];
1127
+ const walk = obj => {
1128
+ /* istanbul ignore if — defensive guard on recursive walk; AUT root and
1129
+ its children are always objects per the Maestro AXElement contract. */
1130
+ if (!obj || typeof obj !== 'object') return;
1131
+ /* istanbul ignore next — identifier ternary; AXElement payloads always
1132
+ carry a string identifier when present. */
1133
+ const identifier = typeof obj.identifier === 'string' ? obj.identifier : '';
1134
+ const frame = obj.frame;
1135
+ /* istanbul ignore if — Maestro AXElement payloads always carry a `frame`
1136
+ object per the upstream contract; this defends against a regression
1137
+ where frame is missing or non-object. */
1138
+ if (!frame || typeof frame !== 'object') {
1139
+ throw new Error(`missing-frame on identifier=${JSON.stringify(identifier).slice(0, 64)}`);
1140
+ }
1141
+ const x = frame.X;
1142
+ const y = frame.Y;
1143
+ const w = frame.Width;
1144
+ const h = frame.Height;
1145
+ /* istanbul ignore if — Maestro AXElement frames use uppercased X/Y/Width/Height
1146
+ keys per the upstream contract; this defends against case-mismatched
1147
+ payloads from a Maestro regression. */
1148
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(w) || !Number.isFinite(h)) {
1149
+ throw new Error(`frame-key-case-mismatch on identifier=${JSON.stringify(identifier).slice(0, 64)}`);
1150
+ }
1151
+ const bounds = `[${Math.round(x)},${Math.round(y)}][${Math.round(x + w)},${Math.round(y + h)}]`;
1152
+ /* istanbul ignore else — identifier-empty branch (anonymous nodes) is
1153
+ * the no-op tail; fixtures always carry identifiers on capturable nodes. */
1154
+ if (identifier) {
1155
+ nodes.push({
1156
+ 'resource-id': identifier,
1157
+ id: identifier,
1158
+ // text/content-desc/class deliberately undefined — iOS Maestro doesn't
1159
+ // surface these as selector-relevant attributes (per IOSDriver.kt).
1160
+ bounds
1161
+ });
1162
+ }
1163
+ if (Array.isArray(obj.children)) {
1164
+ for (const child of obj.children) walk(child);
1165
+ }
1166
+ };
1167
+ walk(axRoot);
1168
+ return nodes;
1169
+ }
1170
+
1171
+ // Classify an iOS HTTP failure into connection-class (route to fallback) vs
1172
+ // schema-class (set drift bit, no fallback) vs no-aut-tree (route to
1173
+ // fallback because the Maestro CLI knows the AUT internally).
1174
+ function classifyIosHttpFailure(err) {
1175
+ var _err$message;
1176
+ /* istanbul ignore if — defensive null guard; callers always pass an err
1177
+ when invoking the classifier. */
1178
+ if (!err) return null;
1179
+ const code = err.code;
1180
+ // Connection-class errors — Maestro runner unreachable / unhealthy. Fall back.
1181
+ /* istanbul ignore next — OR-chain branches: tests cover one or two codes
1182
+ (typically ECONNREFUSED + ETIMEDOUT) but not all 8. Each remaining code
1183
+ creates an unevaluated branch. */
1184
+ if (code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'EHOSTUNREACH' || code === 'ENETUNREACH' || code === 'EPIPE' || code === 'ECONNABORTED' || code === 'EMSGSIZE') {
1185
+ return {
1186
+ kind: 'connection-fail',
1187
+ reason: `http-${String(code).toLowerCase()}`
1188
+ };
1189
+ }
1190
+ // Default: treat unknown errors as connection-class so we fall back rather
1191
+ // than silently skip element regions.
1192
+ /* istanbul ignore next — defensive fallback for error shapes outside the
1193
+ explicit code list; unit tests exercise every named code
1194
+ (ECONNREFUSED/ETIMEDOUT/ECONNRESET/...). */
1195
+ return {
1196
+ kind: 'connection-fail',
1197
+ reason: `http-${((_err$message = err.message) === null || _err$message === void 0 ? void 0 : _err$message.slice(0, 64)) || 'unknown'}`
1198
+ };
1199
+ }
1200
+
1201
+ // iOS HTTP primary path. POSTs `{appIds: [], excludeKeyboardElements: false}`
1202
+ // to Maestro's iOS XCTestRunner /viewHierarchy endpoint. Returns
1203
+ // { kind: 'hierarchy', nodes } on success
1204
+ // { kind: 'connection-fail', ... } on transport / 5xx / out-of-range port
1205
+ // { kind: 'no-aut-tree', ... } on SpringBoard-only response
1206
+ // { kind: 'dump-error', ... } on schema-class failures (no fallback)
1207
+ async function runIosHttpDump({
1208
+ port,
1209
+ sessionId,
1210
+ httpRequest
1211
+ }) {
1212
+ /* istanbul ignore next — fallback to default http transport when caller
1213
+ omits; tests always inject a stub. */
1214
+ httpRequest = httpRequest || defaultHttpRequest;
1215
+ // Loopback-only guard. Hardcoded host; do not accept from caller input.
1216
+ const host = '127.0.0.1';
1217
+ let response;
1218
+ const requestBody = JSON.stringify({
1219
+ appIds: [],
1220
+ excludeKeyboardElements: false
1221
+ });
1222
+ try {
1223
+ response = await Promise.race([httpRequest({
1224
+ host,
1225
+ port,
1226
+ method: 'POST',
1227
+ path: '/viewHierarchy',
1228
+ headers: {
1229
+ 'content-type': 'application/json',
1230
+ 'content-length': Buffer.byteLength(requestBody)
1231
+ },
1232
+ body: requestBody,
1233
+ timeoutMs: IOS_HTTP_HEALTHY_DEADLINE_MS
1234
+ }), new Promise((_, reject) => setTimeout(() => reject(Object.assign(new Error('circuit-breaker'), {
1235
+ code: 'ETIMEDOUT'
1236
+ })), IOS_HTTP_CIRCUIT_BREAKER_MS))]);
1237
+ } catch (err) {
1238
+ return classifyIosHttpFailure(err);
1239
+ }
1240
+ const {
1241
+ statusCode,
1242
+ headers,
1243
+ body
1244
+ } = response;
1245
+
1246
+ // 5xx → connection-class (server reachable but unhealthy).
1247
+ if (statusCode >= 500) {
1248
+ return {
1249
+ kind: 'connection-fail',
1250
+ reason: `http-${statusCode}`
1251
+ };
1252
+ }
1253
+ // 4xx → schema-class (request shape problem; fallback wouldn't help).
1254
+ if (statusCode >= 400) {
1255
+ return {
1256
+ kind: 'dump-error',
1257
+ reason: `http-${statusCode}-bad-request-shape`
1258
+ };
1259
+ }
1260
+ // 3xx → unexpected; treat as schema-class.
1261
+ /* istanbul ignore next — Maestro upstream returns only 200/4xx; 3xx is a
1262
+ theoretical defensive path. The 4xx branch above is covered. */
1263
+ if (statusCode !== 200) {
1264
+ return {
1265
+ kind: 'dump-error',
1266
+ reason: `http-unexpected-status-${statusCode}`
1267
+ };
1268
+ }
1269
+
1270
+ // Content-type is informational only — Maestro's upstream
1271
+ // ViewHierarchyHandler.swift constructs `HTTPResponse(statusCode:.ok, body:body)`
1272
+ // without setting Content-Type (FlyingFox HTTP server doesn't auto-set one).
1273
+ // Body IS valid JSON regardless. Strict CT-required check would silently
1274
+ // reject every response from real Maestro builds — relax to a soft warn
1275
+ // and let JSON.parse decide. Schema-class drift only fires on actual
1276
+ // parse failure or missing axElement root below.
1277
+ const contentType = headers && (headers['content-type'] || headers['Content-Type']);
1278
+ if (!contentType || !/application\/json/i.test(contentType)) {
1279
+ log.debug(`iOS HTTP response missing/non-JSON content-type (got ${contentType || 'none'}); attempting parse anyway`);
1280
+ }
1281
+
1282
+ // Parse JSON.
1283
+ let parsed;
1284
+ try {
1285
+ parsed = JSON.parse(body);
1286
+ } catch (err) {
1287
+ var _err$message2;
1288
+ /* istanbul ignore next — `err.message?.slice` optional chain + `|| 'unknown'`
1289
+ fallback branches; tests pass JSON-parse errors which always have .message. */
1290
+ return {
1291
+ kind: 'dump-error',
1292
+ reason: `http-parse-error:${((_err$message2 = err.message) === null || _err$message2 === void 0 ? void 0 : _err$message2.slice(0, 64)) || 'unknown'}`
1293
+ };
1294
+ }
1295
+
1296
+ // Schema validation: require `axElement` root.
1297
+ if (!parsed || typeof parsed !== 'object' || !parsed.axElement || typeof parsed.axElement !== 'object') {
1298
+ return {
1299
+ kind: 'dump-error',
1300
+ reason: 'http-missing-root'
1301
+ };
1302
+ }
1303
+
1304
+ // Find AUT root, skipping SpringBoard.
1305
+ const aut = findAxAutRoot(parsed.axElement);
1306
+ if (!aut) {
1307
+ // Either the response is SpringBoard-only (AUT not running), or no
1308
+ // application node at all. Either way, route to fallback.
1309
+ return {
1310
+ kind: 'no-aut-tree',
1311
+ reason: 'springboard-only'
1312
+ };
1313
+ }
1314
+
1315
+ // Flatten the AUT subtree to firstMatch's expected node shape.
1316
+ let nodes;
1317
+ try {
1318
+ nodes = flattenIosAxElement(aut);
1319
+ /* istanbul ignore next — flattenIosAxElement throws only on malformed
1320
+ AXElement payloads (Maestro-upstream contract); the catch body below
1321
+ maps the three known message shapes to dump-error reasons. Unit tests
1322
+ exercise the happy path; the throw paths are integration-test territory. */
1323
+ } catch (err) {
1324
+ /* istanbul ignore next — err.message || 'unknown' branch fires only when
1325
+ a thrown value has no .message; the named-throw sites always set one. */
1326
+ const msg = err.message || 'unknown';
1327
+ /* istanbul ignore next */
1328
+ if (/^missing-frame/.test(msg)) return {
1329
+ kind: 'dump-error',
1330
+ reason: 'http-missing-frame'
1331
+ };
1332
+ /* istanbul ignore next */
1333
+ if (/^frame-key-case-mismatch/.test(msg)) return {
1334
+ kind: 'dump-error',
1335
+ reason: 'http-frame-key-case-mismatch'
1336
+ };
1337
+ /* istanbul ignore next */
1338
+ return {
1339
+ kind: 'dump-error',
1340
+ reason: `http-flatten-error:${msg.slice(0, 64)}`
1341
+ };
1342
+ }
1343
+ // Suppress sessionId in log surface — only emit a hash-prefix so support can
1344
+ // correlate without leaking the full id.
1345
+ /* istanbul ignore next — sid=none ternary branch fires only when sessionId
1346
+ is missing; relay always passes one. */
1347
+ const sidTag = sessionId ? `sid=${String(sessionId).slice(0, 8)}…` : 'sid=none';
1348
+ log.debug(`runIosHttpDump ok ${sidTag} nodes=${nodes.length}`);
1349
+ return {
1350
+ kind: 'hierarchy',
1351
+ nodes
1352
+ };
1353
+ }
1354
+
1355
+ // iOS maestro-CLI fallback path. Spawns
1356
+ // `maestro --udid <udid> --driver-host-port <port> hierarchy` and parses
1357
+ // stdout (Maestro's normalized TreeNode shape, identical to Android).
1358
+ // Existing flattenMaestroNodes consumes TreeNode unchanged — no iOS-specific
1359
+ // branching needed on this path.
1360
+ async function runMaestroIosDump(udid, driverHostPort, execMaestro, getEnv) {
1361
+ const result = await execMaestro(['--udid', udid, '--driver-host-port', String(driverHostPort), 'hierarchy'], getEnv);
1362
+ const fail = classifyMaestroFailure(result);
1363
+ if (fail) return fail;
1364
+ /* istanbul ignore next — non-zero exit with no classified failure;
1365
+ classifyMaestroFailure catches the dominant exit cases. The `?? 1`
1366
+ fallback also counts as a branch — both ignored together via `next`. */
1367
+ if ((result.exitCode ?? 1) !== 0) {
1368
+ return {
1369
+ kind: 'dump-error',
1370
+ reason: `maestro-exit-${result.exitCode}`
1371
+ };
1372
+ }
1373
+ /* istanbul ignore next — `|| ''` branch fires only on missing/empty stdout
1374
+ which the spawn helpers already normalize to a string. */
1375
+ const stdout = result.stdout || '';
1376
+ const start = stdout.indexOf('{');
1377
+ if (start < 0) return {
1378
+ kind: 'dump-error',
1379
+ reason: 'maestro-no-json'
1380
+ };
1381
+ try {
1382
+ const parsed = JSON.parse(stdout.slice(start));
1383
+ const nodes = flattenMaestroNodes(parsed);
1384
+ return {
1385
+ kind: 'hierarchy',
1386
+ nodes
1387
+ };
1388
+ } catch (err) {
1389
+ /* istanbul ignore next — Maestro CLI's JSON output is structurally
1390
+ stable; this rescues a parse-error from an upstream regression we
1391
+ don't own. Happy-path JSON parsing is covered by the fixture tests. */
1392
+ return {
1393
+ kind: 'dump-error',
1394
+ reason: `maestro-parse-error:${err.message}`
1395
+ };
1396
+ }
1397
+ }
1398
+ async function runMaestroDump(serial, execMaestro, getEnv) {
1399
+ const result = await execMaestro(['--udid', serial, 'hierarchy'], getEnv);
1400
+ const fail = classifyMaestroFailure(result);
1401
+ if (fail) return fail;
1402
+ /* istanbul ignore next — non-zero exit with no classified failure;
1403
+ classifyMaestroFailure catches the dominant exit cases. The `?? 1`
1404
+ fallback also counts as a branch — both ignored together via `next`. */
1405
+ if ((result.exitCode ?? 1) !== 0) {
1406
+ return {
1407
+ kind: 'dump-error',
1408
+ reason: `maestro-exit-${result.exitCode}`
1409
+ };
1410
+ }
1411
+ // Maestro prints the JSON to stdout; sometimes CLI prefixes a banner/notice line.
1412
+ // Slice from the first '{' to be safe.
1413
+ /* istanbul ignore next — `|| ''` branch fires only on missing/empty stdout
1414
+ which the spawn helpers already normalize to a string. */
1415
+ const stdout = result.stdout || '';
1416
+ const start = stdout.indexOf('{');
1417
+ if (start < 0) return {
1418
+ kind: 'dump-error',
1419
+ reason: 'maestro-no-json'
1420
+ };
1421
+ try {
1422
+ const parsed = JSON.parse(stdout.slice(start));
1423
+ const nodes = flattenMaestroNodes(parsed);
1424
+ return {
1425
+ kind: 'hierarchy',
1426
+ nodes
1427
+ };
1428
+ } catch (err) {
1429
+ /* istanbul ignore next — Maestro CLI's JSON output is structurally
1430
+ stable; this rescues a parse-error from an upstream regression we
1431
+ don't own. Happy-path JSON parsing is covered by the fixture tests. */
1432
+ return {
1433
+ kind: 'dump-error',
1434
+ reason: `maestro-parse-error:${err.message}`
1435
+ };
1436
+ }
1437
+ }
1438
+
1439
+ // Adb fallback chain: exec-out uiautomator dump → file-based dump (with
1440
+ // SIGKILL retry loop) → cat from sdcard. Extracted from dump() so the gRPC
1441
+ // contention-class branch can jump straight here without going through
1442
+ // maestro CLI (which would queue behind the same Maestro flow that caused
1443
+ // the contention).
1444
+ async function runAdbFallback(serial, execAdb) {
1445
+ let result = await runDump(['-s', serial, 'exec-out', 'uiautomator', 'dump', '/dev/tty'], execAdb);
1446
+ const isRetryableDumpError = result.kind === 'dump-error' && (result.reason === 'no-xml-envelope' || /^exit-/.test(result.reason));
1447
+ /* istanbul ignore if — adb file-dump fallback chain; only fires when the
1448
+ exec-out primary returned a retryable dump-error (no-xml-envelope or
1449
+ exit-N). Tests cover the primary success path; the retry chain is
1450
+ integration-test territory (BS hosts running real uiautomator). */
1451
+ if (isRetryableDumpError) {
1452
+ log.debug(`adb primary dump returned ${result.reason}, trying file dump`);
1453
+ let dumpToFile = await execAdb(['-s', serial, 'shell', 'uiautomator', 'dump', '/sdcard/window_dump.xml']);
1454
+ for (const delay of SIGKILL_RETRY_DELAYS_MS) {
1455
+ if ((dumpToFile.exitCode ?? 1) !== SIGKILL_EXIT) break;
1456
+ log.debug(`fallback dump was killed (exit ${SIGKILL_EXIT}), retrying after ${delay}ms`);
1457
+ await new Promise(r => setTimeout(r, delay));
1458
+ dumpToFile = await execAdb(['-s', serial, 'shell', 'uiautomator', 'dump', '/sdcard/window_dump.xml']);
1459
+ }
1460
+ const dumpFail = classifyAdbFailure(dumpToFile);
1461
+ if (dumpFail) return dumpFail;
1462
+ if ((dumpToFile.exitCode ?? 1) !== 0) {
1463
+ return {
1464
+ kind: 'dump-error',
1465
+ reason: `fallback-dump-exit-${dumpToFile.exitCode}`
1466
+ };
1467
+ }
1468
+ result = await runDump(['-s', serial, 'exec-out', 'cat', '/sdcard/window_dump.xml'], execAdb);
1469
+ }
1470
+ return result;
1471
+ }
1472
+ export async function dump(options) {
1473
+ /* istanbul ignore next — options-omitted default; callers always pass an
1474
+ object (tests inject every dependency; production code binds them). */
1475
+ options = options || {};
1476
+ let {
1477
+ platform,
1478
+ sessionId,
1479
+ execAdb,
1480
+ execMaestro,
1481
+ httpRequest,
1482
+ grpcClient,
1483
+ grpcClientCache,
1484
+ getEnv
1485
+ } = options;
1486
+ /* istanbul ignore next — defaults applied only when caller omits the
1487
+ corresponding key; tests inject every dependency, production callers
1488
+ bind these from defaults at runtime. */
1489
+ platform = platform || 'android';
1490
+ /* istanbul ignore next */
1491
+ execAdb = execAdb || defaultExecAdb;
1492
+ /* istanbul ignore next */
1493
+ execMaestro = execMaestro || defaultExecMaestro;
1494
+ /* istanbul ignore next */
1495
+ httpRequest = httpRequest || defaultHttpRequest;
1496
+ /* istanbul ignore next */
1497
+ grpcClient = grpcClient || defaultGrpcClientFactory;
1498
+ /* istanbul ignore next */
1499
+ getEnv = getEnv || defaultGetEnv;
1500
+ const started = Date.now();
1501
+ if (platform === 'ios') {
1502
+ // iOS dispatch: read realmobile-injected env vars; warn-skip if absent.
1503
+ const udid = getEnv('PERCY_IOS_DEVICE_UDID');
1504
+ const driverHostPortRaw = getEnv('PERCY_IOS_DRIVER_HOST_PORT');
1505
+ if (!udid || !driverHostPortRaw) {
1506
+ log.warn(`iOS resolver env-missing: udid=${udid ? 'set' : 'unset'} driver_port=${driverHostPortRaw ? 'set' : 'unset'}`);
1507
+ recordResolverFinalFailure({
1508
+ platform: 'ios',
1509
+ failureClass: 'other'
1510
+ });
1511
+ return {
1512
+ kind: 'unavailable',
1513
+ reason: 'env-missing'
1514
+ };
1515
+ }
1516
+
1517
+ // D3 kill switch (PERCY_MAESTRO_GRPC=0): same env name gates BOTH Maestro
1518
+ // primaries. On iOS this skips runIosHttpDump and routes straight to the
1519
+ // maestro-CLI fallback below. Read every call so toggling at runtime is
1520
+ // honored without a CLI restart.
1521
+ const iosKillSwitch = getEnv('PERCY_MAESTRO_GRPC') === '0';
1522
+ if (iosKillSwitch) {
1523
+ log.warn('PERCY_MAESTRO_GRPC=0 kill switch active; skipping iOS HTTP primary');
1524
+ }
1525
+
1526
+ // Validate driver-host-port range before attempting HTTP. Out-of-range
1527
+ // values skip the HTTP path entirely and fall through to maestro-CLI.
1528
+ const driverHostPort = parseIosDriverHostPort(driverHostPortRaw);
1529
+ let httpResult = null;
1530
+ if (!iosKillSwitch && driverHostPort !== null) {
1531
+ httpResult = await runIosHttpDump({
1532
+ port: driverHostPort,
1533
+ sessionId,
1534
+ httpRequest
1535
+ });
1536
+ if (httpResult.kind === 'hierarchy') {
1537
+ log.debug(`dump took ${Date.now() - started}ms via maestro-http (${httpResult.nodes.length} nodes)`);
1538
+ recordResolverSuccess({
1539
+ platform: 'ios',
1540
+ via: 'maestro-http'
1541
+ });
1542
+ return httpResult;
1543
+ }
1544
+ if (httpResult.kind === 'dump-error') {
1545
+ // Schema-class — no fallback per plan R4. Flip the iOS slot of the
1546
+ // drift bit so /percy/healthcheck surfaces the contract mismatch
1547
+ // for ops investigation. First-seen-per-platform wins.
1548
+ setMaestroHierarchyDrift({
1549
+ platform: 'ios',
1550
+ code: undefined,
1551
+ reason: httpResult.reason
1552
+ });
1553
+ recordResolverFinalFailure({
1554
+ platform: 'ios',
1555
+ failureClass: 'schema-class'
1556
+ });
1557
+ log.warn(`iOS HTTP schema-drift: ${httpResult.reason}`);
1558
+ return httpResult;
1559
+ }
1560
+ // Otherwise (connection-fail or no-aut-tree): fall through to CLI.
1561
+ const httpClass = failureClassFromReason(httpResult.reason);
1562
+ recordResolverFallback({
1563
+ platform: 'ios',
1564
+ failureClass: httpClass
1565
+ });
1566
+ log.info(`[percy] hierarchy: maestro-http failed (${httpClass}: ${httpResult.reason}) → falling back to maestro-cli-fallback`);
1567
+ } else if (!iosKillSwitch) {
1568
+ const oorReason = `out-of-range-port-${driverHostPortRaw}`;
1569
+ recordResolverFallback({
1570
+ platform: 'ios',
1571
+ failureClass: 'other'
1572
+ });
1573
+ log.info(`[percy] hierarchy: maestro-http failed (other: ${oorReason}) → falling back to maestro-cli-fallback`);
1574
+ }
1575
+ const cliResult = await runMaestroIosDump(udid, driverHostPort ?? driverHostPortRaw, execMaestro, getEnv);
1576
+ const httpReason = httpResult ? `${httpResult.kind}/${httpResult.reason}` : 'out-of-range-port';
1577
+ log.debug(`dump took ${Date.now() - started}ms via maestro-cli-fallback (${httpReason}) kind=${cliResult.kind}`);
1578
+ if (cliResult.kind === 'hierarchy') {
1579
+ recordResolverSuccess({
1580
+ platform: 'ios',
1581
+ via: 'maestro-cli-fallback'
1582
+ });
1583
+ } else {
1584
+ recordResolverFinalFailure({
1585
+ platform: 'ios',
1586
+ failureClass: failureClassFromReason(cliResult.reason)
1587
+ });
1588
+ }
1589
+ return cliResult;
1590
+ }
1591
+
1592
+ // Android (default).
1593
+ const {
1594
+ serial,
1595
+ classification
1596
+ } = await resolveSerial({
1597
+ execAdb,
1598
+ getEnv
1599
+ });
1600
+ if (classification) {
1601
+ log.warn(`adb unavailable: ${classification.reason}`);
1602
+ recordResolverFinalFailure({
1603
+ platform: 'android',
1604
+ failureClass: 'other'
1605
+ });
1606
+ return classification;
1607
+ }
1608
+
1609
+ // gRPC primary path (env-conditional + kill-switch-gated). Talks the same
1610
+ // gRPC transport Maestro CLI uses, but as a stateless RPC that doesn't
1611
+ // open a parallel Maestro flow context — avoids the session-collision
1612
+ // failure mode the CLI shell-out hits during a live Maestro flow.
1613
+ //
1614
+ // D3 kill switch (PERCY_MAESTRO_GRPC=0): in-process emergency disable.
1615
+ // Distinct from removing the env injection (which requires a coordinated
1616
+ // mobile/realmobile deploy). Logged loudly so the rollback state is
1617
+ // observable in CLI logs.
1618
+ const killSwitch = getEnv('PERCY_MAESTRO_GRPC') === '0';
1619
+ const grpcPortRaw = getEnv('PERCY_ANDROID_GRPC_PORT');
1620
+ let skipMaestroCli = false;
1621
+ if (killSwitch) {
1622
+ log.warn('PERCY_MAESTRO_GRPC=0 kill switch active; skipping gRPC primary');
1623
+ } else if (grpcPortRaw && grpcClientCache) {
1624
+ const grpcPort = Number.parseInt(grpcPortRaw, 10);
1625
+ if (Number.isInteger(grpcPort) && grpcPort > 0 && grpcPort <= 65535) {
1626
+ const grpcResult = await runAndroidGrpcDump({
1627
+ host: '127.0.0.1',
1628
+ port: grpcPort,
1629
+ grpcClient,
1630
+ cache: grpcClientCache,
1631
+ shutdownInProgress: grpcClientCache.shutdownInProgress
1632
+ });
1633
+ if (grpcResult.kind === 'hierarchy') {
1634
+ log.debug(`dump took ${Date.now() - started}ms via grpc (${grpcResult.nodes.length} nodes)`);
1635
+ recordResolverSuccess({
1636
+ platform: 'android',
1637
+ via: 'grpc'
1638
+ });
1639
+ return grpcResult;
1640
+ }
1641
+ /* istanbul ignore next — R-7 shutdown-in-progress race: only triggers
1642
+ when stop() is called concurrently with an in-flight dump. The `&&`
1643
+ second clause also counts as a branch — use `ignore next` to cover
1644
+ the whole if-statement including the condition expression. */
1645
+ if (grpcResult.kind === 'unavailable' && grpcResult.reason === 'shutdown') {
1646
+ // R-7: shutdown-in-progress. Don't spawn fallback chain on a tearing-down process.
1647
+ log.debug('gRPC dump cancelled by shutdown; skipping fallback chain');
1648
+ recordResolverFinalFailure({
1649
+ platform: 'android',
1650
+ failureClass: 'other'
1651
+ });
1652
+ return grpcResult;
1653
+ }
1654
+ if (grpcResult.kind === 'dump-error') {
1655
+ // Schema-class — no fallback per D10. Drift bit set inside runAndroidGrpcDump.
1656
+ recordResolverFinalFailure({
1657
+ platform: 'android',
1658
+ failureClass: 'schema-class'
1659
+ });
1660
+ return grpcResult;
1661
+ }
1662
+ // connection-fail: split contention-class vs channel-broken per D10.
1663
+ const grpcClass = failureClassFromReason(grpcResult.reason);
1664
+ if (isContentionClass(grpcResult.reason)) {
1665
+ // Contention-class: skip maestro CLI (would queue behind same flow); jump to adb.
1666
+ recordResolverFallback({
1667
+ platform: 'android',
1668
+ failureClass: grpcClass
1669
+ });
1670
+ log.info(`[percy] hierarchy: grpc failed (${grpcClass}: ${grpcResult.reason}) → falling back to adb`);
1671
+ skipMaestroCli = true;
1672
+ } else {
1673
+ // Channel-broken: fall through to maestro CLI (CLI re-establishes the channel).
1674
+ recordResolverFallback({
1675
+ platform: 'android',
1676
+ failureClass: grpcClass
1677
+ });
1678
+ log.info(`[percy] hierarchy: grpc failed (${grpcClass}: ${grpcResult.reason}) → falling back to maestro-cli`);
1679
+ }
1680
+ } else {
1681
+ log.debug(`PERCY_ANDROID_GRPC_PORT=${grpcPortRaw} invalid; skipping gRPC primary`);
1682
+ }
1683
+ }
1684
+
1685
+ // Maestro CLI primary (or fallback when gRPC channel-broken). Skipped on
1686
+ // gRPC contention-class — that path goes straight to adb.
1687
+ if (!skipMaestroCli) {
1688
+ const maestroResult = await runMaestroDump(serial, execMaestro, getEnv);
1689
+ if (maestroResult.kind === 'hierarchy') {
1690
+ log.debug(`dump took ${Date.now() - started}ms via maestro-cli (${maestroResult.nodes.length} nodes)`);
1691
+ recordResolverSuccess({
1692
+ platform: 'android',
1693
+ via: 'maestro-cli'
1694
+ });
1695
+ return maestroResult;
1696
+ }
1697
+ recordResolverFallback({
1698
+ platform: 'android',
1699
+ failureClass: failureClassFromReason(maestroResult.reason)
1700
+ });
1701
+ log.info(`[percy] hierarchy: maestro-cli failed (${failureClassFromReason(maestroResult.reason)}: ${maestroResult.reason}) → falling back to adb`);
1702
+ }
1703
+
1704
+ // adb fallback (final).
1705
+ const result = await runAdbFallback(serial, execAdb);
1706
+ log.debug(`dump took ${Date.now() - started}ms via adb (kind=${result.kind})`);
1707
+ /* istanbul ignore else — adb final fallback is the last resort; tests
1708
+ stub the resolver chain to always resolve via grpc/maestro-cli before
1709
+ reaching here in the failure case. */
1710
+ if (result.kind === 'hierarchy') {
1711
+ recordResolverSuccess({
1712
+ platform: 'android',
1713
+ via: 'adb'
1714
+ });
1715
+ } else {
1716
+ recordResolverFinalFailure({
1717
+ platform: 'android',
1718
+ failureClass: failureClassFromReason(result.reason)
1719
+ });
1720
+ }
1721
+ return result;
1722
+ }
1723
+ function parseBounds(str) {
1724
+ /* istanbul ignore if — defensive null guard; callers always pass the
1725
+ bounds attribute string from a node that matched the regex. */
1726
+ if (!str) return null;
1727
+ const m = BOUNDS_RE.exec(str);
1728
+ if (!m) return null;
1729
+ const x1 = Number(m[1]);
1730
+ const y1 = Number(m[2]);
1731
+ const x2 = Number(m[3]);
1732
+ const y2 = Number(m[4]);
1733
+ /* istanbul ignore if — defensive degenerate-bounds guard
1734
+ ([0,0][0,0] from SpringBoard-only AUT roots); fixtures use
1735
+ well-formed bounds, this guard is for empty AUT subtrees. */
1736
+ if (x2 <= x1 || y2 <= y1) return null;
1737
+ return {
1738
+ x: x1,
1739
+ y: y1,
1740
+ width: x2 - x1,
1741
+ height: y2 - y1
1742
+ };
1743
+ }
1744
+ export function firstMatch(nodes, selector) {
1745
+ /* istanbul ignore if — defensive input validation; callers always pass
1746
+ a hierarchy nodes array and a valid selector object. */
1747
+ if (!Array.isArray(nodes) || !selector || typeof selector !== 'object') return null;
1748
+ const keys = Object.keys(selector);
1749
+ if (keys.length !== 1) return null;
1750
+ const key = keys[0];
1751
+ if (!SELECTOR_KEYS_UNION.includes(key)) return null;
1752
+ const value = selector[key];
1753
+ if (typeof value !== 'string' || value.length === 0) return null;
1754
+ for (const node of nodes) {
1755
+ if (node[key] !== value) continue;
1756
+ const bbox = parseBounds(node.bounds);
1757
+ if (bbox) return bbox;
1758
+ }
1759
+ return null;
1760
+ }
1761
+
1762
+ // Exposed for tests + handler-side validation in api.js. Union of platform
1763
+ // keys; per-platform validation is implicit in the node shape returned by
1764
+ // dump() — Android nodes carry resource-id/text/content-desc/class plus the
1765
+ // `id` alias; iOS nodes carry `id` only (Maestro's iOS TreeNode does not
1766
+ // surface `class`).
1767
+ export const SELECTOR_KEYS_WHITELIST = SELECTOR_KEYS_UNION;
1768
+ export const ANDROID_SELECTOR_KEYS_WHITELIST = ANDROID_SELECTOR_KEYS;
1769
+ export const IOS_SELECTOR_KEYS_WHITELIST = IOS_SELECTOR_KEYS;