Files
callstack__agent-device/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift
Michał Pierzchała 5676d5ff8a refactor(apple): drop dead runner code and collapse duplicated helpers (#1936)
* refactor(apple): drop dead runner code and collapse duplicated helpers

Removes declarations with no consumers (findScopeElement, interactiveTypes,
two unused PresentedNode convenience inits) and collapses copy-pasted logic:
DataPayload now relies on the synthesized memberwise init, TvRemoteButton is
String-raw-valued, point-hit sorting shares smallestElementFirst, command-id
trim-or-nil lives once on RunnerCommandJournal, scroll/desktopScroll share
direction and durationMs validators, and the seven inline NSError refusals use
unsupportedOperationError. elementTypeName reads a table pinned by the
visibility-fold parity test.

The packager now skips files whose unit-test blocks were their whole body, so
10 test-only files stop shipping (and stop compiling on user machines) as
empty translation units.

Packaged Swift: 432.3 kB -> 427.8 kB; 56 files instead of 66. Net -144 lines.

* test(apple): pin skeleton-file exclusion in the packaging guard

The strip fixture always kept runtime content, so reverting the
skeleton-skip branch left every guard green. The new fixture's whole body is
unit-test blocks; the packaged path must be absent while a non-skeleton
sibling still ships. Observed red with the skip branch disabled before
re-enabling it.

* refactor(apple): tighten runner cleanup boundaries
2026-08-21 16:42:17 +02:00

2820 lines
104 KiB
Swift

import XCTest
#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS)
import ObjectiveC.runtime
private final class RunnerSynthesizedSwipeFailureStub: NSObject {
@objc(synthesizeSwipeWithApplication:x:y:x2:y2:durationMs:)
class func synthesizeSwipe(
application: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double,
durationMs: Double
) -> String? {
"forced private synthesis failure"
}
}
private final class RunnerSynthesizedTapFailureStub: NSObject {
@objc(synthesizeTapWithApplication:x:y:)
class func synthesizeTap(application: XCUIApplication, x: Double, y: Double) -> String? {
"forced private synthesis failure"
}
}
#endif
extension RunnerTests {
// MARK: - Main Thread Dispatch
private func currentUptimeMs() -> Double {
ProcessInfo.processInfo.systemUptime * 1000
}
private func measureGesture(_ action: () -> Void) -> (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double) {
let gestureStartUptimeMs = currentUptimeMs()
action()
return (gestureStartUptimeMs, currentUptimeMs())
}
func synthesizedSwipeFallbackHoldDuration(durationMs: Double) -> TimeInterval {
min(max((durationMs / 5.0) / 1000.0, 0.016), 0.120)
}
func coordinateDragHoldDuration() -> TimeInterval {
0.050
}
func unsupportedResponse(for outcome: RunnerInteractionOutcome) -> Response? {
switch outcome {
case .performed:
return nil
case .unsupported(let message, let hint):
return Response(
ok: false,
error: ErrorPayload(code: "UNSUPPORTED_OPERATION", message: message, hint: hint)
)
}
}
/// Optional visualization frame returned with a gesture response.
enum GestureFrame {
case none
case touch(TouchVisualizationFrame?)
case drag(DragVisualizationFrame)
}
struct GestureFallback {
let strategy: String
let message: String
let hint: String?
}
private func gestureFallback(strategy: String, from outcome: RunnerInteractionOutcome) -> GestureFallback? {
switch outcome {
case .performed:
return nil
case .unsupported(let message, let hint):
return GestureFallback(strategy: strategy, message: message, hint: hint)
}
}
/// Runs a gesture action with uniform timing capture. Touch gestures pass `idleTimeout: true`
/// (the default) to run inside the scroll idle-timeout + quiescence-skip wrapper; synthesis
/// pointer-plan gestures pass `false` because RunnerSynthesizedGesture governs their
/// own timing. Returns the captured timing and the action's outcome.
///
/// NOTE: a new SYNTHESIS gesture must pass `idleTimeout: false` — the default `true` would wrap
/// it in the scroll idle-timeout/quiescence-skip path and change its runtime behavior.
func performGesture(
_ app: XCUIApplication,
idleTimeout: Bool = true,
_ action: () -> RunnerInteractionOutcome
) -> (timing: (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double), outcome: RunnerInteractionOutcome) {
var outcome = RunnerInteractionOutcome.performed
let timing = measureGesture {
if idleTimeout {
withTemporaryScrollIdleTimeoutIfSupported(app) { outcome = action() }
} else {
outcome = action()
}
}
return (timing, outcome)
}
/// Single factory for the success payload every gesture returns (message + gesture timing +
/// an optional touch/drag visualization frame), so the field shape lives in one place.
private func gestureResponse(
message: String,
timing: (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double),
frame: GestureFrame = .none,
fallback: GestureFallback? = nil,
maestroNonHittableCoordinateFallbackUsed: Bool? = nil
) -> Response {
let data: DataPayload
switch frame {
case .none:
data = DataPayload(
message: message,
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs,
gestureFallback: fallback?.strategy,
gestureFallbackMessage: fallback?.message,
gestureFallbackHint: fallback?.hint,
maestroNonHittableCoordinateFallbackUsed: maestroNonHittableCoordinateFallbackUsed
)
case .touch(let f):
data = DataPayload(
message: message,
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs,
x: f?.x,
y: f?.y,
referenceWidth: f?.referenceWidth,
referenceHeight: f?.referenceHeight,
gestureFallback: fallback?.strategy,
gestureFallbackMessage: fallback?.message,
gestureFallbackHint: fallback?.hint,
maestroNonHittableCoordinateFallbackUsed: maestroNonHittableCoordinateFallbackUsed
)
case .drag(let f):
data = DataPayload(
message: message,
gestureStartUptimeMs: timing.gestureStartUptimeMs,
gestureEndUptimeMs: timing.gestureEndUptimeMs,
x: f.x,
y: f.y,
x2: f.x2,
y2: f.y2,
referenceWidth: f.referenceWidth,
referenceHeight: f.referenceHeight,
gestureFallback: fallback?.strategy,
gestureFallbackMessage: fallback?.message,
gestureFallbackHint: fallback?.hint
)
}
return Response(ok: true, data: data)
}
/// Gesture plans already return canonical centroid endpoints from the portable runtime.
/// Keep runner timing/fallback diagnostics, but do not leak the coordinate-drag adapter's
/// visualization frame into only the fast-fling response shape.
private func canonicalPlannedGestureResponse(_ response: Response) -> Response {
guard response.ok, let data = response.data else { return response }
return Response(
ok: true,
data: DataPayload(
message: data.message,
gestureStartUptimeMs: data.gestureStartUptimeMs,
gestureEndUptimeMs: data.gestureEndUptimeMs,
gestureFallback: data.gestureFallback,
gestureFallbackMessage: data.gestureFallbackMessage,
gestureFallbackHint: data.gestureFallbackHint
)
)
}
private func plannedGestureResponse(
plan: RunnerGesturePlan,
timing: (gestureStartUptimeMs: Double, gestureEndUptimeMs: Double),
outcome: RunnerInteractionOutcome
) -> Response {
if let response = unsupportedResponse(for: outcome) {
return response
}
return gestureResponse(message: plan.intent, timing: timing)
}
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
func testGestureResponseIncludesSynthesizedTapFallbackDiagnostics() {
let response = gestureResponse(
message: "tapped",
timing: (gestureStartUptimeMs: 1, gestureEndUptimeMs: 2),
fallback: GestureFallback(
strategy: "xctest-coordinate-tap",
message: "Runner synthesized coordinate tap is unavailable",
hint: "Using XCTest coordinate tap fallback."
)
)
XCTAssertEqual(response.ok, true)
XCTAssertEqual(response.data?.gestureFallback, "xctest-coordinate-tap")
XCTAssertEqual(
response.data?.gestureFallbackMessage,
"Runner synthesized coordinate tap is unavailable"
)
XCTAssertEqual(response.data?.gestureFallbackHint, "Using XCTest coordinate tap fallback.")
}
func testGestureResponseIncludesMaestroNonHittableFallbackUsage() {
let response = gestureResponse(
message: "tapped via non-hittable coordinate fallback",
timing: (gestureStartUptimeMs: 1, gestureEndUptimeMs: 2),
frame: .touch(nil),
maestroNonHittableCoordinateFallbackUsed: true
)
XCTAssertEqual(response.data?.maestroNonHittableCoordinateFallbackUsed, true)
}
func testCanonicalPlannedGestureResponseOmitsDragFrameAndPreservesDiagnostics() {
let response = gestureResponse(
message: "fling",
timing: (gestureStartUptimeMs: 1, gestureEndUptimeMs: 2),
frame: .drag(
DragVisualizationFrame(
x: 160,
y: 150,
x2: 40,
y2: 150,
referenceWidth: 200,
referenceHeight: 300
)
),
fallback: GestureFallback(
strategy: "xctest-coordinate-drag",
message: "Private synthesis unavailable",
hint: "Using XCTest coordinate fallback."
)
)
let canonical = canonicalPlannedGestureResponse(response)
XCTAssertEqual(canonical.data?.gestureStartUptimeMs, 1)
XCTAssertEqual(canonical.data?.gestureEndUptimeMs, 2)
XCTAssertEqual(canonical.data?.gestureFallback, "xctest-coordinate-drag")
XCTAssertEqual(canonical.data?.gestureFallbackMessage, "Private synthesis unavailable")
XCTAssertEqual(canonical.data?.gestureFallbackHint, "Using XCTest coordinate fallback.")
XCTAssertNil(canonical.data?.x)
XCTAssertNil(canonical.data?.y)
XCTAssertNil(canonical.data?.x2)
XCTAssertNil(canonical.data?.y2)
XCTAssertNil(canonical.data?.referenceWidth)
XCTAssertNil(canonical.data?.referenceHeight)
}
#if os(iOS)
func testSinglePointerFlingFallsBackToXCTestCoordinateDragWhenPrivateSynthesisFails() throws {
let selector = NSSelectorFromString(
"synthesizeSwipeWithApplication:x:y:x2:y2:durationMs:"
)
guard
let synthesizedSwipeMethod = class_getClassMethod(RunnerSynthesizedGesture.self, selector),
let failureStubMethod = class_getClassMethod(RunnerSynthesizedSwipeFailureStub.self, selector)
else {
XCTFail("unable to install synthesized swipe failure stub")
return
}
let originalImplementation = method_getImplementation(synthesizedSwipeMethod)
method_setImplementation(
synthesizedSwipeMethod,
method_getImplementation(failureStubMethod)
)
app.launch()
runnerAccessibilityHealth = .healthy
defer {
method_setImplementation(synthesizedSwipeMethod, originalImplementation)
invalidateCachedTarget(reason: "unit_test_cleanup")
app.terminate()
}
let command = try runnerCommandFixture(
"""
{"command":"gesture","commandId":"gesture-fling-fallback","gesturePlan":{"topology":"single","intent":"fling","executionProfile":"endpoint-hold","durationMs":100,"viewport":{"x":0,"y":0,"width":200,"height":300},"pointers":[{"pointerId":0,"samples":[{"offsetMs":0,"point":{"x":160,"y":150}},{"offsetMs":100,"point":{"x":40,"y":150}}]}]}}
"""
)
let response = try executeOnMainPrepared(command: command, activeApp: app)
XCTAssertTrue(response.ok)
XCTAssertEqual(response.data?.message, "fling")
XCTAssertEqual(response.data?.gestureFallback, "xctest-coordinate-drag")
XCTAssertEqual(response.data?.gestureFallbackMessage, "forced private synthesis failure")
XCTAssertEqual(
response.data?.gestureFallbackHint,
"Private XCTest event synthesis is required for AX-free coordinate drag on iOS; update Xcode if this persists."
)
XCTAssertNil(response.data?.x)
XCTAssertNil(response.data?.y)
XCTAssertNil(response.data?.x2)
XCTAssertNil(response.data?.y2)
}
func testSelectorTapFallsBackToXCTestCoordinateWhenPrivateSynthesisFails() throws {
let selector = NSSelectorFromString("synthesizeTapWithApplication:x:y:")
guard
let synthesizedTapMethod = class_getClassMethod(RunnerSynthesizedGesture.self, selector),
let failureStubMethod = class_getClassMethod(RunnerSynthesizedTapFailureStub.self, selector)
else {
XCTFail("unable to install synthesized tap failure stub")
return
}
let originalImplementation = method_getImplementation(synthesizedTapMethod)
method_setImplementation(
synthesizedTapMethod,
method_getImplementation(failureStubMethod)
)
app.launch()
currentApp = app
runnerAccessibilityHealth = .healthy
defer {
method_setImplementation(synthesizedTapMethod, originalImplementation)
invalidateCachedTarget(reason: "unit_test_cleanup")
app.terminate()
}
let command = try runnerCommandFixture(
#"{"command":"tap","commandId":"selector-tap-fallback","selectorKey":"label","selectorValue":"Agent Device Runner","synthesized":true}"#
)
let response = try executeOnMainPrepared(command: command, activeApp: app)
XCTAssertTrue(response.ok)
XCTAssertEqual(response.data?.message, "tapped")
XCTAssertEqual(response.data?.gestureFallback, "xctest-coordinate-tap")
XCTAssertEqual(response.data?.gestureFallbackMessage, "forced private synthesis failure")
XCTAssertEqual(
response.data?.gestureFallbackHint,
"Falling back to XCTest coordinate tap may be slower and can still need a healthy accessibility tree."
)
}
#endif
#if os(iOS)
func testTypeWithoutResolvedInputReturnsTypedFailureBeforeDispatchingText() throws {
let command = try runnerCommandFixture(
#"{"command":"type","commandId":"type-without-focus","text":"hello"}"#
)
let response = executeTypeCommand(
activeApp: XCUIApplication(bundleIdentifier: "com.example.agentdevice.missing-input"),
command: command
)
XCTAssertFalse(response.ok)
XCTAssertEqual(response.error?.code, "TEXT_INPUT_NOT_FOCUSED")
XCTAssertEqual(
response.error?.hint,
"Focus a visible text input, then retry type or fill. If the input is not exposed by accessibility, use a coordinate focus command before typing."
)
}
func testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden() throws {
// The fixture uses a real text responder with an empty input view to model hardware-keyboard input.
app.launchArguments = ["--agent-device-text-entry-regression"]
app.launch()
defer {
invalidateCachedTarget(reason: "unit_test_cleanup")
app.terminate()
}
XCTAssertTrue(app.waitForExistence(timeout: appExistenceTimeout))
let textField = app.textFields["agent-device-hardware-keyboard-input"]
XCTAssertTrue(textField.waitForExistence(timeout: appExistenceTimeout))
let frame = textField.frame
XCTAssertFalse(frame.isEmpty)
let tapCommand = try runnerCommandFixture(
#"{"command":"tap","commandId":"tap-hardware-keyboard-input","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"#
)
let tapResponse = try executeOnMainPrepared(command: tapCommand, activeApp: app)
XCTAssertTrue(tapResponse.ok, String(describing: tapResponse.error))
XCTAssertFalse(
isKeyboardVisible(app: app),
"the test must exercise a focused responder with the software keyboard hidden"
)
let failureCountBefore = currentXCTestFailureCount()
let typeCommand = try runnerCommandFixture(
#"{"command":"type","commandId":"type-hardware-keyboard","text":"hardware-keyboard"}"#
)
let typeResponse = executeTypeCommand(activeApp: app, command: typeCommand)
XCTAssertTrue(typeResponse.ok, String(describing: typeResponse.error))
XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore))
XCTAssertEqual(typeResponse.data?.textEntryRoute, "synthesized-first-responder")
XCTAssertEqual(String(describing: textField.value ?? ""), "hardware-keyboard")
let secondFailureCountBefore = currentXCTestFailureCount()
let secondTypeCommand = try runnerCommandFixture(
#"{"command":"type","commandId":"type-hardware-keyboard-again","text":"-again"}"#
)
let secondTypeResponse = executeTypeCommand(activeApp: app, command: secondTypeCommand)
XCTAssertFalse(secondTypeResponse.ok)
XCTAssertEqual(secondTypeResponse.error?.code, "TEXT_INPUT_NOT_FOCUSED")
XCTAssertFalse(didRecordXCTestFailure(since: secondFailureCountBefore))
XCTAssertEqual(String(describing: textField.value ?? ""), "hardware-keyboard")
}
func testBareDelayedTypeFailsWhenTappedInputDisappearsMidCommand() throws {
app.launchArguments = [
"--agent-device-text-entry-regression",
"--agent-device-text-entry-disappear-after-input",
]
app.launch()
defer {
invalidateCachedTarget(reason: "unit_test_cleanup")
app.terminate()
}
XCTAssertTrue(app.waitForExistence(timeout: appExistenceTimeout))
let textField = app.textFields["agent-device-hardware-keyboard-input"]
XCTAssertTrue(textField.waitForExistence(timeout: appExistenceTimeout))
let tapCommand = try runnerCommandFixture(
#"{"command":"tap","commandId":"tap-disappearing-input","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"#
)
let tapResponse = try executeOnMainPrepared(command: tapCommand, activeApp: app)
XCTAssertTrue(tapResponse.ok, String(describing: tapResponse.error))
let failureCountBefore = currentXCTestFailureCount()
let typeCommand = try runnerCommandFixture(
#"{"command":"type","commandId":"type-disappearing-input","text":"ab","delayMs":50}"#
)
let typeResponse = executeTypeCommand(activeApp: app, command: typeCommand)
XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore))
XCTAssertFalse(typeResponse.ok)
XCTAssertEqual(typeResponse.error?.code, "TEXT_INPUT_NOT_FOCUSED")
XCTAssertFalse(textField.exists)
}
#endif
func testInjectedTapRecordedFailureGateIsTapOnlyAndCountGated() {
// The seam's recording side cannot run in-bundle (a real XCTIssue would
// fail this very test run — same constraint the record(_:) suppression
// tests document); the live daemon proof covers it. This pins the gate.
XCTAssertFalse(RunnerTests.shouldInjectTapRecordedFailure(command: .tap, remaining: 0))
XCTAssertTrue(RunnerTests.shouldInjectTapRecordedFailure(command: .tap, remaining: 1))
XCTAssertFalse(RunnerTests.shouldInjectTapRecordedFailure(command: .type, remaining: 1))
XCTAssertFalse(RunnerTests.shouldInjectTapRecordedFailure(command: .snapshot, remaining: 1))
}
func testXCTestRecordedFailureResponseFailsMutatingSuccesses() throws {
let command = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
let response = Response(ok: true, data: DataPayload(message: "tapped"))
let failureResponse = xctestRecordedFailureResponse(command: command, response: response)
XCTAssertEqual(failureResponse?.ok, false)
XCTAssertEqual(failureResponse?.error?.code, "XCTEST_RECORDED_FAILURE")
XCTAssertEqual(
failureResponse?.error?.message,
"XCTest recorded a failure while executing tap; the action may not have been performed."
)
}
func testXCTestRecordedFailureResponseDoesNotWrapReadOnlyOrRunnerFatalResponses() throws {
let snapshotCommand = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-1"}"#)
let tapCommand = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
let runnerFatalResponse = Response(
ok: true,
data: DataPayload(runnerFatal: true, runnerFatalReason: "ax_snapshot_unavailable")
)
XCTAssertNil(
xctestRecordedFailureResponse(
command: snapshotCommand,
response: Response(ok: true, data: DataPayload(nodes: [], truncated: false))
)
)
XCTAssertNil(xctestRecordedFailureResponse(command: tapCommand, response: runnerFatalResponse))
}
// Simulator-only from here to the matching #endif: these launch the host app, route through
// SpringBoard, or assert the iOS-only alert/system-modal branches. Tests outside the
// `os(iOS)` regions in this file are pure runner decisions and also run on the macOS host
// lane (ci.yml) — see the classification convention in RunnerTests.swift.
#if os(iOS)
func testMissingBundleCommandInvalidatesCompleteCachedTargetState() throws {
app.launch()
currentApp = app
currentBundleId = "com.example.stale-target"
currentAppProcessIdentifier = 42
snapshotXCTestPenaltyWarmupExemptionPending = true
defer {
invalidateCachedTarget(reason: "unit_test_cleanup")
app.terminate()
}
let command = try runnerCommandFixture(
#"{"command":"snapshot","commandId":"snapshot-without-bundle"}"#
)
_ = prepareActiveCommandContext(command: command)
XCTAssertNil(currentApp)
XCTAssertNil(currentBundleId)
XCTAssertNil(currentAppProcessIdentifier)
XCTAssertFalse(snapshotXCTestPenaltyWarmupExemptionPending)
}
func testSkipAppActivationPreflightIncludesForegroundCachedCoordinateOnlyTaps() throws {
app.launch()
currentApp = app
currentBundleId = nil
defer {
currentApp = nil
currentBundleId = nil
app.terminate()
}
let tap = try runnerCommandFixture(
#"{"command":"tap","commandId":"tap-1","x":10,"y":20}"#
)
XCTAssertTrue(shouldSkipAppActivationPreflight(tap))
}
func testSkipAppActivationPreflightRejectsMissingChangedAndBackgroundTargets() throws {
let coordinateTap = try runnerCommandFixture(
#"{"command":"tap","commandId":"tap-1","x":10,"y":20}"#
)
currentApp = nil
currentBundleId = nil
XCTAssertFalse(shouldSkipAppActivationPreflight(coordinateTap))
app.launch()
currentApp = app
currentBundleId = "com.example.current"
defer {
currentApp = nil
currentBundleId = nil
app.terminate()
}
let changedBundleTap = try runnerCommandFixture(
#"{"command":"tap","commandId":"tap-2","appBundleId":"com.example.other","x":10,"y":20}"#
)
XCTAssertFalse(shouldSkipAppActivationPreflight(changedBundleTap))
app.terminate()
currentApp = app
currentBundleId = nil
XCTAssertFalse(shouldSkipAppActivationPreflight(coordinateTap))
}
func testPrepareActiveCommandContextRoutesBlockingSystemModalToSpringboard() throws {
blockingSystemModalPresenceOverrideForTesting = true
currentApp = nil
currentBundleId = nil
defer {
blockingSystemModalPresenceOverrideForTesting = nil
currentApp = nil
currentBundleId = nil
}
let tap = try runnerCommandFixture(
#"{"command":"tap","commandId":"tap-1","x":10,"y":20}"#
)
let preparation = prepareActiveCommandContext(
command: tap,
routeToSpringboard: shouldRouteToSpringboardBlockingSystemModal(tap)
)
guard case .context(let context) = preparation else {
XCTFail("expected command context")
return
}
XCTAssertTrue(context.app === springboard)
}
func testExecuteDispatchedReturnsBusyBeforeBlockingSystemModalProbeDrains() throws {
app.launch()
currentApp = app
currentBundleId = nil
defer {
currentApp = nil
currentBundleId = nil
systemModalProbeOverrideForTesting = nil
clearSnapshotXCTestChannelPenalty(reason: "test-cleanup")
app.terminate()
}
final class ResultBox {
var response: Response?
var error: Error?
var commandRecoveredBeforeRelease = false
var wasBusyBeforeRelease = false
var hadAbandonedProbeBeforeRelease = false
var drained = false
}
let box = ResultBox()
let probeStarted = expectation(description: "system-modal routing probe started")
let verificationFinished = expectation(description: "command recovery and modal probe drain verified")
let probeReleaseGate = DispatchSemaphore(value: 0)
let commandFinishedGate = DispatchSemaphore(value: 0)
systemModalProbeOverrideForTesting = { _ in
probeStarted.fulfill()
_ = probeReleaseGate.wait(timeout: .now() + 15)
return DataPayload(message: "late system modal")
}
let command = try runnerCommandFixture(
#"{"command":"tap","commandId":"bounded-modal-routing","x":10,"y":20}"#
)
DispatchQueue(label: "agent-device.runner.tests.modal-routing-probe").async {
do {
box.response = try self.executeDispatched(command: command)
} catch {
box.error = error
}
commandFinishedGate.signal()
}
DispatchQueue(label: "agent-device.runner.tests.modal-routing-probe-verifier").async {
let commandWait = commandFinishedGate.wait(
timeout: .now() + self.systemModalProbeBudget + 3
)
box.commandRecoveredBeforeRelease = commandWait == .success
&& box.error == nil
&& box.response?.error?.code == "RUNNER_BUSY"
if case .busy = self.currentMainThreadBusyState() {
box.wasBusyBeforeRelease = true
}
box.hadAbandonedProbeBeforeRelease = self.hasAbandonedTreeCapture()
// The XCTest main thread is blocked inside the injected probe, so this verifier owns the
// ordered release after recording the command result and abandoned-work state above.
probeReleaseGate.signal()
let deadline = Date().addingTimeInterval(5)
while self.hasAbandonedTreeCapture(), Date() < deadline {
self.sleepFor(0.002)
}
box.drained = !self.hasAbandonedTreeCapture()
verificationFinished.fulfill()
}
wait(for: [probeStarted, verificationFinished], timeout: 15)
XCTAssertTrue(
box.commandRecoveredBeforeRelease,
"the public coordinate tap must return RUNNER_BUSY before the blocked modal probe drains"
)
XCTAssertTrue(box.wasBusyBeforeRelease)
XCTAssertTrue(box.hadAbandonedProbeBeforeRelease)
XCTAssertTrue(box.drained)
guard case .idle = currentMainThreadBusyState() else {
return XCTFail("expected the runner to become idle after the routing probe drained")
}
XCTAssertFalse(hasAbandonedTreeCapture())
}
func testSkipAppActivationPreflightRejectsSelectorAndMixedSequenceGestures() throws {
app.launch()
currentApp = app
currentBundleId = nil
defer {
currentApp = nil
currentBundleId = nil
app.terminate()
}
let selectorTap = try runnerCommandFixture(
#"{"command":"tap","commandId":"tap-1","selectorKey":"label","selectorValue":"Search","synthesized":true}"#
)
let standardDrag = try runnerCommandFixture(
#"{"command":"drag","commandId":"drag-1","x":10,"y":20,"x2":30,"y2":40}"#
)
let mixedSequence = try runnerCommandFixture(
"""
{"command":"sequence","commandId":"seq-1","steps":[
{"kind":"tap","x":10,"y":20,"synthesized":true},
{"kind":"doubleTap","x":30,"y":40}
]}
"""
)
XCTAssertFalse(shouldSkipAppActivationPreflight(selectorTap))
XCTAssertFalse(shouldSkipAppActivationPreflight(standardDrag))
XCTAssertFalse(shouldSkipAppActivationPreflight(mixedSequence))
}
// Launches nothing, but still simulator-only: `shouldSkipAppActivationPreflight` is
// `#if os(iOS) …guards… #else return false #endif`, so on macOS this asserts a compile-time
// literal and no edit to the iOS body could make it red. Its five siblings above and below
// are gated for the same reason.
func testSkipAppActivationPreflightRequiresCachedForegroundTarget() throws {
currentApp = nil
currentBundleId = nil
let scroll = try runnerCommandFixture(
#"{"command":"scroll","commandId":"scroll-1","direction":"down","pixels":400}"#
)
XCTAssertFalse(shouldSkipAppActivationPreflight(scroll))
}
func testSkipAppActivationPreflightKeepsDragScrollAndSequenceOnForegroundGuard() throws {
app.launch()
currentApp = app
currentBundleId = nil
defer {
currentApp = nil
currentBundleId = nil
app.terminate()
}
let drag = try runnerCommandFixture(
#"{"command":"drag","commandId":"drag-1","x":10,"y":20,"x2":30,"y2":40,"synthesized":true}"#
)
let scroll = try runnerCommandFixture(
#"{"command":"scroll","commandId":"scroll-1","direction":"down","pixels":400}"#
)
let sequence = try runnerCommandFixture(
"""
{"command":"sequence","commandId":"seq-1","steps":[
{"kind":"tap","x":10,"y":20,"synthesized":true},
{"kind":"longPress","x":10,"y":200,"durationMs":300}
]}
"""
)
XCTAssertFalse(shouldSkipAppActivationPreflight(drag))
XCTAssertFalse(shouldSkipAppActivationPreflight(scroll))
XCTAssertFalse(shouldSkipAppActivationPreflight(sequence))
}
func testSkipAppActivationPreflightIncludesAlertCommands() throws {
let alert = try runnerCommandFixture(
#"{"command":"alert","commandId":"alert-1","action":"get"}"#
)
XCTAssertTrue(shouldSkipAppActivationPreflight(alert))
}
#endif
func testExecuteDispatchedReturnsBusyBeforeMainThreadFastPath() throws {
let command = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-busy"}"#)
abandonedMainThreadWorkCount = 1
abandonedMainThreadWorkSince = Date(timeIntervalSinceNow: -2)
defer {
abandonedMainThreadWorkCount = 0
abandonedMainThreadWorkSince = nil
}
let response = try executeDispatched(command: command)
XCTAssertFalse(response.ok)
XCTAssertEqual(response.error?.code, "RUNNER_BUSY")
XCTAssertTrue(response.error?.message.contains("previous command") == true)
}
func testExecuteDispatchedReturnsWedgedBeforeMainThreadFastPath() throws {
let command = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-wedged"}"#)
abandonedMainThreadWorkCount = 1
abandonedMainThreadWorkSince = Date(timeIntervalSinceNow: -(mainThreadWedgeThreshold + 1))
defer {
abandonedMainThreadWorkCount = 0
abandonedMainThreadWorkSince = nil
}
let response = try executeDispatched(command: command)
XCTAssertFalse(response.ok)
XCTAssertEqual(response.error?.code, "RUNNER_WEDGED")
XCTAssertTrue(response.error?.hint?.contains("runner session will be restarted") == true)
}
#if os(iOS)
func testAlertResolutionCannotBypassRequestedDeadline() throws {
final class ResultBox {
var error: Error?
var probeDeadline: Date?
var observedDeadline: Date?
var commandStartedAt: Date?
}
let box = ResultBox()
let releaseResolution = DispatchSemaphore(value: 0)
let resolutionExited = expectation(description: "bounded alert resolution exited")
let commandFinished = expectation(description: "alert command respected its deadline")
let command = try runnerCommandFixture(
#"{"command":"alert","commandId":"alert-deadline","appBundleId":"com.apple.springboard","action":"get","timeoutMs":500}"#
)
currentApp = springboard
currentBundleId = Self.springboardBundleId
systemModalProbeOverrideForTesting = { deadline in
box.probeDeadline = deadline
return nil
}
alertResolutionOverrideForTesting = { deadline in
box.observedDeadline = deadline
_ = releaseResolution.wait(timeout: .now() + 1)
resolutionExited.fulfill()
return nil
}
defer {
releaseResolution.signal()
systemModalProbeOverrideForTesting = nil
alertResolutionOverrideForTesting = nil
currentApp = nil
currentBundleId = nil
}
DispatchQueue(label: "agent-device.runner.tests.alert-deadline").async {
box.commandStartedAt = Date()
do {
_ = try self.executeDispatched(command: command)
} catch {
box.error = error
}
commandFinished.fulfill()
}
wait(for: [commandFinished], timeout: 1)
let error = box.error as NSError?
XCTAssertEqual(error?.domain, RunnerErrorDomain.general)
XCTAssertEqual(error?.code, RunnerErrorCode.mainThreadExecutionTimedOut)
XCTAssertNotNil(box.probeDeadline)
XCTAssertNotNil(box.observedDeadline)
if let probeDeadline = box.probeDeadline,
let observedDeadline = box.observedDeadline,
let commandStartedAt = box.commandStartedAt
{
XCTAssertEqual(probeDeadline.timeIntervalSince(observedDeadline), 0, accuracy: 0.01)
XCTAssertEqual(observedDeadline.timeIntervalSince(commandStartedAt), 0.5, accuracy: 0.05)
}
releaseResolution.signal()
wait(for: [resolutionExited], timeout: 1)
}
#endif
func testRunMainThreadWorkExecutesOffMainCallerOnMainThread() {
final class ResultBox {
var observedMainThread: Bool?
var error: Error?
}
let box = ResultBox()
let finished = expectation(description: "off-main caller finished")
DispatchQueue(label: "agent-device.runner.tests.off-main").async {
do {
box.observedMainThread = try self.runMainThreadWork(
timeout: 1,
timeoutError: self.mainThreadExecutionTimeoutError
) {
Thread.isMainThread
}
} catch {
box.error = error
}
finished.fulfill()
}
wait(for: [finished], timeout: 2)
XCTAssertNil(box.error)
XCTAssertEqual(box.observedMainThread, true)
}
func testRunMainThreadWorkTimeoutMarksAbandonedUntilDrained() {
final class ResultBox {
var error: Error?
var abandonedCount: Int?
var abandonedSinceSet: Bool?
var drainedCount: Int?
var drainedSinceCleared: Bool?
}
let box = ResultBox()
let releaseWork = DispatchSemaphore(value: 0)
let observedAbandoned = DispatchSemaphore(value: 0)
let finished = expectation(description: "off-main caller timed out")
let drained = expectation(description: "abandoned main work drained")
DispatchQueue(label: "agent-device.runner.tests.timeout").async {
do {
_ = try self.runMainThreadWork(
timeout: 0,
timeoutError: self.mainThreadExecutionTimeoutError,
onAbandoned: {
box.abandonedCount = self.abandonedMainThreadWorkCount
box.abandonedSinceSet = self.abandonedMainThreadWorkSince != nil
observedAbandoned.signal()
},
onDrained: {
self.mainThreadWorkLock.lock()
box.drainedCount = self.abandonedMainThreadWorkCount
box.drainedSinceCleared = self.abandonedMainThreadWorkSince == nil
self.mainThreadWorkLock.unlock()
drained.fulfill()
}
) {
_ = releaseWork.wait(timeout: .now() + 1)
return true
}
} catch {
box.error = error
}
finished.fulfill()
}
DispatchQueue(label: "agent-device.runner.tests.release-timeout").async {
_ = observedAbandoned.wait(timeout: .now() + 1)
releaseWork.signal()
}
wait(for: [finished, drained], timeout: 2)
XCTAssertEqual((box.error as NSError?)?.code, RunnerErrorCode.mainThreadExecutionTimedOut)
XCTAssertEqual(box.abandonedCount, 1)
XCTAssertEqual(box.abandonedSinceSet, true)
XCTAssertEqual(box.drainedCount, 0)
XCTAssertEqual(box.drainedSinceCleared, true)
}
func testPostSnapshotDelayMarkDoesNotQueueBehindAbandonedTreeCapture() {
abandonedTreeCaptureCount = 1
defer {
abandonedTreeCaptureCount = 0
needsPostSnapshotInteractionDelay = false
}
let finished = expectation(description: "off-main caller finished")
DispatchQueue(label: "agent-device.runner.tests.post-snapshot-delay").async {
self.setNeedsPostSnapshotInteractionDelay()
finished.fulfill()
}
wait(for: [finished], timeout: 1)
mainThreadWorkLock.lock()
let abandonedWorkCount = abandonedMainThreadWorkCount
mainThreadWorkLock.unlock()
XCTAssertEqual(abandonedWorkCount, 0)
}
#endif
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
func execute(command: Command) throws -> Response {
if command.command == .status {
return executeStatus(command: command)
}
if command.command == .uptime {
return executeUptime()
}
commandJournal.accept(command: command)
return try executeAccepted(command: command)
}
#endif
func executeAccepted(command: Command) throws -> Response {
commandJournal.start(command: command)
do {
let response = try executeDispatched(command: command)
commandJournal.finish(command: command, response: response)
return response
} catch {
commandJournal.fail(command: command, error: error)
throw error
}
}
func executeStatus(command: Command) -> Response {
guard
let statusCommandId = command.statusCommandId?.trimmedNonEmpty
else {
return Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "status requires statusCommandId",
hint: "Set statusCommandId to the commandId of the runner command to inspect."
)
)
}
return Response(ok: true, data: commandJournal.status(normalizedCommandId: statusCommandId))
}
func executeUptime() -> Response {
// Placeholder value: the transport layer (jsonResponse) overwrites currentUptimeMs with a
// fresher send-time stamp on every ok response; kept so direct callers still get a value.
Response(
ok: true,
data: DataPayload(currentUptimeMs: currentUptimeMs())
)
}
/// Tracks one main-queue dispatch so the watchdog and the dispatched block can agree —
/// under `mainThreadWorkLock` — on exactly one of: finished in time, or abandoned.
private final class MainThreadWorkState {
var finished = false
var abandoned = false
}
struct ActiveCommandContext {
let app: XCUIApplication
}
enum ActiveCommandPreparation {
case response(Response)
case context(ActiveCommandContext)
}
enum MainThreadBusyState {
case idle
case busy(abandonedForSeconds: TimeInterval)
case wedged(abandonedForSeconds: TimeInterval)
}
func currentMainThreadBusyState() -> MainThreadBusyState {
mainThreadWorkLock.lock()
defer { mainThreadWorkLock.unlock() }
guard abandonedMainThreadWorkCount > 0 else { return .idle }
let abandonedFor = abandonedMainThreadWorkSince.map { Date().timeIntervalSince($0) } ?? 0
if abandonedFor > mainThreadWedgeThreshold {
return .wedged(abandonedForSeconds: abandonedFor)
}
return .busy(abandonedForSeconds: abandonedFor)
}
private func runnerBusyResponse(command: Command, abandonedForSeconds: TimeInterval) -> Response {
NSLog(
"AGENT_DEVICE_RUNNER_BUSY command=%@ commandId=%@ abandonedForSeconds=%.1f",
command.command.rawValue,
command.commandId ?? "",
abandonedForSeconds
)
return Response(
ok: false,
error: ErrorPayload(
code: "RUNNER_BUSY",
message:
"The iOS runner is still finishing a previous command that exceeded its execution watchdog (usually an accessibility capture on a heavy or animating screen).",
hint:
"Wait a few seconds and retry. If snapshots keep failing on this screen, use screenshot as visual truth and interact by coordinates, or navigate to another screen."
)
)
}
private func runnerWedgedResponse(command: Command, abandonedForSeconds: TimeInterval) -> Response {
NSLog(
"AGENT_DEVICE_RUNNER_WEDGED command=%@ commandId=%@ abandonedForSeconds=%.1f",
command.command.rawValue,
command.commandId ?? "",
abandonedForSeconds
)
return Response(
ok: false,
error: ErrorPayload(
code: "RUNNER_WEDGED",
message:
"The iOS runner main thread has been stuck in abandoned work for \(Int(abandonedForSeconds)) seconds and cannot recover on its own.",
hint:
"The runner session will be restarted. Retry the command after the restart; if this screen keeps wedging captures, use screenshot as visual truth and interact by coordinates."
)
)
}
private func runnerUnavailableResponse(command: Command) -> Response? {
switch currentMainThreadBusyState() {
case .idle:
return nil
case .busy(let abandonedForSeconds):
return runnerBusyResponse(command: command, abandonedForSeconds: abandonedForSeconds)
case .wedged(let abandonedForSeconds):
return runnerWedgedResponse(command: command, abandonedForSeconds: abandonedForSeconds)
}
}
private func executeDispatched(command: Command) throws -> Response {
// XCTest work cannot be cancelled mid-flight: once the watchdog abandons a main-queue
// block, queueing more main-thread commands behind it only buries the runner deeper.
// Refuse fast instead so the daemon backs off while the abandoned work drains; past the
// wedge threshold, escalate so the daemon recycles this runner (#1105).
if let unavailable = runnerUnavailableResponse(command: command) {
return unavailable
}
let alertDeadline = command.command == .alert
? Date().addingTimeInterval(Self.alertCommandTimeout(timeoutMs: command.timeoutMs))
: nil
if Thread.isMainThread {
let routeToSpringboard = shouldRouteToSpringboardBlockingSystemModal(
command,
deadline: alertDeadline
)
return try executeOnMainSafely(
command: command,
alertDeadline: alertDeadline,
routeToSpringboard: routeToSpringboard
)
}
// Resolve this before the command's outer main-thread block. If the bounded probe abandons
// slow XCTest enumeration, return the established recoverable response instead of queueing
// command preparation behind work that may outlive the 30-second command watchdog.
let routeToSpringboard = shouldRouteToSpringboardBlockingSystemModal(
command,
deadline: alertDeadline
)
if let unavailable = runnerUnavailableResponse(command: command) {
return unavailable
}
if command.command == .snapshot {
return try executeSnapshotDispatched(command: command)
}
if command.command == .alert, let deadline = alertDeadline {
return try runMainThreadWork(
timeout: max(0.001, deadline.timeIntervalSinceNow),
timeoutError: mainThreadExecutionTimeoutError
) {
try self.executeOnMainSafely(
command: command,
alertDeadline: deadline,
routeToSpringboard: routeToSpringboard
)
}
}
return try runMainThreadWork(
timeout: mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
) {
try self.executeOnMainSafely(command: command, routeToSpringboard: routeToSpringboard)
}
}
func runMainThreadWork<T>(
timeout: TimeInterval,
timeoutError: @escaping () -> Error,
onAbandoned: (() -> Void)? = nil,
onDrained: (() -> Void)? = nil,
_ work: @escaping () throws -> T
) throws -> T {
if Thread.isMainThread {
return try work()
}
var result: Result<T, Error>?
let semaphore = DispatchSemaphore(value: 0)
let workState = MainThreadWorkState()
DispatchQueue.main.async {
do {
result = .success(try work())
} catch {
result = .failure(error)
}
self.mainThreadWorkLock.lock()
if workState.abandoned {
self.abandonedMainThreadWorkCount -= 1
if self.abandonedMainThreadWorkCount == 0 {
self.abandonedMainThreadWorkSince = nil
NSLog("AGENT_DEVICE_RUNNER_ABANDONED_WORK_DRAINED")
}
self.mainThreadWorkLock.unlock()
onDrained?()
} else {
workState.finished = true
self.mainThreadWorkLock.unlock()
}
semaphore.signal()
}
let waitResult = semaphore.wait(timeout: .now() + timeout)
if waitResult == .timedOut {
mainThreadWorkLock.lock()
let stillRunning = !workState.finished
if stillRunning {
workState.abandoned = true
abandonedMainThreadWorkCount += 1
if abandonedMainThreadWorkSince == nil {
abandonedMainThreadWorkSince = Date()
}
onAbandoned?()
}
mainThreadWorkLock.unlock()
throw timeoutError()
}
switch result {
case .success(let value):
return value
case .failure(let error):
throw error
case .none:
throw NSError(
domain: RunnerErrorDomain.general,
code: RunnerErrorCode.noResponseFromMainThread,
userInfo: [NSLocalizedDescriptionKey: "no response from main thread"]
)
}
}
private func mainThreadExecutionTimeoutError() -> Error {
NSError(
domain: RunnerErrorDomain.general,
code: RunnerErrorCode.mainThreadExecutionTimedOut,
userInfo: [NSLocalizedDescriptionKey: "main thread execution timed out"]
)
}
// MARK: - Command Handling
private func executeOnMainSafely(
command: Command,
alertDeadline: Date? = nil,
routeToSpringboard: Bool
) throws -> Response {
var hasRetried = false
while true {
var response: Response?
var swiftError: Error?
let failureCountBefore = currentXCTestFailureCount()
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
do {
response = try self.executeOnMain(
command: command,
alertDeadline: alertDeadline,
routeToSpringboard: routeToSpringboard
)
} catch {
swiftError = error
}
})
if let exceptionMessage {
invalidateCachedTarget(reason: "objc_exception")
if !hasRetried, shouldRetryException(command, message: exceptionMessage) {
NSLog(
"AGENT_DEVICE_RUNNER_RETRY command=%@ reason=objc_exception",
command.command.rawValue
)
hasRetried = true
sleepFor(retryCooldown)
continue
}
throw NSError(
domain: RunnerErrorDomain.exception,
code: RunnerErrorCode.objcException,
userInfo: [NSLocalizedDescriptionKey: exceptionMessage]
)
}
if let swiftError {
throw swiftError
}
guard let response else {
throw NSError(
domain: RunnerErrorDomain.general,
code: RunnerErrorCode.commandReturnedNoResponse,
userInfo: [NSLocalizedDescriptionKey: "command returned no response"]
)
}
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
// #1605 merge gate: the REAL gesture already executed above; recording a
// production-shaped issue here makes the per-command failure-count
// conversion below fire exactly as in the field (bsky-24: activation
// lands, bookkeeping records a failure). Compiled out of production.
if consumeInjectedTapRecordedFailureForTesting(command: command.command) {
record(
XCTIssue(
type: .assertionFailure,
compactDescription: "Injected tap recorded-failure (#1605 corroboration merge gate)"
)
)
}
#endif
if didRecordXCTestFailure(since: failureCountBefore),
let failureResponse = xctestRecordedFailureResponse(command: command, response: response)
{
invalidateCachedTarget(reason: "xctest_recorded_failure")
return failureResponse
}
if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
NSLog(
"AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
command.command.rawValue
)
hasRetried = true
invalidateCachedTarget(reason: "response_unavailable")
sleepFor(retryCooldown)
continue
}
return response
}
}
private func executeSnapshotDispatched(command: Command) throws -> Response {
try executeDispatchedWithRecovery(command: command) {
try self.executeSnapshotDispatchedOnce(command: command)
}
}
/// The dispatched snapshot recovery loop: read-only retry + XCTest-recorded-failure invalidation,
/// matching what `executeOnMainSafely` gives the generic path. `perform` runs the capture and its
/// own bounded main-thread work.
func executeDispatchedWithRecovery(
command: Command,
perform: () throws -> Response
) throws -> Response {
var hasRetried = false
while true {
let failureCountBefore = try runMainThreadWork(
timeout: mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
) {
self.currentXCTestFailureCount()
}
let response = try perform()
// Recovered independently — re-entering main for bookkeeping would queue behind the still-
// abandoned XCTest query and re-stall the command (#1244), so skip it until that work drains.
if hasAbandonedTreeCapture() {
NSLog(
"AGENT_DEVICE_RUNNER_DISPATCH_RECOVERY_SKIPPED_XCTEST_OCCUPIED command=%@",
command.command.rawValue
)
return response
}
let recordedFailureResponse = try runMainThreadWork(
timeout: mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
) {
self.didRecordXCTestFailure(since: failureCountBefore)
? self.xctestRecordedFailureResponse(command: command, response: response)
: nil
}
if let recordedFailureResponse {
try runMainThreadWork(
timeout: mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
) {
self.invalidateCachedTarget(reason: "xctest_recorded_failure")
}
return recordedFailureResponse
}
if !hasRetried, shouldRetryCommand(command), shouldRetryResponse(response) {
NSLog(
"AGENT_DEVICE_RUNNER_RETRY command=%@ reason=response_unavailable",
command.command.rawValue
)
hasRetried = true
try runMainThreadWork(
timeout: mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
) {
self.invalidateCachedTarget(reason: "response_unavailable")
self.sleepFor(self.retryCooldown)
}
continue
}
return response
}
}
private func executeSnapshotDispatchedOnce(command: Command) throws -> Response {
let preparation = try runMainThreadWork(
timeout: mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
) {
try self.prepareActiveCommandContextSafely(command: command, routeToSpringboard: false)
}
switch preparation {
case .response(let response):
return response
case .context(let context):
return try executeSnapshotPrepared(command: command, activeApp: context.app)
}
}
/// Pure command→options projection, extracted so the runner unit bundle can
/// prove the decoded wire field actually reaches presentation options (#1634 P2).
static func presentationOptions(from command: Command) -> PresentationOptions {
let customActions = command.customActions ?? false
return PresentationOptions(
interactiveOnly: command.interactiveOnly ?? false,
depth: command.depth,
scope: command.scope,
raw: command.raw ?? false,
// Custom actions are only readable through the private AX client, so
// asking for them pins that backend rather than silently returning a
// capture that structurally cannot carry them. An explicit pin wins.
preferredBackend: command.preferredBackend
?? (customActions ? SnapshotBackendKind.privateAX.rawValue : nil),
customActions: customActions
)
}
private func executeSnapshotPrepared(command: Command, activeApp: XCUIApplication) throws -> Response {
let options = Self.presentationOptions(from: command)
do {
let payload: DataPayload
if options.raw {
payload = try snapshotRaw(app: activeApp, options: options)
} else {
payload = try snapshotFast(app: activeApp, options: options)
}
setNeedsPostSnapshotInteractionDelay()
return Response(ok: true, data: payload)
} catch let failure as SnapshotCaptureFailure {
invalidateCachedTargetAfterSnapshotFailure()
return Response(
ok: false,
error: ErrorPayload(
code: failure.code,
message: failure.message,
hint: failure.hint
)
)
}
}
private func setNeedsPostSnapshotInteractionDelay() {
if Thread.isMainThread {
needsPostSnapshotInteractionDelay = true
return
}
guard !hasAbandonedTreeCapture() else {
NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_SKIPPED_XCTEST_OCCUPIED")
return
}
do {
try runMainThreadWork(
timeout: 1,
timeoutError: mainThreadExecutionTimeoutError
) {
self.needsPostSnapshotInteractionDelay = true
}
} catch {
NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_FAILED=%@", String(describing: error))
}
}
private func invalidateCachedTargetAfterSnapshotFailure() {
if Thread.isMainThread {
invalidateCachedTarget(reason: "ax_snapshot_failure")
return
}
do {
try runMainThreadWork(
timeout: 1,
timeoutError: mainThreadExecutionTimeoutError
) {
self.invalidateCachedTarget(reason: "ax_snapshot_failure")
}
} catch {
NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_INVALIDATION_FAILED=%@", String(describing: error))
}
}
private func prepareActiveCommandContextSafely(
command: Command,
routeToSpringboard: Bool
) throws -> ActiveCommandPreparation {
var preparation: ActiveCommandPreparation?
let exceptionMessage = RunnerObjCExceptionCatcher.catchException({
preparation = self.prepareActiveCommandContext(
command: command,
routeToSpringboard: routeToSpringboard
)
})
if let exceptionMessage {
throw NSError(
domain: RunnerErrorDomain.exception,
code: RunnerErrorCode.objcException,
userInfo: [NSLocalizedDescriptionKey: exceptionMessage]
)
}
guard let preparation else {
throw NSError(
domain: RunnerErrorDomain.general,
code: RunnerErrorCode.commandReturnedNoResponse,
userInfo: [NSLocalizedDescriptionKey: "snapshot preflight returned no response"]
)
}
return preparation
}
private func executeOnMain(
command: Command,
alertDeadline: Date?,
routeToSpringboard: Bool
) throws -> Response {
let preparation = prepareActiveCommandContext(
command: command,
routeToSpringboard: routeToSpringboard
)
let activeApp: XCUIApplication
switch preparation {
case .response(let response):
return response
case .context(let context):
activeApp = context.app
}
switch command.command {
case .status:
return executeStatus(command: command)
case .targetReset:
return resetTargetAfterExternalRelaunch()
case .shutdown:
stopRecordingIfNeeded()
return Response(ok: true, data: DataPayload(message: "shutdown"))
case .recordStart:
guard
let requestedOutPath = command.outPath?.trimmingCharacters(in: .whitespacesAndNewlines),
!requestedOutPath.isEmpty
else {
return Response(ok: false, error: ErrorPayload(message: "recordStart requires outPath"))
}
let hasAppBundleId = !(command.appBundleId?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
guard hasAppBundleId else {
return Response(ok: false, error: ErrorPayload(message: "recordStart requires appBundleId"))
}
if activeRecording != nil {
return Response(ok: false, error: ErrorPayload(message: "recording already in progress"))
}
if let requestedFps = command.fps, (requestedFps < minRecordingFps || requestedFps > maxRecordingFps) {
return Response(ok: false, error: ErrorPayload(message: "recordStart fps must be between \(minRecordingFps) and \(maxRecordingFps)"))
}
do {
let resolvedOutPath = resolveRecordingOutPath(requestedOutPath)
let fpsLabel = command.fps.map(String.init) ?? String(RunnerTests.defaultRecordingFps)
NSLog(
"AGENT_DEVICE_RUNNER_RECORD_START requestedOutPath=%@ resolvedOutPath=%@ fps=%@",
requestedOutPath,
resolvedOutPath,
fpsLabel
)
let recorder = ScreenRecorder(
outputPath: resolvedOutPath,
fps: command.fps.map { Int32($0) }
)
try recorder.start { [weak self] in
return self?.captureRunnerFrame()
}
activeRecording = recorder
return Response(ok: true, data: DataPayload(message: "recording started"))
} catch {
activeRecording = nil
return Response(ok: false, error: ErrorPayload(message: "failed to start recording: \(error.localizedDescription)"))
}
case .recordStop:
guard let recorder = activeRecording else {
// The runner protocol is the durable cleanup primitive. A daemon may crash after the
// native stop succeeds but before it commits the resource transition, so exact-owner
// recovery must be able to repeat this command safely. Public `record stop` still owns
// its user-facing no-active validation through the daemon session manifest.
return Response(ok: true, data: DataPayload(message: "recording already stopped"))
}
do {
try recorder.stop()
activeRecording = nil
return Response(ok: true, data: DataPayload(message: "recording stopped"))
} catch {
activeRecording = nil
return Response(ok: false, error: ErrorPayload(message: "failed to stop recording: \(error.localizedDescription)"))
}
case .uptime:
return executeUptime()
case .activate:
guard
let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleId.isEmpty
else {
return Response(ok: false, error: ErrorPayload(message: "activate requires appBundleId"))
}
// prepareActiveCommandContext already activated this bundle. Keep this case as the
// explicit acknowledgement after that preflight, not as a second activation.
return Response(ok: true, data: DataPayload(message: "app activated"))
case .terminate:
guard
let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleId.isEmpty
else {
return Response(ok: false, error: ErrorPayload(message: "terminate requires appBundleId"))
}
XCUIApplication(bundleIdentifier: bundleId).terminate()
if currentBundleId == bundleId {
invalidateCachedTarget(reason: "target_terminated")
}
return Response(ok: true, data: DataPayload(message: "app terminated"))
default:
break
}
return try executeOnMainPrepared(
command: command,
activeApp: activeApp,
alertDeadline: alertDeadline
)
}
private func prepareActiveCommandContext(
command: Command,
routeToSpringboard: Bool = false
) -> ActiveCommandPreparation {
var activeApp = currentApp ?? app
if routeToSpringboard {
activeApp = springboard
} else if shouldSkipAppActivationPreflight(command) {
activeApp = resolveAppWithoutActivation(command: command)
} else if !isRunnerLifecycleCommand(command.command) {
let normalizedBundleId = command.appBundleId?
.trimmingCharacters(in: .whitespacesAndNewlines)
let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
if let bundleId = requestedBundleId {
if currentBundleId != bundleId || currentApp == nil {
_ = activateTarget(bundleId: bundleId, reason: "bundle_changed")
} else {
refreshCachedTargetIfProcessChanged(bundleId: bundleId)
}
} else {
// Do not reuse stale bundle targets when the caller does not explicitly request one.
invalidateCachedTarget(reason: "missing_app_bundle")
}
activeApp = currentApp ?? app
if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) {
activeApp = activateTarget(bundleId: bundleId, reason: "stale_target")
} else if requestedBundleId == nil, targetNeedsActivation(activeApp) {
ensureRunnerHostAppActive(reason: "missing_app_bundle")
activeApp = app
}
let skipExistenceWait = canUseFastForegroundAppGuard(
activeApp: activeApp,
requestedBundleId: requestedBundleId,
command: command.command
)
if !skipExistenceWait && !activeApp.waitForExistence(timeout: appExistenceTimeout) {
if let bundleId = requestedBundleId {
activeApp = activateTarget(bundleId: bundleId, reason: "missing_after_wait")
guard activeApp.waitForExistence(timeout: appExistenceTimeout) else {
return .response(Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available")))
}
} else {
return .response(Response(ok: false, error: ErrorPayload(message: "runner app is not available")))
}
}
if isInteractionCommand(command.command) {
if let bundleId = requestedBundleId, activeApp.state != .runningForeground {
activeApp = activateTarget(bundleId: bundleId, reason: "interaction_foreground_guard")
} else if requestedBundleId == nil, activeApp.state != .runningForeground {
ensureRunnerHostAppActive(reason: "interaction_missing_app_bundle")
activeApp = app
}
let skipInteractionExistenceWait = canUseFastForegroundAppGuard(
activeApp: activeApp,
requestedBundleId: requestedBundleId,
command: command.command
)
if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) {
if let bundleId = requestedBundleId {
return .response(Response(ok: false, error: ErrorPayload(message: "app '\(bundleId)' is not available")))
}
return .response(Response(ok: false, error: ErrorPayload(message: "runner app is not available")))
}
applyInteractionStabilizationIfNeeded()
}
}
return .context(ActiveCommandContext(app: activeApp))
}
func executeOnMainPrepared(
command: Command,
activeApp: XCUIApplication,
alertDeadline: Date? = nil
) throws -> Response {
var activeApp = activeApp
if command.command != .tap && command.command != .type && !isReadOnlyCommand(command) {
clearRememberedTextEntryTap()
}
switch command.command {
case .status, .activate, .terminate, .targetReset, .shutdown, .recordStart, .recordStop, .uptime:
return Response(
ok: false,
error: ErrorPayload(
code: "UNSUPPORTED_OPERATION",
message: "\(command.command.rawValue) cannot be executed through the prepared command path"
)
)
case .tap:
if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
let expectedPoint: CGPoint?
if command.allowNonHittableCoordinateFallback == true,
let x = command.x,
let y = command.y
{
expectedPoint = CGPoint(x: x, y: y)
} else {
expectedPoint = nil
}
let match = findElement(
app: activeApp,
selectorKey: selectorKey,
selectorValue: selectorValue,
allowNonHittableFallback: command.allowNonHittableCoordinateFallback == true,
expectedPoint: expectedPoint
)
if match.isAmbiguous {
clearRememberedTextEntryTap()
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
}
if let element = match.element {
let frame = element.frame
// XCTest reports closed-drawer/off-viewport items as hittable, then
// "taps" coordinates outside the visible window as a silent no-op.
// Refuse instead; the daemon falls back to tree-based resolution,
// which can prefer an on-screen candidate or explain the off-screen
// state. The check uses the main window frame, not app.frame: on RN
// apps app.frame unions transformed subtrees (a closed drawer at
// negative x), so it happily "contains" unreachable coordinates.
// The DECISION lives in TapPointPolicy (golden parity table with the
// TS twin); onScreenWindowFrame only supplies the frame.
if !match.usedNonHittableFallback
&& !TapPointPolicy.isAllowed(
elementFrame: frame,
windowFrame: onScreenWindowFrame(app: activeApp)
) {
clearRememberedTextEntryTap()
return Response(ok: false, error: ErrorPayload(
code: "ELEMENT_OFFSCREEN",
message: "element resolved off-screen at (\(Int(frame.midX)), \(Int(frame.midY)))"))
}
let isTextEntry = isTextEntryElement(element)
let touchPoint = expectedPoint ?? CGPoint(x: frame.midX, y: frame.midY)
let touchFrame = resolvedTouchVisualizationFrame(
app: activeApp,
x: touchPoint.x,
y: touchPoint.y
)
var fallback: GestureFallback?
if command.synthesized == true {
let policyKind = SynthesizedGesturePolicyKind.coordinateTap
let context = synthesizedCoordinateContext(
app: activeApp,
policy: synthesizedGesturePolicy(policyKind)
)
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
synthesizedTapAt(
app: activeApp,
x: touchPoint.x,
y: touchPoint.y,
context: context
)
}
if case .performed = outcome {
logSynthesizedGesturePolicyDecision(
kind: policyKind,
context: context,
fallbackAttempted: false
)
if isTextEntry {
waitForTextEntryReadinessAfterTap(app: activeApp, element: element)
}
rememberTextEntryTap(isTextEntry ? element : nil)
return gestureResponse(
message: match.usedNonHittableFallback
? "tapped via non-hittable coordinate fallback"
: "tapped",
timing: timing,
frame: .touch(touchFrame),
maestroNonHittableCoordinateFallbackUsed:
command.allowNonHittableCoordinateFallback == true
? match.usedNonHittableFallback
: nil
)
}
logSynthesizedGesturePolicyDecision(
kind: policyKind,
context: context,
fallbackAttempted: true
)
fallback = gestureFallback(strategy: "xctest-coordinate-tap", from: outcome)
}
let (timing, outcome) = performGesture(activeApp) {
if expectedPoint != nil || match.usedNonHittableFallback {
// Maestro compatibility: RN E2E backdoor controls can be 1x1 and
// reported non-hittable by XCTest. Snapshot-resolved targets also
// need coordinate delivery because XCUIElement.activate() does not
// invoke every React Native accessibility wrapper.
return tapAt(app: activeApp, x: touchPoint.x, y: touchPoint.y)
}
return activateElement(app: activeApp, element: element, action: "tap by selector")
}
if let response = unsupportedResponse(for: outcome) {
clearRememberedTextEntryTap()
return response
}
if isTextEntry {
waitForTextEntryReadinessAfterTap(app: activeApp, element: element)
}
rememberTextEntryTap(isTextEntry ? element : nil)
return gestureResponse(
message: match.usedNonHittableFallback ? "tapped via non-hittable coordinate fallback" : "tapped",
timing: timing,
frame: .touch(touchFrame),
fallback: fallback,
maestroNonHittableCoordinateFallbackUsed:
command.allowNonHittableCoordinateFallback == true
? match.usedNonHittableFallback
: nil
)
}
clearRememberedTextEntryTap()
return Response(ok: false, error: ErrorPayload(code: "ELEMENT_NOT_FOUND", message: "element not found"))
}
if let x = command.x, let y = command.y {
let xCTestChannelPenalized = isSnapshotXCTestChannelPenalized(
bundleId: currentBundleId
)
let xCTestTextInputProbeSkipped = !shouldProbeCoordinateTapTextInput(
xCTestChannelPenalized: xCTestChannelPenalized
)
let textInput: XCUIElement?
if !xCTestTextInputProbeSkipped {
textInput = textInputAt(app: activeApp, x: x, y: y)
} else {
// A process-scoped tap cannot authorize later typing without concrete element identity.
textInput = nil
NSLog(
"AGENT_DEVICE_RUNNER_COORDINATE_TAP_TEXT_INPUT_PROBE_SKIPPED bundle=%@",
currentBundleId ?? ""
)
}
var fallback: GestureFallback?
if command.synthesized == true {
let policyKind = SynthesizedGesturePolicyKind.coordinateTap
let context = synthesizedCoordinateContext(
app: activeApp,
policy: synthesizedGesturePolicy(policyKind)
)
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
synthesizedTapAt(app: activeApp, x: x, y: y, context: context)
}
if case .performed = outcome {
logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: false)
rememberTextEntryTap(textInput)
return gestureResponse(message: "tapped", timing: timing)
}
logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: true)
fallback = gestureFallback(strategy: "xctest-coordinate-tap", from: outcome)
}
let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
let (timing, outcome) = performGesture(activeApp) { tapAt(app: activeApp, x: x, y: y) }
if let response = unsupportedResponse(for: outcome) {
clearRememberedTextEntryTap()
return response
}
rememberTextEntryTap(textInput)
return gestureResponse(
message: "tapped",
timing: timing,
frame: .touch(touchFrame),
fallback: fallback
)
}
clearRememberedTextEntryTap()
return Response(ok: false, error: ErrorPayload(message: "tap requires a selector or x/y"))
case .mouseClick:
guard let x = command.x, let y = command.y else {
return Response(ok: false, error: ErrorPayload(message: "mouseClick requires x and y"))
}
let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
do {
// mouseClick throws (it has no RunnerInteractionOutcome), so it keeps raw measureGesture
// and only routes the success payload through gestureResponse.
var clickError: Error?
let timing = measureGesture {
do {
try mouseClickAt(app: activeApp, x: x, y: y, button: command.button ?? "primary")
} catch {
clickError = error
}
}
if let clickError {
throw clickError
}
return gestureResponse(message: "clicked", timing: timing, frame: .touch(touchFrame))
} catch {
return Response(ok: false, error: ErrorPayload(message: error.localizedDescription))
}
case .longPress:
guard let x = command.x, let y = command.y else {
return Response(ok: false, error: ErrorPayload(message: "longPress requires x and y"))
}
let duration = (command.durationMs ?? 800) / 1000.0
let touchFrame = resolvedTouchVisualizationFrame(app: activeApp, x: x, y: y)
let (timing, outcome) = performGesture(activeApp) {
longPressAt(app: activeApp, x: x, y: y, duration: duration)
}
if let response = unsupportedResponse(for: outcome) {
return response
}
return gestureResponse(message: "long pressed", timing: timing, frame: .touch(touchFrame))
case .drag:
guard let x = command.x, let y = command.y, let x2 = command.x2, let y2 = command.y2 else {
return Response(ok: false, error: ErrorPayload(message: "drag requires x, y, x2, and y2"))
}
let defaults = runnerDragCommandDefaults(command)
return executeDragGesture(
activeApp: activeApp,
x: x,
y: y,
x2: x2,
y2: y2,
durationMs: defaults.durationMs,
synthesized: command.synthesized == true,
message: "dragged",
synthesizedPolicyKind: .synthesizedDrag
)
case .scroll:
// Fused frame-resolve + drag scroll for non-tvOS. On iOS this intentionally stays on the
// AX-free synthesized coordinate lane so scroll keeps working when XCTest cannot serialize
// the accessibility tree.
guard let rawDirection = command.direction,
let direction = RunnerScrollDirection(rawValue: rawDirection)
else {
return invalidScrollDirectionResponse(commandName: "scroll")
}
let scrollPolicyKind = SynthesizedGesturePolicyKind.scroll
guard let scrollContext = synthesizedCoordinateContext(
app: activeApp,
policy: synthesizedGesturePolicy(scrollPolicyKind)
) else {
return Response(
ok: false,
error: ErrorPayload(message: "scroll could not resolve a usable interaction frame")
)
}
let frame = scrollReferenceFrame(app: activeApp, context: scrollContext)
guard frame.width > 0, frame.height > 0 else {
return Response(
ok: false,
error: ErrorPayload(message: "scroll could not resolve a usable interaction frame")
)
}
let defaults = runnerDragCommandDefaults(command)
guard let plan = runnerScrollGesturePlan(
direction: direction,
amount: defaults.scrollAmount,
pixels: command.pixels,
referenceWidth: frame.width,
referenceHeight: frame.height
) else {
return Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "scroll could not compute a gesture plan"
)
)
}
guard scrollDurationIsValid(command.durationMs) else {
return invalidScrollDurationResponse(commandName: "scroll")
}
return executeScrollDragGesture(
activeApp: activeApp,
x: frame.minX + plan.x1,
y: frame.minY + plan.y1,
x2: frame.minX + plan.x2,
y2: frame.minY + plan.y2,
durationMs: defaults.durationMs,
message: "scrolled",
context: scrollContext.withReferenceFrame(frame),
releaseBehavior: command.scrollReleaseBehavior
)
case .desktopScroll:
guard let rawDirection = command.direction,
let direction = RunnerScrollDirection(rawValue: rawDirection)
else {
return invalidScrollDirectionResponse(commandName: "desktopScroll")
}
let appFrame = activeApp.frame
let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: appFrame)
guard frame.width > 0, frame.height > 0 else {
return Response(
ok: false,
error: ErrorPayload(message: "desktopScroll could not resolve a usable interaction frame")
)
}
guard let plan = runnerScrollGesturePlan(
direction: direction,
amount: command.amount,
pixels: command.pixels,
referenceWidth: frame.width,
referenceHeight: frame.height
) else {
return Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "desktopScroll could not compute a wheel plan"
)
)
}
let x = frame.midX
let y = frame.midY
let localX = x - (appFrame.isEmpty ? frame.minX : appFrame.minX)
let localY = y - (appFrame.isEmpty ? frame.minY : appFrame.minY)
guard scrollDurationIsValid(command.durationMs) else {
return invalidScrollDurationResponse(commandName: "desktopScroll")
}
let touchFrame = resolvedTouchVisualizationFrame(
app: activeApp,
x: localX,
y: localY
)
do {
var scrollError: Error?
let timing = measureGesture {
do {
try desktopScrollAt(
app: activeApp,
x: x,
y: y,
direction: direction,
pixels: plan.travelPixels,
durationMs: command.durationMs
)
} catch {
scrollError = error
}
}
if let scrollError {
throw scrollError
}
return gestureResponse(message: "scrolled", timing: timing, frame: .touch(touchFrame))
} catch {
return Response(ok: false, error: ErrorPayload(message: error.localizedDescription))
}
case .remotePress:
guard let button = tvRemoteButton(from: command.remoteButton) else {
return Response(ok: false, error: ErrorPayload(message: "remotePress requires remoteButton"))
}
let duration = (command.durationMs ?? 0) / 1000.0
guard pressTvRemote(button, duration: duration) else {
return Response(
ok: false,
error: ErrorPayload(code: "UNSUPPORTED_OPERATION", message: "remotePress is only supported on tvOS")
)
}
return Response(ok: true, data: DataPayload(message: "remote pressed"))
case .type:
var response: Response?
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
response = executeTypeCommand(activeApp: activeApp, command: command)
}
return response ?? Response(ok: false, error: ErrorPayload(message: "type produced no response"))
case .swipe:
guard let direction = command.direction else {
return Response(ok: false, error: ErrorPayload(message: "swipe requires direction"))
}
// swipe returns an optional frame (tvOS-only) rather than a RunnerInteractionOutcome, so it
// keeps raw measureGesture and only routes the success payload through gestureResponse.
var executedFrame: DragVisualizationFrame?
let timing = measureGesture {
withTemporaryScrollIdleTimeoutIfSupported(activeApp) {
executedFrame = swipe(app: activeApp, direction: direction)
}
}
guard let dragFrame = executedFrame else {
return Response(ok: false, error: ErrorPayload(message: "swipe is only supported on tvOS"))
}
return gestureResponse(message: "swiped", timing: timing, frame: .drag(dragFrame))
case .findText:
guard let text = command.text else {
return Response(ok: false, error: ErrorPayload(message: "findText requires text"))
}
let found = findElement(app: activeApp, text: text) != nil
return Response(ok: true, data: DataPayload(found: found))
case .querySelector:
guard let selectorKey = command.selectorKey, let selectorValue = command.selectorValue else {
return Response(ok: false, error: ErrorPayload(message: "querySelector requires selectorKey and selectorValue"))
}
return queryElement(app: activeApp, selectorKey: selectorKey, selectorValue: selectorValue)
case .readText:
guard let x = command.x, let y = command.y else {
return Response(ok: false, error: ErrorPayload(message: "readText requires x and y"))
}
guard let text = readTextAt(app: activeApp, x: x, y: y) else {
return Response(ok: false, error: ErrorPayload(message: "readText did not resolve text"))
}
return Response(ok: true, data: DataPayload(text: text))
case .snapshot:
return try executeSnapshotPrepared(command: command, activeApp: activeApp)
case .screenshot:
let screenshot: XCUIScreenshot
#if os(macOS)
// macOS keeps the app-targeted capture behavior for window-level screenshots.
if let bundleId = command.appBundleId, !bundleId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let targetApp = XCUIApplication(bundleIdentifier: bundleId)
targetApp.activate()
activeApp = targetApp
// Brief wait for the app transition animation to complete
sleepFor(0.5)
}
if command.fullscreen == true {
screenshot = XCUIScreen.main.screenshot()
} else if let bundleId = command.appBundleId, !bundleId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
screenshot = screenshotRoot(app: activeApp).screenshot()
} else {
screenshot = XCUIScreen.main.screenshot()
}
#else
screenshot = XCUIScreen.main.screenshot()
#endif
guard let pngData = runnerPngData(for: screenshot.image) else {
return Response(ok: false, error: ErrorPayload(message: "Failed to encode screenshot as PNG"))
}
if command.inlineScreenshot == true {
return Response(
ok: true,
data: DataPayload(imageBase64: pngData.base64EncodedString())
)
}
let fileName = "screenshot-\(Int(Date().timeIntervalSince1970 * 1000)).png"
let filePath = (NSTemporaryDirectory() as NSString).appendingPathComponent(fileName)
do {
try pngData.write(to: URL(fileURLWithPath: filePath))
} catch {
return Response(ok: false, error: ErrorPayload(message: "Failed to write screenshot: \(error.localizedDescription)"))
}
#if os(macOS)
return Response(ok: true, data: DataPayload(message: filePath))
#else
// Return path relative to app container root (tmp/ maps to NSTemporaryDirectory)
return Response(ok: true, data: DataPayload(message: "tmp/\(fileName)"))
#endif
case .back, .backInApp:
if tapInAppBackControl(app: activeApp) {
let message = command.command == .back ? "back" : "backInApp"
return Response(ok: true, data: DataPayload(message: message))
}
return Response(ok: false, error: ErrorPayload(message: "in-app back control is not available"))
case .backSystem:
if performSystemBackAction(app: activeApp) {
return Response(ok: true, data: DataPayload(message: "backSystem"))
}
return Response(ok: false, error: ErrorPayload(message: "system back is not available"))
case .home:
pressHomeButton()
return Response(ok: true, data: DataPayload(message: "home"))
case .rotate:
return executeRotateCommand(command)
case .appSwitcher:
performAppSwitcherGesture(app: activeApp)
return Response(ok: true, data: DataPayload(message: "appSwitcher"))
case .keyboardDismiss:
let result = dismissKeyboard(app: activeApp)
if result.wasVisible && !result.dismissed {
return Response(
ok: false,
error: ErrorPayload(
code: "UNSUPPORTED_OPERATION",
message: "Unable to dismiss the iOS keyboard: the keyboard exposes no dismiss key, and background taps are never attempted (no tap outside the keyboard can be proven side-effect-free)",
hint:
"The on-screen keyboard usually does not block agent-device interactions: press the next target directly instead of retrying dismiss. If that press fails or reports no visible effect, scroll the target into view, or use keyboard enter to press the return key when submission is wanted."
)
)
}
return Response(
ok: true,
data: DataPayload(
message: "keyboardDismiss",
visible: result.visible,
wasVisible: result.wasVisible,
dismissed: result.dismissed,
keyboardDismissMechanism: result.mechanism?.rawValue
)
)
case .keyboardReturn:
let result = pressKeyboardReturn(app: activeApp)
if !result.pressed {
return Response(
ok: false,
error: ErrorPayload(
code: "UNSUPPORTED_OPERATION",
message: "Unable to press the iOS keyboard return key"
)
)
}
return Response(
ok: true,
data: DataPayload(
message: "keyboardReturn",
visible: result.visible,
wasVisible: result.wasVisible
)
)
case .alert:
let action = (command.action ?? "get").lowercased()
let deadline = alertDeadline ?? Date().addingTimeInterval(
Self.alertCommandTimeout(timeoutMs: command.timeoutMs)
)
guard let alert = resolveAlert(app: activeApp, deadline: deadline) else {
return Response(ok: false, error: ErrorPayload(message: "alert not found"))
}
return handleAlert(alert, action: action, deadline: deadline)
case .gesture:
guard let plan = command.gesturePlan else {
return Response(
ok: false,
error: ErrorPayload(code: "INVALID_ARGS", message: "gesture requires gesturePlan")
)
}
if let validationError = plannedGestureValidationError(plan) {
return Response(
ok: false,
error: ErrorPayload(code: "INVALID_ARGS", message: validationError)
)
}
switch plannedGestureExecution(for: plan) {
case .fastSwipe:
// Validation above guarantees a non-empty, single-pointer path for this execution kind.
let first = plan.pointers[0].samples.first!.point
let last = plan.pointers[0].samples.last!.point
return canonicalPlannedGestureResponse(
executeDragGesture(
activeApp: activeApp,
x: first.x,
y: first.y,
x2: last.x,
y2: last.y,
durationMs: plan.durationMs,
synthesized: true,
message: plan.intent,
synthesizedPolicyKind: .synthesizedDrag,
synthesizedProfile: .fastSwipe
)
)
case .sampled:
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
sampledPlannedGesture(app: activeApp, plan: plan)
}
return plannedGestureResponse(plan: plan, timing: timing, outcome: outcome)
}
case .gestureViewport:
let frame = resolvedTouchReferenceFrame(app: activeApp, appFrame: activeApp.frame)
guard !frame.isNull, !frame.isInfinite, !frame.isEmpty else {
return Response(ok: false, error: ErrorPayload(code: "COMMAND_FAILED", message: "Active app interaction viewport is unavailable"))
}
return Response(ok: true, data: DataPayload(message: "gestureViewport", x: frame.minX, y: frame.minY, x2: frame.width, y2: frame.height))
case .sequence:
return executeSequence(command: command, activeApp: activeApp)
}
}
private func invalidScrollDirectionResponse(commandName: String) -> Response {
Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "\(commandName) requires direction up|down|left|right"
)
)
}
private func scrollDurationIsValid(_ durationMs: Double?) -> Bool {
guard let durationMs else { return true }
return durationMs.isFinite && durationMs >= 0 && durationMs <= 10000
}
private func invalidScrollDurationResponse(commandName: String) -> Response {
return Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "\(commandName) durationMs must be between 0 and 10000"
)
)
}
private func executeScrollDragGesture(
activeApp: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double,
durationMs: Double,
message: String,
context: SynthesizedCoordinateContext,
releaseBehavior: ScrollReleaseBehavior?
) -> Response {
#if os(iOS)
return executeDragGesture(
activeApp: activeApp,
x: x,
y: y,
x2: x2,
y2: y2,
durationMs: durationMs,
synthesized: true,
message: message,
synthesizedContext: context,
synthesizedPolicyKind: .scroll,
synthesizedProfile: scrollDragProfile(releaseBehavior: releaseBehavior)
)
#else
return executeDragGesture(
activeApp: activeApp,
x: x,
y: y,
x2: x2,
y2: y2,
durationMs: durationMs,
synthesized: false,
message: message,
synthesizedPolicyKind: .scroll
)
#endif
}
/// Shared drag execution for explicit drag commands. The iOS synthesized lane keeps its
/// fallback policy explicit; viewport scrolling owns a separate single drag specification.
private func executeDragGesture(
activeApp: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double,
durationMs: Double?,
synthesized: Bool,
message: String,
synthesizedContext: SynthesizedCoordinateContext? = nil,
synthesizedPolicyKind: SynthesizedGesturePolicyKind,
synthesizedProfile: SynthesizedDragProfile = .continuous
) -> Response {
let durationMs = durationMs ?? runnerDefaultDragDurationMs
let commandName = dragCommandName(message: message)
guard x.isFinite, y.isFinite, x2.isFinite, y2.isFinite else {
return Response(
ok: false,
error: ErrorPayload(code: "INVALID_ARGS", message: "\(commandName) requires finite coordinates")
)
}
if synthesized, let synthesizedResponse = executeSynthesizedDragGesture(
activeApp: activeApp,
x: x,
y: y,
x2: x2,
y2: y2,
durationMs: durationMs,
message: message,
context: synthesizedContext,
policyKind: synthesizedPolicyKind,
profile: synthesizedProfile
) {
return synthesizedResponse
}
let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
let dragFrame = resolvedDragVisualizationFrame(
app: activeApp,
x: dragPoints.x,
y: dragPoints.y,
x2: dragPoints.x2,
y2: dragPoints.y2
)
var fallback: GestureFallback?
if synthesized {
let durationMs = min(max(durationMs, 16), 10000)
let context = synthesizedCoordinateContext(
app: activeApp,
policy: synthesizedGesturePolicy(synthesizedPolicyKind)
)
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
synthesizedDragAt(
app: activeApp,
x: dragPoints.x,
y: dragPoints.y,
x2: dragPoints.x2,
y2: dragPoints.y2,
durationMs: durationMs,
profile: synthesizedProfile,
context: context
)
}
if case .performed = outcome {
return gestureResponse(message: message, timing: timing, frame: .drag(dragFrame))
}
fallback = gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
}
let holdDuration = synthesized
? synthesizedSwipeFallbackHoldDuration(durationMs: durationMs)
: coordinateDragHoldDuration()
let (timing, outcome) = performGesture(activeApp) {
dragAt(
app: activeApp,
x: dragPoints.x,
y: dragPoints.y,
x2: dragPoints.x2,
y2: dragPoints.y2,
holdDuration: holdDuration
)
}
if let response = unsupportedResponse(for: outcome) {
return response
}
return gestureResponse(
message: message,
timing: timing,
frame: .drag(dragFrame),
fallback: fallback
)
}
private func executeSynthesizedDragGesture(
activeApp: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double,
durationMs: Double,
message: String,
context: SynthesizedCoordinateContext?,
policyKind: SynthesizedGesturePolicyKind,
profile: SynthesizedDragProfile
) -> Response? {
#if os(iOS)
let policy = synthesizedGesturePolicy(policyKind)
let context = context ?? synthesizedCoordinateContext(app: activeApp, policy: policy)
guard let plan = axFreeSynthesizedDragPlan(
app: activeApp,
x: x,
y: y,
x2: x2,
y2: y2,
context: context
)
else {
if context?.allowsXCTestCoordinateFallback == true {
logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: true)
return executeCoordinateDragFallback(
activeApp: activeApp,
x: x,
y: y,
x2: x2,
y2: y2,
durationMs: durationMs,
message: message,
fallback: nil
)
}
logSynthesizedGesturePolicyDecision(kind: policyKind, context: context, fallbackAttempted: false)
return Response(
ok: false,
error: ErrorPayload(
code: "INVALID_ARGS",
message: "\(dragCommandName(message: message)) could not resolve a finite synthesized coordinate frame"
)
)
}
let durationMs = min(max(durationMs, 16), 10000)
let dragFrame = axFreeDragVisualizationFrame(
x: plan.points.x,
y: plan.points.y,
x2: plan.points.x2,
y2: plan.points.y2,
referenceFrame: plan.referenceFrame
)
let (timing, outcome) = performGesture(activeApp, idleTimeout: false) {
synthesizedDragAt(
app: activeApp,
x: plan.points.x,
y: plan.points.y,
x2: plan.points.x2,
y2: plan.points.y2,
durationMs: durationMs,
profile: profile,
context: plan.context
)
}
if case .performed = outcome {
logSynthesizedGesturePolicyDecision(kind: policyKind, context: plan.context, fallbackAttempted: false)
return gestureResponse(message: message, timing: timing, frame: .drag(dragFrame))
}
if plan.context.allowsXCTestCoordinateFallback {
logSynthesizedGesturePolicyDecision(kind: policyKind, context: plan.context, fallbackAttempted: true)
return executeCoordinateDragFallback(
activeApp: activeApp,
x: plan.points.x,
y: plan.points.y,
x2: plan.points.x2,
y2: plan.points.y2,
durationMs: durationMs,
message: message,
fallback: gestureFallback(strategy: "xctest-coordinate-drag", from: outcome)
)
}
logSynthesizedGesturePolicyDecision(kind: policyKind, context: plan.context, fallbackAttempted: false)
return unsupportedResponse(for: outcome)
#else
return nil
#endif
}
private func executeCoordinateDragFallback(
activeApp: XCUIApplication,
x: Double,
y: Double,
x2: Double,
y2: Double,
durationMs: Double,
message: String,
fallback: GestureFallback?
) -> Response {
let dragPoints = keyboardAvoidingDragPoints(app: activeApp, x: x, y: y, x2: x2, y2: y2)
let dragFrame = resolvedDragVisualizationFrame(
app: activeApp,
x: dragPoints.x,
y: dragPoints.y,
x2: dragPoints.x2,
y2: dragPoints.y2
)
let holdDuration = synthesizedSwipeFallbackHoldDuration(durationMs: durationMs)
let (timing, outcome) = performGesture(activeApp) {
dragAt(
app: activeApp,
x: dragPoints.x,
y: dragPoints.y,
x2: dragPoints.x2,
y2: dragPoints.y2,
holdDuration: holdDuration
)
}
if let response = unsupportedResponse(for: outcome) {
return response
}
return gestureResponse(
message: message,
timing: timing,
frame: .drag(dragFrame),
fallback: fallback
)
}
private func scrollReferenceFrame(app: XCUIApplication, context: SynthesizedCoordinateContext) -> CGRect {
#if os(iOS)
return synthesizedFrameAvoidingKeyboardWhenAllowed(app: app, context: context)
#else
return resolvedTouchReferenceFrame(app: app, appFrame: app.frame)
#endif
}
private func dragCommandName(message: String) -> String {
return message == "scrolled" ? "scroll" : "drag"
}
func currentXCTestFailureCount() -> Int {
return testRun?.failureCount ?? 0
}
func didRecordXCTestFailure(since failureCountBefore: Int) -> Bool {
return currentXCTestFailureCount() > failureCountBefore
}
private func xctestRecordedFailureResponse(command: Command, response: Response) -> Response? {
guard response.ok else { return nil }
if response.data?.runnerFatal == true {
return nil
}
guard !isReadOnlyCommand(command), !isRunnerLifecycleCommand(command.command) else {
return nil
}
return Response(
ok: false,
error: ErrorPayload(
code: "XCTEST_RECORDED_FAILURE",
message: "XCTest recorded a failure while executing \(command.command.rawValue); the action may not have been performed.",
hint: "The iOS runner session was invalidated. Re-observe with a fresh snapshot before retrying; if the accessibility tree is unavailable, use screenshot plus coordinate commands instead of retrying the tap blindly."
)
)
}
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
func runnerCommandFixture(_ json: String) throws -> Command {
try JSONDecoder().decode(Command.self, from: Data(json.utf8))
}
#endif
private func shouldSkipAppActivationPreflight(_ command: Command) -> Bool {
#if os(iOS)
if command.command == .alert {
return true
}
// Coordinate-only synthesized taps can run after an AX-fatal foreground screen because they do not
// need app activation, window lookup, keyboard lookup, or element resolution. Selector/text
// interactions intentionally stay on the normal AX path because they need an element query.
// Scroll/drag/sequence keep the normal foreground guard and stabilization path.
guard command.text == nil, command.selectorKey == nil else { return false }
guard hasCachedTargetForActivationSkip(command: command) else { return false }
return isCoordinateOnlyTap(command)
#else
return false
#endif
}
private func shouldRouteToSpringboardBlockingSystemModal(
_ command: Command,
deadline: Date? = nil
) -> Bool {
#if os(iOS)
guard command.command == .alert || isCoordinateOnlyTap(command) else {
return false
}
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
if let override = blockingSystemModalPresenceOverrideForTesting {
return override
}
#endif
let budgetDeadline = Date().addingTimeInterval(systemModalProbeBudget)
let probeDeadline = deadline.map { min($0, budgetDeadline) } ?? budgetDeadline
// `runMainThreadWork` executes inline for a main-thread caller, so that path cannot use its
// timeout machinery. Direct main-thread dispatch keeps the prior synchronous modal check;
// normal off-main command dispatch uses the bounded probe and post-probe busy recovery.
if Thread.isMainThread {
return firstBlockingSystemModal(
in: springboard,
deadline: probeDeadline
) != nil
}
return boundedBlockingSystemAlertSnapshot(
deadline: probeDeadline
) != nil
#else
return false
#endif
}
private func isCoordinateOnlyTap(_ command: Command) -> Bool {
return command.command == .tap
&& command.text == nil
&& command.selectorKey == nil
&& command.x != nil
&& command.y != nil
}
private func hasCachedTargetForActivationSkip(command: Command) -> Bool {
guard let currentApp, currentApp.state == .runningForeground else { return false }
guard let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleId.isEmpty
else {
return true
}
return currentBundleId == bundleId
}
private func resolveAppWithoutActivation(command: Command) -> XCUIApplication {
guard let bundleId = command.appBundleId?
.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleId.isEmpty
else {
return currentApp ?? app
}
if currentBundleId == bundleId, let currentApp {
return currentApp
}
return XCUIApplication(bundleIdentifier: bundleId)
}
func executeTypeCommand(activeApp: XCUIApplication, command: Command) -> Response {
guard let text = command.text else {
return Response(ok: false, error: ErrorPayload(message: "type requires text"))
}
let delaySeconds = Double(max(command.delayMs ?? 0, 0)) / 1000.0
let textEntryMode = resolveTextEntryMode(command)
let target: TextEntryTarget
var resolvedCoordinateContext: SynthesizedCoordinateContext?
var maestroNonHittableCoordinateFallbackUsed: Bool?
if command.allowNonHittableCoordinateFallback == true,
command.x != nil,
command.y != nil
{
// The shared runtime has already resolved this node as non-hittable and
// deliberately selected Maestro's coordinate compatibility route.
maestroNonHittableCoordinateFallbackUsed = true
}
let focusStartedAt = Date()
#if os(iOS)
let xCTestChannelPenalized = isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)
var resolvedCoordinateTarget: TextEntryTarget?
if Self.shouldUseResolvedCoordinateTextEntryRoute(
repairMode: textEntryMode,
hasX: command.x != nil,
hasY: command.y != nil,
xCTestChannelPenalized: xCTestChannelPenalized
), let x = command.x, let y = command.y {
let policyKind = SynthesizedGesturePolicyKind.coordinateTap
let context = synthesizedCoordinateContext(
app: activeApp,
policy: synthesizedGesturePolicy(policyKind)
)
let (_, outcome) = performGesture(activeApp, idleTimeout: false) {
synthesizedTapAt(app: activeApp, x: x, y: y, context: context)
}
if Self.shouldFallbackFromSynthesizedTextEntryFocus(outcome) {
logSynthesizedGesturePolicyDecision(
kind: policyKind,
context: context,
fallbackAttempted: true
)
} else {
logSynthesizedGesturePolicyDecision(
kind: policyKind,
context: context,
fallbackAttempted: false
)
resolvedCoordinateContext = context
resolvedCoordinateTarget = TextEntryTarget(
element: nil,
refreshPoint: CGPoint(x: x, y: y),
prefersFocusedElement: false
)
}
}
#else
let xCTestChannelPenalized = false
let resolvedCoordinateTarget: TextEntryTarget? = nil
#endif
if let resolvedCoordinateTarget {
target = resolvedCoordinateTarget
} else if let selectorKey = command.selectorKey, let selectorValue = command.selectorValue {
// Released daemons may still send selector-keyed type commands even though current
// daemons resolve fill selectors through the runtime tree before reaching the runner.
let match = findElement(
app: activeApp,
selectorKey: selectorKey,
selectorValue: selectorValue,
allowNonHittableFallback: command.allowNonHittableCoordinateFallback == true
)
if match.isAmbiguous {
return Response(ok: false, error: ErrorPayload(code: "AMBIGUOUS_MATCH", message: "selector matched multiple elements"))
}
guard let element = match.element else {
return Response(ok: false, error: ErrorPayload(code: "NO_MATCH", message: "selector did not match an element"))
}
guard isTextEntryElement(element) else {
return Response(ok: false, error: ErrorPayload(code: "INVALID_TARGET", message: "selector did not match a text input"))
}
if command.allowNonHittableCoordinateFallback == true {
maestroNonHittableCoordinateFallbackUsed = match.usedNonHittableFallback
}
target = focusTextInputForTextEntry(app: activeApp, element: element)
} else {
target = focusTextInputForTextEntry(app: activeApp, x: command.x, y: command.y)
}
NSLog(
"AGENT_DEVICE_RUNNER_TEXT_ENTRY_PHASE commandId=%@ phase=focus durationMs=%.1f chars=%d mode=%@",
command.commandId ?? "",
Date().timeIntervalSince(focusStartedAt) * 1000.0,
text.count,
textEntryModeName(textEntryMode)
)
if textEntryMode == .replacement {
#if os(iOS)
let canReplaceResolvedFirstResponder = Self.shouldUseSynthesizedFirstResponderReplacement(
hasResolvedElement: target.element != nil,
hasRefreshPoint: target.refreshPoint != nil,
xCTestChannelPenalized: xCTestChannelPenalized
)
#else
let canReplaceResolvedFirstResponder = false
#endif
guard target.element != nil || canReplaceResolvedFirstResponder else {
let message =
(command.x != nil && command.y != nil)
? "no text input found at the provided coordinates to clear"
: "no focused text input to clear"
return Response(ok: false, error: ErrorPayload(message: message))
}
}
let textResult = typeTextReliably(
app: activeApp,
target: target,
text: text,
delaySeconds: delaySeconds,
repairMode: textEntryMode,
xCTestChannelPenalized: xCTestChannelPenalized,
synthesizer: PrivateXCTestTextEntrySynthesizer(),
commandId: command.commandId
)
if let failure = textResult.failure {
return Response(
ok: false,
error: ErrorPayload(code: failure.rawValue, message: failure.message, hint: failure.hint)
)
}
if textResult.verified == false {
let expected = textResult.expectedText ?? ""
let observed = textResult.observedText ?? ""
return Response(
ok: false,
error: ErrorPayload(
code: "TEXT_ENTRY_MISMATCH",
message: "text entry verification failed: expected \"\(expected)\", observed \"\(observed)\""
)
)
}
let point = target.refreshPoint
let frame: CGRect
if let resolvedCoordinateContext {
frame = resolvedCoordinateContext.referenceFrame
} else if point != nil {
frame = activeApp.frame
} else {
// Bare `type` has no coordinate response to normalize. Avoid serializing the
// application AX tree only to emit unused reference dimensions.
frame = .zero
}
return Response(
ok: true,
data: DataPayload(
message: textResult.repaired ? "typed after repair" : "typed",
x: point.map { Double($0.x) },
y: point.map { Double($0.y) },
referenceWidth: frame.isEmpty ? nil : Double(frame.width),
referenceHeight: frame.isEmpty ? nil : Double(frame.height),
maestroNonHittableCoordinateFallbackUsed: maestroNonHittableCoordinateFallbackUsed,
textEntryRoute: textResult.textEntryRoute
)
)
}
}