diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift index c2aa672ebf..ef37d87dad 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -536,12 +536,27 @@ extension RunnerTests { #else // The platform exception, written once: `SystemSurfaceHostRegistry` registers no hosts off iOS, // so nothing is ever served in place there and such a command keeps the activation route this - // axis found it on. + // axis found it on. The host never sends either command here on macOS — `alert` answers through + // the helper and `action-button` is refused by its owner fact before dispatch — so off iOS this + // route is reached only by the tvOS and visionOS runners. return prepareActivatedTarget(command: command) #endif case .existingApp: // No request-dependent bypass here: it decides by querying the cached target's state, and a // command that may bring nothing forward has nothing for it to settle. +#if os(macOS) + if let bundleId = command.appBundleId?.trimmedNonEmpty, + macReadMayBeServedInBackground( + command: command, + targetState: XCUIApplication(bundleIdentifier: bundleId).state + ) + { + // Identity first, through the shared rule: a served read must answer the process a bound + // read would have read, not a pid an outside relaunch replaced. + refreshCachedTargetIfProcessChanged(bundleId: bundleId) + return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command))) + } +#endif return prepareActivatedTarget(command: command) case .mayLaunch: if shouldSkipAppActivationPreflight(command) { @@ -775,4 +790,16 @@ extension RunnerTests { } return XCUIApplication(bundleIdentifier: bundleId) } + + /// A macOS `.existingApp` read is served in place only for `.runningBackground`: the one state + /// whose tree answers while the app sits behind other windows. Every other state keeps the + /// activating route, matching `targetNeedsActivation`'s macOS set, and serving in place books no + /// activation fact or marker — the fact channel records only a repair performed, so a served + /// read answers as an ordinary read (#3254; decision record in #3338). + /// + /// The probe handle must be fresh, not cached: a cached handle can address a pid an outside + /// relaunch replaced, whose `.state` would answer for the dead process. + func macReadMayBeServedInBackground(command: Command, targetState: XCUIApplication.State) -> Bool { + command.appBundleId?.trimmedNonEmpty != nil && targetState == .runningBackground + } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 121f5f4931..d8fd33a985 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -428,18 +428,27 @@ extension RunnerTests { return Response(ok: true, data: DataPayload(text: text)) case .screenshot: #if os(macOS) - // macOS keeps the app-targeted capture behavior for window-level screenshots. - if let bundleId = command.appBundleId, !bundleId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + // An app-targeted window-level capture is a screen-region grab: from the background the + // occluding app's pixels land in the image, so the raise is what makes the pixels correct + // (measured; record in #3338). `--fullscreen` reads the whole display and needs no raise. + // The wait belongs to the raise: only a raised app has an activation animation to settle. + let screenshotBundleId = command.appBundleId?.trimmedNonEmpty + if let bundleId = screenshotBundleId { let targetApp = XCUIApplication(bundleIdentifier: bundleId) - targetApp.activate() + if macAppCaptureNeedsRaise( + fullscreen: command.fullscreen, + targetState: targetApp.state + ) { + targetApp.activate() + // Brief wait for the app transition animation to complete + sleepFor(0.5) + } activeApp = targetApp - // Brief wait for the app transition animation to complete - sleepFor(0.5) } let screenshot: XCUIScreenshot if command.fullscreen == true { screenshot = XCUIScreen.main.screenshot() - } else if let bundleId = command.appBundleId, !bundleId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + } else if screenshotBundleId != nil { screenshot = screenshotRoot(app: activeApp).screenshot() } else { screenshot = XCUIScreen.main.screenshot() diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift index 757d2c7df6..9e2e179e81 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift @@ -284,6 +284,14 @@ extension RunnerTests { return false } + /// The state rule for the macOS app-targeted capture raise: only a window-level grab of an app + /// that is not already foreground raises, and a stopped app raises in both shapes because there + /// the activation is the launch the capture has always performed (#3254; record in #3338). + func macAppCaptureNeedsRaise(fullscreen: Bool?, targetState: XCUIApplication.State) -> Bool { + guard targetState != .runningForeground else { return false } + return fullscreen != true || targetState == .notRunning + } + @MainActor func canUseFastForegroundAppGuard( activeApp: XCUIApplication, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 604bb2b255..dd35949a9b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -66,6 +66,9 @@ enum CommandLaunchPolicy: Equatable { /// enforced on iOS only: `notRunningRefusal` is `#if os(iOS)`, the platform that can read an app's /// state without launching it. Off iOS these commands keep the activation route they had before /// this axis existed, and no refusal can occur there. + /// On macOS the policy also decides the foreground question: a read of an app that is running + /// behind other windows is served in place without raising it, while a stopped app keeps the + /// activating route's launch (#3254; record in #3338). case existingApp /// Brings the app forward, which bare-launches it when it is not running. case mayLaunch diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift index 20f81f8dd3..e98cb87986 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift @@ -690,3 +690,172 @@ extension RunnerTests { } } #endif + +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(macOS) +extension RunnerTests { + /// Part of every macOS install, never the runner or the test host, and cheap to leave terminated. + /// Its windows sit over or behind the test host's own, which is exactly the desktop shape #3254 + /// describes: the user's frontmost app is a different app from the session app. + private static let macBackgroundTargetBundleId = "com.apple.systempreferences" + private static let macForegroundStealBundleId = "com.apple.finder" + + /// Brings Settings up, then hands the foreground to Finder, and waits until Settings reports + /// itself background. `activate()` on another app is how a desktop backgrounds an app without + /// quitting it, so this leaves the state a read must answer — not a launchable absence. + @MainActor + private func startMacTargetAndLoseTheForeground() throws -> XCUIApplication { + let target = XCUIApplication(bundleIdentifier: Self.macBackgroundTargetBundleId) + target.launch() + XCTAssertTrue(target.waitForExistence(timeout: appExistenceTimeout)) + _ = XCUIApplication(bundleIdentifier: Self.macForegroundStealBundleId).activate() + XCTAssertTrue( + target.wait(for: .runningBackground, timeout: 10), + "Finder must take the foreground and the target must settle background before the read answers from it" + ) + return target + } + + /// The #3254 fix on the policy axis: on macOS a `.existingApp` read of a session app that is + /// running behind other windows is served from where it sits. Three things must be true at once: + /// the command is prepared against the app it names, no activation fact is booked (the response + /// therefore discloses no repair because none happened), and the app is still background when the + /// read is done — the user's frontmost app never changed. Covers the user-level read and the two + /// leading reads an interaction carries (gesture viewport, selector resolution), the same shapes + /// the iOS surface arm pins. Deleting the macOS arm of `.existingApp` fails every row. + @MainActor + func testMacReadOfABackgroundAppIsServedInBackgroundWithoutActivating() throws { + let target = try startMacTargetAndLoseTheForeground() + let bundleId = Self.macBackgroundTargetBundleId + defer { + invalidateCachedTarget(reason: "unit_test_cleanup") + pendingTargetActivation = nil + target.terminate() + } + for request in [ + #"{"command":"snapshot","commandId":"read","appBundleId":"\#(bundleId)"}"#, + #"{"command":"gestureViewport","commandId":"read","appBundleId":"\#(bundleId)"}"#, + #"{"command":"querySelector","selectorKey":"label","selectorValue":"General","commandId":"read","appBundleId":"\#(bundleId)"}"# + ] { + invalidateCachedTarget(reason: "unit_test_setup") + pendingTargetActivation = nil + let command = try runnerCommandFixture(request) + + guard case .context(let prepared) = prepareActiveCommandContext(command: command) else { + return XCTFail("\(request) must be served, not refused") + } + XCTAssertEqual( + prepared.app.state, + .runningBackground, + "\(request) must be served against the background app it names" + ) + XCTAssertNil(pendingTargetActivation, "\(request) may not book an activation fact") + // The disclosure decision, pinned with #3254: a background-served read answers as an ordinary + // read with no substitute marker (no observation payload, no fact). The tree is live, not + // degraded; a future disclosure must edit this line on purpose. + XCTAssertNil(prepared.observation, "\(request) must not invent an observation payload") + XCTAssertEqual( + target.state, + .runningBackground, + "\(request) may not take the user's frontmost app away" + ) + } + } + + /// The identity rule the served read must go through (#3254 review): with the cache bound to a + /// pid an outside relaunch replaced, a served read answers the process a bound read would have + /// read, not the dead handle the cache holds. The bogus cached pid makes the wiring observable: + /// the served preparation must leave the live pid behind, so deleting the + /// `refreshCachedTargetIfProcessChanged` call from the arm leaves the bogus value and fails here. + @MainActor + func testMacBackgroundReadRefreshesACachedHandleFromADeadPid() throws { + let target = try startMacTargetAndLoseTheForeground() + let bundleId = Self.macBackgroundTargetBundleId + defer { + invalidateCachedTarget(reason: "unit_test_cleanup") + pendingTargetActivation = nil + target.terminate() + } + let command = try runnerCommandFixture( + #"{"command":"snapshot","commandId":"read","appBundleId":"\#(bundleId)"}"# + ) + // Plant the residue an outside relaunch leaves between two reads: the cache still names this + // app and still holds a handle, but the pid it recorded is dead. + mainOwned.app = target + mainOwned.bundleId = bundleId + mainOwned.processIdentifier = 999_999 + pendingTargetActivation = nil + + guard case .context(let prepared) = prepareActiveCommandContext(command: command) else { + return XCTFail("the read must be served") + } + XCTAssertEqual(prepared.app.state, .runningBackground, "still served in place") + XCTAssertNil(pendingTargetActivation, "the refresh must not activate") + XCTAssertNotEqual( + mainOwned.processIdentifier, + 999_999, + "a served read must run the shared identity refresh, not answer from a dead pid" + ) + } + + /// The scope limit that keeps this from being a blanket no-activate rule: an interaction against + /// the same background app still comes forward through the same preparation, because on macOS the + /// XCTest path drives events into the foreground window and a background click would land on + /// whatever window sits on top. This is the same call with `.mayLaunch`; the two tests above and + /// this one together pin the boundary the policy axis draws. + @MainActor + func testMacInteractionOnABackgroundAppStillActivates() throws { + let target = try startMacTargetAndLoseTheForeground() + defer { + invalidateCachedTarget(reason: "unit_test_cleanup") + pendingTargetActivation = nil + target.terminate() + } + invalidateCachedTarget(reason: "unit_test_setup") + pendingTargetActivation = nil + let command = try runnerCommandFixture( + #"{"command":"tap","x":10,"y":10,"commandId":"tap-bg","appBundleId":"\#(Self.macBackgroundTargetBundleId)"}"# + ) + + guard case .context = prepareActiveCommandContext(command: command) else { + return XCTFail("the tap must be prepared") + } + XCTAssertNotNil( + pendingTargetActivation, + "an interaction on a background app must still book its activation" + ) + XCTAssertEqual(target.state, .runningForeground, "the tap must run against the foreground app") + } + + /// Stopped apps behave exactly as they did before this axis read macOS (#3254 correction): a read + /// of a not-running session app still goes through the activating route and the launch it performs, + /// because macOS has no refusal there and callers rely on `open`-then-read working. `snapshot` is + /// the read whose policy the background change touched, so it is the read that proves the change is + /// scoped to the running app only: the app comes up, the activation fact is booked, and nothing is + /// refused. Making `macReadMayBeServedInBackground` answer `.notRunning` too would turn this + /// context into a refusal and fail on the first assertion. + @MainActor + func testMacReadOfANotRunningAppStillLaunchesIt() throws { + let target = XCUIApplication(bundleIdentifier: Self.macBackgroundTargetBundleId) + target.terminate() + invalidateCachedTarget(reason: "unit_test_setup") + pendingTargetActivation = nil + defer { + invalidateCachedTarget(reason: "unit_test_cleanup") + pendingTargetActivation = nil + target.terminate() + } + let command = try runnerCommandFixture( + #"{"command":"snapshot","commandId":"read","appBundleId":"\#(Self.macBackgroundTargetBundleId)"}"# + ) + + guard case .context = prepareActiveCommandContext(command: command) else { + return XCTFail("a read of a stopped app must keep the launch route it has always had") + } + XCTAssertNotEqual(target.state, .notRunning, "the stopped app must come up as it did before") + XCTAssertNotNil( + pendingTargetActivation, + "the command that launched the app discloses the activation it paid for" + ) + } +} +#endif diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift index 6b3d40df2e..1bc8c37b91 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift @@ -117,6 +117,67 @@ extension RunnerTests { } #endif +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(macOS) +extension RunnerTests { + /// Pins every state the macOS screenshot raise condition reads (#3254), together with the read + /// arm's answer for the same state, so one table owns the pair. Both predicates run their real + /// bodies: a drifted condition on either side lands as a red row rather than a silent behavior + /// change. The rows encode the two platform facts the split rests on: the macOS SDK declares no + /// suspended state (see `RunnerTests+ApplicationStateRawValueTests`), so these two predicates + /// cannot disagree on it here, and every state that is not `.runningBackground` keeps its + /// activating route for reads — which for a capture is also where the raise lives. The one + /// deliberate divergence is background itself: a read is served in place because the tree answers + /// from anywhere, while a window-level capture raises because its pixels are a region grab that + /// the occluding app spoils (#3254's measurement). Full-screen captures raise only a stopped app, + /// where the activation is the launch it has always performed. + func testMacBackgroundServingAndCaptureRaiseConditionsArePinnedPerState() throws { + let read = try runnerCommandFixture(#"{"command":"snapshot","commandId":"table","appBundleId":"com.example.any"}"#) + let noBundle = try runnerCommandFixture(#"{"command":"snapshot","commandId":"table"}"#) + let states: [(state: XCUIApplication.State, expected: Bool)] = [ + (.unknown, false), + (.notRunning, false), + (.runningBackground, true), + (.runningForeground, false), + ] + let foreground = XCUIApplication.State.runningForeground + let unknown = XCUIApplication.State.unknown + let notRunning = XCUIApplication.State.notRunning + let raiseRows: [(fullscreen: Bool?, state: XCUIApplication.State, expected: Bool)] = [ + (nil, foreground, false), + (nil, .runningBackground, true), + (nil, unknown, true), + (nil, notRunning, true), + (false, foreground, false), + (false, .runningBackground, true), + (false, unknown, true), + (false, notRunning, true), + (true, foreground, false), + (true, .runningBackground, false), + (true, unknown, false), + (true, notRunning, true), + ] + for row in states { + XCTAssertEqual( + macReadMayBeServedInBackground(command: read, targetState: row.state), + row.expected, + "read state=\(row.state.rawValue)" + ) + } + XCTAssertFalse( + macReadMayBeServedInBackground(command: noBundle, targetState: .runningBackground), + "a read naming no app has no session app to serve in place" + ) + for row in raiseRows { + XCTAssertEqual( + macAppCaptureNeedsRaise(fullscreen: row.fullscreen, targetState: row.state), + row.expected, + "capture fullscreen=\(String(describing: row.fullscreen)) state=\(row.state.rawValue)" + ) + } + } +} +#endif + #if AGENT_DEVICE_RUNNER_UNIT_TESTS extension RunnerTests { @MainActor diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 934a84fae9..6e73a7eb17 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -739,7 +739,8 @@ Rules: Context menus are not ambient UI: secondary-click a visible target, then re-snapshot and use the new menu-item refs. Do not let iOS simulator-set scoping hide macOS desktop targets. Prefer refs/selectors over raw coordinates. - macOS snapshot rects are window-space; use current refs or overlay refs instead of guessing coordinates.`, + macOS snapshot rects are window-space; use current refs or overlay refs instead of guessing coordinates. + On the default XCTest backend a read (snapshot, get, find) of a session app that sits behind other windows is answered from where it sits, without raising the app; interactions and a window-level screenshot of that app still bring it forward.`, }, web: { summary: 'Minimal browser workflow with the managed web backend',