Skip to content

feat(core): direct gRPC client for Maestro view-hierarchy resolver (Phase 2.2)#2210

Closed
Sriram567 wants to merge 4 commits into
feat/ios-element-regions-maestro-hierarchyfrom
feat/grpc-element-region-resolver
Closed

feat(core): direct gRPC client for Maestro view-hierarchy resolver (Phase 2.2)#2210
Sriram567 wants to merge 4 commits into
feat/ios-element-regions-maestro-hierarchyfrom
feat/grpc-element-region-resolver

Conversation

@Sriram567

Copy link
Copy Markdown
Contributor

Summary

Phase 2.2 perf optimization for the Maestro Android element-region resolver. Replaces the JVM-cold-start maestro --udid <serial> hierarchy shell-out (~9s p50) with a direct gRPC call to dev.mobile.maestro on device port 6790. Maestro CLI shell-out is preserved as a graceful fallback for local dev, schema drift, and connection-class errors.

Customer-visible impact: element-region resolution latency drops from ~9s to <100ms per snapshot. A 10-snapshot flow with element regions now adds ~1s instead of ~90s.

What changed

  • Vendored maestro_android.proto from upstream (mobile-dev-inc/Maestro @ bc8bde1b, CLI release cli-2.5.1) at packages/core/src/proto/.
  • Added @grpc/grpc-js@^1.14.3 + @grpc/proto-loader@^0.8.0 (both Node-14-safe, used on BS hosts).
  • New runGrpcDump + classifyGrpcFailure in maestro-hierarchy.js with:
    • Module-scope Client cache per (host, port); eager close + evict on connection-class failure.
    • Two-tier deadline: 250ms healthy-call (CallOptions.deadline) + 2s circuit-breaker (Promise.race) — bounds the blast radius of grpc-node#2620 "stuck CONNECTING" + #2285 "lying READY state."
    • 14-row error-classification table (schema-class vs connection-class). Schema-class skips fallback per R3.
    • Reuses existing sliceXmlEnvelope + flattenNodes — Maestro's ViewHierarchyResponse.hierarchy is the same UiAutomator XML format (verified at source level via ViewHierarchy.kt).
  • New discoverGrpcPort: MAESTRO_GRPC_PORT env preferred, falls back to parsing adb -s <serial> forward --list for tcp:6790.
  • New PERCY_MAESTRO_GRPC=0 kill switch — short-circuits to maestro CLI fallback, logged at WARN on every dump call so rollback state is observable.
  • Healthcheck dirty bit: /percy/healthcheck exposes maestroHierarchyDrift: { code, reason, firstSeenAt } on first schema-class failure (closes the silent-drift gap that bit PERCY_LABELS).
  • Concurrent-access regression harness at packages/core/test/integration/ — env-gated on MAESTRO_ANDROID_TEST_DEVICE, skips silently in CI.

Plan + scope

  • Driven by docs/plans/2026-04-29-001-feat-grpc-element-region-resolver-plan.md in percy-maestro-android.
  • iOS is out of scope. wda-hierarchy.js and the PERCY_IOS_RESOLVER switch are unchanged.
  • No SDK changes. No api.js handler changes (memoization at api.js:559 is unchanged).
  • Unit 5 (delete dead runAdbFallback) is split to a follow-up PR, gated on ≥1 week of via grpc log dominance in production.

PR dependency

This branch sits on top of PR #2202 (iOS regions Phase 1). Once #2202 lands on master, GitHub auto-retargets this PR. The 4 Phase 2.2 commits at the tip are:

d13b9dfd test(core): concurrent-access harness + README for the gRPC resolver merge gate
f2d3db82 feat(core): surface maestro hierarchy schema drift via /percy/healthcheck
9052dcce feat(core): wire gRPC primary into dump() with port discovery + kill switch
3587ea5b feat(core): direct gRPC client for Maestro view-hierarchy resolver

Testing

Local — completed ✅

  • 45 specs pass in packages/core/test/unit/maestro-hierarchy.test.js + maestro-hierarchy-grpc.test.js (existing 36 + 45 new gRPC cases). Updated below once full-suite numbers land.
  • Lint clean for the 3 files this PR authored (maestro-hierarchy.js, maestro-hierarchy-grpc.test.js, the api.js healthcheck change). One pre-existing unused-import was removed from maestro-hierarchy.test.js as a drive-by.
  • yarn build produces dist/proto/maestro_android.proto (Babel CLI's copyFiles: true confirms the runtime asset is shipped).
  • proto-loader.loadSync smoke-test confirms the vendored proto parses cleanly and MaestroDriver/viewHierarchy is callable.
  • Harness skip-path: with MAESTRO_ANDROID_TEST_DEVICE unset, the harness exits 0 with skip: MAESTRO_ANDROID_TEST_DEVICE not set. CI default behavior.

Pre-existing test failures

The full yarn workspace @percy/core test run shows 27 failures on this machine — all pre-existing and unrelated to this PR:

  • 5 × Install in executable Chromium downloads the correct files for * — environment-specific Chromium download failure
  • ~22 × api.test.js — pre-existing ENOENT: no such file or directory, open '...request.js' path-resolution issue, also fails identically on the unmodified base branch (verified by stashing this PR's changes and re-running)

Real-device validation — required before merge ⚠️

The R6 merge gate requires running the concurrent-access harness on a real Android device + active Maestro flow. Cannot be run from CI (no device pool). Recipe in packages/core/test/integration/README.md.

MAESTRO_ANDROID_TEST_DEVICE=<adb-serial> \
  MAESTRO_BIN=/path/to/maestro \
  ANDROID_SERIAL=<adb-serial> \
  node packages/core/test/integration/maestro-hierarchy-concurrent.harness.js

Required to merge: paste the green output of this harness (incl. p50/p95/p99 timings) below. If p99 ≥ 250ms × 0.9, the plan KTD calls for bumping GRPC_HEALTHY_DEADLINE_MS to p99 × 2 before merge (the 2s circuit-breaker is independent and stays at 2000).

<harness output goes here>

Real-fixture capture — required before merge ⚠️

packages/core/test/fixtures/maestro-hierarchy/grpc-response.xml is currently a synthesized placeholder (mirrors simple.xml plus the three Maestro-only attributes). Before merge, replace with the real wire-format payload per the procedure in grpc-capture-notes.md — drop a temporary console.log('GRPC_RAW_HIERARCHY=' + response.hierarchy) into runGrpcDump, deploy via the host-overlay technique to a pinned BS host, run a Percy-Maestro flow with at least one element-region snapshot, capture the log, save the XML, revert the log.

Post-Deploy Monitoring & Validation

  • What to monitor / search
    • Logs (Percy CLI debug):
      • [percy:core:maestro-hierarchy] dump took Nms via grpc (N nodes) — healthy gRPC path, expected on ≥99% of element-region dumps post-rollout
      • [percy:core:maestro-hierarchy] dump took Nms via maestro (N nodes) — healthy maestro CLI fallback, acceptable but not the steady state
      • [percy:core:maestro-hierarchy] gRPC viewHierarchy connection-class failure (...) — falls back; expected occasionally on adb daemon restart
      • [percy:core:maestro-hierarchy] gRPC viewHierarchy schema-class failure (...) — Maestro version drift, investigate immediately
      • [percy:core:maestro-hierarchy] PERCY_MAESTRO_GRPC kill switch active — if logged in production, the rollback flag is unintentionally set; investigate
  • Validation checks (post-rollout)
    • grep "via grpc" percy-cli-debug.log | wc -l vs grep "via maestro" ... | wc -l — gRPC path should dominate (>99%) within an hour of rollout on a single BS host
    • curl http://localhost:5338/percy/healthcheck | jq '.maestroHierarchyDrift' — should return null in steady state. Any non-null value indicates Maestro proto schema drift
  • Expected healthy behavior
    • Element-region dump latency drops to <100ms per snapshot in the percy CLI debug log
    • No new dump-error reasons surfaced in Percy CLI logs beyond the existing connection-fail and unavailable classifications
    • maestroHierarchyDrift field absent from /percy/healthcheck responses
  • Failure signal(s) / rollback trigger
    • maestroHierarchyDrift field appears on any healthcheck response — Maestro version on the device has drifted past our vendored proto. Mitigation: re-vendor the proto (per packages/core/src/proto/README.md) and ship a patch CLI release; user-visible impact is "element regions skipped on affected snapshots" until the patch lands.
    • via grpc rate stays low / via maestro dominates post-rollout — port discovery is failing in production. Investigate MAESTRO_GRPC_PORT env injection from cli_manager.rb or adb forward --list parse output.
    • Element-region snapshots fail at higher rate than pre-rollout baseline — ROLLBACK via PERCY_MAESTRO_GRPC=0 env on BS hosts. The kill switch routes back to the maestro CLI fallback (the validated Phase 2.1 path). User pays the 9s tax until a real fix ships, but element regions keep working.
  • Validation window & owner
    • Window: 1 week post-rollout for the via grpc dominance check; ongoing for the healthcheck drift bit.
    • Owner: @arumullasriram (initial), Percy team for ongoing.

Risks acknowledged in the plan

  • Maestro proto schema drift — mitigated by the healthcheck dirty bit + maestro CLI fallback as graceful degradation.
  • @grpc/grpc-js Node 14 surfaceengines.node >= 12.10.0 declared, manually verified to load on Node 14.17.3. Per institutional learning, every error's name/message/code is logged before normalization.
  • Concurrent-access harness flakinessextendedWaitUntil is not a hard mutex; the sub-millisecond gap between polling iterations is unexploitable in practice (Real-Android session acquisition takes 50-200ms).
  • Module-scope dead-channel — eager close + evict on connection-class failure bounds blast radius to one bad request, not a permanent regression.
  • Removing the adb-fallback path before trust is established — Unit 5 split to a follow-up PR after ≥1 week of via grpc log dominance.

Compound Engineering v2.54.0
🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via Claude Code + Compound Engineering v2.54.0

Sriram567 and others added 4 commits May 4, 2026 10:59
Replaces the JVM-cold-start `maestro --udid <serial> hierarchy` shell-out
(~9s p50) with a direct gRPC call to dev.mobile.maestro on device port
6790 (host-side via adb forward). Maestro CLI shell-out is preserved as
graceful fallback; this commit only adds the gRPC building blocks —
wiring into dump() comes in the next commit.

- Vendor maestro_android.proto from mobile-dev-inc/Maestro
  (commit bc8bde1b, CLI release cli-2.5.1).
- Add @grpc/grpc-js@^1.14.3 + @grpc/proto-loader@^0.8.0 (Node 14 safe).
- Eager-load proto at module init via static-import chain
  percy.js → api.js → maestro-hierarchy.js — keeps the 15-40ms
  proto-parse cost off the request path.
- runGrpcDump: module-scope Client cache per (host, port); two-tier
  deadline (250ms healthy / 2s circuit breaker) per the plan KTD;
  eager close + evict on connection-class failure.
- classifyGrpcFailure: 14-row error-class table (schema vs.
  connection); schema-class skips the maestro CLI fallback; loud WARN
  + healthcheck dirty bit closes the silent-drift gap that bit
  PERCY_LABELS.
- Reuses existing sliceXmlEnvelope + flattenNodes — Maestro's
  ViewHierarchyResponse.hierarchy carries the same UIAutomator XML
  format as `adb shell uiautomator dump` (verified at source-level via
  ViewHierarchy.kt, fixture capture deferred to Unit 6 e2e validation).
- 31 new unit tests covering happy path, full classification matrix,
  cache + eviction, both deadlines, and the dirty bit.

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
…switch

Resolver dispatch is now: gRPC primary → maestro CLI fallback → adb
fallback. Drops element-region resolver latency from ~9s p50 to <100ms p50
when port discovery succeeds.

- discoverGrpcPort: MAESTRO_GRPC_PORT env var preferred (mobile-repo
  cli_manager.rb will inject this); falls back to parsing
  `adb -s <serial> forward --list` for a tcp:6790 line. Returns
  unavailable on probe failure → falls through to maestro CLI.
- PERCY_MAESTRO_GRPC=0 kill switch at top of dump(): short-circuits to
  maestro CLI fallback, logged at WARN on every dump call so the rollback
  state is observable.
- gRPC dispatch outcomes:
    hierarchy        → return immediately (skips maestro CLI + adb)
    connection-fail  → fall through to maestro CLI fallback
    dump-error       → return as-is (schema-class — no fallback per R3)
- 14 new dispatch + port-discovery tests covering kill switch,
  port-not-found, connection-class fallback, schema-class no-fallback,
  and adb call shape (-s <serial> on every call including the probe).
- Existing maestro-hierarchy.test.js updated for the new probe call:
  inline execAdb stubs return no-port for `forward --list`, the
  serial-forwarding assertion now searches for the dump call rather than
  relying on call ordering.

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
…heck

When the gRPC view-hierarchy resolver hits its first schema-class failure,
maestro-hierarchy.js sets a module-level dirty bit. The healthcheck
endpoint includes that bit when set so BS infra and the SDK's healthcheck
poller see drift without needing new metrics infrastructure.

Closes the silent-drift gap that bit PERCY_LABELS — see
docs/solutions/integration-issues/percy-labels-cli-schema-rejection-2026-04-23.md.

The maestroHierarchyDrift field is omitted from the response when no
drift has been observed, preserving the existing healthcheck shape.

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
…merge gate

Documented harness for R6 — calls dump() against a real Android device
while a parallel Maestro flow holds the UiAutomator session via
extendedWaitUntil + impossible selector, asserts hierarchy on every
iteration, and confirms the Maestro flow stays alive. Records p50/p95/p99
timings across N=100 iterations to feed the GRPC_HEALTHY_DEADLINE_MS
tuning decision in the plan KTD.

CI skips silently when MAESTRO_ANDROID_TEST_DEVICE is unset. Run on a
BrowserStack host or dev machine before merging Phase 2.2 — paste the
output (incl. p50/p95/p99) into the PR description.

The negative-control output (against a build with the dead
adb-uiautomator-dump path as primary) deterministically reproduces the
SIGKILL-137 contention this PR exists to prevent regressing.

Co-Authored-By: Claude Opus 4.7 (1M context, extended thinking) <noreply@anthropic.com>
@Sriram567
Sriram567 requested a review from a team as a code owner May 4, 2026 07:43
@Sriram567
Sriram567 requested review from amandeepsingh333 and ninadbstack and removed request for a team May 4, 2026 07:43
Sriram567 added a commit that referenced this pull request May 8, 2026
…26-05-07-002 Unit 1)

Adds @grpc/grpc-js@^1.14.3 and @grpc/proto-loader@^0.8.0 as @percy/core
dependencies, plus the vendored maestro_android.proto from upstream
mobile-dev-inc/Maestro at SHA bc8bde1b (cli-2.5.1, 2025-05-26). Both deps
declare engines.node floors well below @percy/cli's >=14 (grpc-js >=12.10.0,
proto-loader >=6) — no min-Node bump required.

Only MaestroDriver/viewHierarchy(ViewHierarchyRequest) returns
(ViewHierarchyResponse) and the `string hierarchy = 1` field on the response
are consumed at runtime; the rest of the proto is preserved verbatim so
future updates can be a clean upstream re-copy without surgical edits.

The proto/ dir is preserved into dist/ via Babel CLI's existing
copyFiles: true setting (no build-system change needed).

Refs:
- 2026-05-07-002 plan Unit 1 + D6 + D12
- Replaces in-flight PR #2210 (will be closed once this PR merges)
Sriram567 added a commit that referenced this pull request May 8, 2026
…omy (Units 2/3/7)

Adds runAndroidGrpcDump as the Android primary path in maestro-hierarchy.js's
dump({platform:'android'}) dispatch, talking the same gRPC transport Maestro
CLI uses but as a stateless RPC that doesn't open a parallel flow context.
Avoids the session-collision failure mode the maestro CLI shell-out hits
during a live Maestro flow (root cause of the 2026-05-07 BS validation
fallback-dump-exit-137 result).

Three-class error taxonomy (D10) — splits PR #2210's two-class scheme:
  - schema-class (INVALID_ARGUMENT, FAILED_PRECONDITION, OUT_OF_RANGE,
    UNIMPLEMENTED, DATA_LOSS, decoder-failure) → drift bit, no fallback,
    cache PRESERVED
  - channel-broken (UNAVAILABLE, INTERNAL, CANCELLED, unmapped codes) →
    fallback chain runs, cache EVICTED (channel actually broke)
  - contention-class (DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, ABORTED) →
    skip CLI fallback (would queue behind same flow), straight to adb,
    cache PRESERVED (timeout = backpressure, not channel-breakage; reconnect
    would waste TCP+HTTP/2+TLS for nothing)

Symmetric timeout architecture (D11): GRPC_HEALTHY_DEADLINE_MS=1500 +
GRPC_CIRCUIT_BREAKER_MS=5000 — parity with iOS HTTP path. Outer Promise.race
is defense-in-depth against grpc-node#2620-style channel sticking (closed
in 1.9.11 but cheap insurance).

Dispatch (D3 + D9):
  PERCY_MAESTRO_GRPC=0 kill switch  → skip gRPC entirely (in-process rollback)
  PERCY_ANDROID_GRPC_PORT set+valid → runAndroidGrpcDump → branch by class
  env absent / invalid              → maestro CLI primary → adb (current behavior)

R-7 (shutdown race): runAndroidGrpcDump accepts shutdownInProgress flag
sourced from grpcClientCache.shutdownInProgress (set by percy.stop()
before close). CANCELLED-during-shutdown returns {kind:'unavailable',
reason:'shutdown'} — no fallback chain on a tearing-down process.

Drift recording uses this branch's existing two-slot
setMaestroHierarchyDrift({platform:'android', code, reason}) — drops
PR #2210's separate single-slot recordSchemaDrift.

Cleanup: removes the stale "Single-author note about PR #2210" comment
and a stale grpc-node#2620 framing reference (issue is closed).

Refs:
- 2026-05-07-002 plan Units 2, 3, 7 + D1, D3, D9, D10, D11
- Replaces PR #2210's runGrpcDump
@Sriram567

Copy link
Copy Markdown
Contributor Author

Closing in favor of #2217 (consolidated cross-platform resolver)

This PR's gRPC-direct Android view-hierarchy work has been absorbed into #2217, which now ships the cross-platform symmetric resolver in a single CLI release:

Platform Primary Fallback chain
Android gRPC POST MaestroDriver/viewHierarchy on 127.0.0.1:$PERCY_ANDROID_GRPC_PORT (this PR's work) maestro CLI shell-out → adb
iOS HTTP POST /viewHierarchy on 127.0.0.1:$PERCY_IOS_DRIVER_HOST_PORT maestro CLI shell-out

Three architectural refinements applied during absorption

  1. Three-class error taxonomy (vs this PR's two-class). DEADLINE_EXCEEDED and other backpressure-class failures now skip CLI fallback (would queue behind same flow) and keep the cached gRPC client — eviction wastes a TCP+HTTP/2+TLS reconnect on backpressure evidence.
  2. Per-Percy cache scope (vs module-global). Matches @percy/core's established ownership pattern — channels are transport state, owned by the Percy instance, disposed in stop(). Module-global state would leak channels between concurrent Percy instances.
  3. Drift recorder consolidated to the two-slot setMaestroHierarchyDrift({platform}) setter from the iOS branch, dropping this PR's separate single-slot recordSchemaDrift. One drift contract for both platforms.

Plus: env var renamed MAESTRO_GRPC_PORTPERCY_ANDROID_GRPC_PORT for cross-platform symmetry with PERCY_IOS_DRIVER_HOST_PORT. The kill switch PERCY_MAESTRO_GRPC=0 is preserved per ops-needed cross-deploy-cycle escape hatch.

Full architectural rationale + research backing in the deepened plan at docs/plans/2026-05-07-002-feat-android-grpc-direct-resolver-plan.md (local; not in the repo since docs/ is .gitignored).

Closing this PR. The feat/grpc-element-region-resolver branch will be deleted after #2217 merges to master.

@Sriram567 Sriram567 closed this May 8, 2026
Sriram567 added a commit that referenced this pull request May 26, 2026
…ot filePath relay + fallback observability (#2217)

* Harden /percy/comparison/upload endpoint

- Add empty body guard (400 instead of TypeError)
- Add Busboy fileSize limit to reject oversized uploads during parsing
- Use Object.create(null) and field allowlist to prevent prototype pollution
- Add stream error handler on Readable source
- Use HTTP 413 for oversized files

* Fix misleading error on oversized file upload

Reject immediately on Busboy 'limit' event with 413 instead of
setting fileBuffer to null which produced 'Missing required file part'.

* add dependencies

* feat: Add /percy/maestro-screenshot relay endpoint

Accepts {name, sessionId} as JSON, finds the screenshot file on disk
at /tmp/<sessionId>_test_suite/logs/*/screenshots/<name>.png,
base64-encodes it, and processes as a standard comparison.

This enables real-time Percy uploads from Maestro flows where the
JS sandbox cannot access screenshot files directly.

* feat: Extend /percy/maestro-screenshot relay with regions, sync, tile metadata

Accept statusBarHeight, navBarHeight, fullscreen from request instead of
hardcoding 0/false. Transform coordinate-based regions to CLI boundingBox
format. Add sync mode support via percy.syncMode() + handleSyncJob().
Forward thTestCaseExecutionId to comparison pipeline.

Element-based regions log a warning and are skipped — ADB uiautomator
resolution will be added as a follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: iOS support in /percy/maestro-screenshot relay

Platform-aware screenshot discovery:
- Accept platform field with strict whitelist (ios/android); 400 on unknown
- iOS glob: /tmp/{sessionId}/*_maestro_debug_*/{name}.png
- Android glob unchanged; backward compat with SDK v0.2.0 (no platform → Android)

Path-safety hardening:
- Tighten name/sessionId from blocklist to strict character-class allowlist
- fs.realpath canonicalization + session-root prefix check defeats symlink swap
- Handles macOS /tmp → /private/tmp symlink transparently

Pick most recently modified file when multiple match (iOS same-name-across-flows).

* feat(core): add ADB view-hierarchy resolver for maestro element regions

Introduces packages/core/src/adb-hierarchy.js with two plain exports:
dump() and firstMatch(nodes, selector). The resolver:

- Reads process.env.ANDROID_SERIAL; falls back to one adb devices probe
  (requires exactly one attached device to avoid wrong-device dumps under
  multi-session CLI concurrency). Never accepts serial from request input.
- Shells out via cross-spawn with a 2s hard timeout (mirrors the
  browser.js:256-297 spawn+cleanup pattern).
- Classifies results into one of three shapes — unavailable, dump-error,
  hierarchy — so the relay can distinguish environmental failures from
  transient dump failures.
- Streams primary via adb exec-out uiautomator dump /dev/tty; falls back
  to file-based dump + cat only on wrong-mechanism signals (exit≠0 or
  missing <?xml prefix). Terminal signals (oversize / parse-error) do not
  retry — prevents attack amplification on adversarial payloads.
- Slices the XML envelope to the first </hierarchy> (strips uiautomator's
  trailer line and defends against embedded adversarial XML blocks).
- Enforces a 5MB stdout cap before parse.
- Parses with fast-xml-parser configured for defense-in-depth
  (processEntities: false, allowBooleanAttributes: false).
- Exposes firstMatch with pre-order DFS + strictly-anchored bounds regex;
  zero-area nodes are non-matches, negative coordinates (clipped views)
  are allowed.

Adds fast-xml-parser ^4.4.1 as a new dependency of @percy/core.

27 unit tests cover the parser + selector logic and all classification
branches via a parameter-injected execAdb seam. No real ADB calls; no
filesystem or network access.

* feat(core): resolve maestro element regions via ADB hierarchy

Wires the /percy/maestro-screenshot relay to the new adb-hierarchy
resolver. Replaces the existing element-region warn-and-skip stub with
actual resolution via ADB + uiautomator dump on Android.

Handler changes:
- Early 400 validation on region shape before file I/O or ADB work:
  whitelist selector keys (resource-id/text/content-desc/class), require
  exactly one selector key per region, string-typed value, length ≤512,
  total regions per request ≤50.
- Android element regions: lazy dump on first element region, memoize
  the result (including error classes) for the whole request. Pre-scan
  element-region count so the skip warning reports N regions accurately.
  Both unavailable and dump-error poison the rest of the request with
  one warning — bounds worst-case per-request ADB time to one 2s timeout
  regardless of element-region count (closes the timeout-accumulation
  DoS vector).
- iOS element regions: preserve existing warn-and-skip semantics. Not
  a 400. Avoids a breaking change for any iOS caller today.
- Coordinate regions: unchanged; still transform {top,bottom,left,right}
  to elementSelector.boundingBox.
- Miss on element resolution: per-element warning, region skipped,
  request still uploads.

First-ever /percy/maestro-screenshot handler tests cover input
validation (9 × 400 paths), coordinate-only flow regression, iOS
warn-and-skip behavior, end-to-end forwarding of testCase/labels/
thTestCaseExecutionId/tile-metadata/sync, and the missing-screenshot
404 path. ADB-integration paths (element resolution against a real
device) are covered by the adb-hierarchy unit tests and Unit 7 E2E
validation on BrowserStack Maestro.

* fix(core): retry uiautomator fallback dump once on SIGKILL (exit 137)

E2E validation on BrowserStack Maestro against host 31.6.63.33 / Pixel 7 Pro
showed the primary exec-out path intermittently returning no-xml-envelope
and the file-dump fallback exiting 137 (SIGKILL of uiautomator on the
device). The kill is triggered by concurrent uiautomator/automation
activity on the device during a live Maestro session — not a device-wide
or permissions issue (manual dumps from the shell return 44KB XML fine).

A single 300ms-delayed retry of the fallback dump command recovers the
common case without masking genuine device unavailability. If the second
attempt also fails, we still fall through to the existing dump-error
classification.

Test: the adb-hierarchy spec adds a retry test where the first fallback
exec returns 137 and the second returns the fixture XML; resolver returns
hierarchy and fileDumpCalls == 2.

* fix(core): exponential backoff on SIGKILL up to 3.5s budget

Strengthens the SIGKILL retry from a single 300ms attempt to three retries
at 500ms/1s/2s (3.5s total window). Exits early as soon as a dump succeeds.

Rationale: single short retry wasn't enough against persistent device
contention observed during BrowserStack Maestro sessions. The wider budget
catches transient uiautomator kills on less-contended devices while still
failing fast on genuinely unavailable devices. Captured limitation: when
Maestro holds uiautomator throughout a flow (its observed behavior on real
devices), no reasonable retry count recovers — the mechanism itself needs
to change (e.g., Maestro API integration or an accessibility-service
sidecar). That's a Phase 2 follow-up, not part of this patch.

Tests cover both the "succeeds on Nth retry" case and the "all retries
exhausted" case.

* feat(core): use `maestro hierarchy` as primary view-tree source

E2E on BrowserStack Maestro showed `adb exec-out uiautomator dump` is
fundamentally incompatible with live Maestro flows — Maestro holds the
uiautomator lock throughout a flow and competing dumps get SIGKILLed.
The `maestro --udid <serial> hierarchy` CLI command reuses Maestro's
existing gRPC connection to dev.mobile.maestro on the device and works
reliably during live sessions (verified by probing twice mid-flow —
both probes returned valid JSON while the flow was running).

Changes in packages/core/src/adb-hierarchy.js:

- Primary dump mechanism is now `maestro --udid <serial> hierarchy`.
- Parse the resulting JSON (slice from the first `{` to tolerate banner
  lines), flatten the tree into the existing node shape.
- Map `accessibilityText` → `content-desc` at flatten time so `firstMatch`
  still uses the SDK's selector vocabulary unchanged.
- Maestro CLI timeout: 15s (JVM cold start ~9s + headroom).
- Honor `MAESTRO_BIN` env var for alternate paths; default `maestro`
  on PATH.
- New `spawnWithTimeout` helper shared between maestro and adb code paths.
- Classification extended with maestro-specific reasons (`maestro-not-found`,
  `maestro-timeout`, `maestro-no-device`, `maestro-no-json`,
  `maestro-parse-error:*`, `maestro-spawn-error:*`, `maestro-exit-*`,
  `maestro-oversize`).

Fallback: when maestro returns anything other than `hierarchy`, fall
through to the existing `adb exec-out uiautomator dump` flow (including
SIGKILL retry/backoff and file-dump fallback). Useful when the maestro
binary isn't installed on the CLI host.

Cost: 9s JVM cold start per screenshot that uses element regions.
Acceptable today because the alternative is 100% skip. Phase 2.2 follow-up:
replace the CLI invocation with a direct gRPC client against device port
6790 (typical latency <100ms) — infrastructure already in place (adb
forwards tcp:8206 → 6790 per device on BrowserStack hosts).

Tests: 36 specs total. New `dump (maestro hierarchy primary)` describe
block adds 7 scenarios (happy path, content-desc mapping, ENOENT→adb
fallback, unavailable propagation when both fail, timeout → adb recovery,
banner prefix tolerance, no-json). Existing 29 tests now inject an
execMaestro stub that reports ENOENT so they exercise the adb fallback
path exactly as before.

* feat(core): extract PNG_MAGIC_BYTES + add IHDR dimension parser (B1)

New module png-dimensions.js serves both:
- existing /percy/comparison/upload signature check (api.js import)
- upcoming /percy/maestro-screenshot iOS path (scale factor via
  pngWidth / wda_window_logical_width + aspect-ratio landscape fallback)

Exports:
- PNG_MAGIC_BYTES (moved from api.js route-local scope)
- parsePngDimensions(buf) → {width, height} via IHDR hand-parse
  (24-byte prefix read, no new dependency)
- isPortrait / isLandscape with default threshold 1.25
  (iPad portrait ratio 1.334; margin empirically confirmable via A1 Probe 6)
- DEFAULT_ORIENTATION_THRESHOLD exported for override in tests / A1 Probe 6

Test-first: 17 specs covering happy path iPhone/iPad portrait+landscape,
dimensions > 65535, truncated buffer, bad signature, zero width/height,
non-Buffer input, threshold override, near-square ambiguity. All pass.

api.js: removes inline PNG_MAGIC_BYTES declaration from the upload route
handler; imports the shared constant. Upload signature-check behavior
unchanged.

Unit B1 of the iOS Maestro element-regions plan (v1.0); serves as the
Phase 1 CI coverage preflight per plan.

* feat(core): add wda-session-resolver (B2) — reader for realmobile wda-meta.json

Reader side of the realmobile ↔ Percy CLI contract v1.0.0 for iOS
element-region resolution on shared BS iOS hosts. Given a Maestro sessionId,
resolves /tmp/<sid>/wda-meta.json → { ok: true, port } or { ok: false, reason }
with TOCTOU-safe validation per contract §8:

File-level (SEI CERT POS35-C ordering, no lstat prefix):
- openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK) — atomic symlink refusal;
  ELOOP → 'symlink', ENOENT → 'missing', else → 'read-error'
- fstatSync on the opened fd — authoritative mode/uid/nlink check:
  - st.mode mismatch 0o100600 → 'wrong-mode'
  - st.uid mismatch getuid() → 'wrong-owner'
  - st.nlink != 1 → 'multi-link' (hardlink attack per Apple Secure Coding
    Guide, CVE-2005-2519 class)
  - !st.isFile() → 'not-regular-file'

Content validation (contract §2):
- JSON.parse → 'malformed-json'
- schema_version semver-major != 1 → 'schema-version-unsupported'
  (accepts 1.x minor bumps; unknown fields ignored for forward-compat)
- wdaPort out of 8400-8410 integer range → 'out-of-range-port'
- sessionId mismatch vs request → 'session-mismatch'
- flowStartTimestamp < getStartupTimestamp() - 5min → 'stale-timestamp'

Input guardrails:
- sessionId regex [A-Za-z0-9_-]{16,64} + null-byte/slash rejection →
  'invalid-session-id' (path-traversal defense before any fs touch)

Log scrubbing (contract §5):
- All failure paths emit only the reason tag via logger.debug()
- No selector values, sessionIds, port numbers, paths, or uids in logs
- Verified by a cross-scenario scrub-assertion test

DI: { getuid, getStartupTimestamp } injected for deterministic tests.

22 specs pass. Tests use real fs tmpdirs (bypass memfs) because the module
relies on POSIX O_NOFOLLOW / hardlink semantics memfs doesn't implement.

* feat(core): add wda-hierarchy iOS element resolver — source-dump path (B3)

Core iOS element-region resolver for /percy/maestro-screenshot. Single
GET /session/:sid/source per screenshot, parsed locally via fast-xml-parser,
mirrors the Android adb-hierarchy.js architecture.

Exports:
- resolveIosRegions({regions, sessionId, pngWidth, pngHeight, isPortrait, deps})
  → {resolvedRegions: [{elementSelector, boundingBox, algorithm}], warnings: []}
- shutdown() — aborts all in-flight WDA HTTP AbortControllers (wired to
  percy.stop() by B4)
- XCUI_ALLOWLIST — exported Set of ~80 XCUIElement.ElementType values from
  the Xcode 16 SDK (Apple XCUIElement.ElementType docs); serves as DoS
  guardrail per WDA issue #292

Resolution path (A1-chosen):
1. Landscape gate (isPortrait arg)
2. Kill-switch gate (process.env.PERCY_DISABLE_IOS_ELEMENT_REGIONS from
   startup env only; NOT tenant-forwarded via appPercy.env)
3. readWdaMeta dep returns port from realmobile-written wda-meta.json; port
   validated in 8400-8410 range
4. GET /wda/screen (loopback-only) → scale from integer `scale` field;
   fallback to width-ratio (pngWidth / logical_w) snapped to {2, 3};
   fail-closed on raw ratio outside [1.9, 3.1]; LRU cache cap 64 per-session
5. GET /session/:sid/source (loopback-only):
   - 20 MB response cap enforced BEFORE parse
   - Pre-parse DOCTYPE/ENTITY regex rejection (primary XXE defense)
   - fast-xml-parser with processEntities:false (defense-in-depth)
   - Cached per screenshot; all regions reuse single fetch
6. Per region:
   - Only `id` and `class` accepted in V1; `text`/`xpath` → selector-key-not-in-v1
   - class short-form (Button) normalized to long-form (XCUIElementTypeButton);
     rejected if normalized form not in allowlist → class-not-allowlisted
   - selector > 256 chars → selector-too-long
   - tree pre-order first match (zero-match on no-match)
   - scale points → pixels, validate in-bounds + non-trivial area (≥4×4) →
     bbox-out-of-bounds / bbox-too-small
   - outbound elementSelector.class uses normalized long-form (canonical form
     on Percy dashboard regardless of customer input style)

HTTP: @percy/client/utils#request via injectable httpClient dep; 500 ms
AbortController timeout per call; retries: 0 to keep timeout honest.
inflight Set tracks active controllers; shutdown() aborts all.

Log scrubbing (contract §5): reason tags only. Verified across all paths —
no selector values, sessionIds, ports, or coords in logs.

23 specs pass. Tests use an injectable fake httpClient + in-memory
handlers; no real network required.

* feat(core): integrate wda-hierarchy into /percy/maestro-screenshot relay (B4)

Wires B1/B2/B3 into api.js's /percy/maestro-screenshot handler. For iOS
requests with element regions:

1. Parse IHDR from the already-read fileContent (one buffer read total —
   no extra fs hit). Failure → warn-skip all iOS element regions with
   png-unparseable; coord regions + screenshot upload continue.
2. Call resolveIosRegions() once per request with a real @percy/client/utils
   #request httpClient and a resolveWdaSession-wrapped readWdaMeta dep.
3. Surface each warning to percy.log.warn so support runbook tags are
   visible in Maestro stdout.
4. Walk the original regions array in input order; positional index into
   the sparse resolvedRegions produced by the resolver keeps coord and
   element regions interleaved correctly in the outbound Percy payload.

wda-hierarchy now returns a SPARSE array (one entry per input element
region; null = skipped) instead of a dense array. Preserves input ordering
when element and coord regions are interleaved. All B3 unit tests updated
accordingly (22 still pass).

percy.js stop() invokes wdaHierarchyShutdown() before server.close() to
abort in-flight WDA HTTP calls — http.request has no SIGKILL analog, so
a slow /source fetch could otherwise keep the event loop alive past
graceful-shutdown timeout.

api.test.js: replaced the pre-V1 iOS stub test (which asserted
"Element-based region selectors are not yet supported on iOS") with a V1
behavioral test that exercises the full iOS element-region pipeline with
a real PNG IHDR header fixture (1170×2532 iPhone 14) and asserts V1
warn-skip semantics for an Android-style `resource-id` selector on iOS
(not-in-V1 key).

Test suite baseline: 28 pre-existing failures (chromium/doctor download
tests unrelated to this change). After B4: 27 failures — same chromium/
doctor failures, plus the iOS stub test now passes with its updated V1
assertions. Zero iOS/wda-hierarchy/maestro-screenshot regressions.

Kill-switch (PERCY_DISABLE_IOS_ELEMENT_REGIONS=1) read from Percy CLI
process startup env inside wda-hierarchy.js per plan — host-level only,
NOT forwarded from tenant appPercy.env (A0.3 property: pending staging
verification).

* feat(core): WDA-direct iOS regions — 3 fixes for Node 14 + WDA sid + retries

Implements the three layered fixes documented in
percy-maestro/docs/solutions/integration-issues/ios-wda-session-id-and-node14-abortcontroller-2026-04-23.md.
Each addresses a distinct iOS-region failure mode that surfaced during
2026-04-23 BrowserStack live validation on host 52:

Fix C — Node 14 AbortController feature-detect (callWda):
  BS iOS hosts pin to Node 14.17.3 (Nix). AbortController became a global
  in Node 15. Without feature detection, the timeout path threw
  ReferenceError caught by generic error handling and surfaced as the
  same 'wda-error' tag as legitimate WDA failures, masking the other two
  fixes during diagnosis. Now: typeof globalThis.AbortController guard +
  Promise.race fallback. Adds diagnostic logging on /wda/screen failures
  showing err.name/message/code/status/aborted/body.

Fix B — Stale WDA sessionId retry via error-envelope extraction:
  WDA's session-scoped routes (/session/:sid/source) reject any sid that
  isn't the currently-active session. Maestro spawns its own WDA session
  per xctest run, so realmobile's write-time sid capture goes stale
  during the test. Refactored fetchAndParseSource into tryFetchSource +
  retry coordinator. On staleSession (`{ value: { error: 'invalid session
  id' } }`), extracts the top-level `sessionId` from the error envelope
  (authoritative for "currently active") and retries once. Falls back to
  /status probe if the error body lacks a usable sid.

Fix A (reader side) — wdaSessionId surfacing per contract v1.1.0:
  realmobile contract v1.1.0+ probes /status at write_wda_meta time and
  surfaces the WDA UUID under wda-meta.json's optional `wdaSessionId`
  field. wda-session-resolver now validates the field against
  /^[A-Fa-f0-9-]{16,64}$/ (generous bounds for cross-version tolerance)
  and surfaces it on the {ok, port, wdaSessionId?} return shape. v1.0.0
  writers that omit the field cause callers to fall back to SDK
  sessionId (the fast path 404s, then Fix B's retry recovers).

Tests cover all three paths: feature-detected timeout, staleSession
retry from error envelope, /status fallback when error body lacks sid,
v1.1.0 wdaSessionId pass-through, v1.0.0 absence handling, malformed
wdaSessionId rejection.

Note for downstream: this WDA-direct path is gated for deletion by the
2026-04-27 plan (percy-maestro/docs/plans/2026-04-27-001-feat-ios-element-regions-maestro-hierarchy-plan.md)
once Phase 0.5 empirical probe passes. Until then, this is the
production iOS resolver path.

* refactor(core): rename adb-hierarchy → maestro-hierarchy + compat shim

The Android view-hierarchy resolver is becoming the cross-platform Maestro
resolver (per percy-maestro/docs/plans/2026-04-27-001-feat-ios-element-regions-maestro-hierarchy-plan.md
Unit 1). Rename + shim is purely additive — no behavior change.

- Move src/adb-hierarchy.js → src/maestro-hierarchy.js (git mv preserves history).
- Move test/unit/adb-hierarchy.test.js → test/unit/maestro-hierarchy.test.js.
- Move test/fixtures/adb-hierarchy/ → test/fixtures/maestro-hierarchy/.
- Replace src/adb-hierarchy.js with a 5-line re-export shim. Removed in V1.1
  per the plan's deprecation guidance.
- Update api.js import to ./maestro-hierarchy.js.
- Update logger namespace from core:adb-hierarchy → core:maestro-hierarchy.
- Update file header to reflect cross-platform intent (the file body has been
  maestro-first for some time; the previous file name was always misleading).
- Update test describe block + import + fixture path.

Behavior unchanged. Subsequent units in Phase 1 will add the iOS branch and
api.js dispatch logic; this commit is just the rename so the diffs in those
units stay focused.

* feat(core): scaffold iOS branch in maestro-hierarchy + R1 vocabulary parity

Phase 1 Unit 2a per percy-maestro/docs/plans/2026-04-27-001-feat-ios-element-regions-maestro-hierarchy-plan.md.
Lands the platform-dispatch scaffolding and the cross-platform selector
vocabulary alias. Real iOS resolver implementation deferred to Unit 2b
post Phase 0.5 fixture capture (FIXME-PHASE-0.5 in code).

Platform dispatch:
- dump({ platform }) accepts 'android' (default — backwards compatible) or
  'ios'. iOS branch reads PERCY_IOS_DEVICE_UDID + PERCY_IOS_DRIVER_HOST_PORT
  from env (realmobile-injected per Unit 10a; the wda_port + 2700 formula
  is realmobile-owned per maestro_session.rb:831). Warn-skip with
  reason='env-missing' if either var is unset. Otherwise calls
  runMaestroIosDump which currently returns
  { kind: 'unavailable', reason: 'not-implemented' } as the FIXME-PHASE-0.5
  stub.
- iOS path never invokes adb (verified by test).

R1 vocabulary parity (Android `id` alias):
- flattenMaestroNodes (Android branch) now surfaces resource-id under both
  `resource-id` AND `id` canonical keys on each node. Customer selectors
  `{element: {id: "submit-btn"}}` and `{element: {resource-id: "submit-btn"}}`
  resolve the same node. iOS users writing `{id: ...}` and Android users
  writing the same yaml hit the same code path. Full unified-key migration
  (deprecating `resource-id`) deferred to V1.1.
- SELECTOR_KEYS_UNION = [resource-id, text, content-desc, class, id]
  drives firstMatch validation. ANDROID_SELECTOR_KEYS_WHITELIST and
  IOS_SELECTOR_KEYS_WHITELIST exported separately for callers that want
  per-platform validation.

Tests added:
- Android `id` alias resolves same bbox as `resource-id` (3 tests).
- iOS env-missing path (3 tests covering each env-var combination).
- iOS env-set returns 'not-implemented' (FIXME stub).
- iOS dispatch never invokes adb.
- Default (no platform arg) preserves Android behavior.

Smoke-tested via direct node import; full @percy/core test suite has 27
pre-existing failures in Unit / Install in executable Chromium (unrelated
infrastructure issue), but no regressions in the resolver tests.

* feat(core): api.js dispatch behind PERCY_IOS_RESOLVER env switch

Phase 1 Unit 3 per percy-maestro/docs/plans/2026-04-27-001-feat-ios-element-regions-maestro-hierarchy-plan.md.

Wires the maestro-hierarchy resolver into the /percy/maestro-screenshot
relay's iOS element-region dispatch, gated by an env switch so default
(unset) behavior is unchanged. Phase 0.5 empirical probe gates the
default flip to the new path; Phase 4 deletes the legacy iOS branch.

- New: read PERCY_IOS_RESOLVER from process.env. When equal to
  'maestro-hierarchy', iOS element regions flow through the same
  lazy maestroDump({ platform: 'ios' }) + per-region firstMatch
  pattern Android already uses. When unset (or any other value),
  legacy WDA-direct path remains active — no behavior change for
  customers in production today.
- Refactor: the up-front PNG-parse + resolveIosRegions block now only
  fires when the env switch is OFF. With the switch on, that work is
  unnecessary (the resolver is engineered to be lazy + per-region).
- The cross-platform branch in the per-region loop now also covers iOS
  when the switch is on. Same shape as Android: cachedDump lazy memo,
  warn-skip on hierarchy-unavailable, firstMatch + bbox forward on
  success.

Today (env switch unset): only the cross-platform Android path is
exercised. The iOS branch with the switch on is exercised by the
maestro-hierarchy unit tests landed in Unit 2a (which covers the
'env-missing' and 'not-implemented' stub paths). Unit 4 adds the
parity test that exercises both platforms via the same handler.

A real production rollout flips the default to 'maestro-hierarchy'
in Phase 4 (Unit 9) after Phase 0.5 PASSes; until then, keep the
default off.

* test(core): cross-platform parity test + fix XML-path id alias

Phase 1 Unit 4 per percy-maestro/docs/plans/2026-04-27-001-feat-ios-element-regions-maestro-hierarchy-plan.md.

New test file: test/unit/maestro-hierarchy.parity.test.js. Locks in the
contract that both platform branches return the same { kind, ... } envelope,
that the public API surface (SELECTOR_KEYS_WHITELIST + per-platform
whitelists) is consistent, and that platform dispatch isolates the env-var
reads (Android never reads PERCY_IOS_*; iOS never reads ANDROID_SERIAL).

Bug fix discovered during smoke test:
- flattenNodes (the XML/uiautomator code path) was missing the R1 `id`
  alias surface that flattenMaestroNodes (the maestro CLI JSON path)
  already had. So `firstMatch(nodes, { id: 'X' })` worked when nodes came
  from the maestro path but returned null when nodes came from the adb
  fallback path. Now both code paths surface resource-id under both
  `resource-id` and `id` keys consistently.

iOS-side parity assertions in this test are scoped to what Unit 2a's stub
can actually cover — envelope shape, whitelist exports, dispatch isolation.
The Phase 4 follow-up (post Phase 0.5 + Unit 2b) extends this file with
real iOS attribute-mapping assertions backed by a captured iOS hierarchy
fixture.

Smoke-tested via direct node import. The full @percy/core test suite has
27 pre-existing Chromium-installer failures unrelated to this work.

* feat(core): vendor iOS Maestro viewHierarchy fixtures from cli-2.0.7 source

Source-synthesized fixtures for the new HTTP-XCTest path Unit 2 will build,
plus the maestro CLI iOS stdout shape for the fallback path. All shapes
verified against mobile-dev-inc/Maestro at ref=cli-2.0.7 (realmobile production
default per /usr/local/.browserstack/realmobile/config/constants.yml).

Notable findings recorded in capture-notes.md:
- PR #2365 has landed: server detects AUT itself; appIds is wire-vestigial.
  Percy CLI can send {"appIds": [], "excludeKeyboardElements": false}.
  YAML-based bundleId discovery is no longer required for the realmobile
  fast path.
- PR #2402 has landed but with a different wrap from cli-1.39.13: response
  is now {axElement: {children: [appHierarchy, statusBarsContainer]}, depth}
  rather than [springboard, AUT]. The deepening pass's parser rule
  ('first elementType == 1 whose identifier != com.apple.springboard')
  remains correct because the statusBars wrapper has elementType == 0.
- iOS Maestro's TreeNode does NOT carry a 'class' attribute. iOS selector
  vocabulary is 'id' only (maps to attributes['resource-id']). The
  originally absorbed Unit 2b XCUI elementType integer-to-name table is
  not needed for selector matching.

Wire-bytes confidence boost is deferred to Unit 5/6/7 BS validation rather
than blocking on a Unit-1 BS session capture (see plan Viability Gate 2).

* feat(core): iOS Maestro HTTP transport for view-hierarchy resolution (Unit 2)

Adds runIosHttpDump as the iOS primary path for /percy/maestro-screenshot
element regions: POST {appIds: [], excludeKeyboardElements: false} to Maestro's
iOS XCTestRunner /viewHierarchy endpoint at http://127.0.0.1:wda+2700. Server
detects AUT itself at cli-2.0.7+ (PR #2365 landed); empty appIds returns the
foreground AUT directly.

Replaces the iOS-WIP runMaestroIosDump stub with a real maestro-CLI shell-out
parser (the connection-class fallback path). Maestro's iOS CLI stdout is its
normalized TreeNode shape; existing flattenMaestroNodes consumes it without
iOS-specific code.

Adds flattenIosAxElement adapter for the HTTP path's raw AXElement shape:
walks to first elementType==1 with identifier!='com.apple.springboard' (skips
SpringBoard sibling on cli-1.39.13 wrap; works for both v1.39.13 [springboard,AUT]
and post-PR-2402 single-AUT-root shapes). Frame keys converted from PascalCase
{X,Y,Width,Height} to bracket-format bounds string.

Narrows IOS_SELECTOR_KEYS_WHITELIST from ['id', 'class'] to ['id'].
IOSDriver.mapViewHierarchy at cli-2.0.7 does not populate 'class' on iOS
TreeNode (only 'resource-id' from identifier), so Percy keeps iOS selector
vocabulary aligned with Maestro's actual capability.

Schema-class failures (missing root, missing frame, malformed JSON, 4xx,
non-JSON content-type) return dump-error without falling back. Connection-class
failures (ECONNREFUSED, ETIMEDOUT, ECONNRESET, 5xx) and no-aut-tree responses
(SpringBoard-only) fall back to the maestro-CLI path.

Two-tier deadline mirrors PR #2210's pattern: 1500ms healthy + 5000ms circuit-
breaker. Out-of-range PERCY_IOS_DRIVER_HOST_PORT (outside 11100-11110) skips
the HTTP path entirely. Loopback-only URL guard.

Drift-bit handling deferred to plan Unit 4 (cross-PR coordination with #2210);
schema-class failures currently log-only.

77 of 77 specs pass: 26 new iOS-path scenarios (HTTP primary, CLI fallback,
env handling, parity), and all existing Android tests unchanged.

Plan: percy-maestro/docs/plans/2026-05-06-004-feat-cross-platform-maestro-resolver-unification-plan.md
Fixtures: 65e54b9f (Unit 1)

* feat(core): wire iOS resolver-choice cascade in /percy/maestro-screenshot (Unit 3a)

Adds a three-tier cascade for iOS element-region resolver selection:
1. Per-snapshot override: `request.body.resolver` (validated against
   ['wda-direct', 'maestro-hierarchy']; HTTP 400 on unknown values).
2. Process env: `PERCY_IOS_RESOLVER` (same allowlist; unknown values
   warn + fall through).
3. Default: 'wda-direct' (Unit 3a is opt-in only — Unit 3b's env-conditional
   flip is a separate follow-up PR after the validation window).

The per-snapshot `resolver` body field is the ops escape valve documented
in the plan: lets operators `curl` a single snapshot with a specific
resolver for diagnostics without redeploying the CLI. SDK does not set
this today (R8 unchanged).

When the cascade chooses 'maestro-hierarchy', api.js calls the unified
`maestroDump({platform: 'ios', sessionId})` from Unit 2 (HTTP primary
+ CLI fallback). When 'wda-direct', the legacy `resolveIosRegions`
(WDA source-dump) path runs unchanged.

Threads sessionId from the relay request through to maestroDump for
log-scrubbed correlation tagging (Unit 2's `runIosHttpDump` uses sid
prefix in debug logs).

Tests: 6 new Unit 3a scenarios in api.test.js — body.resolver validation,
default-unchanged behavior, env-only path, per-snapshot override (both
directions), graceful fallback on garbage env values. All 6 pass; existing
tests unchanged.

Plan: percy-maestro/docs/plans/2026-05-06-004-feat-cross-platform-maestro-resolver-unification-plan.md

* feat(core): two-slot maestroHierarchyDrift envelope on /percy/healthcheck (Unit 4)

Adds module-level maestroHierarchyDrift state with {android, ios} slots that
record the first schema-class failure per platform, plus the setter/getter
exported for cross-cutting wiring. Both slots are null in steady state.

Wires Unit 2's iOS HTTP schema-class failures (missing axElement root,
missing frame, malformed JSON, 4xx, non-JSON content-type) to call
setMaestroHierarchyDrift({platform: 'ios', ...}). Connection-class failures
and no-aut-tree responses do NOT flip the bit (only schema-class — those
are the genuine 'Maestro upstream wire-format drifted' signals that need
ops attention).

Extends /percy/healthcheck to always emit:
  maestroHierarchyDrift: { android: ... | null, ios: ... | null }

The android slot is unwritten in this branch — PR #2210's gRPC drift
surface (recordSchemaDrift) sits on a sibling branch. When #2210 merges
and this PR rebases, #2210's Android schema-class call sites retrofit to
use the setter exported here. Companion artifact:
percy-maestro/docs/plans/2026-05-06-004-pr2210-coordination-comment.md.

Tests: 6 new Unit 4 scenarios in maestro-hierarchy.test.js — initial state,
iOS schema-class flips ios slot only, first-seen-per-platform wins,
connection-class doesn't flip, SpringBoard-only doesn't flip, reset helper.
Plus existing /healthcheck test updated to include the new field. Per-platform
slot independence is the central invariant — locks in the design rationale
that simultaneous-drift signal on both platforms is preserved (the
single-field-with-discriminator design rejected during document-review would
have lost that).

Plan: percy-maestro/docs/plans/2026-05-06-004-feat-cross-platform-maestro-resolver-unification-plan.md

* test(core): integration harnesses for iOS resolver validation (Units 5/6/7)

Three env-gated harnesses + supporting fixtures, all skipped in CI and run
manually during BS validation. Paste-output-into-PR pattern matches the
gRPC harness shape from the originally-planned PR #2210.

Unit 7 — maestro-hierarchy-ios-http-concurrent.harness.js (V4.2):
  Concurrent-access regression. While a real Maestro flow holds the iOS
  device active via extendedWaitUntil + impossible-selector polling
  (fixtures/pause-30s-flow-ios.yaml), the harness calls runIosHttpDump
  N=100 times and records p50/p95/p99 timings. KTD threshold check warns
  when p95 is within 10% of IOS_HTTP_HEALTHY_DEADLINE_MS (1500ms) so the
  deadline can be bumped before Unit 3b's flip.
  Env: MAESTRO_IOS_TEST_DEVICE, PERCY_IOS_DRIVER_HOST_PORT.

Unit 6 — maestro-ios-hierarchy-regression.harness.js (V3):
  WDA failure-class regression. Runs ios-aut-crash-regions.yaml twice:
  once with PERCY_IOS_RESOLVER=wda-direct (legacy WDA path — element
  regions silently skip when AUT bundleId isn't running, the production
  failure mode), and once with =maestro-hierarchy (HTTP path — regions
  resolve via Maestro's runner which walks system UI without bundleId
  binding). Output is logged for human verification of the two Percy
  build URLs.
  Env: MAESTRO_IOS_TEST_DEVICE, PERCY_SERVER, PERCY_IOS_DRIVER_HOST_PORT.

Unit 5 — cross-platform-parity.harness.js (V2):
  R6 cross-platform parity check. Runs parity-flow-android.yaml +
  parity-flow-ios.yaml against their respective devices, both resolving
  {id: 'submitBtn'} through Percy's relay. V1 is log-only — manual eyeball
  of the side-by-side Percy snapshots — because DPI normalization between
  Android pixels and iOS logical points is non-trivial without a documented
  example-app dimension table. V1.1 can tighten to programmatic ±2px
  assertion later.
  Env: MAESTRO_PARITY_DEVICES (format: <android-serial>:<ios-udid>),
       PERCY_SERVER, PERCY_IOS_DRIVER_HOST_PORT.

Plan: percy-maestro/docs/plans/2026-05-06-004-feat-cross-platform-maestro-resolver-unification-plan.md

* refactor(core): retire wda-hierarchy + collapse resolver-choice cascade (Units 3b + 8 consolidated)

Removes the legacy iOS WDA-direct resolver and the now-dead resolver-choice
machinery that selected between WDA and the new HTTP/CLI path. The unified
maestro-hierarchy resolver becomes the only iOS path; element regions resolve
via runIosHttpDump → maestro-CLI shell-out fallback.

Deleted (8 files, ~1700 lines net):
- packages/core/src/wda-hierarchy.js (legacy WDA /source resolver)
- packages/core/src/wda-session-resolver.js (TOCTOU-safe wda-meta.json reader,
  consumed only by wda-hierarchy)
- packages/core/src/png-dimensions.js (PNG IHDR parser, used only by
  wda-hierarchy for scale-factor derivation)
- packages/core/test/unit/wda-hierarchy.test.js
- packages/core/test/unit/wda-session-resolver.test.js
- packages/core/test/unit/png-dimensions.test.js
- packages/core/test/integration/maestro-ios-hierarchy-regression.harness.js
  (Unit 6 — was a wda-direct vs maestro-hierarchy comparator; meaningless
  with wda-direct gone)
- packages/core/test/integration/fixtures/ios-aut-crash-regions.yaml
  (paired with the regression harness)

Stripped from api.js:
- import resolveIosRegions / resolveWdaSession / parsePngDimensions
- body.resolver field validation (single path → meaningless)
- PERCY_IOS_RESOLVER env handling (single path → meaningless)
- iOS WDA-direct branch + iosResult / iosIndex bookkeeping
- The resolver-choice cascade comment block

Stripped from percy.js: wdaHierarchyShutdown import + shutdown call. The
maestro-hierarchy HTTP path uses a stateless http.Agent that closes when
the process exits; no explicit shutdown needed.

Stripped from api.test.js: the 6 Unit-3a resolver-cascade tests (validated
behavior that no longer exists). The 'iOS element region with Android-style
selector key' test consolidated into a single combined test that exercises
the unified iOS path with a mix of element + coord regions.

Plan implications (Plan: percy-maestro/docs/plans/2026-05-06-004-...):
- Unit 3a's per-snapshot resolver override and PERCY_IOS_RESOLVER env: REMOVED.
- Unit 3b's telemetry-gated default flip: CONSOLIDATED. The default IS now
  maestro-hierarchy because there is no other path; no flip pending.
- Unit 8's wda-hierarchy.js retirement: SHIPPED HERE rather than ≥1 week
  post-Unit-3b.

Regression risk acknowledged (the P0 from document review): self-hosted
iOS Percy customers without realmobile-injected PERCY_IOS_DRIVER_HOST_PORT
AND without a working maestro CLI installed lose element-region support
on this code path. Their previously-working WDA-direct happy path is
gone. Coord regions still work; element regions skip gracefully with a
'[percy] Element-region resolver unavailable' warn. Customers in that
situation should switch to coord regions or wait for a future Android-style
gRPC-direct path. Customer-side rollback: pin to a CLI version before this
PR.

Test status: 148 of 148 specs run; 6 pre-existing failures unchanged
(Jest .toHaveProperty matcher in Jasmine context; AggregateError vs
ECONNREFUSED network-stack flake — all unrelated to this work).

* refactor(core): drop deprecated adb-hierarchy.js shim + dead imports + stale comments

Final cleanup pass:

- Delete packages/core/src/adb-hierarchy.js — deprecated compat shim that
  re-exports from maestro-hierarchy.js. Comment said 'removed in V1.1'; no
  internal code imports from it. External consumers should import from
  maestro-hierarchy.js directly.

- Rename adbDump/adbFirstMatch import aliases to maestroDump/maestroFirstMatch
  in api.js. The old names predate the cross-platform rename and were
  misleading (the function dispatches to platform-appropriate transports,
  not just adb).

- Drop unused 'percyRequest' import in api.js. It was only consumed by the
  resolveIosRegions call site, which the WDA retirement commit removed.

- Update stale comments in maestro-hierarchy.js that referenced 'Unit 2a/2b'
  scaffolding terminology and the 'Phase 0.5 stub' that's no longer present.

* feat(core): vendor maestro_android.proto + add @grpc/grpc-js deps (2026-05-07-002 Unit 1)

Adds @grpc/grpc-js@^1.14.3 and @grpc/proto-loader@^0.8.0 as @percy/core
dependencies, plus the vendored maestro_android.proto from upstream
mobile-dev-inc/Maestro at SHA bc8bde1b (cli-2.5.1, 2025-05-26). Both deps
declare engines.node floors well below @percy/cli's >=14 (grpc-js >=12.10.0,
proto-loader >=6) — no min-Node bump required.

Only MaestroDriver/viewHierarchy(ViewHierarchyRequest) returns
(ViewHierarchyResponse) and the `string hierarchy = 1` field on the response
are consumed at runtime; the rest of the proto is preserved verbatim so
future updates can be a clean upstream re-copy without surgical edits.

The proto/ dir is preserved into dist/ via Babel CLI's existing
copyFiles: true setting (no build-system change needed).

Refs:
- 2026-05-07-002 plan Unit 1 + D6 + D12
- Replaces in-flight PR #2210 (will be closed once this PR merges)

* feat(core): direct gRPC Android resolver with three-class error taxonomy (Units 2/3/7)

Adds runAndroidGrpcDump as the Android primary path in maestro-hierarchy.js's
dump({platform:'android'}) dispatch, talking the same gRPC transport Maestro
CLI uses but as a stateless RPC that doesn't open a parallel flow context.
Avoids the session-collision failure mode the maestro CLI shell-out hits
during a live Maestro flow (root cause of the 2026-05-07 BS validation
fallback-dump-exit-137 result).

Three-class error taxonomy (D10) — splits PR #2210's two-class scheme:
  - schema-class (INVALID_ARGUMENT, FAILED_PRECONDITION, OUT_OF_RANGE,
    UNIMPLEMENTED, DATA_LOSS, decoder-failure) → drift bit, no fallback,
    cache PRESERVED
  - channel-broken (UNAVAILABLE, INTERNAL, CANCELLED, unmapped codes) →
    fallback chain runs, cache EVICTED (channel actually broke)
  - contention-class (DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, ABORTED) →
    skip CLI fallback (would queue behind same flow), straight to adb,
    cache PRESERVED (timeout = backpressure, not channel-breakage; reconnect
    would waste TCP+HTTP/2+TLS for nothing)

Symmetric timeout architecture (D11): GRPC_HEALTHY_DEADLINE_MS=1500 +
GRPC_CIRCUIT_BREAKER_MS=5000 — parity with iOS HTTP path. Outer Promise.race
is defense-in-depth against grpc-node#2620-style channel sticking (closed
in 1.9.11 but cheap insurance).

Dispatch (D3 + D9):
  PERCY_MAESTRO_GRPC=0 kill switch  → skip gRPC entirely (in-process rollback)
  PERCY_ANDROID_GRPC_PORT set+valid → runAndroidGrpcDump → branch by class
  env absent / invalid              → maestro CLI primary → adb (current behavior)

R-7 (shutdown race): runAndroidGrpcDump accepts shutdownInProgress flag
sourced from grpcClientCache.shutdownInProgress (set by percy.stop()
before close). CANCELLED-during-shutdown returns {kind:'unavailable',
reason:'shutdown'} — no fallback chain on a tearing-down process.

Drift recording uses this branch's existing two-slot
setMaestroHierarchyDrift({platform:'android', code, reason}) — drops
PR #2210's separate single-slot recordSchemaDrift.

Cleanup: removes the stale "Single-author note about PR #2210" comment
and a stale grpc-node#2620 framing reference (issue is closed).

Refs:
- 2026-05-07-002 plan Units 2, 3, 7 + D1, D3, D9, D10, D11
- Replaces PR #2210's runGrpcDump

* feat(core): per-Percy gRPC client cache lifecycle (Unit 5)

Constructs grpcClientCache as a per-Percy-instance Map in the constructor
(matching the established ownership pattern for browser, server, queues,
client, monitoring). Disposes via closeGrpcClientCache(this.grpcClientCache)
in stop()'s teardown block.

Module-global state would leak channels between concurrent Percy instances
in a single process (programmatic-API users, test harnesses) and create
shutdown races where one instance's stop() invalidates another's pending
RPCs. Per-instance ownership matches every other long-lived resource on
Percy.

Asymmetry with maestroHierarchyDrift (deliberate, documented in
maestro-hierarchy.js header): drift envelope stays module-scoped because
drift is observability state — surfaced process-wide on /percy/healthcheck.
Channels are transport state — per-instance lifecycle.

R-7 (shutdown race): sets cache.shutdownInProgress = true BEFORE closing
channels so any in-flight runAndroidGrpcDump() that hits CANCELLED returns
{kind:'unavailable', reason:'shutdown'} instead of triggering the maestro
CLI + adb fallback chain on a tearing-down process.

api.js relay handler now threads percy.grpcClientCache through to
maestroDump() so the Android gRPC primary can reuse channels across
snapshots in the same session.

Mitigates grpc-node#2964 (open) — ChannelzTrace memory leak when Client
is not explicitly .close()-d.

Refs:
- 2026-05-07-002 plan Unit 5 + D9
- R-7 (shutdown race), R-3 (cache scope) resolved by this commit

* test(core): Android gRPC test coverage for D10 three-class taxonomy + dispatch (Units 2/3/4/5)

28 new specs covering the absorbed gRPC primary path:

classifyGrpcFailure (D10 three classes):
  - schema-class: missing code → grpc-decode; INVALID_ARGUMENT,
    FAILED_PRECONDITION, OUT_OF_RANGE, UNIMPLEMENTED, DATA_LOSS
  - contention-class: DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, ABORTED
  - channel-broken: UNAVAILABLE, INTERNAL, CANCELLED, unmapped codes
  - returns null for falsy errors

runAndroidGrpcDump (success + failure paths):
  - hierarchy parsed from gRPC response.hierarchy XML envelope
  - schema-class UNIMPLEMENTED → drift bit set on android slot,
    no eviction, ios slot stays null
  - contention-class DEADLINE_EXCEEDED → cache PRESERVED (D10)
  - channel-broken UNAVAILABLE → cache evicted, client.close() called
  - CANCELLED-during-shutdown → unavailable/shutdown (R-7), no fallback
  - CANCELLED outside shutdown → channel-broken (cache evicted)
  - empty hierarchy field → grpc-no-xml-envelope drift

Cache reuse + per-instance isolation (D9):
  - reuses same client across calls to same address
  - two independent caches do not share clients
  - connection-fail in cache A does not invalidate cache B

closeGrpcClientCache (Unit 5):
  - closes every cached client, clears map
  - idempotent on empty cache
  - handles undefined / null gracefully

dump({platform:'android'}) dispatch (Unit 3):
  - env set + gRPC success: gRPC primary, no CLI/adb
  - env set + schema-class: returns immediately, no fallback
  - env set + contention-class: SKIPS CLI, goes straight to adb
  - env set + channel-broken: falls through to maestro CLI
  - PERCY_MAESTRO_GRPC=0 kill switch: skips gRPC entirely
  - env absent: gRPC NOT attempted, maestro CLI primary
  - malformed env: falls through cleanly

Test mocking pattern: factory injection (makeFakeFactory + makeFixedClient)
matches the iOS HTTP path's makeFakeHttpRequest. Inlined GRPC_STATUS enum
isolates classifier coverage from @grpc/grpc-js runtime drift.

Test count: 779 → 807 specs total. All 28 new tests pass green; the 27
pre-existing failures (Install Chromium, runDoctorOnFailure, env-flake) are
unchanged from master.

Refs:
- 2026-05-07-002 plan Units 2, 3, 4, 5

* test(core): concurrent-access merge gate harness for Android gRPC (Unit 6)

Env-gated integration harness that exercises the gRPC primary path under
realistic Maestro flow contention. Spawns a real Maestro flow that holds
the device's gRPC agent active via extendedWaitUntil, then runs N=100
parallel runAndroidGrpcDump() iterations against the same agent. Asserts
{kind:'hierarchy'} on every iteration and records p50/p95/p99 timings.

Pre-merge gate (D11): p95 < 1200ms AND p99 < 2000ms across 100 iterations
under live tapOn flow load. Failure means D11's 1500ms healthy / 5000ms
breaker budget is wrong OR the device-side agent is contention-fragile —
investigate before relaxing the threshold.

Required env (skips cleanly when absent):
  - MAESTRO_ANDROID_TEST_DEVICE: connected Android serial
  - PERCY_ANDROID_GRPC_PORT: realmobile/mobile-injected gRPC port (or
    manual `adb forward tcp:<host_port> tcp:7001` for local validation)
  - MAESTRO_BIN: optional, defaults to `maestro` on PATH

Fixtures:
  - test/integration/fixtures/pause-30s-flow.yaml — Maestro flow that
    parks the device in a known-busy state for 30s
  - test/fixtures/maestro-hierarchy/grpc-response.xml — captured response
    against cli-2.5.1 for fixture-driven unit tests
  - test/fixtures/maestro-hierarchy/grpc-capture-notes.md — recapture
    procedure when Maestro version drifts

Per-Percy cache equivalent: harness instantiates a fresh Map() shared
across iterations so it exercises real channel reuse + the
contention-vs-channel-broken eviction policy from D10.

Refs:
- 2026-05-07-002 plan Unit 6

* fix(core): relax iOS HTTP content-type check (Maestro upstream omits CT)

The 2026-05-07 BS validation surfaced `iOS HTTP schema-drift: http-non-json-content-type` against this realmobile deployment's Maestro version. Root cause traced upstream:

  https://github.com/mobile-dev-inc/Maestro/blob/main/maestro-ios-xctest-runner/maestro-driver-iosUITests/Routes/Handlers/ViewHierarchyHandler.swift

`ViewHierarchyHandler` returns `HTTPResponse(statusCode: .ok, body: body)`
without setting Content-Type. The FlyingFox HTTP server library doesn't
auto-set one from the body bytes. So Maestro's /viewHierarchy responses
go out with valid JSON in the body but NO Content-Type header — every
real Maestro build hits this.

Our resolver was rejecting on missing or non-application/json CT
(reason `http-non-json-content-type`), which classifies as schema-class
under the existing taxonomy → no fallback, drift bit set, every
element-region snapshot silently dropped on real iOS builds.

Fix: treat content-type as informational. Log debug warn when missing
or non-JSON, but still attempt JSON.parse. Schema-class drift only
fires on actual parse failure (`http-parse-error`) or missing axElement
root (`http-missing-root`) — both of which are real wire-format bugs,
not header-omission quirks.

Test updated:
  - Replaces the old "non-JSON CT → dump-error" test (asserted the wrong
    behavior) with two new tests:
    - `falls through to JSON parse when content-type is missing or non-JSON`
      — covers Maestro's actual response shape (no CT + valid JSON body)
      and `text/json` (other JSON variants).
    - `returns http-parse-error when body is not valid JSON regardless
      of content-type` — confirms parse failure still classifies correctly.

Refs:
- BS validation result 2026-05-07 (Percy build 49501844 on iOS host
  185.255.127.52, drift envelope captured `http-non-json-content-type`)
- Maestro upstream `ViewHierarchyHandler.swift` (verified missing CT)

Note: an upstream fix to set `Content-Type: application/json` on the
Maestro side is the proper long-term fix; this CLI relaxation is the
pragmatic interop fix for current and historical Maestro versions.

* feat(core): accept optional filePath in /percy/maestro-screenshot relay

The SDK can now post an absolute filePath in the request body. When present,
the relay reads the file directly and skips the legacy glob — same realpath
+ per-platform session-root prefix check enforces the security invariant
for both paths. When absent (older SDKs), the existing glob behavior runs
unchanged.

Eliminates the hidden coupling between BS-infra SCREENSHOTS_DIR conventions
and the relay's hardcoded glob pattern, which caused the "Snapshot command
was not called" regression on Android (build 0444158…) when the v3 infra
patch put files one directory level too shallow.

Validation:
- filePath must be a string, ≤1024 chars, absolute (400 on shape errors).
- Empty string treated as absent → falls through to legacy glob.
- realpath + startsWith(sessionRoot) catches traversal, symlink-escape, and
  cross-sessionId access (404 with the existing message shape, no leak of
  the resolved path).

9 new jasmine tests in packages/core/test/api.test.js cover the contract:
happy paths (Android + iOS), shape rejection (400), missing-file (404),
traversal / outside-root / cross-sid (404), and back-compat (empty string →
glob fallback).

* feat(core): surface gRPC + iOS HTTP hierarchy fallback transitions on /percy/healthcheck

Extends the per-platform maestroHierarchyDrift slot with three resolver
activity counters and bumps every primary→fallback transition log site
from log.debug to log.info with a structured shape. R7/R8 from the
2026-05-11 origin doc; R9 (no cascade behaviour change) preserved.

Envelope shape (additive — existing code/reason/firstSeenAt drift-bit
fields are unchanged, slot stays null until any resolver activity):
  maestroHierarchyDrift: {
    android: { lastFailureClass, fallbackCount, succeededVia, [code, reason, firstSeenAt]? },
    ios:     { lastFailureClass, fallbackCount, succeededVia, [code, reason, firstSeenAt]? }
  }

  lastFailureClass: 'schema-class' | 'channel-broken' | 'contention-class' | 'other' | null
  succeededVia:    'grpc' | 'maestro-cli' | 'adb' (Android) /
                   'maestro-http' | 'maestro-cli-fallback' (iOS) / 'none' | null

Info-level log shape at every fallback edge:
  [percy] hierarchy: <primary> failed (<class>: <reason>) → falling back to <next>

Examples:
  [percy] hierarchy: grpc failed (contention-class: grpc-contention-deadline_exceeded) → falling back to adb
  [percy] hierarchy: grpc failed (channel-broken: grpc-channel-broken-unavailable) → falling back to maestro-cli
  [percy] hierarchy: maestro-cli failed (other: maestro-exit-1) → falling back to adb
  [percy] hierarchy: maestro-http failed (channel-broken: http-econnrefused) → falling back to maestro-cli-fallback
  [percy] hierarchy: maestro-http failed (other: out-of-range-port-99999) → falling back to maestro-cli-fallback

This makes the gRPC primary's per-build behaviour readable from a single
healthcheck probe + default-verbosity logs — one BS build now produces
hard data on whether the primary failure class is channel-broken,
contention-class, or schema-class, and the cumulative fallback count
shows how often it falls through across a session.

Context: a recent BS validation observed PERCY_ANDROID_GRPC_PORT injected
correctly, adb forward succeeded, yet the cascade fell back to adb. The
prior session's theory was channel-broken because dev.mobile.maestro is
single-client. Investigation of upstream Maestro source
(MaestroDriverService.kt in mobile-dev-inc/maestro) refuted that —
NettyServerBuilder with default executor, no locking on viewHierarchy(),
multi-client by construction. So the single observed fallback was likely
contention-class (DEADLINE_EXCEEDED under UiAutomation contention) or a
transient connection-level event, NOT an architectural problem. The
observability surface here distinguishes the cases without needing
another speculation pass.

Tests:
- 24 new specs in packages/core/test/unit/maestro-hierarchy.test.js
  inside a new "resolver activity counters + transition logs (R7/R8)"
  describe block. Covers: initial state, Android success paths (gRPC /
  maestro-cli / adb), Android gRPC contention/channel-broken/schema
  cascades, multi-hop fallbacks (fallbackCount=2), cumulative counter
  across calls, sticky lastFailureClass after later success, iOS
  HTTP/CLI cascades, env-missing, adb-unavailable, cross-platform
  isolation, reset, and all 6 transition log lines.
- Two existing tests (connection-class / SpringBoard-only "drift NOT
  flipped") updated to acknowledge the slot now populates with activity
  counters on those paths while drift-bit fields stay absent. Original
  invariant (drift-bit fires only on schema-class) preserved.
- All 22 existing /percy/maestro-screenshot relay tests, 80+ existing
  maestro-hierarchy specs, and the cross-platform parity harness pass
  unchanged.

R10 (decide whether to skip gRPC on BS, share channel, or accept the
cascade) is explicitly deferred to a follow-up plan once production
data flows through this surface.

* docs(core): inline regions end-to-end architecture in api.js relay handler

Adds a ~50-line architecture block documenting the two region types,
the data flow from SDK to Percy backend through the resolver cascade,
the observability surface (healthcheck envelope + info-level transition
logs), and the failure shape (graceful degradation of element regions).
The comment lives at the maestro-screenshot relay's region-handling site
where reviewers and future maintainers encounter the logic.

* docs(core): correct maestro-hierarchy.js comment on gRPC primary purpose

2026-05-16 empirical investigation (full doc at docs/solutions/best-practices/
2026-05-16-grpc-unavailable-investigation.md, local-only since docs/ is
gitignored) probed dev.mobile.maestro:6790 via existing adb forwards on a
quiescent device AND during live `maestro hierarchy` runs across Maestro
1.39.13 and 2.0.7. Outcome: the gRPC service is never bound device-side; the
package is never installed; Maestro CLI fetches view hierarchy via a
different (uiautomator-based) IPC.

The header comment's claim that "Maestro's CLI uses" the same gRPC transport
was wrong for the test-running CLI on these distros — that's a maestro studio
detail, not maestro test. Updated the comment to reflect this as forward-
compatibility infrastructure for future Maestro distributions that DO install
the instrumentation APK.

No code change. The three-class taxonomy already classifies UNAVAILABLE
correctly as channel-broken; the cascade falls through to maestro-cli → adb.
Closes R10 (was deferred in the Issue 2 plan with the contention-vs-channel-
broken hypothesis; the empirical answer is "neither — the service simply
isn't present").

* fix(core): drain percy.upload async generator in sync relay routes

percy.upload returns an async generator whose IIFE body pushes the snapshot
options onto the internal #snapshots queue. The sync branches of /percy/comparison,
/percy/maestro-screenshot, and /percy/automateScreenshot constructed a Promise
whose executor called percy.upload and discarded the returned generator. Without
iteration the queue was never enqueued, the syncQueue callbacks (resolve/reject)
were never invoked, and handleSyncJob hung forever.

Drain the generator inside the Promise executor at all three routes so the
queue runs as expected. Adds drain-canary regression tests for the three routes
plus a reject-branch test for /percy/maestro-screenshot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(core): accept ignoreRegions and considerRegions on /percy/maestro-screenshot

Extend the Maestro relay to accept three parallel region-input arrays —
regions, ignoreRegions, considerRegions — each capped at 50 items per array.
Same per-item shape and validation across all three; algorithm is regions-only
(the comparison schema's algorithm enum is standard|layout|ignore|intelliignore,
so 'consider' must travel as a separate payload field).

Emit each input to its schema-aligned payload field:
  - regions[] → payload.regions[] (existing comparison shape with algorithm)
  - ignoreRegions[] → payload.ignoredElementsData.ignoreElementsData[]
  - considerRegions[] → payload.consideredElementsData.considerElementsData[]

Share a single per-request maestroDump across all three so a screen with
mixed-input regions only pays the hierarchy cost once. Element-region skip
warnings remain warn-once across the combined element-region count.

Adds 8 regression tests covering shape validation, per-array cap, boundary
case (150 total regions), payload-field separation, and the three-field
parallel emit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(core): cli-side algorithm pass-through coverage for regions[]

The Maestro relay treats regions[].algorithm as opaque pass-through string;
the comparison schema downstream enforces the enum (standard|layout|ignore|
intelliignore). Lock that contract with four tests: 'ignore', 'standard',
the invalid value 'bogus', and the no-algorithm default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(core): gate iOS HTTP hierarchy on PERCY_MAESTRO_GRPC=0 kill switch

The kill switch was only checked at the Android dispatch path. Extend it to
the iOS dispatch path so one env var routes BOTH primaries (Android gRPC +
iOS HTTP) to their maestro-CLI fallback. Read fresh on every dump() call
so an on-call can toggle it mid-process without a CLI restart.

Add specs verifying:
- iOS HTTP primary is skipped when PERCY_MAESTRO_GRPC=0
- env var is re-read on every dump call (toggling mid-process flips behavior)

* fix(core): restore PNG_MAGIC_BYTES + lint compliance after master merge

The merge of master (which added the /percy/comparison/upload hardening
commit) dropped the PNG_MAGIC_BYTES constant declaration while preserving
its reference at line 259 — restore the const inside the route handler
scope where master originally declared it.

Run eslint --fix on auto-merged files (api.js, percy.js, api.test.js,
maestro-hierarchy.test.js) to resolve the 99 object-property-newline style
violations introduced by the merge.

Add eslint-disable-next-line on the three `for await (const _ of upload)`
drain patterns — `_` is intentional but the configured no-unused-vars rule
has no varsIgnorePattern. Disable is the least-invasive fix; alternatives
(rename, void usage) trip other rules.

Remove unused `adbHierarchyOk` fixture binding in maestro-hierarchy.test.js
(leftover from a refactor).

* fix(core): suppress semgrep path-traversal false positives in maestro relay

semgrep flags path.join calls in the maestro-screenshot fallback walker
(api.js:441, 460) as path-traversal sinks because it cannot follow the
upstream SAFE_ID validation chain:

- name AND sessionId are validated against /^[a-zA-Z0-9_-]+$/ at the top
  of the route handler (line 322) — traversal sequences are rejected
  before any path.join runs.
- Depth-capped walker (15 levels) bounds blast radius even on adversarial
  /tmp/<sid> contents.

Suppress at the line level with rationale comments, matching the
existing pattern in lock.js, archive.js, and the cli-doctor checks.

* fix(core): use bare nosemgrep comment to suppress maestro relay false positives

The previous attempt used rule-specific `nosemgrep: <rule-id>` comments
above the path.join calls but with intervening rationale comment lines.
semgrep treats only the comment IMMEDIATELY preceding the source line as
a suppression directive — the rationale block was breaking the chain.

Move the rationale to a block comment above the loop body and put a bare
`// nosemgrep` directly above each path.join call. Matches the working
pattern from packages/cli-doctor/src/checks/pac.js.

* fix(core): use same-line nosemgrep suppression with explicit rule IDs

The preceding-line `// nosemgrep` directive (with or without rule ID) was
not suppressing the path-traversal findings. semgrep CI continued to
report 12 blocking findings on api.js lines 446 + 464 after each attempt.

Move to same-line trailing comment with explicit rule IDs — this is the
most explicit form and matches semgrep's documented ergonomics best.

* fix(core): file-level semgrep suppression for path-traversal false positives

The inline `// nosemgrep` directives (preceding-line, same-line trailing,
with and without explicit rule IDs) were not honored by the semgrep CI
version in use — 12 findings continued to block every push.

Fall back to file-level .semgrepignore entries (matching the established
lock.js / archive.js precedent in this repo):

- packages/core/src/api.js — fallback walker path.join calls. Upstream
  SAFE_ID validation (^[a-zA-Z0-9_-]+$) at the route handler entry
  rejects traversal sequences before the joins run; depth-capped walker
  bounds blast radius further.
- packages/core/test/unit/maestro-hierarchy{,parity}.test.js — fixture
  loaders use path.join with static literal filenames. No user input.

Inline `// nosemgrep` directives removed (now redundant); rationale
comments retained inline at each path.join site and in .semgrepignore.

* feat(core): derive Maestro snapshot tag dims from PNG header in relay

Adds a small `parsePngDimensions` helper at module level and wires it
into the /percy/maestro-screenshot handler to populate
payload.tag.width / payload.tag.height when the customer didn't supply
them. The relay already reads the screenshot file as a Buffer to base64-
encode the tile content — the IHDR chunk (bytes 16-23, big-endian
uint32) carries the actual rendered dimensions, so this is a non-
duplicative read at fixed offsets with no library dependency.

Why: the PNG bytes ARE what Percy stores and compares against. Host-
side env-var injection of dims (per the 2026-05-22 plan) failed on
hosts without xcrun devicectl (BS iOS test host 185.255.127.52
lacked the tool entirely) and on iOS 14/15/16 devices regardless of
host. The PNG-relay derivation works universally — any iOS version,
any host (BS / Maestro Cloud / self-hosted), pixel-exact.

Fill-don't-override semantic: customer-supplied tag.width/height
continue to win for backward compatibility. PNG fills only when the
field is missing, zero, or NaN.

Defensive: signature check before reading IHDR offsets; truncated
files (<24 bytes) skip silently; zero IHDR values skip; non-PNG
signatures skip (preserves whatever the customer provided).

7 new test scenarios in api.test.js cover: happy-path fill,
customer-pinned override, partial fill (one missing field), non-PNG
signature, truncated file, width=0 defense, filePath-supplied path
parity.

Also commits the 2026-05-22 Maestro precedence experiment results
doc (was missing from the prior s…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant