From 082ea2c774c871f863a5a4fe72cef9fb3698bc61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 25 Sep 2026 09:28:15 +0200 Subject: [PATCH 1/8] fix(ios-runner): keep the presented surface's route for no-app commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prepareActiveCommandContext` consulted `presentedSystemSurfaceHost()` before the lifecycle branch, so a `screenshot`, `status`, or `uptime` taken while a registered system surface was foregrounded was served that host in place with its provenance (#2438). The launch policy axis added in #2899 answered `.noApp` by resolving a target from the request, dropping both the presented route and the standing cached target the merge-base fell through to. Neither answer brings an app forward, so only the target and its disclosure differ; on a foldable the first is the panel that is lit. Restored with a regression test over a forced-presented host, and a second half that names a bundle the session never bound — the only request shape that tells the standing target apart from one resolved from the request. --- .../RunnerTests+CommandDispatch.swift | 32 ++++++-- .../RunnerTests+Models.swift | 7 +- .../RunnerTests.swift | 6 ++ .../RunnerTests+CommandDispatchTests.swift | 79 +++++++++++++++++++ 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift index cc0cbaa84e..ecc1ba15bb 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -468,9 +468,17 @@ extension RunnerTests { } switch command.traits.launchPolicy { case .noApp: - // Answers from the runner's own capture and state, so the target is resolved exactly as it - // stands. - return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command))) + // A surface that is genuinely on screen is the screen this command would observe, so it is + // served in place first, exactly as it is for the reads: bringing nothing forward is what makes + // an observation honest, and it is not the same promise as ignoring what is presented (#2438). + if let presented = presentedSystemSurfaceHost() { + return .context(ActiveCommandContext(app: presented.app, systemSurface: presented.host)) + } + // Nothing is presented, so the target is the one that already stands: the cached session app, + // or the runner host when nothing is bound. Not the request's bundle id — this route never + // resolves a bundle it has not already bound, which is what keeps an observation from deciding + // which app it is about. + return .context(ActiveCommandContext(app: mainOwned.app ?? app)) case .presentedSurface: // The command is about the surface that already has focus; activating an app under it would // cancel exactly what the command is about. @@ -581,9 +589,8 @@ extension RunnerTests { private func presentedSystemSurfaceHost() -> (host: SystemSurfaceHost, app: XCUIApplication)? { #if os(iOS) for host in SystemSurfaceHostRegistry.hosts { - let candidate = XCUIApplication(bundleIdentifier: host.bundleId) - if candidate.state == .runningForeground { - return (host, candidate) + if systemSurfaceHostState(host) == .runningForeground { + return (host, XCUIApplication(bundleIdentifier: host.bundleId)) } } return nil @@ -592,6 +599,19 @@ extension RunnerTests { #endif } + /// Whether a registered host is on screen. A registered host is an out-of-process service that only + /// comes up because some app presented it, and `open` refuses to launch one, so no in-bundle test + /// can make the system report one foreground; the named hosts answer that one question here and the + /// registry order and the foreground condition above stay the production ones. + private func systemSurfaceHostState(_ host: SystemSurfaceHost) -> XCUIApplication.State { + #if AGENT_DEVICE_RUNNER_UNIT_TESTS + if presentedSystemSurfaceForegroundOverrideForTesting?.contains(host.bundleId) == true { + return .runningForeground + } + #endif + return XCUIApplication(bundleIdentifier: host.bundleId).state + } + func currentXCTestFailureCount() -> Int { return testRun?.failureCount ?? 0 } diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index b1b707e7d7..f5e9086fae 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -44,8 +44,11 @@ enum CommandType: String, Codable, CaseIterable { /// decides whether a stopped app is started, so it is declared per command rather than inferred /// from whether the command may be replayed (#2890). enum CommandLaunchPolicy: Equatable { - /// Never brings an app forward: the command answers from the runner's own capture and state, or - /// drives the runner's own lifecycle. + /// Preparation brings no app forward and binds no target: the command answers from the runner's own + /// capture and state, or drives the runner's own lifecycle, so it is served the standing cached + /// target. A surface that is genuinely presented is served in place instead, because that is the + /// screen the command observes (#2438). Scoped to preparation: a command body may still go to the + /// app it names, as macOS `screenshot` does. case noApp /// Answers from the surface that already has focus, where activating an app would cancel exactly /// what the command is about: an in-place system surface, or a press that belongs to the system. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index cb8c0e2cb1..d4e7a3c0fa 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -202,6 +202,12 @@ final class RunnerTests: XCTestCase { var blockingSystemModalPresenceOverrideForTesting: Bool? var alertResolutionOverrideForTesting: (@MainActor (Date) -> RunnerAlert?)? var alertButtonHittabilityProbeOverrideForTesting: (@MainActor (Date) -> Bool)? + // Unit-test-only seam for the in-place surface probe (`presentedSystemSurfaceHost`): a registered + // host is an out-of-process XPC service that only comes up because some app presented it, and + // `open` refuses to launch one, so the foreground answer for the named registered hosts is + // supplied here. The probe's registry order and its `.runningForeground` condition stay the + // production ones. Production never compiles this property. + var presentedSystemSurfaceForegroundOverrideForTesting: Set? // 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: (@Sendable () -> Void)? diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift index 49980d0968..31a9570891 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift @@ -143,6 +143,85 @@ extension RunnerTests { } } + /// `.noApp` owes both of the merge-base's answers: a registered host presented over the session is + /// served in place (#2438), and with nothing presented the standing cached target is served rather + /// than a target resolved from the request. The policy axis dropped both. The request names a bundle + /// the session never bound because that is the only shape separating the two targets — when the + /// request agrees with the cache, both answers name the same app. The seam is the host's foreground + /// state alone: the probe's registry walk and foreground condition stay the production ones, and the + /// `snapshot`/`querySelector` route that consumes this preparation is pinned live by the replay + /// layers, which need a host something actually presented. + @MainActor + func testNoAppCommandStillServesAPresentedSurfaceInPlaceAndOtherwiseTheStandingTarget() throws { + let cachedBundleId = "com.example.session" + let requestedBundleId = "com.example.requested-but-never-bound" + defer { + presentedSystemSurfaceForegroundOverrideForTesting = nil + invalidateCachedTarget(reason: "unit_test_cleanup") + } + + let host = try XCTUnwrap(SystemSurfaceHostRegistry.hosts.first) + let screenshot = try runnerCommandFixture( + #"{"command":"screenshot","commandId":"capture-1","appBundleId":"\#(requestedBundleId)"}"# + ) + mainOwned.app = app + mainOwned.bundleId = cachedBundleId + + presentedSystemSurfaceForegroundOverrideForTesting = [host.bundleId] + guard case .context(let presented) = prepareActiveCommandContext(command: screenshot) else { + return XCTFail("screenshot must be prepared, not refused") + } + XCTAssertEqual( + presented.systemSurface, + host, + "a capture taken under a presented surface must carry that surface's provenance (#2438)" + ) + XCTAssertFalse( + presented.app === app, + "the capture target is the presented host, not the standing session target" + ) + XCTAssertFalse( + presented.app === springboard, + "a presented surface is served in place, never through SpringBoard" + ) + XCTAssertNil(pendingTargetActivation, "serving a surface in place may not record an activation") + XCTAssertEqual( + mainOwned.bundleId, + cachedBundleId, + "serving a surface in place may not rebind the session target" + ) + + // A second host reported instead of the first: an arm that returned the registry's first entry + // rather than walking it would pass everything above and fail here. + let secondHost = SystemSurfaceHostRegistry.hosts[1] + presentedSystemSurfaceForegroundOverrideForTesting = [secondHost.bundleId] + guard case .context(let other) = prepareActiveCommandContext(command: screenshot) else { + return XCTFail("screenshot must be prepared, not refused") + } + XCTAssertEqual( + other.systemSurface, + secondHost, + "the probe must serve the host that is reported foreground, not the registry's first entry" + ) + + // The other half, with nothing presented. Without this the arm could pass by serving a surface + // that is not there. + presentedSystemSurfaceForegroundOverrideForTesting = nil + guard case .context(let standing) = prepareActiveCommandContext(command: screenshot) else { + return XCTFail("screenshot must be prepared, not refused") + } + XCTAssertNil( + standing.systemSurface, + "no surface is presented, so nothing may be disclosed as one" + ) + XCTAssertTrue( + standing.app === app, + "naming another bundle is no licence to point the observation away from the standing target" + ) + XCTAssertNil(pendingTargetActivation) + XCTAssertEqual(mainOwned.bundleId, cachedBundleId, "preparing a capture binds nothing") + } + @MainActor func testSkipAppActivationPreflightIncludesForegroundCachedCoordinateOnlyTaps() throws { app.launch() From 3c7e0dffd8da3a1689177f6c890458dde698cece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 25 Sep 2026 09:28:23 +0200 Subject: [PATCH 2/8] refactor(ios-runner): put the activation bypass only where launch is on the table The combined `case .existingApp, .mayLaunch:` arm asked every read whether it could skip preflight. That bypass requires a coordinate-only `.tap` with a cached foreground target, so it was unreachable for the `.existingApp` half and read as if a read could skip preflight. Split so the check lives only under `.mayLaunch`; those commands never satisfied it before either, so behavior is unchanged. --- .../RunnerTests+CommandDispatch.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift index ecc1ba15bb..477828ca4c 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -490,9 +490,11 @@ extension RunnerTests { // axis found it on. return prepareActivatedTarget(command: command) #endif - case .existingApp, .mayLaunch: - // Asked only where activation is on the table: the bypass decides by querying the cached - // target's state, and a command that may bring nothing forward has nothing for it to settle. + 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. + return prepareActivatedTarget(command: command) + case .mayLaunch: if shouldSkipAppActivationPreflight(command) { // The one request-dependent bypass: a coordinate-only synthesized tap whose cached target is // already foreground needs nothing brought forward. From f29786c9947623a2b10f3f69b0858278fc6f5d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 25 Sep 2026 09:28:28 +0200 Subject: [PATCH 3/8] refactor(ios-runner): name the text-entry witness rule at its owning type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consumer derived it inline from `convertsRecordedFailure` and the owner set, so the rule was a predicate inside one function that no table could pin. `Command` now answers it once — still derived rather than declared as a fifth fact the retired axis was — and `executeOnMainPrepared` reads that. The truth table is unchanged for every command. --- .../RunnerTests+CommandExecution.swift | 6 +----- .../RunnerTests+Models.swift | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index e8389af402..a96560e417 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -9,11 +9,7 @@ extension RunnerTests { alertDeadline: Date? = nil ) throws -> Response { var activeApp = activeApp - // Every command that reaches here with a mutation to prove makes a remembered text-entry tap - // stale; the two commands that own that witness decide for themselves in their own cases below. - if command.traits.convertsRecordedFailure, - !CommandTraits.textEntryWitnessOwners.contains(command.command) - { + if command.invalidatesRememberedTextEntryTap { clearRememberedTextEntryTap() } switch command.command { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index f5e9086fae..5007deb8d0 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -160,13 +160,21 @@ fileprivate extension CommandTraits { extension CommandTraits { /// The commands that own the remembered text-entry witness instead of invalidating it: `tap` /// records it (and clears it where a tap demonstrably did not land), and `type` reads the one this - /// command relies on. Everywhere else on the prepared command path it is having a mutation to - /// prove that makes a remembered tap stale, so clearing is derived from `convertsRecordedFailure` - /// together with this set at that one consumer — not declared as a fifth fact, which the commands - /// answered before that path would have carried without ever being read (#2890 review). + /// command relies on. static let textEntryWitnessOwners: Set = [.tap, .type] } +extension Command { + /// Whether arriving at the prepared command path invalidates a remembered text-entry tap. Not a + /// fifth trait: everywhere but the two owner commands, it is having a mutation to prove that makes + /// the witness stale, so this reads `convertsRecordedFailure` and that set rather than declaring a + /// fact no command would answer for itself (#2890 review). `executeOnMainPrepared` is its only + /// consumer, and the exhaustive table test pins the answer for every command. + var invalidatesRememberedTextEntryTap: Bool { + traits.convertsRecordedFailure && !CommandTraits.textEntryWitnessOwners.contains(command) + } +} + struct Command: Codable { let command: CommandType let commandId: String? From 7a2eb0df99ca4e3678cb6beae493bb802fbd7a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 25 Sep 2026 09:28:50 +0200 Subject: [PATCH 4/8] docs(ios-runner): scope the existing-app refusal to where it is enforced `notRunningRefusal` is `#if os(iOS)`, so off iOS an `.existingApp` command reaches activation exactly as it did before this axis existed and no refusal can occur there. `existingApp` promised a refusal with no platform stated, and `selectorResolution` repeated it, which is how #2890's own `querySelector` fix reads as a macOS/tvOS guarantee it never was. Enlarging the refusal to those platforms would change behavior this issue never touched, so the scope is stated instead. --- .../AgentDeviceRunnerUITests/RunnerTests+Models.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 5007deb8d0..b3ce288c5b 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -57,8 +57,10 @@ enum CommandLaunchPolicy: Equatable { case presentedSurface /// Refuses with `APP_NOT_RUNNING` rather than starting a stopped app, because `activate()` on a /// not-running app is a bare launch (#2852). The refusal is about a session app, so it answers an - /// explicitly requested bundle id on the platform that can read that app's state; a request naming - /// no app has no session app to refuse. + /// explicitly requested bundle id; a request naming no app has no session app to refuse. It is + /// 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. case existingApp /// Brings the app forward, which bare-launches it when it is not running. case mayLaunch @@ -129,7 +131,8 @@ fileprivate extension CommandTraits { /// Selector resolution is an observation: it refuses a stopped app instead of bare-launching it, /// and the runner still must not replay it after session invalidation. Those are two facts about - /// one command, which is why they are two declarations (#2890). + /// one command, which is why they are two declarations (#2890). The refusal is the iOS-enforced + /// half: off iOS a selector read of a stopped app still activates it, as it did before this axis. static let selectorResolution = CommandTraits( launchPolicy: .existingApp, convertsRecordedFailure: true From c0a7adb452986abd072cf276afeefc633f91dec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 25 Sep 2026 09:30:22 +0200 Subject: [PATCH 5/8] test(apple): say the alert fixture records whether an action changes anything The column is true only for `get`; `accept` and `dismiss` are false because they mutate, so the comment naming the shared fact as "the alert request changes nothing" contradicted two of the four rows it then reads. Phrased to match the Swift side, which already names `alert get` as the one alert action that may be replayed. --- .../src/runner/__tests__/runner-command-traits.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts index 68b7276ef8..e698119eb3 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-traits.test.ts @@ -86,9 +86,10 @@ test('runner command trait helpers read from the shared trait table', () => { }); test('alert actions match the native read-only golden table', () => { - // The fixture's `query` column names the shared fact — the alert request changes nothing — which - // each side consumes under its own name: `readOnly` for this daemon trait, retry eligibility for - // the Apple runner, which no longer classifies commands by read-only-ness at all. + // The fixture's `query` column records whether the alert request changes anything — `get` is the + // one action that is side-effect-free — and each side consumes it under its own name: `readOnly` + // for this daemon trait, retry eligibility for the Apple runner, which no longer classifies + // commands by read-only-ness at all. const cases = JSON.parse( fs.readFileSync( new URL('../../../../../contracts/fixtures/alert-command-traits.json', import.meta.url), From 09a172b5d9c1da2919abc088cf326d057d057cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 25 Sep 2026 09:30:22 +0200 Subject: [PATCH 6/8] chore(gates): pin witness clearing for every command in the trait table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether the prepared path drops a remembered text-entry tap was argued from the retired read-only fact rather than asserted. The table now carries that column for every command, compared against the merge-base predicate plus a literal copy of its read-only set, so neither replay eligibility nor witness clearing can move with the classification under test. The commands `executeOnMain` answers before `executeOnMainPrepared` runs are compared nowhere: the old predicate never evaluated for them and neither does the derived one. `snapshot` stays in scope because it reaches that body, and `appState` postdates the merge-base and owes it no equality. Adding the `querySelector` bundle id to the witness-owner set — the change the review asked for — turns that row red. --- .../UnitTests/RunnerTests+ModelsTests.swift | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift index 69f1170c25..e018dd5e39 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift @@ -188,6 +188,55 @@ extension RunnerTests { ) } + /// The commands the merge-base classified read-only, copied from its `CommandType.traits` + /// (`git show 6428c54853:…/RunnerTests+Models.swift`) rather than read from anything under test: + /// `readOnly: .always`, plus `alert`'s `get` action, which its `.conditional` case resolved to the + /// same answer. Both consumers that fact had — replay eligibility and the prepared path's witness + /// rule — are pinned against this literal below, so neither can move with the table (#2890 review). + private static let mergeBaseReadOnlyCommands: Set = [ + .findText, .readText, .snapshot, .gestureViewport, .screenshot, .status, .alert, + ] + + /// Commands that did not exist at the merge-base, so no classification of its is compared with + /// theirs. `appState` arrived with #2929. + private static let commandsNewerThanTheMergeBase: Set = [.appState] + + /// The commands production never runs through the prepared path's body: `executeOnMain` answers + /// these before `executeOnMainPrepared` runs, and `executeDispatched` answers `snapshot` earlier + /// still, on both this and the merge-base chain. The merge-base witness predicate was therefore + /// never evaluated for them and neither is the derived one. If a command starts reaching the + /// prepared path, removing it here is a claim the equivalence assertion below has to keep proving. + private static let commandsAnsweredBeforeThePreparedPath: Set = [ + .status, .uptime, .appState, .activate, .terminate, .targetReset, .shutdown, + .recordStart, .recordStop, .snapshot, + ] + + private func assertRememberedTextEntryWitnessInvalidation( + _ command: Command, + type: CommandType, + wasReadOnlyAtMergeBase: Bool, + _ request: String + ) { + if !Self.commandsNewerThanTheMergeBase.contains(type) { + XCTAssertEqual( + command.traits.retryOnSessionLoss, + wasReadOnlyAtMergeBase, + "\(request) must stay replayable exactly where the merge-base classified it read-only" + ) + } + // The merge-base rule (`RunnerTests+CommandExecution.swift:11`): + // `command != .tap && command != .type && !isReadOnlyCommand(command)`. `querySelector` is the + // row this review round was about: it was never read-only, so the merge-base cleared a + // remembered tap for it too and this column says so for every command, not just that one. + let mergeBaseClears = type != .tap && type != .type && !wasReadOnlyAtMergeBase + guard !Self.commandsAnsweredBeforeThePreparedPath.contains(type) else { return } + XCTAssertEqual( + command.invalidatesRememberedTextEntryTap, + mergeBaseClears, + "\(request) must invalidate a remembered text-entry tap exactly as the merge-base did" + ) + } + /// Every decision the runner makes from a classification, asserted for every command from one /// table. `retry` is replay eligibility and `launch` is what the runner may do about a stopped /// app: `querySelector` is the row that proves one does not set the other (#2890). Each row names @@ -247,6 +296,12 @@ extension RunnerTests { let command = try runnerCommandFixture(request) XCTAssertEqual(command.command, type, request) assertTraits(command.traits, matches: rowExpectation, request) + assertRememberedTextEntryWitnessInvalidation( + command, + type: type, + wasReadOnlyAtMergeBase: Self.mergeBaseReadOnlyCommands.contains(type), + request + ) } XCTAssertEqual( Set(table.map { $0.0 }), @@ -271,7 +326,16 @@ extension RunnerTests { for alertCase in alertCases { let request = alertCase.action.map { #"{"command":"alert","action":"\#($0)"}"# } ?? #"{"command":"alert"}"# - assertTraits(try runnerCommandFixture(request).traits, matches: alertCase.expectation, request) + let command = try runnerCommandFixture(request) + assertTraits(command.traits, matches: alertCase.expectation, request) + // The merge-base resolved `alert` through `readOnly: .conditional`, whose rule was this same + // action test, so only `get` — and the missing action it defaults to — was read-only there. + assertRememberedTextEntryWitnessInvalidation( + command, + type: .alert, + wasReadOnlyAtMergeBase: (command.action ?? "get").lowercased() == "get", + request + ) } } } From 099778f2b00ab30c9f13021164e9d109c5cbc698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 25 Sep 2026 11:56:35 +0200 Subject: [PATCH 7/8] test(ios-runner): make the presented-surface override answer every registered host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The override for `systemSurfaceHostState` only forced the hosts a test named, so every other registered host still answered from live simulator state: a registry walk could not be pinned end to end, and a new registry entry made the test's answer depend on the machine again. The override is now total while set — members report foreground, non-members do not. Production is unaffected: the seam is compiled only under the unit-test flag. This is hardening, not a mutation-proofed fix. Mutating the override back to the member-only form stays green on a simulator, because the two registered hosts already report notRunning there — which is the very reason the seam exists. Also write down what the `.noApp` presented arm actually controls. Tracing the first parent of c7b79ee5a: no `.noApp` command read the prepared target or the disclosed surface even pre-split (`status`/`uptime`/`recordStop`/`terminate`/ `targetReset`/`shutdown` answer before reading a target, iOS `screenshot` re-resolves its capture display (#2728), macOS `screenshot` resolves the named app, and `snapshot` — the only provenance consumer — is `.existingApp` and keeps its route in `prepareActivatedTarget`). The arm restores the prepared subject and disclosure, which is what the merge-base chain gave these commands; the test says so instead of claiming a payload the runner never stamped. --- .../RunnerTests+CommandDispatch.swift | 25 ++++++++--- .../RunnerTests+Models.swift | 8 ++-- .../RunnerTests.swift | 5 ++- .../RunnerTests+CommandDispatchTests.swift | 44 +++++++++++++------ 4 files changed, 57 insertions(+), 25 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift index 477828ca4c..8477af781f 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -468,9 +468,18 @@ extension RunnerTests { } switch command.traits.launchPolicy { case .noApp: - // A surface that is genuinely on screen is the screen this command would observe, so it is - // served in place first, exactly as it is for the reads: bringing nothing forward is what makes - // an observation honest, and it is not the same promise as ignoring what is presented (#2438). + // The merge-base's prepared contract, which the policy axis dropped: a genuinely presented + // surface is consulted first and served in place as the prepared target with that surface + // disclosed, activating nothing and rebinding no session target (#2438). Both facts are + // preparation-level and neither has a consumer for this class today — the same as at the + // merge-base for every command it had: `status`, `uptime`, `recordStop`, `terminate`, + // `targetReset` and `shutdown` answer before reading a target; iOS `screenshot` resolves its + // own capture display on purpose (#2728) and macOS `screenshot` resolves the named app or the + // full screen, so neither reads the prepared target either; off iOS the probe serves nothing + // in place; and `snapshot` — the only provenance consumer — is `.existingApp` and keeps its + // presented route in `prepareActivatedTarget`. `appState` is newer than the merge-base and + // reads the named bundle's state itself. What the arm owns is the prepared subject and + // disclosure, restored to the contract every following command was classified against. if let presented = presentedSystemSurfaceHost() { return .context(ActiveCommandContext(app: presented.app, systemSurface: presented.host)) } @@ -603,12 +612,14 @@ extension RunnerTests { /// Whether a registered host is on screen. A registered host is an out-of-process service that only /// comes up because some app presented it, and `open` refuses to launch one, so no in-bundle test - /// can make the system report one foreground; the named hosts answer that one question here and the - /// registry order and the foreground condition above stay the production ones. + /// can make the system report one foreground; the override answers that one question, and when it + /// is set it is authoritative for every registered host — members are foreground, non-members are + /// not — so a test pins the whole registry walk rather than the live state of what it left out. + /// The registry order and the foreground condition above stay the production ones. private func systemSurfaceHostState(_ host: SystemSurfaceHost) -> XCUIApplication.State { #if AGENT_DEVICE_RUNNER_UNIT_TESTS - if presentedSystemSurfaceForegroundOverrideForTesting?.contains(host.bundleId) == true { - return .runningForeground + if let override = presentedSystemSurfaceForegroundOverrideForTesting { + return override.contains(host.bundleId) ? .runningForeground : .notRunning } #endif return XCUIApplication(bundleIdentifier: host.bundleId).state diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index b3ce288c5b..37c525272a 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -46,9 +46,11 @@ enum CommandType: String, Codable, CaseIterable { enum CommandLaunchPolicy: Equatable { /// Preparation brings no app forward and binds no target: the command answers from the runner's own /// capture and state, or drives the runner's own lifecycle, so it is served the standing cached - /// target. A surface that is genuinely presented is served in place instead, because that is the - /// screen the command observes (#2438). Scoped to preparation: a command body may still go to the - /// app it names, as macOS `screenshot` does. + /// target. A surface that is genuinely presented is served in place instead, which is the prepared + /// contract those commands had before the launch-policy axis (#2438); no command in this class + /// consumes the prepared target or the disclosed surface today, and the arm that says so names + /// where each command's answer actually comes from. Scoped to preparation either way: a command + /// body may still go to the app it names, as macOS `screenshot` does. case noApp /// Answers from the surface that already has focus, where activating an app would cancel exactly /// what the command is about: an in-place system surface, or a press that belongs to the system. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift index d4e7a3c0fa..3140827424 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift @@ -204,8 +204,9 @@ final class RunnerTests: XCTestCase { var alertButtonHittabilityProbeOverrideForTesting: (@MainActor (Date) -> Bool)? // Unit-test-only seam for the in-place surface probe (`presentedSystemSurfaceHost`): a registered // host is an out-of-process XPC service that only comes up because some app presented it, and - // `open` refuses to launch one, so the foreground answer for the named registered hosts is - // supplied here. The probe's registry order and its `.runningForeground` condition stay the + // `open` refuses to launch one, so the foreground answer for every registered host is supplied + // here when set — members are foreground, non-members are not — which lets a test pin the whole + // registry walk. The probe's registry order and its `.runningForeground` condition stay the // production ones. Production never compiles this property. var presentedSystemSurfaceForegroundOverrideForTesting: Set? // Runs on the waiting thread after `runMainThreadWork`'s wait timed out and before it takes the diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift index 31a9570891..b1023be2cf 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift @@ -144,13 +144,17 @@ extension RunnerTests { } /// `.noApp` owes both of the merge-base's answers: a registered host presented over the session is - /// served in place (#2438), and with nothing presented the standing cached target is served rather - /// than a target resolved from the request. The policy axis dropped both. The request names a bundle - /// the session never bound because that is the only shape separating the two targets — when the - /// request agrees with the cache, both answers name the same app. The seam is the host's foreground - /// state alone: the probe's registry walk and foreground condition stay the production ones, and the - /// `snapshot`/`querySelector` route that consumes this preparation is pinned live by the replay - /// layers, which need a host something actually presented. + /// served in place with its surface disclosed (#2438), and with nothing presented the standing cached + /// target is served rather than a target resolved from the request. The policy axis dropped both. + /// The request names a bundle the session never bound because that is the only shape separating the + /// two targets — when the request agrees with the cache, both answers name the same app. The seam is + /// the hosts' foreground state alone and it is authoritative while set, so every registered host's + /// answer is pinned here and none is read from live state; the probe's registry walk and foreground + /// condition stay the production ones. This pins preparation, which is the whole contract for this + /// class: no `.noApp` command body reads the prepared target or the disclosure, on this chain or at + /// the merge-base, so the arm that says so in `prepareActiveCommandContext` is the consumer of + /// record. Deleting the presented arm fails the first block, and resolving the target from the + /// request instead of the cache fails the last. @MainActor func testNoAppCommandStillServesAPresentedSurfaceInPlaceAndOtherwiseTheStandingTarget() throws { let cachedBundleId = "com.example.session" @@ -174,11 +178,11 @@ extension RunnerTests { XCTAssertEqual( presented.systemSurface, host, - "a capture taken under a presented surface must carry that surface's provenance (#2438)" + "a capture prepared under a presented surface must name that surface as its prepared subject (#2438)" ) XCTAssertFalse( presented.app === app, - "the capture target is the presented host, not the standing session target" + "the prepared subject is the presented host, not the standing session target" ) XCTAssertFalse( presented.app === springboard, @@ -192,7 +196,8 @@ extension RunnerTests { ) // A second host reported instead of the first: an arm that returned the registry's first entry - // rather than walking it would pass everything above and fail here. + // rather than walking it would pass everything above and fail here. The total override makes the + // first host's not-foreground answer pinned too, not merely observed. let secondHost = SystemSurfaceHostRegistry.hosts[1] presentedSystemSurfaceForegroundOverrideForTesting = [secondHost.bundleId] guard case .context(let other) = prepareActiveCommandContext(command: screenshot) else { @@ -204,9 +209,22 @@ extension RunnerTests { "the probe must serve the host that is reported foreground, not the registry's first entry" ) - // The other half, with nothing presented. Without this the arm could pass by serving a surface - // that is not there. - presentedSystemSurfaceForegroundOverrideForTesting = nil + // Both reported: the registry's own order decides, because live state cannot be told to present + // two hosts at once. Totality is what keeps the block's answer free of what the sim happens to + // report for any host a future registry entry adds. + presentedSystemSurfaceForegroundOverrideForTesting = [host.bundleId, secondHost.bundleId] + guard case .context(let both) = prepareActiveCommandContext(command: screenshot) else { + return XCTFail("screenshot must be prepared, not refused") + } + XCTAssertEqual( + both.systemSurface, + host, + "with every host foreground the probe must serve them in registry order" + ) + + // The other half, with nothing presented — an empty total override, so this half pins the + // hosts' answers too. Without this the arm could pass by serving a surface that is not there. + presentedSystemSurfaceForegroundOverrideForTesting = [] guard case .context(let standing) = prepareActiveCommandContext(command: screenshot) else { return XCTFail("screenshot must be prepared, not refused") } From 511dbb4f56bba47d19c8805a37db0c6d47425352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 25 Sep 2026 12:47:44 +0200 Subject: [PATCH 8/8] docs(ios-runner): point the no-app arm at the trait table instead of copying it --- .../RunnerTests+CommandDispatch.swift | 20 ++++--------------- .../RunnerTests+Models.swift | 10 ++++++---- .../RunnerTests+CommandDispatchTests.swift | 20 ++++++++----------- 3 files changed, 18 insertions(+), 32 deletions(-) diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift index 8477af781f..83915aacde 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift @@ -468,25 +468,13 @@ extension RunnerTests { } switch command.traits.launchPolicy { case .noApp: - // The merge-base's prepared contract, which the policy axis dropped: a genuinely presented - // surface is consulted first and served in place as the prepared target with that surface - // disclosed, activating nothing and rebinding no session target (#2438). Both facts are - // preparation-level and neither has a consumer for this class today — the same as at the - // merge-base for every command it had: `status`, `uptime`, `recordStop`, `terminate`, - // `targetReset` and `shutdown` answer before reading a target; iOS `screenshot` resolves its - // own capture display on purpose (#2728) and macOS `screenshot` resolves the named app or the - // full screen, so neither reads the prepared target either; off iOS the probe serves nothing - // in place; and `snapshot` — the only provenance consumer — is `.existingApp` and keeps its - // presented route in `prepareActivatedTarget`. `appState` is newer than the merge-base and - // reads the named bundle's state itself. What the arm owns is the prepared subject and - // disclosure, restored to the contract every following command was classified against. + // Serves a genuinely presented surface in place with its provenance, else the standing cached + // target, activating nothing and binding nothing (#2438); `Command.traits` is the member list. if let presented = presentedSystemSurfaceHost() { return .context(ActiveCommandContext(app: presented.app, systemSurface: presented.host)) } - // Nothing is presented, so the target is the one that already stands: the cached session app, - // or the runner host when nothing is bound. Not the request's bundle id — this route never - // resolves a bundle it has not already bound, which is what keeps an observation from deciding - // which app it is about. + // The standing target, not the request's bundle id: this route never resolves a bundle it has + // not already bound, which is what keeps an observation from deciding which app it is about. return .context(ActiveCommandContext(app: mainOwned.app ?? app)) case .presentedSurface: // The command is about the surface that already has focus; activating an app under it would diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index 37c525272a..73c7c52761 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -47,10 +47,12 @@ enum CommandLaunchPolicy: Equatable { /// Preparation brings no app forward and binds no target: the command answers from the runner's own /// capture and state, or drives the runner's own lifecycle, so it is served the standing cached /// target. A surface that is genuinely presented is served in place instead, which is the prepared - /// contract those commands had before the launch-policy axis (#2438); no command in this class - /// consumes the prepared target or the disclosed surface today, and the arm that says so names - /// where each command's answer actually comes from. Scoped to preparation either way: a command - /// body may still go to the app it names, as macOS `screenshot` does. + /// contract those commands had before the launch-policy axis (#2438). No member's response body + /// reads the prepared target or the disclosed surface: the names a `.noApp` command answers from + /// are its own capture and state, or the bundle it names — which is why neither prepared fact + /// needs a consumer here, and why a proof of this arm is a preparation test. Scoped to + /// preparation either way: a command body may still go to the app it names, as macOS `screenshot` + /// does. case noApp /// Answers from the surface that already has focus, where activating an app would cancel exactly /// what the command is about: an in-place system surface, or a press that belongs to the system. diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift index b1023be2cf..76c6a642a9 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift @@ -143,18 +143,14 @@ extension RunnerTests { } } - /// `.noApp` owes both of the merge-base's answers: a registered host presented over the session is - /// served in place with its surface disclosed (#2438), and with nothing presented the standing cached - /// target is served rather than a target resolved from the request. The policy axis dropped both. - /// The request names a bundle the session never bound because that is the only shape separating the - /// two targets — when the request agrees with the cache, both answers name the same app. The seam is - /// the hosts' foreground state alone and it is authoritative while set, so every registered host's - /// answer is pinned here and none is read from live state; the probe's registry walk and foreground - /// condition stay the production ones. This pins preparation, which is the whole contract for this - /// class: no `.noApp` command body reads the prepared target or the disclosure, on this chain or at - /// the merge-base, so the arm that says so in `prepareActiveCommandContext` is the consumer of - /// record. Deleting the presented arm fails the first block, and resolving the target from the - /// request instead of the cache fails the last. + /// Pins the `.noApp` preparation contract in `prepareActiveCommandContext`: a presented host is + /// served in place with its surface disclosed, and with nothing presented the standing cached + /// target is served rather than one resolved from the request. The request names a bundle the + /// session never bound because that is the only shape separating the two targets — when the + /// request agrees with the cache, both answers name the same app. The override is authoritative + /// for every registered host while set, so no live state is read; the probe's registry walk and + /// foreground condition stay the production ones. Deleting the presented arm fails the first + /// block, and resolving the target from the request instead of the cache fails the last. @MainActor func testNoAppCommandStillServesAPresentedSurfaceInPlaceAndOtherwiseTheStandingTarget() throws { let cachedBundleId = "com.example.session"