diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift new file mode 100644 index 0000000000..a791984dc1 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -0,0 +1,620 @@ +import XCTest +import AgentDeviceSnapshotPresentation + +extension RunnerTests { + // MARK: - Main Thread Dispatch + + func executeAccepted(command: Command) throws -> Response { + commandJournal.start(command: command) + pendingTargetActivation = nil + do { + let response = try executeDispatched(command: command) + commandJournal.finish(command: command, response: response) + guard let fact = pendingTargetActivation else { return response } + // Stamped after `finish`, like the uptime anchor: a journal-replayed result carries no + // activation fact, because the command that paid for it is the one being replayed, not one + // that just repaired foreground (#2682). + pendingTargetActivation = nil + return response.stampingTargetActivation(fact) + } catch { + pendingTargetActivation = nil + 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()) + ) + } + + struct ActiveCommandContext { + let app: XCUIApplication + /// Set when `app` is a system surface served in place over the still-bound session app (#2438). + var systemSurface: SystemSurfaceHost? = nil + } + + enum ActiveCommandPreparation { + case response(Response) + case context(ActiveCommandContext) + } + + 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) + } + } + + 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 + // 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) + 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( + "command_execution", + timeout: max(0.001, deadline.timeIntervalSinceNow), + timeoutError: mainThreadExecutionTimeoutError + ) { + try self.executeOnMainSafely( + command: command, + alertDeadline: deadline, + routeToSpringboard: routeToSpringboard + ) + } + } + return try runMainThreadWork( + "command_execution", + timeout: mainThreadExecutionTimeout, + timeoutError: mainThreadExecutionTimeoutError + ) { + try self.executeOnMainSafely(command: command, routeToSpringboard: routeToSpringboard) + } + } + + // 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 + } + } + + /// 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( + "recorded_failure_count", + 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 hasAbandonedMainThreadWork() { + NSLog( + "AGENT_DEVICE_RUNNER_DISPATCH_RECOVERY_SKIPPED_XCTEST_OCCUPIED command=%@", + command.command.rawValue + ) + return response + } + let recordedFailureResponse = try runMainThreadWork( + "recorded_failure_count", + timeout: mainThreadExecutionTimeout, + timeoutError: mainThreadExecutionTimeoutError + ) { + self.didRecordXCTestFailure(since: failureCountBefore) + ? self.xctestRecordedFailureResponse(command: command, response: response) + : nil + } + if let recordedFailureResponse { + try runMainThreadWork( + "target_invalidation", + 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( + "target_invalidation", + timeout: mainThreadExecutionTimeout, + timeoutError: mainThreadExecutionTimeoutError + ) { + self.invalidateCachedTarget(reason: "response_unavailable") + self.sleepFor(self.retryCooldown) + } + continue + } + return response + } + } + + 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 + guard let self else { return .failure(.unresolvedScreen) } + return self.captureRunnerFrameResult(app: activeApp) + } + activeRecording = recorder + return Response(ok: true, data: DataPayload(message: "recording started")) + } catch { + activeRecording = nil + return Response(ok: false, error: Self.recordingStartErrorPayload(for: error)) + } + 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 + ) + } + + func prepareActiveCommandContext( + command: Command, + routeToSpringboard: Bool = false + ) -> ActiveCommandPreparation { + var activeApp = currentApp ?? app + var systemSurface: SystemSurfaceHost? = nil + if routeToSpringboard { + activeApp = springboard + } else if shouldSkipAppActivationPreflight(command) { + activeApp = resolveAppWithoutActivation(command: command) + } else if let presented = presentedSystemSurfaceHost() { + // Serve and drive the presented surface IN PLACE: never activate it (that cancels what it + // presents) and never adopt it as the cached session target, so once it is gone the next + // command resolves back to the still-bound session app (#2438). + activeApp = presented.app + systemSurface = presented.host + if isInteractionCommand(command.command) { + applyInteractionStabilizationIfNeeded() + } + } 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 + ) + 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: .targetAppUnavailable(bundleId: bundleId))) + } + } else { + return .response(Response(ok: false, error: .targetAppUnavailable(bundleId: nil))) + } + } + + 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 + ) + if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) { + return .response( + Response(ok: false, error: .targetAppUnavailable(bundleId: requestedBundleId)) + ) + } + applyInteractionStabilizationIfNeeded() + } + } + return .context(ActiveCommandContext(app: activeApp, systemSurface: systemSurface)) + } + + /// A registered system surface host that is genuinely on screen, or nil. Presence is foreground + /// state, not tree content: a torn-down host still serves a rich tree, and it can only be + /// foreground-with-a-stale-tree if something activated it, which the open guard refuses. `state` + /// never activates and is cheap when the host is absent. See docs/adr/0004. + private func presentedSystemSurfaceHost() -> (host: SystemSurfaceHost, app: XCUIApplication)? { +#if os(iOS) + for host in SystemSurfaceHostRegistry.hosts { + let candidate = XCUIApplication(bundleIdentifier: host.bundleId) + if candidate.state == .runningForeground { + return (host, candidate) + } + } + return nil +#else + return nil +#endif + } + + func currentXCTestFailureCount() -> Int { + return testRun?.failureCount ?? 0 + } + + func didRecordXCTestFailure(since failureCountBefore: Int) -> Bool { + return currentXCTestFailureCount() > failureCountBefore + } + + 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." + ) + ) + } + + func shouldSkipAppActivationPreflight(_ command: Command) -> Bool { +#if os(iOS) + if command.command == .alert { + return true + } + // A hardware Action Button press belongs to the system, not to the session app: the Shortcut or + // App Intent behind it is expected to run whether that app is foregrounded, backgrounded, or + // terminated, and activating first would foreground exactly what the press should leave alone. + // The press keeps its recorded-failure conversion, which `isLifecycle` would have removed + // (#2699, #2702 review). + if command.command == .actionButton { + 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 + } + + func shouldRouteToSpringboardBlockingSystemModal( + _ command: Command + ) -> Bool { +#if os(iOS) + guard isCoordinateOnlyTap(command) else { + return false + } + #if AGENT_DEVICE_RUNNER_UNIT_TESTS + if let override = blockingSystemModalPresenceOverrideForTesting { + return override + } + #endif + let probeDeadline = Date().addingTimeInterval(systemModalProbeBudget) + 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 + } + + 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) + } +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index c1aa92ca27..77bdd1a17f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -2,827 +2,6 @@ import XCTest import AgentDeviceSnapshotPresentation 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 { - withBoundedInteractionIdleTimeoutIfSupported(app, waits: .bothSkipped) { - 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. - 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. - 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) - } - - func executeAccepted(command: Command) throws -> Response { - commandJournal.start(command: command) - pendingTargetActivation = nil - do { - let response = try executeDispatched(command: command) - commandJournal.finish(command: command, response: response) - guard let fact = pendingTargetActivation else { return response } - // Stamped after `finish`, like the uptime anchor: a journal-replayed result carries no - // activation fact, because the command that paid for it is the one being replayed, not one - // that just repaired foreground (#2682). - pendingTargetActivation = nil - return response.stampingTargetActivation(fact) - } catch { - pendingTargetActivation = nil - 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()) - ) - } - - struct ActiveCommandContext { - let app: XCUIApplication - /// Set when `app` is a system surface served in place over the still-bound session app (#2438). - var systemSurface: SystemSurfaceHost? = nil - } - - enum ActiveCommandPreparation { - case response(Response) - case context(ActiveCommandContext) - } - - 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) - } - } - - 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 - // 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) - 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( - "command_execution", - timeout: max(0.001, deadline.timeIntervalSinceNow), - timeoutError: mainThreadExecutionTimeoutError - ) { - try self.executeOnMainSafely( - command: command, - alertDeadline: deadline, - routeToSpringboard: routeToSpringboard - ) - } - } - return try runMainThreadWork( - "command_execution", - timeout: mainThreadExecutionTimeout, - timeoutError: mainThreadExecutionTimeoutError - ) { - try self.executeOnMainSafely(command: command, routeToSpringboard: routeToSpringboard) - } - } - - // 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( - "recorded_failure_count", - 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 hasAbandonedMainThreadWork() { - NSLog( - "AGENT_DEVICE_RUNNER_DISPATCH_RECOVERY_SKIPPED_XCTEST_OCCUPIED command=%@", - command.command.rawValue - ) - return response - } - let recordedFailureResponse = try runMainThreadWork( - "recorded_failure_count", - timeout: mainThreadExecutionTimeout, - timeoutError: mainThreadExecutionTimeoutError - ) { - self.didRecordXCTestFailure(since: failureCountBefore) - ? self.xctestRecordedFailureResponse(command: command, response: response) - : nil - } - if let recordedFailureResponse { - try runMainThreadWork( - "target_invalidation", - 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( - "target_invalidation", - 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( - "command_preparation", - 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, - systemSurface: context.systemSurface - ) - } - } - - /// 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, - systemSurface: SystemSurfaceHost? - ) throws -> Response { - let options = Self.presentationOptions(from: command) - do { - var payload: DataPayload - if options.raw { - payload = try snapshotRaw(app: activeApp, options: options) - } else { - payload = try snapshotFast(app: activeApp, options: options) - } - if let systemSurface { - payload.systemSurface = SystemSurfaceProvenancePayload( - bundleId: systemSurface.bundleId, - kind: systemSurface.kind.rawValue - ) - } - 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 - ) - ) - } - } - - func setNeedsPostSnapshotInteractionDelay() { - guard !hasAbandonedMainThreadWork() else { - NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_SKIPPED_XCTEST_OCCUPIED") - return - } - do { - try runMainThreadWork( - "post_snapshot_delay_mark", - timeout: 1, - timeoutError: mainThreadExecutionTimeoutError - ) { - self.needsPostSnapshotInteractionDelay = true - } - } catch { - NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_FAILED=%@", String(describing: error)) - } - } - - func invalidateCachedTargetAfterSnapshotFailure() { - // Abandoned work ahead of this hop cannot be cancelled: queue the drop behind it without - // waiting, so the failed capture answers now and the next command still finds the target gone. - guard !hasAbandonedMainThreadWork() else { - NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_INVALIDATION_DEFERRED_XCTEST_OCCUPIED") - DispatchQueue.main.async { - self.invalidateCachedTarget(reason: "ax_snapshot_failure") - } - return - } - do { - try runMainThreadWork( - "target_invalidation", - 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 - guard let self else { return .failure(.unresolvedScreen) } - return self.captureRunnerFrameResult(app: activeApp) - } - activeRecording = recorder - return Response(ok: true, data: DataPayload(message: "recording started")) - } catch { - activeRecording = nil - return Response(ok: false, error: Self.recordingStartErrorPayload(for: error)) - } - 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 - ) - } - - func prepareActiveCommandContext( - command: Command, - routeToSpringboard: Bool = false - ) -> ActiveCommandPreparation { - var activeApp = currentApp ?? app - var systemSurface: SystemSurfaceHost? = nil - if routeToSpringboard { - activeApp = springboard - } else if shouldSkipAppActivationPreflight(command) { - activeApp = resolveAppWithoutActivation(command: command) - } else if let presented = presentedSystemSurfaceHost() { - // Serve and drive the presented surface IN PLACE: never activate it (that cancels what it - // presents) and never adopt it as the cached session target, so once it is gone the next - // command resolves back to the still-bound session app (#2438). - activeApp = presented.app - systemSurface = presented.host - if isInteractionCommand(command.command) { - applyInteractionStabilizationIfNeeded() - } - } 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 - ) - 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: .targetAppUnavailable(bundleId: bundleId))) - } - } else { - return .response(Response(ok: false, error: .targetAppUnavailable(bundleId: nil))) - } - } - - 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 - ) - if !skipInteractionExistenceWait && !activeApp.waitForExistence(timeout: 2) { - return .response( - Response(ok: false, error: .targetAppUnavailable(bundleId: requestedBundleId)) - ) - } - applyInteractionStabilizationIfNeeded() - } - } - return .context(ActiveCommandContext(app: activeApp, systemSurface: systemSurface)) - } - - /// A registered system surface host that is genuinely on screen, or nil. Presence is foreground - /// state, not tree content: a torn-down host still serves a rich tree, and it can only be - /// foreground-with-a-stale-tree if something activated it, which the open guard refuses. `state` - /// never activates and is cheap when the host is absent. See docs/adr/0004. - private func presentedSystemSurfaceHost() -> (host: SystemSurfaceHost, app: XCUIApplication)? { -#if os(iOS) - for host in SystemSurfaceHostRegistry.hosts { - let candidate = XCUIApplication(bundleIdentifier: host.bundleId) - if candidate.state == .runningForeground { - return (host, candidate) - } - } - return nil -#else - return nil -#endif - } - func executeOnMainPrepared( command: Command, activeApp: XCUIApplication, @@ -1456,528 +635,4 @@ extension RunnerTests { 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, - message: message, - synthesizedContext: context, - synthesized: (profile: scrollDragProfile(releaseBehavior: releaseBehavior), policyKind: .scroll) - ) -#else - return executeDragGesture( - activeApp: activeApp, - x: x, - y: y, - x2: x2, - y2: y2, - durationMs: durationMs, - message: message - ) -#endif - } - - /// Shared coordinate drag execution. Callers that pass `synthesized` take the iOS synthesized - /// lane with that profile and fallback policy; the rest perform an XCTest coordinate drag. - private func executeDragGesture( - activeApp: XCUIApplication, - x: Double, - y: Double, - x2: Double, - y2: Double, - durationMs: Double?, - message: String, - synthesizedContext: SynthesizedCoordinateContext? = nil, - synthesized: (profile: SynthesizedDragProfile, policyKind: SynthesizedGesturePolicyKind)? = nil - ) -> 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 let synthesized, let synthesizedResponse = executeSynthesizedDragGesture( - activeApp: activeApp, - x: x, - y: y, - x2: x2, - y2: y2, - durationMs: durationMs, - message: message, - context: synthesizedContext, - policyKind: synthesized.policyKind, - profile: synthesized.profile - ) { - 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 - ) - let holdDuration = synthesized == nil - ? coordinateDragHoldDuration() - : 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)) - } - - 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 - ) - } - - /// Adds the #2500 avoidance evidence to a scroll response. Only the frame resolver knows whether - /// it trimmed the swipe for a keyboard, and only `scroll` has this evidence to carry, so it is - /// attached where the frame was resolved rather than threaded through every gesture response. - /// The refusal a keyboard forces. It performs no gesture: swiping into the keys would leave the - /// surface where it was, which the daemon's no-progress fingerprint reads as a stuck container - /// (#2499) and an agent reads as a broken scroll. The TS owner maps the code to the - /// `scroll_keyboard_occludes_surface` reason and the "dismiss the keyboard" hint. - private func scrollKeyboardOccludedResponse( - direction: String, - keyboardMinY: Double, - visibleHeight: Double - ) -> Response { - return Response( - ok: false, - error: ErrorPayload( - code: ScrollViewportPolicy.occlusionRunnerCode, - message: String( - format: - "scroll %@ refused: the keyboard leaves %.0fpt of visible surface above it, too little to swipe", - direction, - visibleHeight - ) - ) - ) - } - - 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 - } - - 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." - ) - ) - } - - func shouldSkipAppActivationPreflight(_ command: Command) -> Bool { -#if os(iOS) - if command.command == .alert { - return true - } - // A hardware Action Button press belongs to the system, not to the session app: the Shortcut or - // App Intent behind it is expected to run whether that app is foregrounded, backgrounded, or - // terminated, and activating first would foreground exactly what the press should leave alone. - // The press keeps its recorded-failure conversion, which `isLifecycle` would have removed - // (#2699, #2702 review). - if command.command == .actionButton { - 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 - } - - func shouldRouteToSpringboardBlockingSystemModal( - _ command: Command - ) -> Bool { -#if os(iOS) - guard isCoordinateOnlyTap(command) else { - return false - } - #if AGENT_DEVICE_RUNNER_UNIT_TESTS - if let override = blockingSystemModalPresenceOverrideForTesting { - return override - } - #endif - let probeDeadline = Date().addingTimeInterval(systemModalProbeBudget) - 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? - // The shared runtime has already resolved this node as non-hittable and - // deliberately selected Maestro's coordinate compatibility route. - let maestroNonHittableCoordinateFallbackUsed: Bool? = - command.allowNonHittableCoordinateFallback == true && command.x != nil && command.y != nil - ? true - : nil - 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 { - 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 - ) - ) - } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+GestureExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+GestureExecution.swift new file mode 100644 index 0000000000..46d14d17f7 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+GestureExecution.swift @@ -0,0 +1,165 @@ +import XCTest +import AgentDeviceSnapshotPresentation + +extension RunnerTests { + func currentUptimeMs() -> Double { + ProcessInfo.processInfo.systemUptime * 1000 + } + + 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? + } + + 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 { + withBoundedInteractionIdleTimeoutIfSupported(app, waits: .bothSkipped) { + 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. + 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. + 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 + ) + ) + } + + 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) + } +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollDragExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollDragExecution.swift new file mode 100644 index 0000000000..022a23d61c --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollDragExecution.swift @@ -0,0 +1,287 @@ +import XCTest +import AgentDeviceSnapshotPresentation + +extension RunnerTests { + func invalidScrollDirectionResponse(commandName: String) -> Response { + Response( + ok: false, + error: ErrorPayload( + code: "INVALID_ARGS", + message: "\(commandName) requires direction up|down|left|right" + ) + ) + } + + func scrollDurationIsValid(_ durationMs: Double?) -> Bool { + guard let durationMs else { return true } + return durationMs.isFinite && durationMs >= 0 && durationMs <= 10000 + } + + func invalidScrollDurationResponse(commandName: String) -> Response { + return Response( + ok: false, + error: ErrorPayload( + code: "INVALID_ARGS", + message: "\(commandName) durationMs must be between 0 and 10000" + ) + ) + } + + 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, + message: message, + synthesizedContext: context, + synthesized: (profile: scrollDragProfile(releaseBehavior: releaseBehavior), policyKind: .scroll) + ) +#else + return executeDragGesture( + activeApp: activeApp, + x: x, + y: y, + x2: x2, + y2: y2, + durationMs: durationMs, + message: message + ) +#endif + } + + /// Shared coordinate drag execution. Callers that pass `synthesized` take the iOS synthesized + /// lane with that profile and fallback policy; the rest perform an XCTest coordinate drag. + func executeDragGesture( + activeApp: XCUIApplication, + x: Double, + y: Double, + x2: Double, + y2: Double, + durationMs: Double?, + message: String, + synthesizedContext: SynthesizedCoordinateContext? = nil, + synthesized: (profile: SynthesizedDragProfile, policyKind: SynthesizedGesturePolicyKind)? = nil + ) -> 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 let synthesized, let synthesizedResponse = executeSynthesizedDragGesture( + activeApp: activeApp, + x: x, + y: y, + x2: x2, + y2: y2, + durationMs: durationMs, + message: message, + context: synthesizedContext, + policyKind: synthesized.policyKind, + profile: synthesized.profile + ) { + 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 + ) + let holdDuration = synthesized == nil + ? coordinateDragHoldDuration() + : 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)) + } + + 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 + ) + } + + /// Adds the #2500 avoidance evidence to a scroll response. Only the frame resolver knows whether + /// it trimmed the swipe for a keyboard, and only `scroll` has this evidence to carry, so it is + /// attached where the frame was resolved rather than threaded through every gesture response. + /// The refusal a keyboard forces. It performs no gesture: swiping into the keys would leave the + /// surface where it was, which the daemon's no-progress fingerprint reads as a stuck container + /// (#2499) and an agent reads as a broken scroll. The TS owner maps the code to the + /// `scroll_keyboard_occludes_surface` reason and the "dismiss the keyboard" hint. + func scrollKeyboardOccludedResponse( + direction: String, + keyboardMinY: Double, + visibleHeight: Double + ) -> Response { + return Response( + ok: false, + error: ErrorPayload( + code: ScrollViewportPolicy.occlusionRunnerCode, + message: String( + format: + "scroll %@ refused: the keyboard leaves %.0fpt of visible surface above it, too little to swipe", + direction, + visibleHeight + ) + ) + ) + } + + private func dragCommandName(message: String) -> String { + return message == "scrolled" ? "scroll" : "drag" + } +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift new file mode 100644 index 0000000000..d17d79eeba --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift @@ -0,0 +1,151 @@ +import XCTest +import AgentDeviceSnapshotPresentation + +extension RunnerTests { + func executeSnapshotDispatched(command: Command) throws -> Response { + try executeDispatchedWithRecovery(command: command) { + try self.executeSnapshotDispatchedOnce(command: command) + } + } + + private func executeSnapshotDispatchedOnce(command: Command) throws -> Response { + let preparation = try runMainThreadWork( + "command_preparation", + 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, + systemSurface: context.systemSurface + ) + } + } + + 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 + } + + /// 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, + systemSurface: SystemSurfaceHost? + ) throws -> Response { + let options = Self.presentationOptions(from: command) + do { + var payload: DataPayload + if options.raw { + payload = try snapshotRaw(app: activeApp, options: options) + } else { + payload = try snapshotFast(app: activeApp, options: options) + } + if let systemSurface { + payload.systemSurface = SystemSurfaceProvenancePayload( + bundleId: systemSurface.bundleId, + kind: systemSurface.kind.rawValue + ) + } + 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 + ) + ) + } + } + + func setNeedsPostSnapshotInteractionDelay() { + guard !hasAbandonedMainThreadWork() else { + NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_SKIPPED_XCTEST_OCCUPIED") + return + } + do { + try runMainThreadWork( + "post_snapshot_delay_mark", + timeout: 1, + timeoutError: mainThreadExecutionTimeoutError + ) { + self.needsPostSnapshotInteractionDelay = true + } + } catch { + NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_FAILED=%@", String(describing: error)) + } + } + + func invalidateCachedTargetAfterSnapshotFailure() { + // Abandoned work ahead of this hop cannot be cancelled: queue the drop behind it without + // waiting, so the failed capture answers now and the next command still finds the target gone. + guard !hasAbandonedMainThreadWork() else { + NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_INVALIDATION_DEFERRED_XCTEST_OCCUPIED") + DispatchQueue.main.async { + self.invalidateCachedTarget(reason: "ax_snapshot_failure") + } + return + } + do { + try runMainThreadWork( + "target_invalidation", + timeout: 1, + timeoutError: mainThreadExecutionTimeoutError + ) { + self.invalidateCachedTarget(reason: "ax_snapshot_failure") + } + } catch { + NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_INVALIDATION_FAILED=%@", String(describing: error)) + } + } +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift new file mode 100644 index 0000000000..2150047869 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift @@ -0,0 +1,142 @@ +import XCTest +import AgentDeviceSnapshotPresentation + +extension RunnerTests { + 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? + // The shared runtime has already resolved this node as non-hittable and + // deliberately selected Maestro's coordinate compatibility route. + let maestroNonHittableCoordinateFallbackUsed: Bool? = + command.allowNonHittableCoordinateFallback == true && command.x != nil && command.y != nil + ? true + : nil + 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 { + 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 + ) + ) + } +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift new file mode 100644 index 0000000000..dee860248e --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift @@ -0,0 +1,409 @@ +import XCTest +import AgentDeviceSnapshotPresentation + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +extension RunnerTests { + 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 testXCTestRecordedFailureResponseFailsActionButtonSuccess() throws { + // The Action Button press carries no settle and no post-action observation, so this conversion is + // the only evidence the press landed. That is why the press is not classified runner-lifecycle: + // `isLifecycle` would silence the conversion here (#2699, #2702 review). + let command = try runnerCommandFixture(#"{"command":"actionButton","commandId":"action-button-1"}"#) + let response = Response(ok: true, data: DataPayload(message: "actionButton")) + + 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 actionButton; 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 testActionButtonPressSkipsAppActivationPreflightWithoutBeingRunnerLifecycle() throws { + currentApp = nil + currentBundleId = nil + let press = try runnerCommandFixture(#"{"command":"actionButton","commandId":"action-button-1"}"#) + + // The skip is its own decision, reached without the lifecycle flag that would also drop the + // recorded-failure conversion; no cached target and no foreground app is required for it. + XCTAssertFalse(isRunnerLifecycleCommand(.actionButton)) + XCTAssertTrue(shouldSkipAppActivationPreflight(press)) + } + + 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.hasAbandonedMainThreadWork() + + // 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.hasAbandonedMainThreadWork(), Date() < deadline { + self.sleepFor(0.002) + } + box.drained = !self.hasAbandonedMainThreadWork() + 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(hasAbandonedMainThreadWork()) + } + + 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}"# + ) + 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 testDispatchReturnsBusyBeforeQueueingMainThreadWork() throws { + let command = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-busy"}"#) + abandonedMainThreadWorkCount = 1 + abandonedMainThreadWorkSince = Date(timeIntervalSinceNow: -2) + defer { + abandonedMainThreadWorkCount = 0 + abandonedMainThreadWorkSince = nil + } + + let response = try execute(command: command) + + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, "RUNNER_BUSY") + XCTAssertTrue(response.error?.message.contains("previous command") == true) + } + + func testDispatchReturnsWedgedBeforeQueueingMainThreadWork() 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 execute(command: command) + + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, "RUNNER_WEDGED") + XCTAssertTrue(response.error?.hint?.contains("runner session will be restarted") == true) + } + + func testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied() { + // The #1244 recovery shape: the modal probe abandoned an XCTest query that is still grinding on + // main, the capture recovered independently, and its response is ready. The recovery loop must + // return it without re-entering the main queue for recorded-failure/retry bookkeeping (that hop + // would block behind the abandoned query and re-stall the command), and a later command must + // still see the runner busy until the abandoned work drains. Removing the guard regresses this. + let command = try! JSONDecoder().decode( + Command.self, + from: Data(#"{"command":"snapshot","commandId":"recovery-guard"}"#.utf8) + ) + let recovered = Response(ok: false, error: .targetAppUnavailable(bundleId: nil)) + + setAbandonedMainThreadWork(1) + defer { setAbandonedMainThreadWork(0) } + guard case .busy = currentMainThreadBusyState() else { + return XCTFail("expected RUNNER_BUSY while abandoned XCTest work is outstanding") + } + + var occupiedCalls = 0 + let occupied = try! executeDispatchedWithRecovery(command: command) { + occupiedCalls += 1 + return recovered + } + XCTAssertEqual(occupiedCalls, 1, "recovered response must not retry behind abandoned XCTest work") + XCTAssertEqual(occupied.ok, false) + + setAbandonedMainThreadWork(0) + guard case .idle = currentMainThreadBusyState() else { + return XCTFail("runner should be idle once the abandoned work drained") + } + var drainedCalls = 0 + _ = try! executeDispatchedWithRecovery(command: command) { + drainedCalls += 1 + return recovered + } + XCTAssertEqual(drainedCalls, 2, "with the channel free the read-only retry runs once") + } + + private func setAbandonedMainThreadWork(_ count: Int) { + mainThreadWorkLock.lock() + abandonedMainThreadWorkCount = count + abandonedMainThreadWorkSince = count > 0 ? Date(timeIntervalSinceNow: -1) : nil + mainThreadWorkLock.unlock() + } +} +#endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift index b763b9e07e..33d3a82a25 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift @@ -4,21 +4,6 @@ import AgentDeviceSnapshotPresentation #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) import ObjectiveC.runtime -private final class RunnerSynthesizedSwipeFailureStub: NSObject { - @objc(synthesizeSwipeWithApplication:resolvedWindow:x:y:x2:y2:durationMs:) - class func synthesizeSwipe( - application: XCUIApplication, - resolvedWindow: Any?, - x: Double, - y: Double, - x2: Double, - y2: Double, - durationMs: Double - ) -> String? { - "forced private synthesis failure" - } -} - private final class RunnerSynthesizedTapFailureStub: NSObject { @objc(synthesizeTapWithApplication:resolvedWindow:x:y:) class func synthesizeTap(application: XCUIApplication, resolvedWindow: Any?, x: Double, y: Double) -> String? { @@ -29,119 +14,7 @@ private final class RunnerSynthesizedTapFailureStub: NSObject { #if AGENT_DEVICE_RUNNER_UNIT_TESTS extension RunnerTests { - 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:resolvedWindow: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:resolvedWindow:x:y:") guard @@ -232,473 +105,5 @@ extension RunnerTests { ) } #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 testXCTestRecordedFailureResponseFailsActionButtonSuccess() throws { - // The Action Button press carries no settle and no post-action observation, so this conversion is - // the only evidence the press landed. That is why the press is not classified runner-lifecycle: - // `isLifecycle` would silence the conversion here (#2699, #2702 review). - let command = try runnerCommandFixture(#"{"command":"actionButton","commandId":"action-button-1"}"#) - let response = Response(ok: true, data: DataPayload(message: "actionButton")) - - 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 actionButton; 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 testActionButtonPressSkipsAppActivationPreflightWithoutBeingRunnerLifecycle() throws { - currentApp = nil - currentBundleId = nil - let press = try runnerCommandFixture(#"{"command":"actionButton","commandId":"action-button-1"}"#) - - // The skip is its own decision, reached without the lifecycle flag that would also drop the - // recorded-failure conversion; no cached target and no foreground app is required for it. - XCTAssertFalse(isRunnerLifecycleCommand(.actionButton)) - XCTAssertTrue(shouldSkipAppActivationPreflight(press)) - } - - 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.hasAbandonedMainThreadWork() - - // 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.hasAbandonedMainThreadWork(), Date() < deadline { - self.sleepFor(0.002) - } - box.drained = !self.hasAbandonedMainThreadWork() - 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(hasAbandonedMainThreadWork()) - } - - 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}"# - ) - 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 testDispatchReturnsBusyBeforeQueueingMainThreadWork() throws { - let command = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-busy"}"#) - abandonedMainThreadWorkCount = 1 - abandonedMainThreadWorkSince = Date(timeIntervalSinceNow: -2) - defer { - abandonedMainThreadWorkCount = 0 - abandonedMainThreadWorkSince = nil - } - - let response = try execute(command: command) - - XCTAssertFalse(response.ok) - XCTAssertEqual(response.error?.code, "RUNNER_BUSY") - XCTAssertTrue(response.error?.message.contains("previous command") == true) - } - - func testDispatchReturnsWedgedBeforeQueueingMainThreadWork() 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 execute(command: command) - - XCTAssertFalse(response.ok) - XCTAssertEqual(response.error?.code, "RUNNER_WEDGED") - XCTAssertTrue(response.error?.hint?.contains("runner session will be restarted") == true) - } - - func testPostSnapshotDelayMarkDoesNotQueueBehindAbandonedMainThreadWork() { - abandonedMainThreadWorkCount = 1 - defer { - abandonedMainThreadWorkCount = 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, 1, "the skipped mark must not add an abandoned unit") - XCTAssertFalse(needsPostSnapshotInteractionDelay) - } - - func testSnapshotFailureInvalidationQueuesBehindAbandonedMainThreadWorkWithoutWaiting() { - currentBundleId = "com.example.stale-target" - defer { currentBundleId = nil } - - final class ResultBox { - var elapsed: TimeInterval? - var bundleStillCachedWhileBlocked: Bool? - var abandonedWhileBlocked: Int? - } - let box = ResultBox() - let mainBlocked = DispatchSemaphore(value: 0) - let releaseMain = DispatchSemaphore(value: 0) - let finished = expectation(description: "invalidation returned while main was blocked") - - DispatchQueue(label: "agent-device.runner.tests.snapshot-invalidation").async { - _ = try? self.runMainThreadWork( - "command_execution", - timeout: 0, - timeoutError: self.mainThreadExecutionTimeoutError - ) { - mainBlocked.signal() - _ = releaseMain.wait(timeout: .now() + 5) - return true - } - _ = mainBlocked.wait(timeout: .now() + 2) - let startedAt = Date() - self.invalidateCachedTargetAfterSnapshotFailure() - box.elapsed = Date().timeIntervalSince(startedAt) - box.bundleStillCachedWhileBlocked = self.currentBundleId != nil - self.mainThreadWorkLock.lock() - box.abandonedWhileBlocked = self.abandonedMainThreadWorkCount - self.mainThreadWorkLock.unlock() - releaseMain.signal() - finished.fulfill() - } - - wait(for: [finished], timeout: 8) - let drainDeadline = Date().addingTimeInterval(2) - while hasAbandonedMainThreadWork() || currentBundleId != nil, Date() < drainDeadline { - sleepFor(0.005) - } - - XCTAssertLessThan( - box.elapsed ?? .infinity, - 0.5, - "the failed capture must not wait behind abandoned main-thread work" - ) - XCTAssertEqual( - box.bundleStillCachedWhileBlocked, - true, - "the drop must queue behind the blocked main thread, not run early" - ) - XCTAssertEqual(box.abandonedWhileBlocked, 1, "the deferred drop must not add an abandoned unit") - XCTAssertFalse(hasAbandonedMainThreadWork()) - XCTAssertNil(currentBundleId, "the drop must run once the main thread frees") - } - - /// Routes `command` through the transport's inline and queued paths. The calling test's main - /// thread serves the command's main-thread work while it waits. - func execute(command: Command) throws -> Response { - dispatchPrecondition(condition: .onQueue(.main)) - if let response = inlineResponse(for: command) { - return response - } - final class ResultBox { - var result: Result? - } - let box = ResultBox() - let executed = XCTestExpectation(description: "\(command.command.rawValue) executed off main") - enqueueAccepted(command: command) { result in - box.result = result - executed.fulfill() - } - guard XCTWaiter.wait(for: [executed], timeout: mainThreadExecutionTimeout + 5) == .completed, - let result = box.result - else { - throw NSError( - domain: RunnerErrorDomain.general, - code: RunnerErrorCode.commandReturnedNoResponse, - userInfo: [NSLocalizedDescriptionKey: "command did not finish on the command queue"] - ) - } - return try result.get() - } - - func runnerCommandFixture(_ json: String) throws -> Command { - try JSONDecoder().decode(Command.self, from: Data(json.utf8)) - } } #endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+GestureExecutionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+GestureExecutionTests.swift new file mode 100644 index 0000000000..d5c517cf45 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+GestureExecutionTests.swift @@ -0,0 +1,73 @@ +import XCTest +import AgentDeviceSnapshotPresentation + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +extension RunnerTests { + 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) + } +} +#endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift index d0079bf728..eab82171dc 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift @@ -28,5 +28,9 @@ extension RunnerTests { "runner app is not available" ) } + + func runnerCommandFixture(_ json: String) throws -> Command { + try JSONDecoder().decode(Command.self, from: Data(json.utf8)) + } } #endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScrollDragExecutionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScrollDragExecutionTests.swift new file mode 100644 index 0000000000..440f19fe90 --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScrollDragExecutionTests.swift @@ -0,0 +1,72 @@ +import XCTest +import AgentDeviceSnapshotPresentation + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) +import ObjectiveC.runtime + +private final class RunnerSynthesizedSwipeFailureStub: NSObject { + @objc(synthesizeSwipeWithApplication:resolvedWindow:x:y:x2:y2:durationMs:) + class func synthesizeSwipe( + application: XCUIApplication, + resolvedWindow: Any?, + x: Double, + y: Double, + x2: Double, + y2: Double, + durationMs: Double + ) -> String? { + "forced private synthesis failure" + } +} +#endif + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +extension RunnerTests { +#if os(iOS) + func testSinglePointerFlingFallsBackToXCTestCoordinateDragWhenPrivateSynthesisFails() throws { + let selector = NSSelectorFromString( + "synthesizeSwipeWithApplication:resolvedWindow: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) + } +#endif +} +#endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotExecutionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotExecutionTests.swift new file mode 100644 index 0000000000..fa637a853b --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotExecutionTests.swift @@ -0,0 +1,84 @@ +import XCTest +import AgentDeviceSnapshotPresentation + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS +extension RunnerTests { + func testPostSnapshotDelayMarkDoesNotQueueBehindAbandonedMainThreadWork() { + abandonedMainThreadWorkCount = 1 + defer { + abandonedMainThreadWorkCount = 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, 1, "the skipped mark must not add an abandoned unit") + XCTAssertFalse(needsPostSnapshotInteractionDelay) + } + + func testSnapshotFailureInvalidationQueuesBehindAbandonedMainThreadWorkWithoutWaiting() { + currentBundleId = "com.example.stale-target" + defer { currentBundleId = nil } + + final class ResultBox { + var elapsed: TimeInterval? + var bundleStillCachedWhileBlocked: Bool? + var abandonedWhileBlocked: Int? + } + let box = ResultBox() + let mainBlocked = DispatchSemaphore(value: 0) + let releaseMain = DispatchSemaphore(value: 0) + let finished = expectation(description: "invalidation returned while main was blocked") + + DispatchQueue(label: "agent-device.runner.tests.snapshot-invalidation").async { + _ = try? self.runMainThreadWork( + "command_execution", + timeout: 0, + timeoutError: self.mainThreadExecutionTimeoutError + ) { + mainBlocked.signal() + _ = releaseMain.wait(timeout: .now() + 5) + return true + } + _ = mainBlocked.wait(timeout: .now() + 2) + let startedAt = Date() + self.invalidateCachedTargetAfterSnapshotFailure() + box.elapsed = Date().timeIntervalSince(startedAt) + box.bundleStillCachedWhileBlocked = self.currentBundleId != nil + self.mainThreadWorkLock.lock() + box.abandonedWhileBlocked = self.abandonedMainThreadWorkCount + self.mainThreadWorkLock.unlock() + releaseMain.signal() + finished.fulfill() + } + + wait(for: [finished], timeout: 8) + let drainDeadline = Date().addingTimeInterval(2) + while hasAbandonedMainThreadWork() || currentBundleId != nil, Date() < drainDeadline { + sleepFor(0.005) + } + + XCTAssertLessThan( + box.elapsed ?? .infinity, + 0.5, + "the failed capture must not wait behind abandoned main-thread work" + ) + XCTAssertEqual( + box.bundleStillCachedWhileBlocked, + true, + "the drop must queue behind the blocked main thread, not run early" + ) + XCTAssertEqual(box.abandonedWhileBlocked, 1, "the deferred drop must not add an abandoned unit") + XCTAssertFalse(hasAbandonedMainThreadWork()) + XCTAssertNil(currentBundleId, "the drop must run once the main thread frees") + } +} +#endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTests.swift index 527da828fb..83ac2f5a00 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTests.swift @@ -196,50 +196,5 @@ extension RunnerTests { } } #endif - - func testDispatchRecoverySkipsBookkeepingWhileXCTestChannelOccupied() { - // The #1244 recovery shape: the modal probe abandoned an XCTest query that is still grinding on - // main, the capture recovered independently, and its response is ready. The recovery loop must - // return it without re-entering the main queue for recorded-failure/retry bookkeeping (that hop - // would block behind the abandoned query and re-stall the command), and a later command must - // still see the runner busy until the abandoned work drains. Removing the guard regresses this. - let command = try! JSONDecoder().decode( - Command.self, - from: Data(#"{"command":"snapshot","commandId":"recovery-guard"}"#.utf8) - ) - let recovered = Response(ok: false, error: .targetAppUnavailable(bundleId: nil)) - - setAbandonedMainThreadWork(1) - defer { setAbandonedMainThreadWork(0) } - guard case .busy = currentMainThreadBusyState() else { - return XCTFail("expected RUNNER_BUSY while abandoned XCTest work is outstanding") - } - - var occupiedCalls = 0 - let occupied = try! executeDispatchedWithRecovery(command: command) { - occupiedCalls += 1 - return recovered - } - XCTAssertEqual(occupiedCalls, 1, "recovered response must not retry behind abandoned XCTest work") - XCTAssertEqual(occupied.ok, false) - - setAbandonedMainThreadWork(0) - guard case .idle = currentMainThreadBusyState() else { - return XCTFail("runner should be idle once the abandoned work drained") - } - var drainedCalls = 0 - _ = try! executeDispatchedWithRecovery(command: command) { - drainedCalls += 1 - return recovered - } - XCTAssertEqual(drainedCalls, 2, "with the channel free the read-only retry runs once") - } - - private func setAbandonedMainThreadWork(_ count: Int) { - mainThreadWorkLock.lock() - abandonedMainThreadWorkCount = count - abandonedMainThreadWorkSince = count > 0 ? Date(timeIntervalSinceNow: -1) : nil - mainThreadWorkLock.unlock() - } } #endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift index 42c0f53875..ffa3d56d36 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift @@ -39,5 +39,33 @@ extension RunnerTests { XCTAssertFalse(inFlightCommandIds.contains("snapshot-coalesce")) XCTAssertNil(inFlightCommandWaiters["snapshot-coalesce"]) } + + /// Routes `command` through the transport's inline and queued paths. The calling test's main + /// thread serves the command's main-thread work while it waits. + func execute(command: Command) throws -> Response { + dispatchPrecondition(condition: .onQueue(.main)) + if let response = inlineResponse(for: command) { + return response + } + final class ResultBox { + var result: Result? + } + let box = ResultBox() + let executed = XCTestExpectation(description: "\(command.command.rawValue) executed off main") + enqueueAccepted(command: command) { result in + box.result = result + executed.fulfill() + } + guard XCTWaiter.wait(for: [executed], timeout: mainThreadExecutionTimeout + 5) == .completed, + let result = box.result + else { + throw NSError( + domain: RunnerErrorDomain.general, + code: RunnerErrorCode.commandReturnedNoResponse, + userInfo: [NSLocalizedDescriptionKey: "command did not finish on the command queue"] + ) + } + return try result.get() + } } #endif diff --git a/apple/runner/README.md b/apple/runner/README.md index d899f9cfce..8c087f4a61 100644 --- a/apple/runner/README.md +++ b/apple/runner/README.md @@ -26,7 +26,12 @@ Protocol and maintenance references: - `RunnerTests+Models.swift`: wire protocol models (`Command`, `Response`, snapshot payload models). - `RunnerTests+Environment.swift`: environment and CLI argument helpers (`RunnerEnv`). - `RunnerTests+Transport.swift`: TCP request handling and HTTP parsing/encoding. -- `RunnerTests+CommandExecution.swift`: command dispatch (`execute*`) and command switch. +- `RunnerTests+CommandDispatch.swift`: the dispatch entry (`executeAccepted`, `executeDispatched`), + its recovery loops, target preparation, and recorded-failure conversion. +- `RunnerTests+CommandExecution.swift`: the prepared-command switch (`executeOnMainPrepared`). +- `RunnerTests+GestureExecution.swift`, `RunnerTests+ScrollDragExecution.swift`, + `RunnerTests+TypeExecution.swift`, `RunnerTests+SnapshotExecution.swift`: per-family command + execution. - `RunnerTests+Lifecycle.swift`: activation/retry/stabilization and recording lifecycle helpers. - `RunnerTests+Interaction.swift`: tap/drag/swipe/type/home/rotate/app-switcher helpers. - `RunnerTests+Navigation.swift`: back/navigation-control helpers.