feat(core): iOS element regions via WDA-direct (Plan A — gated for deletion in Phase 4)#2201
Draft
feat(core): iOS element regions via WDA-direct (Plan A — gated for deletion in Phase 4)#2201
Conversation
- 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
Reject immediately on Busboy 'limit' event with 413 instead of setting fileBuffer to null which produced 'Missing required file part'.
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.
… 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>
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).
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.
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.
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.
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.
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.
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.
…-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.
… (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.
…lay (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).
…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.
| let entries; | ||
| try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; } | ||
| for (let entry of entries) { | ||
| let full = path.join(dir, entry.name); |
| let entries; | ||
| try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); } catch { return; } | ||
| for (let entry of entries) { | ||
| let full = path.join(dir, entry.name); |
| let baseDir = `/tmp/${sessionId}_test_suite/logs`; | ||
| let logDirs = await fs.promises.readdir(baseDir); | ||
| for (let dir of logDirs) { | ||
| let screenshotPath = path.join(baseDir, dir, 'screenshots', `${name}.png`); |
| let baseDir = `/tmp/${sessionId}_test_suite/logs`; | ||
| let logDirs = await fs.promises.readdir(baseDir); | ||
| for (let dir of logDirs) { | ||
| let screenshotPath = path.join(baseDir, dir, 'screenshots', `${name}.png`); |
| let baseDir = `/tmp/${sessionId}_test_suite/logs`; | ||
| let logDirs = await fs.promises.readdir(baseDir); | ||
| for (let dir of logDirs) { | ||
| let screenshotPath = path.join(baseDir, dir, 'screenshots', `${name}.png`); |
| return { ok: false, reason: 'invalid-session-id' }; | ||
| } | ||
|
|
||
| const filePath = path.join(baseDir, sessionId, 'wda-meta.json'); |
| import { logger, setupTest } from '../helpers/index.js'; | ||
|
|
||
| const fixtureDir = path.resolve(url.fileURLToPath(import.meta.url), '../../fixtures/adb-hierarchy'); | ||
| const loadFixture = name => fs.readFileSync(path.join(fixtureDir, name), 'utf8'); |
| } | ||
|
|
||
| function writeMeta(baseDir, sid, content, { mode = 0o600, dirMode = 0o700 } = {}) { | ||
| const sidDir = path.join(baseDir, sid); |
| } | ||
|
|
||
| function writeMeta(baseDir, sid, content, { mode = 0o600, dirMode = 0o700 } = {}) { | ||
| const sidDir = path.join(baseDir, sid); |
| const sidDir = path.join(baseDir, sid); | ||
| fs.mkdirSync(sidDir, { mode: dirMode }); | ||
| fs.chmodSync(sidDir, dirMode); // mkdir mode is umask-masked | ||
| const file = path.join(sidDir, 'wda-meta.json'); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
iOS element-region resolver via direct WebDriverAgent integration. Resolves
PERCY_REGIONSelement selectors against a Maestro-spawned WDA session onBrowserStack iOS hosts during a live
maestro testflow.This PR is the WDA-direct path (Plan A) per the 2026-04-22 brainstorm and
plan in the percy-maestro repo. It is gated for deletion in Phase 4 of the
2026-04-27 follow-up plan (separate PR #TBD:
feat/ios-element-regions-maestro-hierarchy)once the empirical Phase 0.5 probe of the cross-platform
maestro hierarchyresolver passes.
What lands here
5 commits, in chronological order:
d097f077B1 —png-dimensions.js: PNG IHDR parser for the iOSwidth-ratio scale-factor computation.
1792e376B2 —wda-session-resolver.js: reader for the realmobile↔ Percy CLI
wda-meta.jsoncontract (TOCTOU-safe viaO_NOFOLLOW+fstat, per SEI CERT POS35-C).d0cae9c3B3 —wda-hierarchy.js: iOS element-region resolver.Source-dump path (
GET /session/:sid/source);XCUI_ALLOWLISTforclass-name normalization; in-bounds + min-area bbox validation; scrubbed
reason-tag log surface.
d2eb348fB4 — wires the iOS resolver into the/percy/maestro-screenshotrelay handler inapi.js. Sparse-arrayreturn shape (one resolution per request, shared across element regions).
e7b9938b(latest) 3 fixes from 2026-04-23 live validation:AbortController(BS hosts run Node 14.17.3where it's not a global; previously threw
ReferenceErrormasked as'wda-error').on stale-session (WDA's session lifecycle is not stable across a test
run; the response itself is the authoritative active sid).
wdaSessionIdfromcontract v1.1.0+
wda-meta.jsonpayloads.See full doc:
percy-maestro/docs/solutions/integration-issues/ios-wda-session-id-and-node14-abortcontroller-2026-04-23.md.Status of this path
POST /sessionto re-attach to the foreground bundle id before spawning Percy CLI.maestro hierarchy). If Phase 0.5 PASSes, this path is deleted in Phase 4. If Phase 0.5 FAILs, this path becomes the final iOS element-region implementation and PER-7281 is the gate.Drafted as a holding PR so the work is reviewable while the strategic decision (A vs B) is finalized.
Testing
345-line unit-test diff in
e7b9938bcovers the 3 fixes (feature-detectfallback, error-envelope sid extraction, contract v1.1.0
wdaSessionIdpass-through). Earlier commits each carry their own test files.
The full
@percy/coretest suite has 27 pre-existing failures inUnit / Install in executable Chromiumunrelated to this PR (TypeError onsetof an undefined object — appears to be infrastructure drift).Post-Deploy Monitoring & Validation
wda-hierarchy:andwda-session:reason-tag prefixes.log line:
[percy:core] iOS element region warn-skip: <reason>shouldshow concrete reason tags (
'maestro-timeout','wda-error', etc.) —NOT
ReferenceErroror generic catch-all errors.resolve to bbox payloads in the outbound POST to Percy's
comparison-upload endpoint (verifiable via
PERCY_LOGLEVEL=debug).'wda-error'reason tag with no further detail across manysessions → suspect a regression in the error-envelope retry path; revert.
writer; first 50 iOS Maestro builds with element regions.
🤖 Generated with Claude Opus 4.7 (1M context, extended thinking) via Claude Code