From 2df89243c423e5e6630d145e2f0444a7ae1e093b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 23:41:00 +0200 Subject: [PATCH 1/5] fix(apple-runner): serve a macOS read of a background app without raising it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a desktop, the XCTest foreground repair is not a no-op for the user: a read that names a session app sitting behind other windows took their frontmost app away for a command that only asked to look (#3254). The activation axis already routes by `launchPolicy`, so the fix goes on the existing `.existingApp` arm of `prepareActiveCommandContext`: on macOS, a read whose app answers `.runningBackground` is served in place through `resolveAppWithoutActivation`, and no activation fact is booked for it. Scoping is exact: - a stopped app keeps the launch the activating route performs for it today: `state` answers `.notRunning` and the arm declines, so `open`-then-read still works and no macOS refusal is invented (`notRunningRefusal` stays iOS-only); - interactions keep activating: the macOS XCTest path drives events through the foreground window, and a background click would land on whatever window is on top; - a window-level screenshot keeps its raise (measured: a backgrounded `screenshotRoot(app:).screenshot()` returns the occluding window's content inside the session window's rect), but a `--fullscreen` capture of a running app no longer raises — `XCUIScreen.main` answers from any foreground, and the 0.5 s settle waits belong to the raise and are skipped with it. A stopped app keeps launching either way. The two conditions are functions the call sites read (`macReadMayBeServedInBackground`, `macAppCaptureNeedsRaise`) so the host lane pins every state they branch on. Four host-lane tests: the background read served without a raise or a fact across its three shapes, the interaction on the same app still activating, a stopped read still launching, and the raise-condition table. The canary is the arm's removal: the read test goes red with the app left foreground and a `bundle_changed` fact booked. A live run on the default backend confirms it: with Finder frontmost, `snapshot` and `get` answered from the background app, Finder stayed frontmost, and runner.log booked zero activation facts; the click after them activated with priorState=3 as it must. `help macos` states the new read behavior. Also records at the `.presentedSurface` arm why its macOS route is unreachable from the host (`alert` answers through the helper; `action-button` is refused by its owner fact before dispatch), so no second activation axis is proposed against it. Partial #3254 --- .../RunnerTests+CommandDispatch.swift | 24 +++- .../RunnerTests+CommandExecution.swift | 34 ++++- .../RunnerTests+Lifecycle.swift | 12 ++ .../RunnerTests+Models.swift | 5 + .../RunnerTests+CommandDispatchTests.swift | 129 ++++++++++++++++++ .../RunnerTests+LifecycleTests.swift | 37 +++++ src/commands/schema/cli-help.ts | 3 +- 7 files changed, 236 insertions(+), 8 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift index c2aa672ebf..de1f6f982b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -536,12 +536,22 @@ 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 macOS host never sends one of this arm's commands here: `alert` answers + // through the macOS helper (`runAppleAlert`'s host split) and `action-button` is refused by the + // owner's Action Button fact (`hasAppleActionButton`) before dispatch, so no macOS request can + // be activated from this arm, and only the tvOS and visionOS runners reach it off iOS. 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 macReadMayBeServedInBackground(command) { + // Served in place with no binding, the same shape the iOS `.presentedSurface` arm takes: + // the next command that needs the app forward pays for its own activation. + return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command))) + } +#endif return prepareActivatedTarget(command: command) case .mayLaunch: if shouldSkipAppActivationPreflight(command) { @@ -775,4 +785,16 @@ extension RunnerTests { } return XCUIApplication(bundleIdentifier: bundleId) } + + /// Whether a macOS `.existingApp` read may be served from the app exactly where it sits: the + /// request names an app that is running but behind other windows. On a desktop the foreground + /// repair is not a no-op for the user — it takes their frontmost app away for a command that only + /// asked to look, which is what #3254 reports. The answer comes from `state`, which never launches, + /// so a stopped or unknown app keeps the standing route untouched, including the launch that route + /// performs; only the raise of an already-running app is in question here. + @MainActor + func macReadMayBeServedInBackground(_ command: Command) -> Bool { + guard let bundleId = command.appBundleId?.trimmedNonEmpty else { return false } + return XCUIApplication(bundleIdentifier: bundleId).state == .runningBackground + } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 121f5f4931..704b20a176 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -428,18 +428,40 @@ 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 { + // Window-level app captures raise the app; a full-screen capture of a running app and an app + // already in the foreground do not (#3254). + // + // The raise is load-bearing for the window-level pixels, so it stays there. Measured on a macOS + // host with a known window frame and a second window laid over it: for a `.runningBackground` + // app `screenshotRoot(app:).screenshot()` returns that window's own rect at the right scale but + // not its own content — the occluding app's windows are in the image — and the same call after + // `activate()` returns the app's content. A window-level capture on this backend is a region + // grab, so answering one from the background would silently report another app's screen as this + // app's window. A `--fullscreen` capture is a whole-display grab that `XCUIScreen.main` answers + // whichever app owns the foreground, so nothing about its pixels needs a running app raised, + // and a read that asked for none takes the user's frontmost app away. A stopped app keeps being + // started for either shape: there the activation is the launch it has always performed, which + // this change leaves untouched. The wait belongs to the raise: only a raised app has an + // animation to settle. The macOS helper serves background windows by a different mechanism, + // `SCContentFilter(desktopIndependentWindow:)`, which is the native backend's path and is not + // reachable here. + 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..cd3c074ffe 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift @@ -284,6 +284,18 @@ extension RunnerTests { return false } + /// Whether a macOS app-targeted capture must bring the app forward before answering, and so run + /// the wait that settles the raise (#3254). Three exclusions, each a state or shape where the + /// pixels need nothing brought forward: an app already in the foreground has nothing to raise, and + /// a `--fullscreen` capture of a running app is a whole-display grab that `XCUIScreen.main` + /// answers whichever app owns the foreground, so raising one for it only takes the user's own app + /// away. A stopped app keeps its standing behavior either way: there the activation is also the + /// launch it has always performed, which this change leaves untouched. + 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..6549216372 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -66,6 +66,11 @@ 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 same policy also settles what happens to an app that IS running but behind other + /// windows: a read is served there in place, without bringing it forward, because on a desktop the + /// repair steals the user's own frontmost app for a command that only asked to look (#3254). A + /// stopped macOS app keeps the launch the activating route already performs; only the raise of a + /// running app was removed. 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..db5b09b042 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift @@ -690,3 +690,132 @@ 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") + XCTAssertEqual( + target.state, + .runningBackground, + "\(request) may not take the user's frontmost app away" + ) + } + } + + /// 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..9027a98f49 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift @@ -117,6 +117,43 @@ extension RunnerTests { } #endif +#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(macOS) +extension RunnerTests { + /// Pins every state the macOS screenshot raise condition reads (#3254): only an app that is not + /// already foreground is ever raised, and only a window-level grab needs it — a `--fullscreen` + /// capture of a running app answers from any foreground. A stopped app keeps its standing answer in + /// both shapes, because there the activation is also the launch it has always performed; the + /// change is that full-screen stops paying a raise for a RUNNING app its pixels never needed. The + /// screenshot call site reads this same function, so a drifted condition is a red row rather than a + /// silent behavior change. + func testMacAppCaptureNeedsRaiseIsWindowLevelAndBackgroundOnly() { + let foreground = XCUIApplication.State.runningForeground + let background = XCUIApplication.State.runningBackground + let notRunning = XCUIApplication.State.notRunning + // Rows are literal expectations, not the condition recomputed: a drifted condition must land as + // a red row here, not move both sides of the comparison together. + let table: [(fullscreen: Bool?, state: XCUIApplication.State, expected: Bool)] = [ + (nil, foreground, false), + (nil, background, true), + (nil, notRunning, true), + (false, foreground, false), + (false, background, true), + (false, notRunning, true), + (true, foreground, false), + (true, background, false), + (true, notRunning, true), + ] + for row in table { + XCTAssertEqual( + macAppCaptureNeedsRaise(fullscreen: row.fullscreen, targetState: row.state), + row.expected, + "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', From bca5e2d3fde0819530468cb2335ee21250ef00d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 23:57:31 +0200 Subject: [PATCH 2/5] test(apple-runner): own the background-serve state split and its silence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns two accidents into rules, both asked for in coordinator review of #3339: - `macReadMayBeServedInBackground` splits its state probe from the `state` call so the host lane can pin every state the answer branches on. The pin records why only `.runningBackground` is served in place: on the macOS SDK the suspended case does not exist (the split is pinned by RunnerTests+ApplicationStateRawValueTests), and every other state keeps the activating route so an app that cannot promise an answerable tree pays the repair rather than trading a focus steal for an empty read — the same set `targetNeedsActivation` uses on macOS, so read and capture cannot disagree. - The disclosure decision is made with the reviewer rather than by omission, and pinned: a background-served read books no activation fact and no substitute marker. The existing channels cannot say it honestly — the activation fact records only a repair performed, and the observation payload belongs to the iOS observe-only contract, which refuses exactly this situation. A background tree is live, not degraded (measured rect drift is time, not foreground state), and interactions still activate, so no action path consumes a background tree. `XCTAssertNil(prepared.observation)` makes the absence owned: a future disclosure must edit the assertion on purpose. No behavior change; both live-state tests and the new table pass on the host lane. --- .../RunnerTests+CommandDispatch.swift | 25 ++++++++- .../RunnerTests+CommandDispatchTests.swift | 4 ++ .../RunnerTests+LifecycleTests.swift | 54 +++++++++++++------ 3 files changed, 65 insertions(+), 18 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift index de1f6f982b..85e5049e57 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -792,9 +792,32 @@ extension RunnerTests { /// asked to look, which is what #3254 reports. The answer comes from `state`, which never launches, /// so a stopped or unknown app keeps the standing route untouched, including the launch that route /// performs; only the raise of an already-running app is in question here. + /// + /// Only the one state the host can answer a full tree from is served in place. Anything else — + /// `.unknown`, `.notRunning`, and on non-macOS builds the suspended state, which the macOS SDK + /// does not declare and the host lane therefore cannot name — stays on the activating route on + /// purpose: an app that cannot promise an answerable tree should pay the repair that settles it + /// rather than trade a focus steal for an empty read. This mirrors `targetNeedsActivation`'s + /// macOS set, and `macAppCaptureNeedsRaise` agrees with it state for state: the suspended or + /// unknown app that a read activates is the same app a window-level capture raises. + /// + /// Serving in place books no activation fact and carries no substitute marker, decided with #3254 + /// rather than by omission: the activation channel records only a repair that was performed, and + /// the observation payload's state belongs to the iOS observe-only contract, so an + /// invented marker would put the same integer in two contracts with different meanings. The tree + /// is live, not degraded — the measured rect drift was time, not foreground state — and nothing + /// on the host acts on a "was background" fact today, so the answer travels as an ordinary read. + /// `testMacReadOfABackgroundAppIsServedInBackgroundWithoutActivating` pins the silence so it stays + /// owned: a future disclosure must change that assertion on purpose, not appear silently. @MainActor func macReadMayBeServedInBackground(_ command: Command) -> Bool { guard let bundleId = command.appBundleId?.trimmedNonEmpty else { return false } - return XCUIApplication(bundleIdentifier: bundleId).state == .runningBackground + return macReadMayBeServedInBackground(command: command, targetState: XCUIApplication(bundleIdentifier: bundleId).state) + } + + /// The state split of `macReadMayBeServedInBackground`, separated from the probe so the host lane + /// pins every state the answer branches on, the same way `macAppCaptureNeedsRaise` is pinned. + func macReadMayBeServedInBackground(command: Command, targetState: XCUIApplication.State) -> Bool { + command.appBundleId?.trimmedNonEmpty != nil && targetState == .runningBackground } } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift index db5b09b042..a44411bbca 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift @@ -749,6 +749,10 @@ extension RunnerTests { "\(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, diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift index 9027a98f49..5474084eec 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift @@ -119,35 +119,55 @@ extension RunnerTests { #if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(macOS) extension RunnerTests { - /// Pins every state the macOS screenshot raise condition reads (#3254): only an app that is not - /// already foreground is ever raised, and only a window-level grab needs it — a `--fullscreen` - /// capture of a running app answers from any foreground. A stopped app keeps its standing answer in - /// both shapes, because there the activation is also the launch it has always performed; the - /// change is that full-screen stops paying a raise for a RUNNING app its pixels never needed. The - /// screenshot call site reads this same function, so a drifted condition is a red row rather than a - /// silent behavior change. - func testMacAppCaptureNeedsRaiseIsWindowLevelAndBackgroundOnly() { + /// 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 background = XCUIApplication.State.runningBackground let notRunning = XCUIApplication.State.notRunning - // Rows are literal expectations, not the condition recomputed: a drifted condition must land as - // a red row here, not move both sides of the comparison together. - let table: [(fullscreen: Bool?, state: XCUIApplication.State, expected: Bool)] = [ + let raiseRows: [(fullscreen: Bool?, state: XCUIApplication.State, expected: Bool)] = [ (nil, foreground, false), - (nil, background, true), + (nil, .runningBackground, true), (nil, notRunning, true), (false, foreground, false), - (false, background, true), + (false, .runningBackground, true), (false, notRunning, true), (true, foreground, false), - (true, background, false), + (true, .runningBackground, false), (true, notRunning, true), ] - for row in table { + 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, - "fullscreen=\(String(describing: row.fullscreen)) state=\(row.state.rawValue)" + "capture fullscreen=\(String(describing: row.fullscreen)) state=\(row.state.rawValue)" ) } } From 74f3899f06008761b1edb3521c9d32af09c6ce72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 9 Oct 2026 00:03:54 +0200 Subject: [PATCH 3/5] test(apple-runner): pin the capture raise condition on .unknown too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raise table claimed to pin every state `macAppCaptureNeedsRaise` branches on but omitted `.unknown`, while the read table in the same test pinned it — the state worth pinning on one side could not be unpinnable on the other. The rows convert the silent drift path into a red row: `.unknown` raises for a window-level capture, where the activation is the standing route's attempt to settle an app whose state the SDK cannot report, and stays false for `--fullscreen`, whose pixels answer from any foreground. Expectations verified against the implemented condition, not pasted. No behavior change. Partial #3254 --- .../UnitTests/RunnerTests+LifecycleTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift index 5474084eec..1bc8c37b91 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+LifecycleTests.swift @@ -140,16 +140,20 @@ extension RunnerTests { (.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 { From f6eafa3b17d4e3fb767730d7384667498cd84ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 9 Oct 2026 05:28:49 +0200 Subject: [PATCH 4/5] fix(apple-runner): refresh a served macOS read against a relaunched pid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A read served in place resolved through `resolveAppWithoutActivation`, which returns the cached handle when the cache names the app — and a cached handle can record a pid an outside relaunch replaced. The activating route never answers from that residue because it runs `refreshCachedTargetIfProcessChanged` before its `targetNeedsActivation` read; the new arm bypassed the shared identity rule, so between an outside relaunch and the next read, a served read could address the dead process while claiming to observe the live one. Reachability: an `open`-bound session whose app is quit and reopened by the user leaves exactly this residue, and macOS `notRunningRefusal` never intercepts it. The fix runs the shared rule on the arm before resolution, so identity is settled once and both routes read the same live pid. Pinned by a host-lane test that plants the residue (live app, bogus cached pid) and asserts the served preparation refreshes without activating; canary: deleting the refresh call fails the test with the bogus pid still in place. Also drops the probe wrapper the arm no longer needs: a served read is one `state` query, and the activating route's second read cannot reuse the first because the refresh between them may have replaced the process the first answered for. The arm's and the predicate's comments are cut to the invariant they protect, per AGENTS.md; the decision record lives in #3338. Partial #3254 --- .../RunnerTests+CommandDispatch.swift | 56 +++++++------------ .../RunnerTests+CommandDispatchTests.swift | 36 ++++++++++++ 2 files changed, 55 insertions(+), 37 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift index 85e5049e57..ef37d87dad 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -536,19 +536,24 @@ 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. The macOS host never sends one of this arm's commands here: `alert` answers - // through the macOS helper (`runAppleAlert`'s host split) and `action-button` is refused by the - // owner's Action Button fact (`hasAppleActionButton`) before dispatch, so no macOS request can - // be activated from this arm, and only the tvOS and visionOS runners reach it off iOS. + // 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 macReadMayBeServedInBackground(command) { - // Served in place with no binding, the same shape the iOS `.presentedSurface` arm takes: - // the next command that needs the app forward pays for its own activation. + 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 @@ -786,37 +791,14 @@ extension RunnerTests { return XCUIApplication(bundleIdentifier: bundleId) } - /// Whether a macOS `.existingApp` read may be served from the app exactly where it sits: the - /// request names an app that is running but behind other windows. On a desktop the foreground - /// repair is not a no-op for the user — it takes their frontmost app away for a command that only - /// asked to look, which is what #3254 reports. The answer comes from `state`, which never launches, - /// so a stopped or unknown app keeps the standing route untouched, including the launch that route - /// performs; only the raise of an already-running app is in question here. + /// 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). /// - /// Only the one state the host can answer a full tree from is served in place. Anything else — - /// `.unknown`, `.notRunning`, and on non-macOS builds the suspended state, which the macOS SDK - /// does not declare and the host lane therefore cannot name — stays on the activating route on - /// purpose: an app that cannot promise an answerable tree should pay the repair that settles it - /// rather than trade a focus steal for an empty read. This mirrors `targetNeedsActivation`'s - /// macOS set, and `macAppCaptureNeedsRaise` agrees with it state for state: the suspended or - /// unknown app that a read activates is the same app a window-level capture raises. - /// - /// Serving in place books no activation fact and carries no substitute marker, decided with #3254 - /// rather than by omission: the activation channel records only a repair that was performed, and - /// the observation payload's state belongs to the iOS observe-only contract, so an - /// invented marker would put the same integer in two contracts with different meanings. The tree - /// is live, not degraded — the measured rect drift was time, not foreground state — and nothing - /// on the host acts on a "was background" fact today, so the answer travels as an ordinary read. - /// `testMacReadOfABackgroundAppIsServedInBackgroundWithoutActivating` pins the silence so it stays - /// owned: a future disclosure must change that assertion on purpose, not appear silently. - @MainActor - func macReadMayBeServedInBackground(_ command: Command) -> Bool { - guard let bundleId = command.appBundleId?.trimmedNonEmpty else { return false } - return macReadMayBeServedInBackground(command: command, targetState: XCUIApplication(bundleIdentifier: bundleId).state) - } - - /// The state split of `macReadMayBeServedInBackground`, separated from the probe so the host lane - /// pins every state the answer branches on, the same way `macAppCaptureNeedsRaise` is pinned. + /// 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/UnitTests/RunnerTests+CommandDispatchTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift index a44411bbca..e98cb87986 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift @@ -761,6 +761,42 @@ extension RunnerTests { } } + /// 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 From e0bd7842838c180f4a96414b5f2f2669709cd4b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 9 Oct 2026 05:28:49 +0200 Subject: [PATCH 5/5] docs(apple-runner): cut review history from the background-serve comments AGENTS.md keeps decision and review narration out of implementation comments: the screenshot raise site, `macAppCaptureNeedsRaise`, and the `.existingApp` policy case each keep the one constraint they encode (region-grab pixels, the state rule, the served-read scope) and point at #3338 for the measured record. No behavior change; host-lane evidence for the pinned conditions is unchanged. Partial #3254 --- .../RunnerTests+CommandExecution.swift | 21 ++++--------------- .../RunnerTests+Lifecycle.swift | 10 +++------ .../RunnerTests+Models.swift | 8 +++---- 3 files changed, 10 insertions(+), 29 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 704b20a176..d8fd33a985 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -428,23 +428,10 @@ extension RunnerTests { return Response(ok: true, data: DataPayload(text: text)) case .screenshot: #if os(macOS) - // Window-level app captures raise the app; a full-screen capture of a running app and an app - // already in the foreground do not (#3254). - // - // The raise is load-bearing for the window-level pixels, so it stays there. Measured on a macOS - // host with a known window frame and a second window laid over it: for a `.runningBackground` - // app `screenshotRoot(app:).screenshot()` returns that window's own rect at the right scale but - // not its own content — the occluding app's windows are in the image — and the same call after - // `activate()` returns the app's content. A window-level capture on this backend is a region - // grab, so answering one from the background would silently report another app's screen as this - // app's window. A `--fullscreen` capture is a whole-display grab that `XCUIScreen.main` answers - // whichever app owns the foreground, so nothing about its pixels needs a running app raised, - // and a read that asked for none takes the user's frontmost app away. A stopped app keeps being - // started for either shape: there the activation is the launch it has always performed, which - // this change leaves untouched. The wait belongs to the raise: only a raised app has an - // animation to settle. The macOS helper serves background windows by a different mechanism, - // `SCContentFilter(desktopIndependentWindow:)`, which is the native backend's path and is not - // reachable here. + // 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) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift index cd3c074ffe..9e2e179e81 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift @@ -284,13 +284,9 @@ extension RunnerTests { return false } - /// Whether a macOS app-targeted capture must bring the app forward before answering, and so run - /// the wait that settles the raise (#3254). Three exclusions, each a state or shape where the - /// pixels need nothing brought forward: an app already in the foreground has nothing to raise, and - /// a `--fullscreen` capture of a running app is a whole-display grab that `XCUIScreen.main` - /// answers whichever app owns the foreground, so raising one for it only takes the user's own app - /// away. A stopped app keeps its standing behavior either way: there the activation is also the - /// launch it has always performed, which this change leaves untouched. + /// 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 diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 6549216372..dd35949a9b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -66,11 +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 same policy also settles what happens to an app that IS running but behind other - /// windows: a read is served there in place, without bringing it forward, because on a desktop the - /// repair steals the user's own frontmost app for a command that only asked to look (#3254). A - /// stopped macOS app keeps the launch the activating route already performs; only the raise of a - /// running app was removed. + /// 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