@percy/core 1.32.0-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/api.js +690 -16
- package/dist/maestro-hierarchy.js +1769 -0
- package/dist/percy.js +18 -0
- package/dist/proto/README.md +47 -0
- package/dist/proto/maestro_android.proto +116 -0
- package/package.json +13 -9
|
@@ -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;
|