diff --git a/.github/actions/setup-apple-runner-build/action.yml b/.github/actions/setup-apple-runner-build/action.yml index f2aba5b093..acb36d6910 100644 --- a/.github/actions/setup-apple-runner-build/action.yml +++ b/.github/actions/setup-apple-runner-build/action.yml @@ -44,7 +44,7 @@ runs: id: source-hash run: | set -euo pipefail - echo "value=${{ hashFiles('apple/runner/**', 'apple/snapshot-presentation/**', 'scripts/build-xcuitest-apple.sh', 'scripts/swift-toolchain-tmpdir.ts', 'scripts/write-xcuitest-cache-metadata.mjs', '.github/actions/setup-apple-runner-build/action.yml') }}" >> "$GITHUB_OUTPUT" + echo "value=${{ hashFiles('apple/runner/**', 'apple/snapshot-presentation/**', 'scripts/build-xcuitest-apple.sh', 'scripts/runner-isolation-diagnostics.ts', 'scripts/swift-toolchain-tmpdir.ts', 'scripts/write-xcuitest-cache-metadata.mjs', '.github/actions/setup-apple-runner-build/action.yml') }}" >> "$GITHUB_OUTPUT" shell: bash - name: Resolve Apple runner build variant diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerIsolationCanary.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerIsolationCanary.swift new file mode 100644 index 0000000000..5e1ee97c4a --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerIsolationCanary.swift @@ -0,0 +1,38 @@ +#if AGENT_DEVICE_RUNNER_ISOLATION_CANARY +import Foundation + +/// Positive control for `scripts/runner-isolation-diagnostics.ts`. `scripts/build-xcuitest-apple.sh` +/// compiles this file into every runner build it scans, with the runner's own flags, and the scan +/// fails unless it reports a diagnostic on every line marked `isolation-canary`: a Swift release +/// that rewords or regroups one of these diagnostics fails the gate instead of passing it. Nothing +/// calls these functions, and the npm package does not ship this file. +enum RunnerIsolationCanary { + private final class Counter { + var value = 0 + } + + static func readsMainOwnedStateOffMain(_ state: RunnerMainOwnedState) { + DispatchQueue.global().async { + _ = state.bundleId // isolation-canary + } + } + + static func callsMainActorClosureOffMain(_ work: @escaping @MainActor () -> Void) { + DispatchQueue.global().async { + work() // isolation-canary + } + } + + static func dropsMainActor(_ work: @escaping @MainActor () -> Void) -> @Sendable () -> Void { + work // isolation-canary + } + + @MainActor + static func sendsMainFormedStateOffMain() { + let counter = Counter() + DispatchQueue.global().async { + counter.value += 1 // isolation-canary + } + } +} +#endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerMainOwnedState.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerMainOwnedState.swift new file mode 100644 index 0000000000..6c26c2077b --- /dev/null +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerMainOwnedState.swift @@ -0,0 +1,27 @@ +import XCTest + +/// Runner state only the main thread reads or writes. Off-main code reads target identity from a +/// `SnapshotCaptureTarget` taken on main and writes through `applyMainOwnedSnapshotState`. +@MainActor +final class RunnerMainOwnedState { + var app: XCUIApplication? + var bundleId: String? + var processIdentifier: Int? + var accessibilityHealth: RunnerAccessibilityHealth = .unknown + var needsPostSnapshotInteractionDelay = false + + nonisolated init() {} +} + +/// The runner's one entry into main-actor isolation from code that is on the main thread without +/// being statically isolated: the main hops of `runMainThreadWork` and `applyMainOwnedSnapshotState`, +/// and the blocks XCTest calls back. `MainActor.assumeIsolated` traps when the caller is off main. +/// It returns only `Sendable` values, so the result leaves through a captured `Result`: a +/// `T: Sendable` bound would promise something no gate checks. +func runOnMainActor(_ work: @MainActor () throws -> T) -> Result { + var result: Result? + MainActor.assumeIsolated { + result = Result { try work() } + } + return result! +} diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift index feb47f6d98..3f74388fd9 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Alert.swift @@ -21,6 +21,7 @@ extension RunnerTests { return max(0.001, timeoutMs / 1000) } + @MainActor func resolveAlert(app activeApp: XCUIApplication, deadline: Date) -> RunnerAlert? { #if AGENT_DEVICE_RUNNER_UNIT_TESTS if let override = alertResolutionOverrideForTesting { @@ -50,6 +51,7 @@ extension RunnerTests { return nil } + @MainActor func handleAlert(_ alert: RunnerAlert, action: String, deadline: Date) -> Response { if action == "accept" || action == "dismiss" { guard let button = chooseAlertButton(alert.buttons, action: action) else { @@ -121,6 +123,7 @@ extension RunnerTests { ) } + @MainActor func activateAlertButton( _ alert: RunnerAlert, button: XCUIElement, @@ -296,6 +299,7 @@ extension RunnerTests { // for a fresh hittable read instead of spending it on a dropped tap. The hittable read // is itself a synchronous query a starved host can complete past the deadline, so a read // that lands late forfeits rather than buys back the one activation. + @MainActor private func waitUntilAlertButtonHittable(_ button: XCUIElement, deadline: Date) -> Bool { while Date() < deadline { if probeAlertButtonHittable(button, deadline: deadline) { @@ -306,6 +310,7 @@ extension RunnerTests { return false } + @MainActor private func probeAlertButtonHittable(_ button: XCUIElement, deadline: Date) -> Bool { #if AGENT_DEVICE_RUNNER_UNIT_TESTS if let override = alertButtonHittabilityProbeOverrideForTesting { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift index d412a1dfae..cc0cbaa84e 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -50,6 +50,7 @@ extension RunnerTests { /// The session app's `XCUIApplication.State` by name. A lifecycle read: the activation preflight /// is skipped, so `runningBackground` after `home` is reported rather than repaired away. + @MainActor func executeAppState(command: Command) -> Response { guard let bundleId = command.appBundleId?.trimmedNonEmpty else { return Response( @@ -165,7 +166,7 @@ extension RunnerTests { return try runMainThreadWork( "command_execution", timeout: max(0.001, deadline.timeIntervalSinceNow), - timeoutError: mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { try self.executeOnMainSafely( command: command, @@ -177,7 +178,7 @@ extension RunnerTests { return try runMainThreadWork( "command_execution", timeout: Self.mainThreadExecutionTimeout, - timeoutError: mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { try self.executeOnMainSafely(command: command, routeToSpringboard: routeToSpringboard) } @@ -185,6 +186,7 @@ extension RunnerTests { // MARK: - Command Handling + @MainActor private func executeOnMainSafely( command: Command, alertDeadline: Date? = nil, @@ -280,7 +282,7 @@ extension RunnerTests { let failureCountBefore = try runMainThreadWork( "recorded_failure_count", timeout: Self.mainThreadExecutionTimeout, - timeoutError: mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { self.currentXCTestFailureCount() } @@ -297,7 +299,7 @@ extension RunnerTests { let recordedFailureResponse = try runMainThreadWork( "recorded_failure_count", timeout: Self.mainThreadExecutionTimeout, - timeoutError: mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { self.didRecordXCTestFailure(since: failureCountBefore) ? self.xctestRecordedFailureResponse(command: command, response: response) @@ -307,7 +309,7 @@ extension RunnerTests { try runMainThreadWork( "target_invalidation", timeout: Self.mainThreadExecutionTimeout, - timeoutError: mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { self.invalidateCachedTarget(reason: "xctest_recorded_failure") } @@ -322,7 +324,7 @@ extension RunnerTests { try runMainThreadWork( "target_invalidation", timeout: Self.mainThreadExecutionTimeout, - timeoutError: mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { self.invalidateCachedTarget(reason: "response_unavailable") self.sleepFor(self.retryCooldown) @@ -333,6 +335,7 @@ extension RunnerTests { } } + @MainActor private func executeOnMain( command: Command, alertDeadline: Date?, @@ -438,7 +441,7 @@ extension RunnerTests { return Response(ok: false, error: ErrorPayload(message: "terminate requires appBundleId")) } XCUIApplication(bundleIdentifier: bundleId).terminate() - if currentBundleId == bundleId { + if mainOwned.bundleId == bundleId { invalidateCachedTarget(reason: "target_terminated") } return Response(ok: true, data: DataPayload(message: "app terminated")) @@ -455,6 +458,7 @@ extension RunnerTests { /// The target this command runs against, decided by its `launchPolicy` (#2890). Exhaustive over the /// policy so a new case is a compile error here rather than a fall-through that quietly launches or /// quietly refuses. + @MainActor func prepareActiveCommandContext( command: Command, routeToSpringboard: Bool = false @@ -494,6 +498,7 @@ extension RunnerTests { /// place, and otherwise the requested session app is resolved and activated. What happens to a /// stopped app is the caller's `launchPolicy`; the `.existingApp` refusal belongs to /// `notRunningRefusal` because it is only meaningful once nothing is presented (#2890). + @MainActor private func prepareActivatedTarget(command: Command) -> ActiveCommandPreparation { if let presented = presentedSystemSurfaceHost() { // Serve and drive the presented surface IN PLACE: never activate it (that cancels what it @@ -514,7 +519,7 @@ extension RunnerTests { return .response(notRunning) } if let bundleId = requestedBundleId { - if currentBundleId != bundleId || currentApp == nil { + if mainOwned.bundleId != bundleId || mainOwned.app == nil { _ = activateTarget(bundleId: bundleId, reason: "bundle_changed") } else { refreshCachedTargetIfProcessChanged(bundleId: bundleId) @@ -525,7 +530,7 @@ extension RunnerTests { } // Read back after the bundle resolution above, which is what may have just bound a target. - var activeApp = currentApp ?? app + var activeApp = mainOwned.app ?? app if let bundleId = requestedBundleId, targetNeedsActivation(activeApp) { activeApp = activateTarget(bundleId: bundleId, reason: "stale_target") } else if requestedBundleId == nil, targetNeedsActivation(activeApp) { @@ -616,6 +621,7 @@ extension RunnerTests { /// The one activation bypass that depends on the request rather than on the command: a tap that /// needs nothing the preflight would bring forward. Commands whose own classification answers /// without the session app's foreground state are handled by their `launchPolicy` (#2890). + @MainActor func shouldSkipAppActivationPreflight(_ command: Command) -> Bool { #if os(iOS) // Coordinate-only synthesized taps can run after an AX-fatal foreground screen because they do not @@ -664,25 +670,27 @@ extension RunnerTests { && command.y != nil } + @MainActor private func hasCachedTargetForActivationSkip(command: Command) -> Bool { - guard let currentApp, currentApp.state == .runningForeground else { return false } + guard let boundApp = mainOwned.app, boundApp.state == .runningForeground else { return false } guard let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleId.isEmpty else { return true } - return currentBundleId == bundleId + return mainOwned.bundleId == bundleId } + @MainActor func resolveAppWithoutActivation(command: Command) -> XCUIApplication { guard let bundleId = command.appBundleId? .trimmingCharacters(in: .whitespacesAndNewlines), !bundleId.isEmpty else { - return currentApp ?? app + return mainOwned.app ?? app } - if currentBundleId == bundleId, let currentApp { - return currentApp + if mainOwned.bundleId == bundleId, let boundApp = mainOwned.app { + return boundApp } return XCUIApplication(bundleIdentifier: bundleId) } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index b0a07980d3..e8389af402 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -2,6 +2,7 @@ import XCTest import AgentDeviceSnapshotPresentation extension RunnerTests { + @MainActor func executeOnMainPrepared( command: Command, activeApp: XCUIApplication, @@ -147,7 +148,7 @@ extension RunnerTests { } if let x = command.x, let y = command.y { let xCTestChannelPenalized = isSnapshotXCTestChannelPenalized( - bundleId: currentBundleId + bundleId: mainOwned.bundleId ) let xCTestTextInputProbeSkipped = !shouldProbeCoordinateTapTextInput( xCTestChannelPenalized: xCTestChannelPenalized @@ -160,7 +161,7 @@ extension RunnerTests { textInput = nil NSLog( "AGENT_DEVICE_RUNNER_COORDINATE_TAP_TEXT_INPUT_PROBE_SKIPPED bundle=%@", - currentBundleId ?? "" + mainOwned.bundleId ?? "" ) } var fallback: GestureFallback? diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+GestureExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+GestureExecution.swift index 7959acbf94..2d9e4b2395 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+GestureExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+GestureExecution.swift @@ -57,6 +57,7 @@ extension RunnerTests { /// /// 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. + @MainActor func performGesture( _ app: XCUIApplication, idleTimeout: Bool = true, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift index 8c1f78d028..757d2c7df6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift @@ -180,6 +180,7 @@ extension RunnerTests { // MARK: - Target Activation + @MainActor func ensureRunnerHostAppActive(reason: String) { NSLog( "AGENT_DEVICE_RUNNER_HOST_ACTIVATE state=%d reason=%@", @@ -191,9 +192,9 @@ extension RunnerTests { } else if app.state != .runningForeground { app.activate() } - currentApp = app - currentBundleId = nil - currentAppProcessIdentifier = nil + mainOwned.app = app + mainOwned.bundleId = nil + mainOwned.processIdentifier = nil resetTargetBoundState() } @@ -207,16 +208,18 @@ extension RunnerTests { lastLoggedGesturePolicyLines.removeAll() } + @MainActor func invalidateCachedTarget(reason: String) { - if currentApp != nil || currentBundleId != nil { + if mainOwned.app != nil || mainOwned.bundleId != nil { NSLog("AGENT_DEVICE_RUNNER_TARGET_CACHE_INVALIDATE reason=%@", reason) } - currentApp = nil - currentBundleId = nil - currentAppProcessIdentifier = nil + mainOwned.app = nil + mainOwned.bundleId = nil + mainOwned.processIdentifier = nil resetTargetBoundState() } + @MainActor func resetTargetAfterExternalRelaunch() -> Response { invalidateCachedTarget(reason: "external_app_relaunch") // The app process is replaced, but the retained runner survives. Clear @@ -228,22 +231,23 @@ extension RunnerTests { return Response(ok: true, data: DataPayload(message: "target reset")) } + @MainActor func refreshCachedTargetIfProcessChanged(bundleId: String) { - guard currentBundleId == bundleId, currentApp != nil else { return } + guard mainOwned.bundleId == bundleId, mainOwned.app != nil else { return } let candidate = XCUIApplication(bundleIdentifier: bundleId) let observedProcessIdentifier = Self.processIdentifier(of: candidate) guard Self.shouldRefreshCachedTarget( - cachedProcessIdentifier: currentAppProcessIdentifier, + cachedProcessIdentifier: mainOwned.processIdentifier, observedProcessIdentifier: observedProcessIdentifier ) else { return } NSLog( "AGENT_DEVICE_RUNNER_TARGET_CACHE_REFRESH bundle=%@ previousPid=%d currentPid=%d", bundleId, - currentAppProcessIdentifier ?? 0, + mainOwned.processIdentifier ?? 0, observedProcessIdentifier ?? 0 ) - currentApp = candidate - currentAppProcessIdentifier = observedProcessIdentifier + mainOwned.app = candidate + mainOwned.processIdentifier = observedProcessIdentifier resetTargetBoundState() clearSnapshotXCTestChannelPenalty(reason: "target_process_changed") clearPrivateAXAcceptedDepth(reason: "target_process_changed") @@ -280,11 +284,12 @@ extension RunnerTests { return false } + @MainActor func canUseFastForegroundAppGuard( activeApp: XCUIApplication, requestedBundleId: String? ) -> Bool { - guard let requestedBundleId, currentBundleId == requestedBundleId, currentApp != nil else { + guard let requestedBundleId, mainOwned.bundleId == requestedBundleId, mainOwned.app != nil else { return false } guard activeApp.state == .runningForeground else { return false } @@ -292,6 +297,7 @@ extension RunnerTests { return true } + @MainActor func writeFastAppGuardMarker(bundleId: String, state: XCUIApplication.State) { // The command is on the adjacent COMMAND_ACCEPTED line; repeating it here would make a deduped // marker read as if only that command ever passed the guard. @@ -342,6 +348,7 @@ extension RunnerTests { #endif } + @MainActor func activateTarget(bundleId: String, reason: String) -> XCUIApplication { let target = XCUIApplication(bundleIdentifier: bundleId) let initialState = target.state @@ -375,9 +382,9 @@ extension RunnerTests { otherActiveApplicationPid.map(String.init) ?? "-" ) } - currentApp = target - currentBundleId = bundleId - currentAppProcessIdentifier = Self.processIdentifier(of: target) + mainOwned.app = target + mainOwned.bundleId = bundleId + mainOwned.processIdentifier = Self.processIdentifier(of: target) resetTargetBoundState() beginFirstInteractionStabilization() return target @@ -388,10 +395,11 @@ extension RunnerTests { /// interaction themselves first (a scroll needs no extra wait, a text field is located, an alert /// button is read as hittable), which is what the dropped pre-event wait replaces rather than a /// check the runner skips (#2546). + @MainActor func withBoundedInteractionIdleTimeoutIfSupported( _ target: XCUIApplication, waits: RunnerInteractionIdleWaits, - operation: () -> Void + operation: @MainActor () -> Void ) { let setter = NSSelectorFromString("setWaitForIdleTimeout:") let supportsWaitForIdleTimeout = target.responds(to: setter) @@ -410,10 +418,11 @@ extension RunnerTests { } // Some apps never report post-gesture quiescence, even after XCTest has synthesized the event. + @MainActor private func performWithQuiescenceSkippedIfSupported( _ target: XCUIApplication, waits: RunnerInteractionIdleWaits, - operation: () -> Void + operation: @MainActor () -> Void ) { let selector = NSSelectorFromString("_performWithInteractionOptions:block:") guard target.responds(to: selector) else { @@ -441,7 +450,7 @@ extension RunnerTests { options = skipPreEventQuiescence } withoutActuallyEscaping(operation) { escapableOperation in - let block: @convention(block) () -> Void = escapableOperation + let block: @convention(block) () -> Void = { _ = runOnMainActor(escapableOperation) } performWithOptions( target, selector, @@ -466,10 +475,11 @@ extension RunnerTests { // MARK: - Interaction Stabilization + @MainActor func applyInteractionStabilizationIfNeeded() { - if needsPostSnapshotInteractionDelay { + if mainOwned.needsPostSnapshotInteractionDelay { sleepFor(postSnapshotInteractionDelay) - needsPostSnapshotInteractionDelay = false + mainOwned.needsPostSnapshotInteractionDelay = false } if let readyUptime = firstInteractionReadyUptime { sleepFor(readyUptime - ProcessInfo.processInfo.systemUptime) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+MainThreadWork.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+MainThreadWork.swift index 267f2b0b61..bccd95caaa 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+MainThreadWork.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+MainThreadWork.swift @@ -57,12 +57,12 @@ extension RunnerTests { func runMainThreadWork( _ operation: String, timeout: TimeInterval, - timeoutError: @escaping () -> Error, - onAbandoned: (() -> Void)? = nil, - _ work: @escaping () throws -> T + timeoutError: @escaping @Sendable () -> Error, + onAbandoned: (@Sendable () -> Void)? = nil, + _ work: @escaping @MainActor () throws -> T ) throws -> T { if Thread.isMainThread { - return try work() + return try runOnMainActor(work).get() } mainThreadWorkLock.lock() let state = enqueueMainThreadWorkLocked(operation, work) @@ -85,8 +85,8 @@ extension RunnerTests { func runMainThreadWorkIfIdle( _ operation: String, timeout: TimeInterval, - timeoutError: @escaping () -> Error, - _ work: @escaping () throws -> T + timeoutError: @escaping @Sendable () -> Error, + _ work: @escaping @MainActor () throws -> T ) throws -> T? { if Thread.isMainThread { return nil @@ -109,16 +109,12 @@ extension RunnerTests { private func enqueueMainThreadWorkLocked( _ operation: String, - _ work: @escaping () throws -> T + _ work: @escaping @MainActor () throws -> T ) -> MainThreadWorkState { let state = MainThreadWorkState() mainThreadWorkInFlightCount += 1 DispatchQueue.main.async { - do { - state.result = .success(try work()) - } catch { - state.result = .failure(error) - } + state.result = runOnMainActor(work) self.mainThreadWorkLock.lock() self.mainThreadWorkInFlightCount -= 1 let abandoned = state.abandoned @@ -147,8 +143,8 @@ extension RunnerTests { _ state: MainThreadWorkState, operation: String, timeout: TimeInterval, - timeoutError: @escaping () -> Error, - onAbandoned: (() -> Void)? + timeoutError: @escaping @Sendable () -> Error, + onAbandoned: (@Sendable () -> Void)? ) throws -> T { let waitResult = state.completed.wait(timeout: .now() + timeout) if waitResult == .timedOut { @@ -191,7 +187,7 @@ extension RunnerTests { } } - func mainThreadExecutionTimeoutError() -> Error { + static func mainThreadExecutionTimeoutError() -> Error { NSError( domain: RunnerErrorDomain.general, code: RunnerErrorCode.mainThreadExecutionTimedOut, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index fb2a545c03..b1b707e7d7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -76,7 +76,7 @@ enum CommandLaunchPolicy: Equatable { /// rather than re-derive them from the same names. /// /// The classification is load-bearing for ADR-0002 session invalidation: `retryOnSessionLoss` gates -/// the retry that nulls currentApp/currentBundleId, and `launchPolicy` — never the retry fact — +/// the retry that clears the cached target, and `launchPolicy` — never the retry fact — /// decides whether a stopped app is brought up. struct CommandTraits { /// Whether the command needs the foreground-guard + stabilization preflight before running. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift index 3ab477ff74..9cd790aaa5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift @@ -44,6 +44,7 @@ extension RunnerTests { } } + @MainActor func tapInAppBackControl(app: XCUIApplication) -> InAppBackOutcome { #if os(macOS) if let back = macOSNavigationBackElement(app: app) { @@ -60,8 +61,8 @@ extension RunnerTests { back.tap() return .performed } - if isSnapshotXCTestChannelPenalized(bundleId: currentBundleId) { - NSLog("AGENT_DEVICE_RUNNER_IN_APP_BACK_SKIPPED_XCTEST_ENUMERATION bundle=%@", currentBundleId ?? "") + if isSnapshotXCTestChannelPenalized(bundleId: mainOwned.bundleId) { + NSLog("AGENT_DEVICE_RUNNER_IN_APP_BACK_SKIPPED_XCTEST_ENUMERATION bundle=%@", mainOwned.bundleId ?? "") } else if let back = topNavigationBackElement(app: app) { tapElementCenter(app: app, element: back) return .performed @@ -142,6 +143,7 @@ extension RunnerTests { return CGPoint(x: frame.minX + xOffset, y: frame.minY + yOffset) } + @MainActor private func tapTopLeadingNavigationFallback(app: XCUIApplication) -> InAppBackOutcome { #if os(iOS) let frame = onScreenWindowFrame(app: app) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift index c70b0e3228..0bfe1a2e64 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift @@ -36,8 +36,8 @@ extension RunnerTests { /// `bootstrap` must produce the frame that sizes the writer and runs on the caller's thread. /// `frame` answers each tick with an image, or `nil` to drop the tick. func start( - bootstrap: @escaping () -> Result, - frame: @escaping () -> RunnerImage? + bootstrap: () -> Result, + frame: @escaping @Sendable () -> RunnerImage? ) throws { let url = URL(fileURLWithPath: outputPath) let directory = url.deletingLastPathComponent() @@ -132,7 +132,7 @@ extension RunnerTests { let timer = DispatchSource.makeTimerSource(queue: queue) timer.schedule(deadline: .now() + frameInterval, repeating: frameInterval) - timer.setEventHandler { [weak self] in + timer.setEventHandler { @Sendable [weak self] in guard let self else { return } if self.shouldStop() { return } guard let image = frame() else { return } @@ -299,16 +299,17 @@ extension RunnerTests { /// while no other main-thread work is in flight, so it never queues behind a command. /// A capture still running after `recordingFrameCaptureTimeout` is abandoned and its frame dropped; /// its late result is never returned. + @MainActor func startRecording( _ recorder: ScreenRecorder, - capture: @escaping () -> Result + capture: @escaping @MainActor () -> Result ) throws { try recorder.start(bootstrap: capture) { [weak self] in guard let self else { return nil } return try? self.runMainThreadWorkIfIdle( "recording_frame", timeout: self.recordingFrameCaptureTimeout, - timeoutError: self.mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { try capture().get().image } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollDragExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollDragExecution.swift index 77c896c5ba..ea7c8761ba 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollDragExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScrollDragExecution.swift @@ -27,6 +27,7 @@ extension RunnerTests { ) } + @MainActor func executeScrollDragGesture( activeApp: XCUIApplication, x: Double, @@ -65,6 +66,7 @@ extension RunnerTests { /// 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. + @MainActor func executeDragGesture( activeApp: XCUIApplication, x: Double, @@ -125,6 +127,7 @@ extension RunnerTests { return gestureResponse(message: message, timing: timing, frame: .drag(dragFrame)) } + @MainActor private func executeSynthesizedDragGesture( activeApp: XCUIApplication, x: Double, @@ -199,6 +202,7 @@ extension RunnerTests { #endif } + @MainActor private func executeCoordinateDragFallback( activeApp: XCUIApplication, x: Double, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift index 9bc0481861..9445cd814a 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SequenceExecution.swift @@ -19,6 +19,7 @@ extension RunnerTests { let gestureEndUptimeMs: Double } + @MainActor func executeSequence(command: Command, activeApp: XCUIApplication) -> Response { guard let steps = command.steps, !steps.isEmpty else { return sequenceInvalidArgs("sequence requires at least one step") @@ -136,6 +137,7 @@ extension RunnerTests { return nil } + @MainActor private func performSequenceStep( _ step: SequenceStep, activeApp: XCUIApplication, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift index b21d55b1da..3523e632bf 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift @@ -175,6 +175,7 @@ extension RunnerTests { ) } + @MainActor func recursiveTreeSnapshotAcquisition( context: SnapshotTraversalContext, hint: CaptureHint @@ -327,7 +328,7 @@ extension RunnerTests { private func boundedBlockingSystemAlertSnapshotBody( deadline: Date, penaltyTarget: SnapshotProbePenaltyTarget, - probe: @escaping (Date) -> DataPayload? + probe: @escaping @MainActor (Date) -> DataPayload? ) -> DataPayload? { #if os(macOS) return nil @@ -361,7 +362,7 @@ extension RunnerTests { ) } ) { - penaltyIdentity.captureFromMain(bundleId: self.currentBundleId) + penaltyIdentity.captureFromMain(bundleId: self.mainOwned.bundleId) return probe(probeDeadline) } } catch { @@ -437,6 +438,7 @@ extension RunnerTests { ) } + @MainActor func querySweepSnapshotAcquisition( app: XCUIApplication, hint: CaptureHint, @@ -527,7 +529,7 @@ extension RunnerTests { func snapshotAccessibilityUnavailable(failure: SnapshotCaptureFailure) -> DataPayload { NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_AX_UNAVAILABLE=%@", failure.message) applyMainOwnedSnapshotState("ax_unavailable_invalidation") { - self.runnerAccessibilityHealth = .unavailable + self.mainOwned.accessibilityHealth = .unavailable self.invalidateCachedTarget(reason: Self.axSnapshotUnavailableReason) } // This is a planned terminal result, so it carries the structured verdict like every other diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift index 455d0dd4dc..cdf9d7be67 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotAcquisition.swift @@ -93,7 +93,7 @@ extension RunnerTests { } } - private func treeCaptureTimeoutError(sliceSeconds: TimeInterval) -> () -> Error { + private func treeCaptureTimeoutError(sliceSeconds: TimeInterval) -> @Sendable () -> Error { { SnapshotCaptureFailure( code: Self.xCTestSnapshotTimeoutCode, @@ -103,7 +103,7 @@ extension RunnerTests { } } - func snapshotMainThreadTimeoutError(_ operation: String) -> () -> Error { + func snapshotMainThreadTimeoutError(_ operation: String) -> @Sendable () -> Error { { SnapshotCaptureFailure( code: Self.xCTestSnapshotTimeoutCode, @@ -475,6 +475,7 @@ extension RunnerTests { return containerLabel == label && containerIdentifier == identifier } + @MainActor func flatInteractiveElements( app: XCUIApplication, deadline: Date @@ -533,6 +534,7 @@ extension RunnerTests { return (elements, .completed) } + @MainActor func snapshotElementsQuery( _ fetch: () -> [XCUIElement] ) -> (elements: [XCUIElement], axUnavailable: Bool) { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift index 770dde5524..8ef6bc8ca7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCapturePlan.swift @@ -694,7 +694,7 @@ extension RunnerTests { ) -> DataPayload { let health: RunnerAccessibilityHealth = reason?.code == "ax-rejected" ? .unavailable : .healthy applyMainOwnedSnapshotState("accessibility_health") { - self.runnerAccessibilityHealth = health + self.mainOwned.accessibilityHealth = health } let payload = capture.payload let quality = SnapshotQuality( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCaptureTarget.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCaptureTarget.swift index 446db190f2..af106bb702 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCaptureTarget.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotCaptureTarget.swift @@ -2,11 +2,10 @@ import XCTest // MARK: - Snapshot capture target (#2781) // -// Target identity (`currentApp`, `currentBundleId`, `currentAppProcessIdentifier`) and -// `runnerAccessibilityHealth` are owned by main-thread lifecycle code. A capture plan runs on the -// command queue, so it reads the identity from a `SnapshotCaptureTarget` taken on main while the -// command is prepared, and writes health or invalidates the target only through -// `applyMainOwnedSnapshotState`. +// Target identity and accessibility health live in `RunnerMainOwnedState`, owned by main-thread +// lifecycle code. A capture plan runs on the command queue, so it reads the identity from a +// `SnapshotCaptureTarget` taken on main while the command is prepared, and writes health or +// invalidates the target only through `applyMainOwnedSnapshotState`. /// The target one capture plan reads, taken once on the main thread. struct SnapshotCaptureTarget { @@ -24,14 +23,14 @@ enum SnapshotCommandPreparation { /// The target a bounded XCTest probe arms its abandonment penalty with. /// /// The hook that arms the penalty fires on the command queue the moment the probe's slice is spent, -/// while the probe's own work block may still be running on main. `currentBundleId` belongs to main, -/// so it is never read across that boundary: a caller that already took the identity on main hands it -/// over, and a caller that is on the command queue lets the probe's main-side block capture the -/// identity main holds once the work actually starts (#2781). +/// while the probe's own work block may still be running on main. `mainOwned.bundleId` belongs to +/// main, so it is never read across that boundary: a caller that already took the identity on main +/// hands it over, and a caller that is on the command queue lets the probe's main-side block capture +/// the identity main holds once the work actually starts (#2781). enum SnapshotProbePenaltyTarget: Equatable { /// Identity a capture took on main when it prepared its target. case prepared(bundleId: String?) - /// Read `currentBundleId` inside the probe's main-side block. + /// Read `mainOwned.bundleId` inside the probe's main-side block. case mainOwnedTarget } @@ -66,33 +65,36 @@ final class SnapshotProbePenaltyIdentity { } extension RunnerTests { - /// Main thread only: reads the lifecycle-owned target identity. + /// Reads the lifecycle-owned target identity. + @MainActor func takeSnapshotCaptureTarget(app: XCUIApplication) -> SnapshotCaptureTarget { SnapshotCaptureTarget( app: app, - bundleId: currentBundleId, - processIdentifier: currentAppProcessIdentifier + bundleId: mainOwned.bundleId, + processIdentifier: mainOwned.processIdentifier ) } /// Runs `write` against main-owned runner state for a capture that may be on the command queue. /// Abandoned work ahead of the hop cannot be cancelled, so behind it the write queues without /// waiting: the capture answers now and the next command still observes the write. - func applyMainOwnedSnapshotState(_ operation: String, _ write: @escaping () -> Void) { + func applyMainOwnedSnapshotState(_ operation: String, _ write: @escaping @MainActor () -> Void) { if Thread.isMainThread { - write() + _ = runOnMainActor(write) return } guard !hasAbandonedMainThreadWork() else { NSLog("AGENT_DEVICE_RUNNER_SNAPSHOT_STATE_DEFERRED_XCTEST_OCCUPIED operation=%@", operation) - DispatchQueue.main.async(execute: write) + DispatchQueue.main.async { + _ = runOnMainActor(write) + } return } do { try runMainThreadWork( operation, timeout: 1, - timeoutError: mainThreadExecutionTimeoutError, + timeoutError: Self.mainThreadExecutionTimeoutError, write ) } catch { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift index 59ea13b9fa..42d1bfc562 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotExecution.swift @@ -12,7 +12,7 @@ extension RunnerTests { let preparation: SnapshotCommandPreparation = try runMainThreadWork( "command_preparation", timeout: Self.mainThreadExecutionTimeout, - timeoutError: mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { () -> SnapshotCommandPreparation in switch try self.prepareActiveCommandContextSafely(command: command, routeToSpringboard: false) { case .response(let response): @@ -36,6 +36,7 @@ extension RunnerTests { } } + @MainActor private func prepareActiveCommandContextSafely( command: Command, routeToSpringboard: Bool @@ -125,9 +126,9 @@ extension RunnerTests { try runMainThreadWork( "post_snapshot_delay_mark", timeout: 1, - timeoutError: mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { - self.needsPostSnapshotInteractionDelay = true + self.mainOwned.needsPostSnapshotInteractionDelay = true } } catch { NSLog("AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_FAILED=%@", String(describing: error)) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift index fd11051e0f..3a3cfa03a6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotTiming.swift @@ -24,11 +24,11 @@ enum SnapshotCapturePhase: Equatable { } struct SnapshotPhaseTimer { - private let now: () -> Date + private let now: @Sendable () -> Date private var acquisitionSeconds: TimeInterval = 0 private var presentationSeconds: TimeInterval = 0 - init(now: @escaping () -> Date = { Date() }) { + init(now: @escaping @Sendable () -> Date = { Date() }) { self.now = now } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift index 212d136781..1536c0e24f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedGesturePolicy.swift @@ -125,6 +125,7 @@ enum SynthesizedGestureAttempt { } extension RunnerTests { + @MainActor func synthesizedSequenceCoordinateContext( steps: [SequenceStep], app: XCUIApplication @@ -137,6 +138,7 @@ extension RunnerTests { /// `context` is nil when no window frame resolved; `kind`'s fallback policy then reads the /// runner's current accessibility health. + @MainActor func performSynthesizedGesture( _ app: XCUIApplication, kind: SynthesizedGesturePolicyKind, @@ -149,7 +151,7 @@ extension RunnerTests { return .performed(timing: timing) } let fallbackAllowed = synthesizedGesturePolicy(kind).fallbackPolicy.allowsXCTestCoordinateFallback( - accessibilityHealth: context?.accessibilityHealth ?? runnerAccessibilityHealth + accessibilityHealth: context?.accessibilityHealth ?? mainOwned.accessibilityHealth ) logSynthesizedGesturePolicyDecision( kind: kind, @@ -161,6 +163,7 @@ extension RunnerTests { : .refused(timing: timing, message: message, hint: hint) } + @MainActor func logSynthesizedGesturePolicyDecision( kind: SynthesizedGesturePolicyKind, context: SynthesizedCoordinateContext?, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift index 369f588cf9..b6cb79075e 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedInteraction.swift @@ -2,6 +2,7 @@ import XCTest import AgentDeviceSnapshotPresentation extension RunnerTests { + @MainActor func synthesizedDragAt( app: XCUIApplication, x: Double, @@ -89,6 +90,7 @@ extension RunnerTests { #endif } + @MainActor func synthesizedTapAt( app: XCUIApplication, x: Double, @@ -276,6 +278,7 @@ extension RunnerTests { #endif } + @MainActor func axFreeSynthesizedDragPlan( app: XCUIApplication, x: Double, @@ -364,12 +367,13 @@ extension RunnerTests { ) } + @MainActor func synthesizedCoordinateContext( app: XCUIApplication, policy: SynthesizedGesturePolicy ) -> SynthesizedCoordinateContext? { #if os(iOS) - let health = runnerAccessibilityHealth + let health = mainOwned.accessibilityHealth let resolved = resolveRunnerWindow(app: app) guard let window = resolved.window else { return nil diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift index 1d1e911d71..16f34d1618 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SynthesizedTextEntry.swift @@ -147,6 +147,7 @@ extension RunnerTests { } } + @MainActor func runSynthesizedReplacementRoute( _ request: SynthesizedReplacementRequest ) -> SynthesizedReplacementRouteOutcome { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift index 6d49503938..94f8f5eaa6 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntryFocus.swift @@ -6,6 +6,7 @@ import XCTest // is RunnerTests+TextEntryReadiness.swift's question, and this file asks it rather than answering // it. extension RunnerTests { + @MainActor func rememberTextEntryTap(_ element: XCUIElement?) { guard let element, isTextEntryElement(element) else { clearRememberedTextEntryTap() @@ -13,8 +14,8 @@ extension RunnerTests { } textEntryTapWitness = TextEntryTapWitness( element: element, - bundleId: currentBundleId, - processIdentifier: currentAppProcessIdentifier + bundleId: mainOwned.bundleId, + processIdentifier: mainOwned.processIdentifier ) } @@ -22,6 +23,7 @@ extension RunnerTests { textEntryTapWitness = nil } + @MainActor private func rememberedTextEntryTarget() -> TextEntryTarget? { guard let witness = textEntryTapWitness else { return nil @@ -30,8 +32,8 @@ extension RunnerTests { // the element so a failed or interrupted type cannot reuse stale focus evidence. clearRememberedTextEntryTap() guard witness.matches( - bundleId: currentBundleId, - processIdentifier: currentAppProcessIdentifier + bundleId: mainOwned.bundleId, + processIdentifier: mainOwned.processIdentifier ) else { return nil } @@ -80,6 +82,7 @@ extension RunnerTests { #endif } + @MainActor func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) -> TextEntryTarget { guard let x, let y else { let softwareKeyboardVisible = isKeyboardVisible(app: app) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift index 79abf8ebf7..18de273f9a 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextTyping.swift @@ -3,6 +3,7 @@ import XCTest // Text typing, verification, and repair. Kept separate from focus/readiness so each // text-entry policy remains a bounded review surface. extension RunnerTests { + @MainActor func typeTextReliably( app: XCUIApplication, target: TextEntryTarget, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift index 9db9f0f7e7..549b8fbca5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift @@ -33,11 +33,7 @@ extension RunnerTests { let combined = buffer + data if let body = self.parseRequest(data: combined) { self.handleRequestBody(body) { [weak self] result in - self?.sendResponse(result.data, over: connection) { [weak self] in - if result.shouldFinish { - self?.finish() - } - } + self?.sendResult(result, over: connection) } } else { self.receiveRequest(connection: connection, buffer: combined) @@ -45,10 +41,21 @@ extension RunnerTests { } } + private func sendResult( + _ result: (data: Data, shouldFinish: Bool), + over connection: NWConnection + ) { + sendResponse(result.data, over: connection) { [weak self] in + if result.shouldFinish { + self?.finish() + } + } + } + private func sendResponse( _ response: Data, over connection: NWConnection, - afterSend: @escaping () -> Void = {} + afterSend: @escaping @Sendable () -> Void = {} ) { connection.send(content: response, isComplete: true, completion: .contentProcessed { error in if let error { @@ -89,7 +96,7 @@ extension RunnerTests { private func handleRequestBody( _ body: Data, - completion: @escaping ((data: Data, shouldFinish: Bool)) -> Void + completion: @escaping @Sendable ((data: Data, shouldFinish: Bool)) -> Void ) { guard String(data: body, encoding: .utf8) != nil else { completion(( @@ -187,7 +194,7 @@ extension RunnerTests { /// queue. func enqueueAccepted( command: Command, - completion: @escaping (Result) -> Void + completion: @escaping @Sendable (Result) -> Void ) { commandJournal.accept(command: command) commandExecutionQueue.async { @@ -202,7 +209,7 @@ extension RunnerTests { /// false so the caller enqueues the (single) execution. func attachToInFlightCommandIfNeeded( command: Command, - completion: @escaping ((data: Data, shouldFinish: Bool)) -> Void + completion: @escaping @Sendable ((data: Data, shouldFinish: Bool)) -> Void ) -> Bool { guard let commandId = command.commandId?.trimmedNonEmpty else { return false } inFlightCommandLock.lock() @@ -226,7 +233,7 @@ extension RunnerTests { result: (data: Data, shouldFinish: Bool), completion: ((data: Data, shouldFinish: Bool)) -> Void ) { - var waiters: [((data: Data, shouldFinish: Bool)) -> Void] = [] + var waiters: [@Sendable ((data: Data, shouldFinish: Bool)) -> Void] = [] if let commandId = command.commandId?.trimmedNonEmpty { inFlightCommandLock.lock() inFlightCommandIds.remove(commandId) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift index 5ec6f27826..44e74a41f7 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TypeExecution.swift @@ -2,6 +2,7 @@ import XCTest import AgentDeviceSnapshotPresentation extension RunnerTests { + @MainActor func executeTypeCommand(activeApp: XCUIApplication, command: Command) -> Response { guard let text = command.text else { return Response(ok: false, error: ErrorPayload(message: "type requires text")) @@ -18,7 +19,7 @@ extension RunnerTests { : nil let focusStartedAt = Date() #if os(iOS) - let xCTestChannelPenalized = isSnapshotXCTestChannelPenalized(bundleId: currentBundleId) + let xCTestChannelPenalized = isSnapshotXCTestChannelPenalized(bundleId: mainOwned.bundleId) var resolvedCoordinateTarget: TextEntryTarget? if Self.shouldUseResolvedCoordinateTextEntryRoute( repairMode: textEntryMode, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index 473307e802..cb8c0e2cb1 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -54,11 +54,7 @@ final class RunnerTests: XCTestCase { let commandExecutionQueue = DispatchQueue(label: "agent-device.runner.commands") let app = XCUIApplication() lazy var springboard = XCUIApplication(bundleIdentifier: Self.springboardBundleId) - // Main-thread owned, like `runnerAccessibilityHealth`: an off-main capture plan reads them only - // through its `SnapshotCaptureTarget` and writes them only through `applyMainOwnedSnapshotState`. - var currentApp: XCUIApplication? - var currentBundleId: String? - var currentAppProcessIdentifier: Int? + let mainOwned = RunnerMainOwnedState() // Set while serving a command that had to re-activate the bound app, and stamped onto that // command's response before it leaves the execution queue (#2682). var pendingTargetActivation: TargetActivationFactPayload? @@ -83,27 +79,25 @@ final class RunnerTests: XCTestCase { // screenshot round trip, not the frame interval: a capture slower than the interval lowers the // frame rate, and only a capture this slow counts as main-thread occupancy. let recordingFrameCaptureTimeout: TimeInterval = 1 - var needsPostSnapshotInteractionDelay = false // Per-command markers that restate a fact of the bound target (the fast app guard, the // synthesized gesture policy per gesture kind) write only when that fact changes; otherwise a // long session fills runner.log with one identical line per command. Cleared with the rest of the // target-bound state so a rebind states the fact once more. var lastLoggedFastAppGuardLine: String? var lastLoggedGesturePolicyLines: [SynthesizedGesturePolicyKind: String] = [:] - var runnerMarkerWriter: (String) -> Void = { NSLog("%@", $0) } + var runnerMarkerWriter: @MainActor (String) -> Void = { NSLog("%@", $0) } /// When the first interaction after an activation may run, on the monotonic uptime clock. /// The guarantee is a minimum gap *since the activation*, not a pause at the interaction: /// a caller that already spent that gap elsewhere (an agent's round trip is 190-260 ms) /// has satisfied it and waits for nothing. `nil` = no activation is pending stabilization. var firstInteractionReadyUptime: TimeInterval? - var runnerAccessibilityHealth: RunnerAccessibilityHealth = .unknown var activeRecording: ScreenRecorder? let commandJournal = RunnerCommandJournal() // Coalesces duplicate transport sends of the same commandId onto the single in-flight // execution instead of enqueueing them again behind it (#1105 capture pileup). let inFlightCommandLock = NSLock() var inFlightCommandIds: Set = [] - var inFlightCommandWaiters: [String: [((data: Data, shouldFinish: Bool)) -> Void]] = [:] + var inFlightCommandWaiters: [String: [@Sendable ((data: Data, shouldFinish: Bool)) -> Void]] = [:] // Tracks main-queue work abandoned by the execution watchdog (runMainThreadWork). While any is // outstanding the main thread is occupied: new main-thread commands fail fast as busy instead // of queueing behind work that cannot be cancelled, capture plans skip XCTest-backed tiers, @@ -204,13 +198,13 @@ final class RunnerTests: XCTestCase { // body runs in place of `blockingSystemAlertSnapshot` so it can force a real timeout without a // live SpringBoard alert. Production never compiles this property. Stored here (rather than in // the extension that reads it) because Swift extensions cannot hold stored properties. - var systemModalProbeOverrideForTesting: ((Date) -> DataPayload?)? + var systemModalProbeOverrideForTesting: (@MainActor (Date) -> DataPayload?)? var blockingSystemModalPresenceOverrideForTesting: Bool? - var alertResolutionOverrideForTesting: ((Date) -> RunnerAlert?)? - var alertButtonHittabilityProbeOverrideForTesting: ((Date) -> Bool)? + var alertResolutionOverrideForTesting: (@MainActor (Date) -> RunnerAlert?)? + var alertButtonHittabilityProbeOverrideForTesting: (@MainActor (Date) -> Bool)? // Runs on the waiting thread after `runMainThreadWork`'s wait timed out and before it takes the // lock that decides between finished and abandoned, so a test can finish the work in that window. - var mainThreadWorkTimedOutForTesting: (() -> Void)? + var mainThreadWorkTimedOutForTesting: (@Sendable () -> Void)? #endif // Observability for the record(_:) suppression below: how many AX-broken-screen snapshot // issues this session muted, so wedge investigations see the volume without grepping logs. @@ -316,7 +310,7 @@ final class RunnerTests: XCTestCase { deadline: .now() + xctestIdleKeepaliveInterval, repeating: xctestIdleKeepaliveInterval ) - idleKeepaliveTimer.setEventHandler { + idleKeepaliveTimer.setEventHandler { @Sendable in NSLog("AGENT_DEVICE_RUNNER_IDLE_KEEPALIVE") } idleKeepaliveTimer.resume() diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertDispatchTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertDispatchTests.swift index 0fe213c883..a9746cc34d 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertDispatchTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertDispatchTests.swift @@ -2,14 +2,15 @@ import XCTest #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) extension RunnerTests { + @MainActor func testAlertDispatchResolvesItsOwnModalWithoutCoordinateTapRoutingProbe() throws { final class ResultBox { var routingProbeCount = 0 var resolutionCount = 0 } let box = ResultBox() - currentApp = springboard - currentBundleId = Self.springboardBundleId + mainOwned.app = springboard + mainOwned.bundleId = Self.springboardBundleId systemModalProbeOverrideForTesting = { _ in box.routingProbeCount += 1 return nil @@ -21,8 +22,8 @@ extension RunnerTests { defer { systemModalProbeOverrideForTesting = nil alertResolutionOverrideForTesting = nil - currentApp = nil - currentBundleId = nil + mainOwned.app = nil + mainOwned.bundleId = nil } let command = try runnerCommandFixture( #"{"command":"alert","commandId":"alert-routing-once","appBundleId":"com.apple.springboard","action":"get","timeoutMs":1000}"# @@ -33,6 +34,7 @@ extension RunnerTests { XCTAssertEqual(box.routingProbeCount, 0) } + @MainActor func testAlertResolutionCannotBypassRequestedDeadline() throws { final class ResultBox { var observedDeadline: Date? @@ -43,8 +45,8 @@ extension RunnerTests { let command = try runnerCommandFixture( #"{"command":"alert","commandId":"alert-deadline","appBundleId":"com.apple.springboard","action":"get","timeoutMs":500}"# ) - currentApp = springboard - currentBundleId = Self.springboardBundleId + mainOwned.app = springboard + mainOwned.bundleId = Self.springboardBundleId alertResolutionOverrideForTesting = { deadline in box.observedDeadline = deadline _ = releaseResolution.wait(timeout: .now() + 1) @@ -54,8 +56,8 @@ extension RunnerTests { defer { releaseResolution.signal() alertResolutionOverrideForTesting = nil - currentApp = nil - currentBundleId = nil + mainOwned.app = nil + mainOwned.bundleId = nil } let commandStartedAt = Date() diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertObservationTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertObservationTests.swift index 65865a7dc0..936de386a9 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertObservationTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertObservationTests.swift @@ -2,14 +2,17 @@ import XCTest extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) + @MainActor func testAlertAcceptDoesNotActivateAReplacementWithASharedButton() throws { try assertReplacementAlertUntouched(action: "accept", arguments: [], confirmed: true) } + @MainActor func testAlertDismissDoesNotActivateAReplacementWithTheSameTitle() throws { try assertReplacementAlertUntouched(action: "dismiss", arguments: ["--agent-device-alert-same-title"], confirmed: true) } + @MainActor func testAlertCannotProveAnIdenticalReplacementAndDoesNotActivateIt() throws { try assertReplacementAlertUntouched( action: "accept", @@ -18,6 +21,7 @@ extension RunnerTests { ) } + @MainActor func testAlertDeadlineBeforeActivationLeavesTheOriginalUntouched() throws { app.launchArguments = ["--agent-device-alert-replacement-regression"] app.launch() @@ -34,6 +38,7 @@ extension RunnerTests { XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 0; replacement actions: 0") } + @MainActor func testAlertHittableProbeCompletingAfterDeadlineLeavesTheOriginalUntouched() throws { app.launchArguments = ["--agent-device-alert-replacement-regression"] app.launch() @@ -57,6 +62,7 @@ extension RunnerTests { XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 0; replacement actions: 0") } + @MainActor func testAlertActivationAfterDeadlineDoesNotTapTheOriginal() throws { app.launchArguments = ["--agent-device-alert-replacement-regression"] app.launch() @@ -76,6 +82,7 @@ extension RunnerTests { XCTAssertEqual(app.staticTexts["agent-device-alert-actions"].label, "First actions: 0; replacement actions: 0") } + @MainActor func testAlertActivationIgnoresAnAppThatNeverSettlesBeforeTheDeadline() throws { app.launchArguments = [ "--agent-device-alert-replacement-regression", @@ -101,6 +108,7 @@ extension RunnerTests { XCTAssertEqual(app.staticTexts["agent-device-alert-busy-answer"].label, "Answered while busy") } + @MainActor func testAlertActivationDoesNotWaitOutANotificationBanner() throws { app.launchArguments = ["--agent-device-alert-replacement-regression", "--agent-device-alert-banner"] app.launch() @@ -157,10 +165,12 @@ extension RunnerTests { static let alertActivationDeadline: TimeInterval = 30 static let alertBannerActivationDeadline: TimeInterval = 90 + @MainActor private func resolveAlertBeforeTheCommand() throws -> RunnerAlert { try XCTUnwrap(resolveAlert(app: app, deadline: Date().addingTimeInterval(RunnerTests.alertResolutionAllowance))) } + @MainActor private func assertReplacementAlertUntouched(action: String, arguments: [String], confirmed: Bool) throws { app.launchArguments = ["--agent-device-alert-replacement-regression"] + arguments app.launch() diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift index c5c18f6140..dbbb043c00 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+AlertTests.swift @@ -10,6 +10,7 @@ extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) extension RunnerTests { + @MainActor func testAlertResolutionWithoutAnAlertDoesNotReadEveryElementOfTheScreen() throws { launchCrowdedScreen(extraArguments: []) defer { terminateCrowdedScreen() } @@ -21,6 +22,7 @@ extension RunnerTests { XCTAssertLessThan(Date().timeIntervalSince(startedAt), RunnerTests.defaultAlertCommandTimeout / 2) } + @MainActor func testAlertResolutionFindsADismissPopupMarkerOnACrowdedScreen() throws { launchCrowdedScreen(extraArguments: ["--agent-device-dismiss-popup"]) defer { terminateCrowdedScreen() } @@ -33,6 +35,7 @@ extension RunnerTests { XCTAssertTrue(alert.buttons.contains { $0.identifier == " Dismiss Popup " }) } + @MainActor func testAlertResolutionFindsAWindowThatIsItselfTheDismissPopupMarker() throws { launchCrowdedScreen(extraArguments: ["--agent-device-dismiss-popup-window"]) defer { terminateCrowdedScreen() } @@ -51,6 +54,7 @@ extension RunnerTests { XCTAssertTrue(app.staticTexts["agent-device-crowded-row-499"].waitForExistence(timeout: appExistenceTimeout)) } + @MainActor private func terminateCrowdedScreen() { invalidateCachedTarget(reason: "unit_test_cleanup") app.terminate() diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift index 51e9aa4acd..49980d0968 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift @@ -66,11 +66,12 @@ extension RunnerTests { // `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) + @MainActor func testMissingBundleCommandInvalidatesCompleteCachedTargetState() throws { app.launch() - currentApp = app - currentBundleId = "com.example.stale-target" - currentAppProcessIdentifier = 42 + mainOwned.app = app + mainOwned.bundleId = "com.example.stale-target" + mainOwned.processIdentifier = 42 snapshotXCTestPenaltyWarmupExemption.isPending = true defer { invalidateCachedTarget(reason: "unit_test_cleanup") @@ -82,9 +83,9 @@ extension RunnerTests { _ = prepareActiveCommandContext(command: command) - XCTAssertNil(currentApp) - XCTAssertNil(currentBundleId) - XCTAssertNil(currentAppProcessIdentifier) + XCTAssertNil(mainOwned.app) + XCTAssertNil(mainOwned.bundleId) + XCTAssertNil(mainOwned.processIdentifier) XCTAssertFalse(snapshotXCTestPenaltyWarmupExemption.isPending) } @@ -92,6 +93,7 @@ extension RunnerTests { /// stands, leaves a stopped app stopped, and binds nothing, so the next read of that app is refused /// instead of answered by a bare launch (#2890). This is where the table's launch policy is proved /// on the platform that serves surfaces in place. + @MainActor func testPresentedSurfaceCommandLeavesAStoppedAppStoppedAndUnbound() throws { let unstarted = XCUIApplication(bundleIdentifier: "com.apple.Preferences") defer { invalidateCachedTarget(reason: "unit_test_cleanup") } @@ -101,8 +103,8 @@ extension RunnerTests { ] { unstarted.terminate() pendingTargetActivation = nil - currentApp = nil - currentBundleId = nil + mainOwned.app = nil + mainOwned.bundleId = nil let command = try runnerCommandFixture(request) guard case .context(let prepared) = prepareActiveCommandContext(command: command) else { @@ -128,7 +130,7 @@ extension RunnerTests { "\(request) may not foreground the app it was told to leave alone" ) XCTAssertNil(pendingTargetActivation, "\(request) may not record an activation fact") - XCTAssertNil(currentBundleId, "\(request) may not bind a target it never brought forward") + XCTAssertNil(mainOwned.bundleId, "\(request) may not bind a target it never brought forward") } let read = try runnerCommandFixture( @@ -141,13 +143,14 @@ extension RunnerTests { } } + @MainActor func testSkipAppActivationPreflightIncludesForegroundCachedCoordinateOnlyTaps() throws { app.launch() - currentApp = app - currentBundleId = nil + mainOwned.app = app + mainOwned.bundleId = nil defer { - currentApp = nil - currentBundleId = nil + mainOwned.app = nil + mainOwned.bundleId = nil app.terminate() } let tap = try runnerCommandFixture( @@ -157,20 +160,21 @@ extension RunnerTests { XCTAssertTrue(shouldSkipAppActivationPreflight(tap)) } + @MainActor func testSkipAppActivationPreflightRejectsMissingChangedAndBackgroundTargets() throws { let coordinateTap = try runnerCommandFixture( #"{"command":"tap","commandId":"tap-1","x":10,"y":20}"# ) - currentApp = nil - currentBundleId = nil + mainOwned.app = nil + mainOwned.bundleId = nil XCTAssertFalse(shouldSkipAppActivationPreflight(coordinateTap)) app.launch() - currentApp = app - currentBundleId = "com.example.current" + mainOwned.app = app + mainOwned.bundleId = "com.example.current" defer { - currentApp = nil - currentBundleId = nil + mainOwned.app = nil + mainOwned.bundleId = nil app.terminate() } let changedBundleTap = try runnerCommandFixture( @@ -180,20 +184,21 @@ extension RunnerTests { XCTAssertFalse(shouldSkipAppActivationPreflight(changedBundleTap)) app.terminate() - currentApp = app - currentBundleId = nil + mainOwned.app = app + mainOwned.bundleId = nil XCTAssertFalse(shouldSkipAppActivationPreflight(coordinateTap)) } + @MainActor func testPrepareActiveCommandContextRoutesBlockingSystemModalToSpringboard() throws { blockingSystemModalPresenceOverrideForTesting = true - currentApp = nil - currentBundleId = nil + mainOwned.app = nil + mainOwned.bundleId = nil defer { blockingSystemModalPresenceOverrideForTesting = nil - currentApp = nil - currentBundleId = nil + mainOwned.app = nil + mainOwned.bundleId = nil } let tap = try runnerCommandFixture( #"{"command":"tap","commandId":"tap-1","x":10,"y":20}"# @@ -213,11 +218,15 @@ extension RunnerTests { func testExecuteDispatchedReturnsBusyBeforeBlockingSystemModalProbeDrains() throws { app.launch() - currentApp = app - currentBundleId = nil + MainActor.assumeIsolated { + mainOwned.app = app + mainOwned.bundleId = nil + } defer { - currentApp = nil - currentBundleId = nil + MainActor.assumeIsolated { + mainOwned.app = nil + mainOwned.bundleId = nil + } systemModalProbeOverrideForTesting = nil clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") app.terminate() @@ -299,14 +308,18 @@ extension RunnerTests { XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) let pendingBundleId = "com.example.routing-pending-stale" let settledBundleId = "com.example.routing-pending-settled" - currentApp = app - currentBundleId = pendingBundleId + MainActor.assumeIsolated { + mainOwned.app = app + mainOwned.bundleId = pendingBundleId + } snapshotXCTestPenaltyWarmupExemption.isPending = false clearSnapshotXCTestChannelPenalty(reason: "test-setup") defer { systemModalProbeOverrideForTesting = nil clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") - invalidateCachedTarget(reason: "unit_test_cleanup") + MainActor.assumeIsolated { + invalidateCachedTarget(reason: "unit_test_cleanup") + } app.terminate() } @@ -315,7 +328,7 @@ extension RunnerTests { let mainRelease = DispatchSemaphore(value: 0) DispatchQueue.main.async { _ = mainRelease.wait(timeout: .now() + 0.5) - self.currentBundleId = settledBundleId + self.mainOwned.bundleId = settledBundleId } let probeStarted = expectation(description: "system-modal routing probe started") @@ -363,13 +376,14 @@ extension RunnerTests { XCTAssertFalse(hasAbandonedMainThreadWork()) } + @MainActor func testSkipAppActivationPreflightRejectsSelectorAndMixedSequenceGestures() throws { app.launch() - currentApp = app - currentBundleId = nil + mainOwned.app = app + mainOwned.bundleId = nil defer { - currentApp = nil - currentBundleId = nil + mainOwned.app = nil + mainOwned.bundleId = nil app.terminate() } let selectorTap = try runnerCommandFixture( @@ -396,9 +410,10 @@ extension RunnerTests { // `#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. + @MainActor func testSkipAppActivationPreflightRequiresCachedForegroundTarget() throws { - currentApp = nil - currentBundleId = nil + mainOwned.app = nil + mainOwned.bundleId = nil let scroll = try runnerCommandFixture( #"{"command":"scroll","commandId":"scroll-1","direction":"down","pixels":400}"# ) @@ -406,13 +421,14 @@ extension RunnerTests { XCTAssertFalse(shouldSkipAppActivationPreflight(scroll)) } + @MainActor func testSkipAppActivationPreflightKeepsDragScrollAndSequenceOnForegroundGuard() throws { app.launch() - currentApp = app - currentBundleId = nil + mainOwned.app = app + mainOwned.bundleId = nil defer { - currentApp = nil - currentBundleId = nil + mainOwned.app = nil + mainOwned.bundleId = nil app.terminate() } let drag = try runnerCommandFixture( diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift index af9a99939e..0482aa15b0 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandExecutionTests.swift @@ -27,11 +27,12 @@ extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS extension RunnerTests { #if os(iOS) + @MainActor func testSelectorTapFallsBackToXCTestCoordinateWhenPrivateSynthesisFails() throws { let restoreSynthesizedTap = try forceSynthesizedTapFailure() app.launch() - currentApp = app - runnerAccessibilityHealth = .healthy + mainOwned.app = app + mainOwned.accessibilityHealth = .healthy defer { restoreSynthesizedTap() invalidateCachedTarget(reason: "unit_test_cleanup") @@ -64,6 +65,7 @@ extension RunnerTests { // is where both are observable. If either regressed, readiness would silently stop taking the // fallback and spend the full readinessTimeout on every hardware-keyboard field, which no other // assertion would notice. + @MainActor func testHardwareKeyboardResponderConfirmsItsOwnKeyboardFocus() throws { app.launchArguments = ["--agent-device-text-entry-regression"] app.launch() diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CoordinateTextEntryTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CoordinateTextEntryTests.swift index e7bd7961c5..61029f357f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CoordinateTextEntryTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CoordinateTextEntryTests.swift @@ -2,6 +2,7 @@ import XCTest extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) + @MainActor func testPenalizedCoordinateTapOnNonTextControlDoesNotAuthorizeBareType() throws { app.launchArguments = ["--agent-device-text-entry-regression"] app.launch() @@ -18,9 +19,9 @@ extension RunnerTests { XCTAssertTrue(nonTextTarget.waitForExistence(timeout: appExistenceTimeout)) let nonTextFrame = nonTextTarget.frame XCTAssertFalse(nonTextFrame.isEmpty) - currentApp = app - currentBundleId = "com.callstack.agentdevice.runner" - currentAppProcessIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) + mainOwned.app = app + mainOwned.bundleId = "com.callstack.agentdevice.runner" + mainOwned.processIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) let focusCommand = try runnerCommandFixture( #"{"command":"tap","commandId":"tap-stale-responder-input","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"# diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleCacheTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleCacheTests.swift index 3c1aa5a21c..d879f7a365 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleCacheTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleCacheTests.swift @@ -21,6 +21,7 @@ private final class RunnerTargetActivationStub: NSObject { extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) + @MainActor func testActivateTargetSkipsForegroundAndActivatesNonForegroundApplication() { let stateSelector = #selector(getter: XCUIApplication.state) let activateSelector = #selector(XCUIApplication.activate) @@ -116,17 +117,18 @@ extension RunnerTests { XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: "com.example.app")) } + @MainActor func testCachedTargetInvalidationClearsProcessBoundState() { - currentApp = app - currentBundleId = "com.example.app" - currentAppProcessIdentifier = 42 + mainOwned.app = app + mainOwned.bundleId = "com.example.app" + mainOwned.processIdentifier = 42 snapshotXCTestPenaltyWarmupExemption.isPending = true invalidateCachedTarget(reason: "unit_test") - XCTAssertNil(currentApp) - XCTAssertNil(currentBundleId) - XCTAssertNil(currentAppProcessIdentifier) + XCTAssertNil(mainOwned.app) + XCTAssertNil(mainOwned.bundleId) + XCTAssertNil(mainOwned.processIdentifier) XCTAssertFalse(snapshotXCTestPenaltyWarmupExemption.isPending) } @@ -142,10 +144,11 @@ extension RunnerTests { XCTAssertFalse(witness.matches(bundleId: "com.example.app", processIdentifier: 43)) } + @MainActor func testTargetResetInvalidatesProcessBoundStateWithoutRestartingRunner() { - currentApp = app - currentBundleId = "com.example.app" - currentAppProcessIdentifier = 42 + mainOwned.app = app + mainOwned.bundleId = "com.example.app" + mainOwned.processIdentifier = 42 snapshotXCTestPenaltyWarmupExemption.isPending = true firstInteractionReadyUptime = nil penalizeSnapshotXCTestChannel(bundleId: "com.example.app", reason: "test") @@ -154,9 +157,9 @@ extension RunnerTests { let response = resetTargetAfterExternalRelaunch() XCTAssertTrue(response.ok) - XCTAssertNil(currentApp) - XCTAssertNil(currentBundleId) - XCTAssertNil(currentAppProcessIdentifier) + XCTAssertNil(mainOwned.app) + XCTAssertNil(mainOwned.bundleId) + XCTAssertNil(mainOwned.processIdentifier) XCTAssertFalse(snapshotXCTestPenaltyWarmupExemption.isPending) XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: "com.example.app")) XCTAssertNotNil(firstInteractionReadyUptime) @@ -165,8 +168,9 @@ extension RunnerTests { /// The settling window is a deadline measured from the activation, not a pause charged at the /// interaction. A caller that already spent the window elsewhere waits for nothing; one that /// arrives immediately still waits. Without the deadline both cases sleep the full delay. + @MainActor func testFirstInteractionStabilizationWaitsOnlyForTheRemainderOfTheWindow() { - needsPostSnapshotInteractionDelay = false + mainOwned.needsPostSnapshotInteractionDelay = false // An activation whose window has already elapsed: the caller spent it getting back to us. firstInteractionReadyUptime = ProcessInfo.processInfo.systemUptime - 1 @@ -184,6 +188,7 @@ extension RunnerTests { XCTAssertNil(firstInteractionReadyUptime) } + @MainActor private func measureStabilizationDuration() -> TimeInterval { let startedAt = ProcessInfo.processInfo.systemUptime applyInteractionStabilizationIfNeeded() diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift index 93377c219c..6b3d40df2e 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift @@ -119,6 +119,7 @@ extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS extension RunnerTests { + @MainActor func testResettingTargetBoundStateForgetsTheLastWrittenMarkers() { defer { invalidateCachedTarget(reason: "unit_test_cleanup") } lastLoggedFastAppGuardLine = "AGENT_DEVICE_RUNNER_FAST_APP_GUARD bundle=app state=4" @@ -128,6 +129,7 @@ extension RunnerTests { XCTAssertTrue(lastLoggedGesturePolicyLines.isEmpty, "a rebind must state the policy once more") } + @MainActor func testFastAppGuardMarkerWritesOnceUntilTheFactChanges() { var written: [String] = [] runnerMarkerWriter = { written.append($0) } @@ -152,6 +154,7 @@ extension RunnerTests { /// Installed on every Simulator runtime and cheap to leave terminated. private static let notRunningTargetBundleId = "com.apple.Preferences" + @MainActor private func executeOnTerminatedTarget(_ json: String) throws -> (Response, XCUIApplication) { let target = XCUIApplication(bundleIdentifier: Self.notRunningTargetBundleId) target.terminate() @@ -163,6 +166,7 @@ extension RunnerTests { /// Covers a user-level read, a mutation's leading read (a gesture's `gestureViewport`), and the read /// that resolves a selector tap (`querySelector`, whose refusal the retry fact alone used to decide, /// #2890). + @MainActor func testReadRefusesToLaunchANotRunningSessionApp() throws { let bundleId = Self.notRunningTargetBundleId for request in [ @@ -177,6 +181,7 @@ extension RunnerTests { } } + @MainActor func testNonReadCommandStillLaunchesANotRunningSessionApp() throws { let (response, target) = try executeOnTerminatedTarget( #"{"command":"activate","commandId":"repair","appBundleId":"\#(Self.notRunningTargetBundleId)"}"# diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+MainThreadWorkTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+MainThreadWorkTests.swift index 3a292bff9e..6da6f0be5c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+MainThreadWorkTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+MainThreadWorkTests.swift @@ -15,7 +15,7 @@ extension RunnerTests { box.observedMainThread = try self.runMainThreadWork( "command_execution", timeout: 1, - timeoutError: self.mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { Thread.isMainThread } @@ -47,7 +47,7 @@ extension RunnerTests { _ = try self.runMainThreadWork( "command_execution", timeout: 0, - timeoutError: self.mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { _ = releaseWork.wait(timeout: .now() + 2) return true @@ -128,7 +128,7 @@ extension RunnerTests { _ = try? self.runMainThreadWork( "command_execution", timeout: 5, - timeoutError: self.mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { commandEntered.signal() _ = releaseCommand.wait(timeout: .now() + 3) @@ -141,7 +141,7 @@ extension RunnerTests { outcome.whileInFlight = try self.runMainThreadWorkIfIdle( "recording_frame", timeout: 5, - timeoutError: self.mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { () -> Bool in outcome.ranWhileInFlight = true return true @@ -163,7 +163,7 @@ extension RunnerTests { outcome.whenIdle = try self.runMainThreadWorkIfIdle( "recording_frame", timeout: 5, - timeoutError: self.mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { Thread.isMainThread } @@ -200,7 +200,7 @@ extension RunnerTests { _ = try? self.runMainThreadWork( "command_execution", timeout: 0, - timeoutError: self.mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { _ = releaseWork.wait(timeout: .now() + 3) } @@ -212,7 +212,7 @@ extension RunnerTests { outcome.whileAbandoned = try self.runMainThreadWorkIfIdle( "recording_frame", timeout: 5, - timeoutError: self.mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { () -> Bool in outcome.ranWhileAbandoned = true return true @@ -230,7 +230,7 @@ extension RunnerTests { outcome.afterDrain = try self.runMainThreadWorkIfIdle( "recording_frame", timeout: 5, - timeoutError: self.mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { Thread.isMainThread } @@ -276,7 +276,7 @@ extension RunnerTests { outcome.value = try self.runMainThreadWork( "command_execution", timeout: 0, - timeoutError: self.mainThreadExecutionTimeoutError, + timeoutError: Self.mainThreadExecutionTimeoutError, onAbandoned: { outcome.onAbandonedCalls += 1 } ) { _ = releaseWork.wait(timeout: .now() + 2) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+RecordingTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+RecordingTests.swift index 8d2c75b439..dff9955359 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+RecordingTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+RecordingTests.swift @@ -229,7 +229,7 @@ extension RunnerTests { func testRecordingFrameTimeoutDropsTheFrameAndResumesOnceTheWorkDrains() throws { let source = RecordingFrameSource() let recorder = ScreenRecorder(outputPath: recordingTestOutputPath(), fps: 10) - try startRecording(recorder, capture: source.capture) + try startRecording(recorder, from: source) defer { try? recorder.stop() } XCTAssertTrue(pumpMainThread(until: { recorder.appendedFrameSnapshotForTesting().count >= 3 })) @@ -281,7 +281,7 @@ extension RunnerTests { func testRecordingPersistentWedgeKeepsOneCaptureQueuedOnMain() throws { let source = RecordingFrameSource() let recorder = ScreenRecorder(outputPath: recordingTestOutputPath(), fps: 20) - try startRecording(recorder, capture: source.capture) + try startRecording(recorder, from: source) defer { try? recorder.stop() } XCTAssertTrue(pumpMainThread(until: { recorder.appendedFrameSnapshotForTesting().count >= 2 })) @@ -321,7 +321,7 @@ extension RunnerTests { func testRecordingStopDuringATimedOutCaptureAppendsNoLateFrame() throws { let source = RecordingFrameSource() let recorder = ScreenRecorder(outputPath: recordingTestOutputPath(), fps: 10) - try startRecording(recorder, capture: source.capture) + try startRecording(recorder, from: source) XCTAssertTrue(pumpMainThread(until: { recorder.appendedFrameSnapshotForTesting().count >= 2 })) final class Observation { @@ -361,7 +361,7 @@ extension RunnerTests { func testRecordingStopRefusesAFrameThatFinishedAtTheTimeoutBoundary() throws { let source = RecordingFrameSource() let recorder = ScreenRecorder(outputPath: recordingTestOutputPath(), fps: 10) - try startRecording(recorder, capture: source.capture) + try startRecording(recorder, from: source) XCTAssertTrue(pumpMainThread(until: { recorder.appendedFrameSnapshotForTesting().count >= 2 })) final class Observation { @@ -391,7 +391,7 @@ extension RunnerTests { func testRecordingAfterAStopDuringATimedOutCaptureStartsClean() throws { let first = RecordingFrameSource() let firstRecorder = ScreenRecorder(outputPath: recordingTestOutputPath(), fps: 10) - try startRecording(firstRecorder, capture: first.capture) + try startRecording(firstRecorder, from: first) XCTAssertTrue(pumpMainThread(until: { firstRecorder.appendedFrameSnapshotForTesting().count >= 2 })) let stopped = expectation(description: "first recording stopped during its timed-out capture") first.armWedge() @@ -411,7 +411,7 @@ extension RunnerTests { let second = RecordingFrameSource() let secondOutputPath = recordingTestOutputPath() let secondRecorder = ScreenRecorder(outputPath: secondOutputPath, fps: 10) - try startRecording(secondRecorder, capture: second.capture) + try startRecording(secondRecorder, from: second) XCTAssertTrue(pumpMainThread(until: { secondRecorder.appendedFrameSnapshotForTesting().count >= 3 })) try secondRecorder.stop() @@ -429,7 +429,7 @@ extension RunnerTests { func testRecordingFrameNeverQueuesBehindACommandsMainThreadWork() throws { let source = RecordingFrameSource() let recorder = ScreenRecorder(outputPath: recordingTestOutputPath(), fps: 60) - try startRecording(recorder, capture: source.capture) + try startRecording(recorder, from: source) defer { try? recorder.stop() } XCTAssertTrue(pumpMainThread(until: { recorder.appendedFrameSnapshotForTesting().count >= 3 })) @@ -449,7 +449,7 @@ extension RunnerTests { try self.runMainThreadWork( "command_execution", timeout: Self.mainThreadExecutionTimeout, - timeoutError: self.mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { Thread.sleep(forTimeInterval: self.recordingFrameCaptureTimeout + 0.3) } @@ -480,7 +480,7 @@ extension RunnerTests { let interval = 1.0 / Double(fps) let source = RecordingFrameSource(captureDelay: interval * 1.6) let recorder = ScreenRecorder(outputPath: recordingTestOutputPath(), fps: fps) - try startRecording(recorder, capture: source.capture) + try startRecording(recorder, from: source) defer { try? recorder.stop() } XCTAssertTrue(pumpMainThread(until: { recorder.appendedFrameSnapshotForTesting().count >= 2 })) @@ -501,6 +501,10 @@ extension RunnerTests { XCTAssertFalse(occupancy.sawBusy, "an ordinary slow capture keeps the runner available") } + private func startRecording(_ recorder: ScreenRecorder, from source: RecordingFrameSource) throws { + try MainActor.assumeIsolated { try startRecording(recorder) { source.capture() } } + } + private func recordingTestOutputPath() -> String { (NSTemporaryDirectory() as NSString).appendingPathComponent( "record-bounded-\(UUID().uuidString).mp4" diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScrollDragExecutionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScrollDragExecutionTests.swift index 440f19fe90..861556d759 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScrollDragExecutionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ScrollDragExecutionTests.swift @@ -23,6 +23,7 @@ private final class RunnerSynthesizedSwipeFailureStub: NSObject { #if AGENT_DEVICE_RUNNER_UNIT_TESTS extension RunnerTests { #if os(iOS) + @MainActor func testSinglePointerFlingFallsBackToXCTestCoordinateDragWhenPrivateSynthesisFails() throws { let selector = NSSelectorFromString( "synthesizeSwipeWithApplication:resolvedWindow:x:y:x2:y2:durationMs:" @@ -40,7 +41,7 @@ extension RunnerTests { method_getImplementation(failureStubMethod) ) app.launch() - runnerAccessibilityHealth = .healthy + mainOwned.accessibilityHealth = .healthy defer { method_setImplementation(synthesizedSwipeMethod, originalImplementation) invalidateCachedTarget(reason: "unit_test_cleanup") diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SelectorMatchPolicyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SelectorMatchPolicyTests.swift index 84afb05187..2d3249f90f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SelectorMatchPolicyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SelectorMatchPolicyTests.swift @@ -84,6 +84,7 @@ extension RunnerTests { } #if os(iOS) + @MainActor func testQuerySelectorPrefersHittableMatchOverNonHittableDuplicate() throws { let duplicateIdentifier = "agent-device-selector-read-duplicate" app.launchArguments = ["--agent-device-selector-read-regression"] diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SequenceExecutionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SequenceExecutionTests.swift index 8535c29ebb..ab069702e5 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SequenceExecutionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SequenceExecutionTests.swift @@ -20,6 +20,7 @@ extension RunnerTests { XCTAssertEqual(command.steps?[2].pauseMs, 50) } + @MainActor func testSequenceAcceptsDoubleTapKind() { // A doubleTap step missing coords must fail on the coords check, not the kind allowlist — // proving "doubleTap" passes validateSequenceStep without needing a device to execute on. @@ -32,6 +33,7 @@ extension RunnerTests { XCTAssertFalse(response.error?.message.contains("unsupported kind") ?? true) } + @MainActor func testSequenceRejectsUnknownKind() throws { let response = executeSequenceForTest(steps: [ sequenceStep(kind: "tap", x: 1, y: 2), @@ -43,12 +45,14 @@ extension RunnerTests { XCTAssertTrue(response.error?.message.contains("pinch") ?? false) } + @MainActor func testSequenceRejectsEmpty() { let response = executeSequenceForTest(steps: []) XCTAssertEqual(response.ok, false) XCTAssertEqual(response.error?.code, "INVALID_ARGS") } + @MainActor func testSequenceRejectsTooManySteps() { let steps = (0..<21).map { _ in sequenceStep(kind: "tap", x: 1, y: 2) } let response = executeSequenceForTest(steps: steps) @@ -151,6 +155,7 @@ extension RunnerTests { /// Validation runs before any executor call, so the INVALID_ARGS paths are exercised without /// reaching the device executor (which is never invoked when validation rejects). + @MainActor private func executeSequenceForTest(steps: [SequenceStep]) -> Response { let command = makeSequenceCommand(steps: steps) return executeSequence(command: command, activeApp: app) @@ -172,10 +177,11 @@ extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) extension RunnerTests { + @MainActor func testSynthesizedSequenceTapFallsBackToXCTestCoordinateTapWhenAccessibilityIsUnavailable() throws { let restoreSynthesizedTap = try forceSynthesizedTapFailure() app.launch() - currentApp = app + mainOwned.app = app defer { restoreSynthesizedTap() invalidateCachedTarget(reason: "unit_test_cleanup") @@ -184,7 +190,7 @@ extension RunnerTests { let label = app.staticTexts["Agent Device Runner"] XCTAssertTrue(label.waitForExistence(timeout: appExistenceTimeout)) let point = CGPoint(x: label.frame.midX, y: label.frame.midY) - runnerAccessibilityHealth = .unavailable + mainOwned.accessibilityHealth = .unavailable let command = try runnerCommandFixture( #"{"command":"sequence","commandId":"sequence-synthesized-tap-fallback","steps":[{"kind":"tap","x":\#(point.x),"y":\#(point.y),"synthesized":true}]}"# ) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift index 4cc217323e..285e1fd461 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanOccupancyTests.swift @@ -94,10 +94,12 @@ extension RunnerTests { // resolution is slow, and it must not be the block the plan abandons. XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) XCTAssertFalse(app.frame.isEmpty) - currentApp = app - currentBundleId = "com.callstack.agentdevice.runner.tree-capture-test" snapshotXCTestPenaltyWarmupExemption.isPending = true - let captureTarget = takeSnapshotCaptureTarget(app: app) + let captureTarget = MainActor.assumeIsolated { + mainOwned.app = app + mainOwned.bundleId = "com.callstack.agentdevice.runner.tree-capture-test" + return takeSnapshotCaptureTarget(app: app) + } RunnerBlockingSnapshotGate.release = DispatchSemaphore(value: 0) RunnerBlockingSnapshotGate.entered = DispatchSemaphore(value: 0) let originalImplementation = method_getImplementation(snapshotMethod) @@ -107,7 +109,9 @@ extension RunnerTests { method_setImplementation(snapshotMethod, originalImplementation) clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") clearPrivateAXAcceptedDepth(reason: "test-cleanup") - invalidateCachedTarget(reason: "unit_test_cleanup") + MainActor.assumeIsolated { + invalidateCachedTarget(reason: "unit_test_cleanup") + } app.terminate() } @@ -200,16 +204,20 @@ extension RunnerTests { app.launch() XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) XCTAssertFalse(app.frame.isEmpty) - currentApp = app - currentBundleId = "com.callstack.agentdevice.runner.query-sweep-slice-test" - let captureTarget = takeSnapshotCaptureTarget(app: app) + let captureTarget = MainActor.assumeIsolated { + mainOwned.app = app + mainOwned.bundleId = "com.callstack.agentdevice.runner.query-sweep-slice-test" + return takeSnapshotCaptureTarget(app: app) + } RunnerSlowSweepQueryGate.reset() let originalImplementation = method_getImplementation(queryMethod) method_setImplementation(queryMethod, method_getImplementation(stubMethod)) defer { method_setImplementation(queryMethod, originalImplementation) clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") - invalidateCachedTarget(reason: "unit_test_cleanup") + MainActor.assumeIsolated { + invalidateCachedTarget(reason: "unit_test_cleanup") + } app.terminate() } @@ -283,19 +291,23 @@ extension RunnerTests { app.launchArguments = ["--agent-device-selector-read-regression"] app.launch() XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10)) - currentApp = app - currentBundleId = "com.callstack.agentdevice.runner.query-sweep-timeout-test" + MainActor.assumeIsolated { + mainOwned.app = app + mainOwned.bundleId = "com.callstack.agentdevice.runner.query-sweep-timeout-test" + } snapshotXCTestPenaltyWarmupExemption.isPending = false clearSnapshotXCTestChannelPenalty(reason: "test-setup") RunnerSlowSweepQueryGate.reset() - let captureTarget = takeSnapshotCaptureTarget(app: app) + let captureTarget = MainActor.assumeIsolated { takeSnapshotCaptureTarget(app: app) } let originalImplementation = method_getImplementation(queryMethod) method_setImplementation(queryMethod, method_getImplementation(stubMethod)) defer { method_setImplementation(queryMethod, originalImplementation) clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") clearPrivateAXAcceptedDepth(reason: "test-cleanup") - invalidateCachedTarget(reason: "unit_test_cleanup") + MainActor.assumeIsolated { + invalidateCachedTarget(reason: "unit_test_cleanup") + } app.terminate() } @@ -328,7 +340,7 @@ extension RunnerTests { do { box.secondPayload = try self.runSnapshotCapturePlan( Self.regularVisiblePlan, - target: self.takeSnapshotCaptureTarget(app: self.app), + target: captureTarget, options: PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false), terminal: .sparseWithFatalOnAXFailure, deadline: Date().addingTimeInterval(20) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift index b1ea3656d3..96d5eb1a3f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCapturePlanTests.swift @@ -478,13 +478,14 @@ extension RunnerTests { /// and presentation. With a backend depth gate in `captureWithBackend`, private AX returns no /// capture, the plan falls through to the synthetic sparse root, and the daemon rejects that /// zero-rect root as a missing viewport. + @MainActor func testPrivateAXPinnedRegularDepthReachesAcquisitionAndPresentation() throws { app.launchArguments = ["--agent-device-selector-read-regression"] app.launch() - currentApp = app - currentBundleId = nil + mainOwned.app = app + mainOwned.bundleId = nil defer { - currentApp = nil + mainOwned.app = nil clearPrivateAXAcceptedDepth(reason: "test-cleanup") app.terminate() } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCaptureTargetTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCaptureTargetTests.swift index 2139a30ccb..837ce27522 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCaptureTargetTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotCaptureTargetTests.swift @@ -3,16 +3,17 @@ import AgentDeviceSnapshotPresentation extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS + @MainActor func testSnapshotCaptureTargetKeepsPreparedIdentityAndLeavesWarmupExemptionPending() { - currentApp = app - currentBundleId = "com.example.prepared" - currentAppProcessIdentifier = 42 + mainOwned.app = app + mainOwned.bundleId = "com.example.prepared" + mainOwned.processIdentifier = 42 snapshotXCTestPenaltyWarmupExemption.isPending = true defer { invalidateCachedTarget(reason: "unit_test_cleanup") } let target = takeSnapshotCaptureTarget(app: app) - currentBundleId = "com.example.replaced" - currentAppProcessIdentifier = 43 + mainOwned.bundleId = "com.example.replaced" + mainOwned.processIdentifier = 43 XCTAssertTrue(target.app === app) XCTAssertEqual(target.bundleId, "com.example.prepared") @@ -24,15 +25,16 @@ extension RunnerTests { } #if os(iOS) + @MainActor func testBlockingModalSnapshotLeavesWarmupExemptionForTheFirstCapturePlan() throws { - currentApp = app - currentBundleId = "com.example.fresh-process" - currentAppProcessIdentifier = 42 + mainOwned.app = app + mainOwned.bundleId = "com.example.fresh-process" + mainOwned.processIdentifier = 42 snapshotXCTestPenaltyWarmupExemption.isPending = true systemModalProbeOverrideForTesting = { _ in DataPayload(message: "blocking system modal") } defer { systemModalProbeOverrideForTesting = nil - runnerAccessibilityHealth = .unknown + mainOwned.accessibilityHealth = .unknown invalidateCachedTarget(reason: "unit_test_cleanup") } let options = PresentationOptions(interactiveOnly: false, depth: nil, scope: nil, raw: false) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotExecutionTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotExecutionTests.swift index fa637a853b..59e6eaefa0 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotExecutionTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotExecutionTests.swift @@ -7,7 +7,9 @@ extension RunnerTests { abandonedMainThreadWorkCount = 1 defer { abandonedMainThreadWorkCount = 0 - needsPostSnapshotInteractionDelay = false + MainActor.assumeIsolated { + mainOwned.needsPostSnapshotInteractionDelay = false + } } let finished = expectation(description: "off-main caller finished") @@ -21,12 +23,20 @@ extension RunnerTests { let abandonedWorkCount = abandonedMainThreadWorkCount mainThreadWorkLock.unlock() XCTAssertEqual(abandonedWorkCount, 1, "the skipped mark must not add an abandoned unit") - XCTAssertFalse(needsPostSnapshotInteractionDelay) + MainActor.assumeIsolated { + XCTAssertFalse(mainOwned.needsPostSnapshotInteractionDelay) + } } func testSnapshotFailureInvalidationQueuesBehindAbandonedMainThreadWorkWithoutWaiting() { - currentBundleId = "com.example.stale-target" - defer { currentBundleId = nil } + MainActor.assumeIsolated { + mainOwned.bundleId = "com.example.stale-target" + } + defer { + MainActor.assumeIsolated { + mainOwned.bundleId = nil + } + } final class ResultBox { var elapsed: TimeInterval? @@ -42,17 +52,17 @@ extension RunnerTests { _ = try? self.runMainThreadWork( "command_execution", timeout: 0, - timeoutError: self.mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { mainBlocked.signal() _ = releaseMain.wait(timeout: .now() + 5) + box.bundleStillCachedWhileBlocked = self.mainOwned.bundleId != nil 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() @@ -62,7 +72,9 @@ extension RunnerTests { wait(for: [finished], timeout: 8) let drainDeadline = Date().addingTimeInterval(2) - while hasAbandonedMainThreadWork() || currentBundleId != nil, Date() < drainDeadline { + while hasAbandonedMainThreadWork() || MainActor.assumeIsolated({ mainOwned.bundleId }) != nil, + Date() < drainDeadline + { sleepFor(0.005) } @@ -78,7 +90,9 @@ extension RunnerTests { ) 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") + MainActor.assumeIsolated { + XCTAssertNil(mainOwned.bundleId, "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 20b3b9631e..93cb5ee709 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTests.swift @@ -3,9 +3,10 @@ import AgentDeviceSnapshotPresentation #if AGENT_DEVICE_RUNNER_UNIT_TESTS extension RunnerTests { + @MainActor func testSnapshotAccessibilityUnavailableMarksSparseSnapshotRunnerFatal() { - currentApp = app - currentBundleId = "com.example.app" + mainOwned.app = app + mainOwned.bundleId = "com.example.app" let payload = snapshotAccessibilityUnavailable( failure: SnapshotCaptureFailure( @@ -26,8 +27,8 @@ extension RunnerTests { XCTAssertEqual(payload.snapshotQuality?.state, .sparse) XCTAssertEqual(payload.snapshotQuality?.reasonCode, "ax-rejected") XCTAssertEqual(payload.snapshotQuality?.reason, Self.axSnapshotFailureMessage) - XCTAssertNil(currentApp) - XCTAssertNil(currentBundleId) + XCTAssertNil(mainOwned.app) + XCTAssertNil(mainOwned.bundleId) } func testRecoveredSnapshotMessagePreservesHint() { @@ -62,11 +63,15 @@ extension RunnerTests { } func testSnapshotAccessibilityUnavailableQueuesInvalidationBehindAbandonedMainThreadWork() { - currentBundleId = "com.example.stale-target" - runnerAccessibilityHealth = .healthy + MainActor.assumeIsolated { + mainOwned.bundleId = "com.example.stale-target" + mainOwned.accessibilityHealth = .healthy + } defer { - currentBundleId = nil - runnerAccessibilityHealth = .unknown + MainActor.assumeIsolated { + mainOwned.bundleId = nil + mainOwned.accessibilityHealth = .unknown + } } final class ResultBox { @@ -85,10 +90,12 @@ extension RunnerTests { _ = try? self.runMainThreadWork( "command_execution", timeout: 0, - timeoutError: self.mainThreadExecutionTimeoutError + timeoutError: Self.mainThreadExecutionTimeoutError ) { mainBlocked.signal() _ = releaseMain.wait(timeout: .now() + 5) + box.bundleStillCachedWhileBlocked = self.mainOwned.bundleId != nil + box.healthWhileBlocked = self.mainOwned.accessibilityHealth return true } _ = mainBlocked.wait(timeout: .now() + 2) @@ -101,8 +108,6 @@ extension RunnerTests { ) ) box.elapsed = Date().timeIntervalSince(startedAt) - box.bundleStillCachedWhileBlocked = self.currentBundleId != nil - box.healthWhileBlocked = self.runnerAccessibilityHealth self.mainThreadWorkLock.lock() box.abandonedWhileBlocked = self.abandonedMainThreadWorkCount self.mainThreadWorkLock.unlock() @@ -112,7 +117,9 @@ extension RunnerTests { wait(for: [finished], timeout: 8) let drainDeadline = Date().addingTimeInterval(2) - while hasAbandonedMainThreadWork() || currentBundleId != nil, Date() < drainDeadline { + while hasAbandonedMainThreadWork() || MainActor.assumeIsolated({ mainOwned.bundleId }) != nil, + Date() < drainDeadline + { sleepFor(0.005) } @@ -134,8 +141,10 @@ extension RunnerTests { ) XCTAssertEqual(box.abandonedWhileBlocked, 1, "the deferred write must not add an abandoned unit") XCTAssertFalse(hasAbandonedMainThreadWork()) - XCTAssertNil(currentBundleId, "the invalidation must run once the main thread frees") - XCTAssertEqual(runnerAccessibilityHealth, .unavailable) + MainActor.assumeIsolated { + XCTAssertNil(mainOwned.bundleId, "the invalidation must run once the main thread frees") + XCTAssertEqual(mainOwned.accessibilityHealth, .unavailable) + } } func testQuerySweepSliceDeadlineIsTheTierSliceNotThePlanDeadline() { @@ -241,13 +250,17 @@ extension RunnerTests { let targetBundleId = "com.callstack.agentdevice.runner.missing.snapshot-timeout-test" let snapshotTarget = XCUIApplication(bundleIdentifier: targetBundleId) let probeReleaseGate = DispatchSemaphore(value: 0) - currentApp = snapshotTarget - currentBundleId = targetBundleId - let captureTarget = takeSnapshotCaptureTarget(app: snapshotTarget) + let captureTarget = MainActor.assumeIsolated { + mainOwned.app = snapshotTarget + mainOwned.bundleId = targetBundleId + return takeSnapshotCaptureTarget(app: snapshotTarget) + } defer { probeReleaseGate.signal() - currentApp = nil - currentBundleId = nil + MainActor.assumeIsolated { + mainOwned.app = nil + mainOwned.bundleId = nil + } systemModalProbeOverrideForTesting = nil clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTimingTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTimingTests.swift index b815c96725..822f8dbab9 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTimingTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SnapshotTimingTests.swift @@ -106,14 +106,17 @@ extension RunnerTests { } func testSnapshotPhaseTimerReportsAcquisitionAndPresentationSeparately() { - var now = Date(timeIntervalSinceReferenceDate: 100) - var timer = SnapshotPhaseTimer(now: { now }) + final class Clock { + var now = Date(timeIntervalSinceReferenceDate: 100) + } + let clock = Clock() + var timer = SnapshotPhaseTimer(now: { clock.now }) _ = timer.measure(.acquisition) { - now = now.addingTimeInterval(2) + clock.now = clock.now.addingTimeInterval(2) } _ = timer.measure(.presentation) { - now = now.addingTimeInterval(5) + clock.now = clock.now.addingTimeInterval(5) } XCTAssertEqual(timer.timing.acquisitionMs, 2_000, accuracy: 0.001) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedGesturePolicyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedGesturePolicyTests.swift index ed7d2cb4ab..3b0d3a217d 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedGesturePolicyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedGesturePolicyTests.swift @@ -2,6 +2,7 @@ import XCTest #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) extension RunnerTests { + @MainActor func testSynthesizedGesturePolicyMarkerWritesOncePerKindUntilTheDecisionChanges() { var written: [String] = [] runnerMarkerWriter = { written.append($0) } @@ -105,9 +106,10 @@ extension RunnerTests { XCTAssertNil(synthesizedPolicyKind(forSequenceStep: sequenceStep("longPress", synthesized: true))) } + @MainActor func testFailedCoordinateTapSynthesisFallsBackToXCTestAtEveryAccessibilityHealth() { for health: RunnerAccessibilityHealth in [.unknown, .healthy, .unavailable] { - runnerAccessibilityHealth = health + mainOwned.accessibilityHealth = health for context in [nil, synthesizedGestureTestContext(accessibilityHealth: health)] { let label = "axHealth=\(health.rawValue) context=\(context == nil ? "unresolved" : "resolved")" let attempt = performSynthesizedGesture( @@ -121,8 +123,9 @@ extension RunnerTests { } } + @MainActor func testSynthesizedGestureFallbackFollowsItsKindAndTheResolvedAccessibilityHealth() { - runnerAccessibilityHealth = .healthy + mainOwned.accessibilityHealth = .healthy let unavailable = synthesizedGestureTestContext(accessibilityHealth: .unavailable) let unknown = synthesizedGestureTestContext(accessibilityHealth: .unknown) let cases: [(SynthesizedGesturePolicyKind, SynthesizedCoordinateContext?, String)] = [ diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift index 641ee31e18..8b7ddf5a73 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+SynthesizedTextEntryTests.swift @@ -34,15 +34,16 @@ extension RunnerTests { /// Launches the text-entry fixture, focuses its field, and penalizes the XCTest channel, so a /// coordinate replacement takes the synthesized first-responder route. + @MainActor func focusSynthesizedReplacementField(extraLaunchArguments: [String] = []) throws -> XCUIElement { app.launchArguments = ["--agent-device-text-entry-regression"] + extraLaunchArguments app.launch() XCTAssertTrue(app.waitForExistence(timeout: appExistenceTimeout)) let textField = app.textFields["agent-device-hardware-keyboard-input"] XCTAssertTrue(textField.waitForExistence(timeout: appExistenceTimeout)) - currentApp = app - currentBundleId = "com.callstack.agentdevice.runner" - currentAppProcessIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) + mainOwned.app = app + mainOwned.bundleId = "com.callstack.agentdevice.runner" + mainOwned.processIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) let focusCommand = try runnerCommandFixture( #"{"command":"tap","commandId":"tap-replacement-field","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"# ) @@ -52,6 +53,7 @@ extension RunnerTests { return textField } + @MainActor func replaceSynthesizedFieldText( _ textField: XCUIElement, text: String, @@ -77,6 +79,7 @@ extension RunnerTests { return response } + @MainActor func tearDownSynthesizedReplacementField() { clearSnapshotXCTestChannelPenalty(reason: "test-cleanup") invalidateCachedTarget(reason: "unit_test_cleanup") @@ -97,6 +100,7 @@ extension RunnerTests { /// this window keeps up with one particular burst is not something the runner can promise. /// - The runner's: a field the app rewrote mid-burst never reports ok. An ok over a short value /// was the original defect. + @MainActor func testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow() throws { let window = TextEntryTiming.synthesizedAcknowledgeWindowSeconds let textField = try focusSynthesizedReplacementField(extraLaunchArguments: [ @@ -133,6 +137,7 @@ extension RunnerTests { /// A replacement the command budget cannot carry is refused before the first character is posted, /// so a `fill` cannot end in a transport timeout that leaves the runner typing into a field nobody /// is waiting for and the next command finding it busy. + @MainActor func testSynthesizedReplacementRefusesTextBeyondTheDeliveryBudget() throws { let textField = try focusSynthesizedReplacementField() defer { tearDownSynthesizedReplacementField() } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift index a2fb1c1bf9..f5d777a172 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextEntryPolicyTests.swift @@ -334,6 +334,7 @@ extension RunnerTests { } #if os(iOS) + @MainActor func testTypeTextReliablyPacesSynthesizedReplacementThroughProductionCaller() { let synthesizer = RecordingTextEntrySynthesizer() // Springboard, not a bare `XCUIApplication()`: the commit wait now really polls (see below), @@ -393,6 +394,7 @@ extension RunnerTests { // Springboard's home screen has no focused text input — the empty-text replacement path must // fail closed: it used to fall through to the vacuous-typing early return and report // `verified: true` for a clear that never ran. + @MainActor func testEmptyReplacementWithoutResolvableTargetFailsClosed() { let result = typeTextReliably( app: springboard, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift index 28686cec65..aca9d03c6a 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextInputProbeTests.swift @@ -52,6 +52,7 @@ extension RunnerTests { XCTAssertEqual(scope.count, 1) } + @MainActor func testHealthyCoordinateTapPreservesBareTypingWitness() throws { app.launchArguments = ["--agent-device-text-entry-regression"] app.launch() @@ -62,9 +63,9 @@ extension RunnerTests { let field = app.textFields["agent-device-hardware-keyboard-input"] XCTAssertTrue(field.waitForExistence(timeout: appExistenceTimeout)) let frame = field.frame - currentApp = app - currentBundleId = "com.callstack.agentdevice.runner" - currentAppProcessIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) + mainOwned.app = app + mainOwned.bundleId = "com.callstack.agentdevice.runner" + mainOwned.processIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) clearSnapshotXCTestChannelPenalty(reason: "fresh-runner") let failures = currentXCTestFailureCount() let tap = try runnerCommandFixture( @@ -73,7 +74,7 @@ extension RunnerTests { let tapped = try execute(command: tap) XCTAssertTrue(tapped.ok, String(describing: tapped.error)) XCTAssertNotNil(textEntryTapWitness) - XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)) + XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: mainOwned.bundleId)) try XCTSkipIf(isKeyboardVisible(app: app), "software keyboard is up; hidden-keyboard witness cannot be exercised") let type = try runnerCommandFixture(#"{"appBundleId":"com.callstack.agentdevice.runner","command":"type","commandId":"type-healthy-probe","text":"probe-witness","textEntryMode":"append"}"#) let typed = try execute(command: type) @@ -128,6 +129,7 @@ extension RunnerTests { } } + @MainActor func testFreshCoordinateTapContainsUnavailableTextInputProbe() throws { app.launchArguments = ["--agent-device-text-entry-regression"] app.launch() @@ -140,9 +142,9 @@ extension RunnerTests { let target = app.staticTexts["Agent Device Runner"] XCTAssertTrue(target.waitForExistence(timeout: appExistenceTimeout)) let frame = target.frame - currentApp = app - currentBundleId = "com.callstack.agentdevice.runner" - currentAppProcessIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) + mainOwned.app = app + mainOwned.bundleId = "com.callstack.agentdevice.runner" + mainOwned.processIdentifier = try XCTUnwrap(Self.processIdentifier(of: app)) clearSnapshotXCTestChannelPenalty(reason: "fresh-runner") let failures = currentXCTestFailureCount() textInputProbeIssueForTesting = XCTIssue(type: .assertionFailure, compactDescription: "Injected optional text input query failure") @@ -152,7 +154,7 @@ extension RunnerTests { let response = try execute(command: command) XCTAssertTrue(response.ok, String(describing: response.error)) XCTAssertFalse(didRecordXCTestFailure(since: failures)) - XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: currentBundleId)) + XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: mainOwned.bundleId)) XCTAssertNil(textEntryTapWitness) let type = try runnerCommandFixture(#"{"appBundleId":"com.callstack.agentdevice.runner","command":"type","commandId":"type-after-unavailable-probe","text":"must-not-type","textEntryMode":"append"}"#) let typed = try execute(command: type) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift index 737a53c1e1..46d59bdeea 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TextTypingTests.swift @@ -4,6 +4,7 @@ import XCTest // `textEntryMode: "append"`, and the bare submit key with no mode. extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS) + @MainActor func testTypeWithoutResolvedInputReturnsTypedFailureBeforeDispatchingText() throws { let command = try runnerCommandFixture( #"{"command":"type","commandId":"type-without-focus","text":"hello","textEntryMode":"append"}"# @@ -22,6 +23,7 @@ extension RunnerTests { ) } + @MainActor func testBareTypeUsesTappedInputWhenSoftwareKeyboardIsHidden() throws { // The fixture uses a real text responder with an empty input view to model hardware-keyboard input. let textField = try launchHardwareKeyboardFixture() @@ -74,6 +76,7 @@ extension RunnerTests { XCTAssertEqual(textField.value as? String, "hardware-keyboard-again") } + @MainActor func testBareSubmitKeyUsesSynthesizedFirstResponderAfterHiddenKeyboardTap() throws { _ = try launchHardwareKeyboardFixture() try tapHardwareKeyboardInput(commandId: "tap-hardware-keyboard-submit") @@ -93,6 +96,7 @@ extension RunnerTests { XCTAssertNil(textEntryTapWitness, "the submit must consume the tap witness it was addressed by") } + @MainActor func testBareSubmitKeyRefusesWhenPrivateSynthesisIsUnavailable() throws { let textField = try launchHardwareKeyboardFixture() try skipUnlessSoftwareKeyboardIsHidden() @@ -116,6 +120,7 @@ extension RunnerTests { XCTAssertFalse(didRecordXCTestFailure(since: failureCountBefore)) } + @MainActor func testBareDelayedTypeFailsWhenTappedInputDisappearsMidCommand() throws { app.launchArguments = [ "--agent-device-text-entry-regression", @@ -154,6 +159,7 @@ extension RunnerTests { // pieces inside the budget and pace all these characters. The target carries no element by // construction, so nothing on that route can read the value back: the command reports it // unverified and this test reads the field itself to show every character arrived. + @MainActor func testOverBudgetTypeWithoutResolvableElementTypesApplicationWide() throws { app.launchArguments = [ "--agent-device-text-entry-regression", @@ -235,6 +241,7 @@ extension RunnerTests { return textField } + @MainActor private func tapHardwareKeyboardInput(commandId: String) throws { let tapCommand = try runnerCommandFixture( #"{"command":"tap","commandId":"\#(commandId)","selectorKey":"id","selectorValue":"agent-device-hardware-keyboard-input"}"# diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift index bdbd9b5e8c..6595fd31f8 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+TransportTests.swift @@ -8,8 +8,11 @@ extension RunnerTests { Command.self, from: Data(#"{"command":"snapshot","commandId":"snapshot-coalesce"}"#.utf8) ) - var primaryData: Data? - var waiterData: Data? + final class Delivered { + var primaryData: Data? + var waiterData: Data? + } + let delivered = Delivered() defer { inFlightCommandIds.removeAll() inFlightCommandWaiters.removeAll() @@ -17,25 +20,25 @@ extension RunnerTests { XCTAssertFalse( attachToInFlightCommandIfNeeded(command: command) { result in - primaryData = result.data + delivered.primaryData = result.data } ) XCTAssertTrue( attachToInFlightCommandIfNeeded(command: command) { result in - waiterData = result.data + delivered.waiterData = result.data } ) - let delivered = Data("single-result".utf8) + let result = Data("single-result".utf8) deliverCommandResult( command: command, - result: (delivered, false) + result: (result, false) ) { result in - primaryData = result.data + delivered.primaryData = result.data } - XCTAssertEqual(primaryData, delivered) - XCTAssertEqual(waiterData, delivered) + XCTAssertEqual(delivered.primaryData, result) + XCTAssertEqual(delivered.waiterData, result) XCTAssertFalse(inFlightCommandIds.contains("snapshot-coalesce")) XCTAssertNil(inFlightCommandWaiters["snapshot-coalesce"]) } diff --git a/scripts/__tests__/runner-isolation-diagnostics.test.ts b/scripts/__tests__/runner-isolation-diagnostics.test.ts new file mode 100644 index 0000000000..cd86eb5254 --- /dev/null +++ b/scripts/__tests__/runner-isolation-diagnostics.test.ts @@ -0,0 +1,187 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { describe, expect, test } from 'vitest'; +import { mkdtempForTestSync } from '../../src/__tests__/test-utils/tmp-dir.ts'; +import { + ISOLATION_CANARY_PATH, + isolationCanaryLines, + scanRunnerBuildLog, +} from '../runner-isolation-diagnostics.ts'; + +const repoRoot = path.resolve(import.meta.dirname, '..', '..'); +const RUNNER = '/src/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests'; + +// The four warnings the base build printed on CI run 35981303070 (#2882). None is an isolation +// diagnostic, so a log carrying only them passes. +const BASELINE_WARNINGS = [ + `${RUNNER}/RunnerTests+Lifecycle.swift:313:11: warning: conditional cast from 'NSNumber' to 'NSNumber' always succeeds`, + `${RUNNER}/RunnerXCTestEventBridge.h:67:16: warning: pointer is missing a nullability type specifier (_Nonnull, _Nullable, or _Null_unspecified)`, + `${RUNNER}/UnitTests/RunnerTests+SnapshotTimingTests.swift:112:5: warning: using '_' to ignore the result of a Void-returning function is redundant`, + `${RUNNER}/UnitTests/RunnerTests+SnapshotTimingTests.swift:115:5: warning: using '_' to ignore the result of a Void-returning function is redundant`, +]; +const MAIN_ACTOR_ISOLATED_WARNING = `${RUNNER}/RunnerTests+Transport.swift:201:26: warning: main actor-isolated property 'bundleId' can not be referenced from a Sendable closure`; +const LOSES_GLOBAL_ACTOR_WARNING = `${RUNNER}/RunnerTests+Lifecycle.swift:451:61: warning: converting function value of type '@MainActor () -> ()' to '() -> ()' loses global actor 'MainActor'`; + +const CANARY_SOURCE = fs.readFileSync(path.join(repoRoot, ISOLATION_CANARY_PATH), 'utf8'); +const CANARY_FILE = `${RUNNER}/RunnerIsolationCanary.swift`; +const [READ_LINE = 0, CALL_LINE = 0, DROP_LINE = 0, CAPTURE_LINE = 0] = + isolationCanaryLines(CANARY_SOURCE); +// What Swift 6.2.3 (Xcode 26.2) prints for the canary under the runner's flags. +const CANARY_DIAGNOSTICS = [ + `${CANARY_FILE}:${READ_LINE}:17: warning: main actor-isolated property 'bundleId' can not be referenced from a Sendable closure`, + `${CANARY_FILE}:${CALL_LINE}:7: warning: call to main actor-isolated parameter 'work' in a synchronous nonisolated context [#ActorIsolatedCall]`, + `${CANARY_FILE}:${DROP_LINE}:5: warning: converting function value of type '@MainActor @Sendable () -> Void' to '@Sendable () -> Void' loses global actor 'MainActor'; this is an error in the Swift 6 language mode`, + `${CANARY_FILE}:${CAPTURE_LINE}:7: warning: capture of 'counter' with non-Sendable type 'RunnerIsolationCanary.Counter' in a '@Sendable' closure [#SendableClosureCaptures]`, +]; + +function scan(...lines: string[]) { + return scanRunnerBuildLog(log(...lines), CANARY_SOURCE); +} + +function log(...lines: string[]): string { + return [ + 'CompileSwift normal arm64 (in target AgentDeviceRunnerUITests)', + ...lines, + '** TEST BUILD SUCCEEDED **', + '', + ].join('\n'); +} + +describe('scanRunnerBuildLog', () => { + test('a log carrying the baseline warnings and the full canary passes', () => { + expect(scan(...BASELINE_WARNINGS, ...CANARY_DIAGNOSTICS)).toEqual({ + violations: [], + missingCanaryLines: [], + }); + }); + + test('main actor-isolated and loses-global-actor warnings are reported once each', () => { + expect( + scan( + ...BASELINE_WARNINGS, + ...CANARY_DIAGNOSTICS, + MAIN_ACTOR_ISOLATED_WARNING, + ` 201 | _ = self.mainOwned.bundleId`, + " | `- warning: main actor-isolated property 'bundleId' can not be referenced from a Sendable closure", + LOSES_GLOBAL_ACTOR_WARNING, + MAIN_ACTOR_ISOLATED_WARNING, + ).violations, + ).toEqual([MAIN_ACTOR_ISOLATED_WARNING, LOSES_GLOBAL_ACTOR_WARNING]); + }); + + test('an isolation error line is reported like a warning', () => { + const error = `${RUNNER}/RunnerTests+ScreenRecorder.swift:308:11: error: call to main actor-isolated parameter 'capture' in a synchronous nonisolated context [#ActorIsolatedCall]`; + expect(scan(...CANARY_DIAGNOSTICS, error).violations).toEqual([error]); + }); + + test('a Sendable-capture diagnostic is reported by its group or by its prose', () => { + const grouped = `${RUNNER}/RunnerTests.swift:319:9: warning: capture of 'timer' with non-Sendable type 'Timer' in a '@Sendable' closure [#SendableClosureCaptures]`; + const ungrouped = `${RUNNER}/RunnerTests.swift:320:9: warning: mutation of captured var 'count' in concurrently-executing code`; + expect(scan(...CANARY_DIAGNOSTICS, grouped, ungrouped).violations).toEqual([ + grouped, + ungrouped, + ]); + }); + + test('a build log without the canary fails on every canary line', () => { + expect(scan(...BASELINE_WARNINGS)).toEqual({ + violations: [], + missingCanaryLines: [READ_LINE, CALL_LINE, DROP_LINE, CAPTURE_LINE], + }); + }); + + test('a reworded canary diagnostic the scan no longer matches fails its line', () => { + const reworded = `${CANARY_FILE}:${READ_LINE}:17: warning: property 'bundleId' belongs to the main actor and is read from a Sendable closure`; + expect(scan(reworded, ...CANARY_DIAGNOSTICS.slice(1)).missingCanaryLines).toEqual([READ_LINE]); + }); + + test('a concurrency diagnostic on an unmarked canary line is a violation', () => { + const stray = `${CANARY_FILE}:${READ_LINE - 1}:5: warning: main actor-isolated property 'bundleId' can not be referenced from a Sendable closure`; + expect(scan(...CANARY_DIAGNOSTICS, stray).violations).toEqual([stray]); + }); +}); + +// Runs the real build script against a stand-in `xcodebuild` that prints `output` and exits with +// `status`, so the scan and the status plumbing are exercised without Xcode. +function runBuildScript( + output: string, + status: number, + options: { reuseDerivedData?: boolean } = {}, +) { + const root = mkdtempForTestSync('runner-isolation-scan-'); + const bin = path.join(root, 'bin'); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(root, 'xcodebuild-output.txt'), output); + const fakeXcodebuild = path.join(bin, 'xcodebuild'); + const xcodebuildArgs = path.join(root, 'xcodebuild-args.txt'); + fs.writeFileSync( + fakeXcodebuild, + `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(xcodebuildArgs)}\ncat ${JSON.stringify(path.join(root, 'xcodebuild-output.txt'))}\nexit ${status}\n`, + ); + fs.chmodSync(fakeXcodebuild, 0o755); + const derived = path.join(root, 'derived'); + if (options.reuseDerivedData) { + fs.mkdirSync(path.join(derived, 'Build', 'Intermediates.noindex'), { recursive: true }); + } + const result = spawnSync('sh', ['scripts/build-xcuitest-apple.sh'], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ''}`, + AGENT_DEVICE_XCUITEST_PLATFORM: 'macos', + AGENT_DEVICE_XCUITEST_DESTINATION: 'platform=macOS', + AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: derived, + }, + }); + return { ...result, derived, xcodebuildArgs }; +} + +describe('scripts/build-xcuitest-apple.sh isolation scan', () => { + test('a build that printed an isolation warning fails after succeeding', () => { + const result = runBuildScript( + log(...BASELINE_WARNINGS, ...CANARY_DIAGNOSTICS, MAIN_ACTOR_ISOLATED_WARNING), + 0, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain(MAIN_ACTOR_ISOLATED_WARNING); + expect(result.stderr).toMatch(/runner isolation scan: 1 concurrency diagnostic/); + expect(result.stderr).not.toMatch(/no concurrency diagnostic on/); + expect( + fs.readFileSync( + path.join(result.derived, 'Logs', 'agent-device-build-for-testing.log'), + 'utf8', + ), + ).toContain(MAIN_ACTOR_ISOLATED_WARNING); + }); + + test('a build that did not print the canary fails', () => { + const result = runBuildScript(log(...BASELINE_WARNINGS), 0); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/no concurrency diagnostic on .*RunnerIsolationCanary\.swift/); + }); + + test('a failed scan drops the intermediates so the rerun recompiles and rescans every file', () => { + const result = runBuildScript(log(...CANARY_DIAGNOSTICS, MAIN_ACTOR_ISOLATED_WARNING), 0, { + reuseDerivedData: true, + }); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/Isolation scan covers only the files this build recompiled/); + expect(fs.existsSync(path.join(result.derived, 'Build', 'Intermediates.noindex'))).toBe(false); + }); + + test('the build compiles the canary', () => { + const result = runBuildScript(log(...CANARY_DIAGNOSTICS), 65); + expect(result.status).toBe(65); + expect(fs.readFileSync(result.xcodebuildArgs, 'utf8')).toContain( + '-D AGENT_DEVICE_RUNNER_ISOLATION_CANARY', + ); + }); + + test("a failing build keeps xcodebuild's exit status and skips the scan", () => { + const result = runBuildScript(log(MAIN_ACTOR_ISOLATED_WARNING), 65); + expect(result.status).toBe(65); + expect(result.stderr).not.toMatch(/runner isolation scan/); + }); +}); diff --git a/scripts/__tests__/swift-conditional-compilation.test.ts b/scripts/__tests__/swift-conditional-compilation.test.ts index 685779c122..e8e3203d41 100644 --- a/scripts/__tests__/swift-conditional-compilation.test.ts +++ b/scripts/__tests__/swift-conditional-compilation.test.ts @@ -14,6 +14,7 @@ import { describe('the #if evaluator', () => { test.each([ ['AGENT_DEVICE_RUNNER_UNIT_TESTS', { iOS: true, macOS: true, tvOS: true }], + ['AGENT_DEVICE_RUNNER_ISOLATION_CANARY', { iOS: true, macOS: true, tvOS: true }], ['os(iOS)', { iOS: true, macOS: false, tvOS: false }], ['!os(macOS)', { iOS: true, macOS: false, tvOS: true }], ['AGENT_DEVICE_RUNNER_UNIT_TESTS && os(iOS)', { iOS: true, macOS: false, tvOS: false }], diff --git a/scripts/build-xcuitest-apple.sh b/scripts/build-xcuitest-apple.sh index 3142a98be8..6d93bcc0fd 100644 --- a/scripts/build-xcuitest-apple.sh +++ b/scripts/build-xcuitest-apple.sh @@ -133,7 +133,7 @@ if is_truthy "${AGENT_DEVICE_IOS_CLEAN_DERIVED:-}"; then rm -rf "$CLEAN_PATH" fi -SWIFT_FLAGS='$(inherited) -disable-sandbox' +SWIFT_FLAGS='$(inherited) -disable-sandbox -D AGENT_DEVICE_RUNNER_ISOLATION_CANARY' if is_truthy "${AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS:-}"; then SWIFT_FLAGS="$SWIFT_FLAGS -D AGENT_DEVICE_RUNNER_UNIT_TESTS" fi @@ -146,24 +146,55 @@ if [ -n "${AGENT_DEVICE_XCUITEST_ARCHS:-}" ]; then ARCH_BUILD_SETTINGS="ARCHS=$AGENT_DEVICE_XCUITEST_ARCHS" fi -node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts xcodebuild build-for-testing \ - -project "$PROJECT_PATH" \ - -scheme "$SCHEME" \ - -destination "$DESTINATION" \ - -derivedDataPath "$DERIVED_PATH" \ - AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID="$RUNNER_APP_BUNDLE_ID" \ - AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID="$RUNNER_TEST_BUNDLE_ID" \ - COMPILER_INDEX_STORE_ENABLE=NO \ - ENABLE_CODE_COVERAGE=NO \ - ONLY_ACTIVE_ARCH=YES \ - ENABLE_PREVIEWS=NO \ - ENABLE_DEBUG_DYLIB=NO \ - -IDEPackageSupportDisableManifestSandbox=1 \ - -IDEPackageSupportDisablePluginExecutionSandbox=1 \ - ENABLE_USER_SCRIPT_SANDBOXING=NO \ - OTHER_SWIFT_FLAGS="$SWIFT_FLAGS" \ - $ARCH_BUILD_SETTINGS \ - $SIGNING_BUILD_SETTINGS +build_for_testing() { + node --experimental-strip-types scripts/swift-toolchain-tmpdir.ts xcodebuild build-for-testing \ + -project "$PROJECT_PATH" \ + -scheme "$SCHEME" \ + -destination "$DESTINATION" \ + -derivedDataPath "$DERIVED_PATH" \ + AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID="$RUNNER_APP_BUNDLE_ID" \ + AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID="$RUNNER_TEST_BUNDLE_ID" \ + COMPILER_INDEX_STORE_ENABLE=NO \ + ENABLE_CODE_COVERAGE=NO \ + ONLY_ACTIVE_ARCH=YES \ + ENABLE_PREVIEWS=NO \ + ENABLE_DEBUG_DYLIB=NO \ + -IDEPackageSupportDisableManifestSandbox=1 \ + -IDEPackageSupportDisablePluginExecutionSandbox=1 \ + ENABLE_USER_SCRIPT_SANDBOXING=NO \ + OTHER_SWIFT_FLAGS="$SWIFT_FLAGS" \ + $ARCH_BUILD_SETTINGS \ + $SIGNING_BUILD_SETTINGS +} + +# The isolation scan reads the compiler diagnostics in the build log, and an incremental build +# prints them only for the files it recompiles. The scan's positive control must print on every +# build, so its source is always stale. +REUSED_DERIVED_DATA=0 +if [ -d "$DERIVED_PATH/Build/Intermediates.noindex" ]; then + REUSED_DERIVED_DATA=1 +fi +touch apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerIsolationCanary.swift +mkdir -p "$DERIVED_PATH/Logs" +BUILD_LOG="$DERIVED_PATH/Logs/agent-device-build-for-testing.log" +BUILD_STATUS_FILE="$DERIVED_PATH/Logs/agent-device-build-for-testing.status" +{ + BUILD_STATUS=0 + build_for_testing 2>&1 || BUILD_STATUS=$? + printf '%s\n' "$BUILD_STATUS" > "$BUILD_STATUS_FILE" +} | tee "$BUILD_LOG" +BUILD_STATUS="$(cat "$BUILD_STATUS_FILE")" +if [ "$BUILD_STATUS" -ne 0 ]; then + exit "$BUILD_STATUS" +fi +if [ "$REUSED_DERIVED_DATA" = 1 ]; then + echo "Isolation scan covers only the files this build recompiled: it reused DerivedData at $DERIVED_PATH. Run pnpm build:xcuitest:$PLATFORM:clean to scan every file." >&2 +fi +if ! node --experimental-strip-types scripts/runner-isolation-diagnostics.ts "$BUILD_LOG"; then + # Unchanged files print no diagnostics on the next incremental build, so a rerun would pass. + rm -rf "$DERIVED_PATH/Build/Intermediates.noindex" + exit 1 +fi if ! is_truthy "${AGENT_DEVICE_XCUITEST_SKIP_ICON_PATCH:-}"; then node --experimental-strip-types scripts/patch-xcuitest-runner-icon.ts "$DERIVED_PATH" diff --git a/scripts/package-apple-runner-source.mjs b/scripts/package-apple-runner-source.mjs index 3e5374342e..5c546606e5 100644 --- a/scripts/package-apple-runner-source.mjs +++ b/scripts/package-apple-runner-source.mjs @@ -28,6 +28,11 @@ const LEGACY_OUTPUT_DIRS = [ ]; const SKIPPED_DIR_NAMES = new Set(['.build', '.swiftpm', 'UnitTests', 'xcuserdata']); const SKIPPED_ROOT_FILES = new Set(['README.md', 'RUNNER_PROTOCOL.md']); +// The isolation scan's positive control compiles only in scripts/build-xcuitest-apple.sh builds: +// the repo gates and the prebuilt release runner. Runners built from this package omit it. +const SKIPPED_RUNNER_FILE_PATHS = new Set([ + path.join('AgentDeviceRunner', 'AgentDeviceRunnerUITests', 'RunnerIsolationCanary.swift'), +]); // XCTest discovers instance methods named test*; anything matching this that survives stripping // would ship to (and compile on) every user's machine. Only the runner's command-loop entrypoint // is a legitimate test method in the packaged source. @@ -57,7 +62,9 @@ function packageAppleRunnerSource(options = {}) { strippedCommentBytes: 0, }; - processDirectory(sourceRoot, options.checkOnly ? undefined : outputRoot, '', summary); + processDirectory(sourceRoot, options.checkOnly ? undefined : outputRoot, '', summary, { + skipFilePaths: SKIPPED_RUNNER_FILE_PATHS, + }); packageSnapshotPresentationSource(root, options, summary); return summary; } diff --git a/scripts/runner-isolation-diagnostics.ts b/scripts/runner-isolation-diagnostics.ts new file mode 100644 index 0000000000..53ec5f0f7c --- /dev/null +++ b/scripts/runner-isolation-diagnostics.ts @@ -0,0 +1,120 @@ +// The Apple runner builds in Swift 5 language mode, where several off-main uses of main-actor state +// are only warnings: a `RunnerMainOwnedState` read inside a `DispatchQueue.async` closure, or a +// `@MainActor` closure called from one (#2882). `scripts/build-xcuitest-apple.sh` runs this scan +// over the `xcodebuild build-for-testing` log, so any concurrency diagnostic fails the +// swift-runner gates whatever its severity. Other warnings stay out of scope: the base already +// carries unrelated ones, so treating every warning as an error would fail it. +// +// The scan matches English prose where Swift prints no diagnostic group, so it carries a positive +// control: the build compiles `RunnerIsolationCanary.swift` with the runner's own flags, and the +// scan fails unless it saw a diagnostic on every canary line. + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +/** Repository-relative path of the positive-control source the runner gate builds compile. */ +export const ISOLATION_CANARY_PATH = + 'apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerIsolationCanary.swift'; +const ISOLATION_CANARY_MARKER = '// isolation-canary'; + +/** A compiler diagnostic line: `::: warning|error: `. */ +const SWIFT_DIAGNOSTIC_LINE = /^(\S.*):(\d+):\d+: (?:warning|error): (.*)$/; +/** + * Swift's concurrency diagnostics, by the diagnostic group it prints where it has one, and + * otherwise by a phrase of the message: "main actor-isolated property … can not be referenced", + * "converting function value … loses global actor 'MainActor'", "capture of … with non-Sendable + * type", "mutation of captured var … in concurrently-executing code". + */ +const CONCURRENCY_DIAGNOSTIC_GROUPS = ['[#ActorIsolatedCall]', '[#SendableClosureCaptures]']; +const CONCURRENCY_DIAGNOSTIC_PHRASES = [ + 'actor-isolated', + 'loses global actor', + 'non-sendable', + 'concurrently-executing code', +]; + +type ConcurrencyDiagnostic = { line: string; file: string; lineNumber: number }; + +function concurrencyDiagnostic(line: string): ConcurrencyDiagnostic | undefined { + const match = SWIFT_DIAGNOSTIC_LINE.exec(line); + if (!match) return undefined; + const [, file = '', lineNumber = '', message = ''] = match; + const lowerMessage = message.toLowerCase(); + const isConcurrency = + CONCURRENCY_DIAGNOSTIC_GROUPS.some((group) => message.includes(group)) || + CONCURRENCY_DIAGNOSTIC_PHRASES.some((phrase) => lowerMessage.includes(phrase)); + return isConcurrency ? { line, file, lineNumber: Number(lineNumber) } : undefined; +} + +/** The 1-based line numbers of `canarySource` that must each carry a concurrency diagnostic. */ +export function isolationCanaryLines(canarySource: string): number[] { + return canarySource + .split(/\r?\n/) + .flatMap((line, index) => (line.includes(ISOLATION_CANARY_MARKER) ? [index + 1] : [])); +} + +export type RunnerIsolationScan = { + /** Every distinct concurrency diagnostic line outside the canary lines, in first-seen order. */ + violations: string[]; + /** Canary lines the log carried no concurrency diagnostic for. */ + missingCanaryLines: number[]; +}; + +export function scanRunnerBuildLog(log: string, canarySource: string): RunnerIsolationScan { + const expected = new Set(isolationCanaryLines(canarySource)); + const seenCanaryLines = new Set(); + const violations = new Set(); + for (const line of log.split(/\r?\n/)) { + const diagnostic = concurrencyDiagnostic(line); + if (!diagnostic) continue; + const isCanaryLine = + diagnostic.file.endsWith(`/${path.basename(ISOLATION_CANARY_PATH)}`) && + expected.has(diagnostic.lineNumber); + if (isCanaryLine) { + seenCanaryLines.add(diagnostic.lineNumber); + } else { + violations.add(diagnostic.line); + } + } + return { + violations: [...violations], + missingCanaryLines: [...expected].filter((line) => !seenCanaryLines.has(line)), + }; +} + +function main(): number { + const [logPath] = process.argv.slice(2); + if (!logPath) { + process.stderr.write('Usage: runner-isolation-diagnostics.ts \n'); + return 2; + } + const canarySource = fs.readFileSync( + path.resolve(import.meta.dirname, '..', ISOLATION_CANARY_PATH), + 'utf8', + ); + const { violations, missingCanaryLines } = scanRunnerBuildLog( + fs.readFileSync(logPath, 'utf8'), + canarySource, + ); + if (violations.length > 0) { + process.stderr.write(`${violations.join('\n')}\n`); + process.stderr.write( + `runner isolation scan: ${violations.length} concurrency diagnostic(s). Off-main code ` + + 'reads target identity from a SnapshotCaptureTarget taken on main and writes main-owned ' + + 'state through applyMainOwnedSnapshotState; it never names a RunnerMainOwnedState member or ' + + 'calls a @MainActor closure.\n', + ); + } + if (missingCanaryLines.length > 0) { + process.stderr.write( + `runner isolation scan: no concurrency diagnostic on ${ISOLATION_CANARY_PATH} line(s) ` + + `${missingCanaryLines.join(', ')}. Either this build did not compile the canary, or the ` + + 'compiler words or groups that diagnostic differently: update the groups and phrases in ' + + 'scripts/runner-isolation-diagnostics.ts to match it.\n', + ); + } + return violations.length > 0 || missingCanaryLines.length > 0 ? 1 : 0; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exit(main()); diff --git a/scripts/swift-conditional-compilation.ts b/scripts/swift-conditional-compilation.ts index d24351ac6e..d0bbd68087 100644 --- a/scripts/swift-conditional-compilation.ts +++ b/scripts/swift-conditional-compilation.ts @@ -75,9 +75,15 @@ export function evaluateCondition(condition: string, platform: Platform): boolea expectToken(')'); return call(token, argument); } - // Both are defined by every unit-test build: the compile flag by the build script, and - // DEBUG by the Debug configuration every lane builds. - if (token === 'AGENT_DEVICE_RUNNER_UNIT_TESTS' || token === 'DEBUG') return true; + // All three are defined by every unit-test build: the two compile flags by the build script, + // and DEBUG by the Debug configuration every lane builds. + if ( + token === 'AGENT_DEVICE_RUNNER_UNIT_TESTS' || + token === 'AGENT_DEVICE_RUNNER_ISOLATION_CANARY' || + token === 'DEBUG' + ) { + return true; + } return fail(`unsupported ${token}`); }; const unary = (): boolean => (peek() === '!' ? (take(), !unary()) : primary()); diff --git a/src/__tests__/apple-runner-package-source.test.ts b/src/__tests__/apple-runner-package-source.test.ts index b031298007..0c09e7a2e1 100644 --- a/src/__tests__/apple-runner-package-source.test.ts +++ b/src/__tests__/apple-runner-package-source.test.ts @@ -137,7 +137,7 @@ test('package apple runner source empties removed lines so line numbers still ma } }); -test('package apple runner source skips the explicit unit-test directory', async () => { +test('package apple runner source skips the unit-test directory and the isolation canary', async () => { const root = mkdtempForTestSync('agent-device-runner-package-unit-tests-'); onTestFinished(() => fs.rmSync(root, { recursive: true, force: true })); writeFixtureFile(root, 'apple/snapshot-presentation/Package.runner.swift', 'runner package\n'); @@ -161,6 +161,16 @@ test('package apple runner source skips the explicit unit-test directory', async `${uitestsDir}/RunnerTests+RuntimeSibling.swift`, ['extension RunnerTests {', ' func runtimeSiblingHelper() {}', '}', ''].join('\n'), ); + writeFixtureFile( + root, + `${uitestsDir}/RunnerIsolationCanary.swift`, + [ + '#if AGENT_DEVICE_RUNNER_ISOLATION_CANARY', + 'enum RunnerIsolationCanary {}', + '#endif', + '', + ].join('\n'), + ); await runCmd(process.execPath, [packageScript, '--root', root, '--quiet']); @@ -173,6 +183,11 @@ test('package apple runner source skips the explicit unit-test directory', async fs.existsSync(path.join(root, `dist/${uitestsDir}/RunnerTests+RuntimeSibling.swift`)), 'the skeleton skip must not drop files with shippable runtime content', ); + assert.equal( + fs.existsSync(path.join(root, `dist/${uitestsDir}/RunnerIsolationCanary.swift`)), + false, + 'the isolation scan canary compiles only in scripts/build-xcuitest-apple.sh builds', + ); }); test('package apple runner source check rejects unit tests without writing dist', async () => { diff --git a/vitest.config.ts b/vitest.config.ts index bcddac82e9..dde289bed8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -156,6 +156,9 @@ export default defineConfig({ // The line-parity comparison behind `pnpm check:packaged-runner-swift`. Pure text // over two strings; the gate itself is what runs the packager and the Swift parse. 'scripts/__tests__/packaged-runner-swift.test.ts', + // The runner build's actor-isolation log scan, over synthetic logs and a fake + // `xcodebuild` on PATH. + 'scripts/__tests__/runner-isolation-diagnostics.test.ts', // Parse-only guard on the checked-in registry entry: the npm package must declare // the fixed mcp subcommand, or registry-format launchers run the bare CLI. 'scripts/__tests__/mcp-metadata.test.ts',