Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/setup-apple-runner-build/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<T>(_ work: @MainActor () throws -> T) -> Result<T, Error> {
var result: Result<T, Error>?
MainActor.assumeIsolated {
result = Result { try work() }
}
return result!
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -121,6 +123,7 @@ extension RunnerTests {
)
}

@MainActor
func activateAlertButton(
_ alert: RunnerAlert,
button: XCUIElement,
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -177,14 +178,15 @@ extension RunnerTests {
return try runMainThreadWork(
"command_execution",
timeout: Self.mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
timeoutError: Self.mainThreadExecutionTimeoutError
) {
try self.executeOnMainSafely(command: command, routeToSpringboard: routeToSpringboard)
}
}

// MARK: - Command Handling

@MainActor
private func executeOnMainSafely(
command: Command,
alertDeadline: Date? = nil,
Expand Down Expand Up @@ -280,7 +282,7 @@ extension RunnerTests {
let failureCountBefore = try runMainThreadWork(
"recorded_failure_count",
timeout: Self.mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
timeoutError: Self.mainThreadExecutionTimeoutError
) {
self.currentXCTestFailureCount()
}
Expand All @@ -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)
Expand All @@ -307,7 +309,7 @@ extension RunnerTests {
try runMainThreadWork(
"target_invalidation",
timeout: Self.mainThreadExecutionTimeout,
timeoutError: mainThreadExecutionTimeoutError
timeoutError: Self.mainThreadExecutionTimeoutError
) {
self.invalidateCachedTarget(reason: "xctest_recorded_failure")
}
Expand All @@ -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)
Expand All @@ -333,6 +335,7 @@ extension RunnerTests {
}
}

@MainActor
private func executeOnMain(
command: Command,
alertDeadline: Date?,
Expand Down Expand Up @@ -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"))
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import XCTest
import AgentDeviceSnapshotPresentation

extension RunnerTests {
@MainActor
func executeOnMainPrepared(
command: Command,
activeApp: XCUIApplication,
Expand Down Expand Up @@ -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
Expand All @@ -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?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading