mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
113 lines
7.2 KiB
Markdown
113 lines
7.2 KiB
Markdown
# ADR 0005: iOS Runner Interaction Lifecycle
|
|
|
|
## Status
|
|
|
|
Accepted
|
|
|
|
## Context
|
|
|
|
The iOS runner is a long-lived XCTest process with an HTTP command loop. A command can appear to
|
|
complete at the daemon boundary while XCTest is already tearing down the test runner.
|
|
|
|
This was reproduced in the React Navigation playground with navigation-causing selector taps such
|
|
as `Navigate to Details` and `Back to home`. The runner resolved the button and synthesized the tap,
|
|
the app navigated, and then XCTest tried to re-resolve the original `XCUIElement`. Because the
|
|
element had disappeared, xcodebuild recorded `Failed to get matching snapshot` and ended the test
|
|
with `** TEST EXECUTE FAILED **`. The daemon had already received a successful tap response, so the
|
|
next read-only command inherited a stale cached runner.
|
|
|
|
Two older assumptions were wrong:
|
|
|
|
- A recent successful runner response proves the runner is still healthy.
|
|
- `XCUIElement.tap()` is the safest selector-tap primitive once a selector has resolved.
|
|
- A cached `XCUIApplication` target remains safe after XCTest reports that the app's accessibility
|
|
tree cannot be serialized.
|
|
|
|
## Decision
|
|
|
|
Coordinate-first resolved element activation is the iOS/macOS selector-tap model. The runner still
|
|
uses selectors or text queries to find the semantic `XCUIElement`, but when the element has a frame,
|
|
activation taps the resolved center point instead of calling `XCUIElement.tap()`. tvOS remains
|
|
focus/remote-driven because tvOS does not support normal coordinate input.
|
|
|
|
Ready runner sessions are probed with a short `uptime` preflight before command send. Read-only
|
|
startup commands still skip that preflight because the first successful command is the readiness
|
|
proof for a newly launched runner. Readiness probe commands skip preflight to avoid recursion.
|
|
|
|
The daemon may additionally skip the ready-session `uptime` preflight for an explicit allowlist of
|
|
mutating interactions (`tap`, `longPress`, `drag`, `swipe`, `scroll`, `sequence`) when the same
|
|
session produced a healthy mutating response — parsed ok and not carrying `runnerFatal` — for the
|
|
same `appBundleId` within 5 seconds. This recency lives only on the `RunnerSession` object as
|
|
`lastHealthyMutation`, so it dies with every invalidation/restart, and it is recorded only after the
|
|
`runnerFatal` check, so sparse AX-fallback snapshots and `runnerFatal` payloads never refresh it.
|
|
Snapshots and other read-only responses never count as a health signal. This narrow skip is
|
|
permitted now because the future-work precondition below is met: coordinate-first activation removed
|
|
the command-induced teardown trigger, and the lifecycle status journal plus the status-before-
|
|
invalidate recovery is the teardown-surviving status surface that resolves any ambiguous post-send
|
|
failure before invalidation. A transport failure after a skip clears the recency record and is marked
|
|
with the skip context; connection-shaped failures (refused, reset, hung up) run status recovery
|
|
instead of a blind replay, while timeout-shaped failures propagate with the skip context (the same
|
|
classification preflighted sends use).
|
|
|
|
`uptime` is a direct runner listener probe. It is answered before command journaling, the serial
|
|
command execution queue, app activation, and main-thread XCTest dispatch. It should measure only
|
|
whether the runner is alive and accepting new HTTP requests.
|
|
|
|
Dead cached runner processes are invalidated without graceful `shutdown`. A process that already
|
|
stopped cannot answer the shutdown request, so graceful cleanup only adds stale-listener delay.
|
|
|
|
When XCTest reports a root accessibility snapshot failure such as `kAXErrorIllegalArgument`, the
|
|
runner treats the cached app target as suspect. Interactive snapshots fail closed to a truncated
|
|
root-only payload instead of issuing more flat fallback queries against the same broken tree, and
|
|
the cached `XCUIApplication` handle is cleared so the next command reacquires the target through the
|
|
normal activation path.
|
|
|
|
An external iOS-family Simulator relaunch or physical iOS relaunch also invalidates process-bound
|
|
target state. After replacing the app process, the daemon sends a lifecycle reset to the retained
|
|
runner so the next command reacquires `XCUIApplication`; the reset also clears process-bound snapshot
|
|
penalty and private-AX depth state. If that reset cannot be confirmed, or if relaunch fails before
|
|
reset, the daemon discards the runner session. Other physical Apple OS leaves retain their existing
|
|
runner-restart behavior until separately evidenced.
|
|
|
|
The snapshot surface intentionally has two AX-failure shapes. Interactive fast snapshots return a
|
|
truncated success payload with `runnerFatal` so agents can still see that AX state is unavailable
|
|
and recover with a plain screenshot plus coordinate navigation. Raw or strict snapshot paths keep
|
|
returning an error because those callers requested a faithful tree, not a lossy recovery payload.
|
|
|
|
## Consequences
|
|
|
|
Navigation-causing selector taps no longer couple command success to XCTest's post-tap element
|
|
bookkeeping. If the target disappears because navigation happened, the tap remains a normal
|
|
successful interaction and the runner should stay alive.
|
|
|
|
If xcodebuild still exits for another reason, the next command detects the stale runner through
|
|
process/liveness checks and avoids the old 15-second graceful-shutdown wait. The remaining latency is
|
|
fresh xcodebuild runner startup, not a stale transport stall.
|
|
|
|
The daemon no longer models a generic "recent success" cache as a runner-health signal. A proven
|
|
healthy mutating response for the same app — recorded only after the `runnerFatal` check and only
|
|
for allowlisted interactions — is now a real end-to-end liveness proof (HTTP listener through to the
|
|
app target), so a hot loop of allowlisted interactions skips the per-command `uptime` request while
|
|
still re-earning each skip from another healthy mutation. The earlier unconditional `uptime` before
|
|
every ready-session command remains the default for non-allowlisted commands and after any
|
|
invalidation, stale record, app-bundle change, or absent record.
|
|
|
|
Apps with broken accessibility trees may still be impossible for XCTest to inspect deeply, but one
|
|
failed snapshot no longer teaches the runner to keep using a suspect cached app target or to amplify
|
|
the failure by walking every interactive element query.
|
|
|
|
An `XCTEST_RECORDED_FAILURE` returned after a tap is treated as an ambiguous outcome at the daemon
|
|
boundary. When a usable retained snapshot exists, the daemon takes one same-presentation post-action
|
|
capture. A changed accessibility digest is reported as success with an explicit warning so agents do
|
|
not blindly repeat a tap that may already have navigated; unchanged, sparse, mismatched, or unavailable
|
|
evidence remains the original failure.
|
|
|
|
iOS-family Simulator and physical iOS relaunch keep the healthy XCTest process warm without carrying
|
|
an app target across process identity. The reset adds one local runner request instead of paying for a
|
|
runner restart and clears the old process's hostile-screen capture penalty before the replacement is
|
|
reacquired.
|
|
|
|
Future optimization work should only reduce these preflights after the runner exposes status in a
|
|
way that survives command-induced XCTest teardown and can prove the session is still serving new
|
|
requests.
|