Skip to content

Commit 082ea2c

Browse files
committed
fix(ios-runner): keep the presented surface's route for no-app commands
`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.
1 parent 1206495 commit 082ea2c

4 files changed

Lines changed: 116 additions & 8 deletions

File tree

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandDispatch.swift‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -468,9 +468,17 @@ extension RunnerTests {
468468
}
469469
switch command.traits.launchPolicy {
470470
case .noApp:
471-
// Answers from the runner's own capture and state, so the target is resolved exactly as it
472-
// stands.
473-
return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command)))
471+
// A surface that is genuinely on screen is the screen this command would observe, so it is
472+
// served in place first, exactly as it is for the reads: bringing nothing forward is what makes
473+
// an observation honest, and it is not the same promise as ignoring what is presented (#2438).
474+
if let presented = presentedSystemSurfaceHost() {
475+
return .context(ActiveCommandContext(app: presented.app, systemSurface: presented.host))
476+
}
477+
// Nothing is presented, so the target is the one that already stands: the cached session app,
478+
// or the runner host when nothing is bound. Not the request's bundle id — this route never
479+
// resolves a bundle it has not already bound, which is what keeps an observation from deciding
480+
// which app it is about.
481+
return .context(ActiveCommandContext(app: mainOwned.app ?? app))
474482
case .presentedSurface:
475483
// The command is about the surface that already has focus; activating an app under it would
476484
// cancel exactly what the command is about.
@@ -581,9 +589,8 @@ extension RunnerTests {
581589
private func presentedSystemSurfaceHost() -> (host: SystemSurfaceHost, app: XCUIApplication)? {
582590
#if os(iOS)
583591
for host in SystemSurfaceHostRegistry.hosts {
584-
let candidate = XCUIApplication(bundleIdentifier: host.bundleId)
585-
if candidate.state == .runningForeground {
586-
return (host, candidate)
592+
if systemSurfaceHostState(host) == .runningForeground {
593+
return (host, XCUIApplication(bundleIdentifier: host.bundleId))
587594
}
588595
}
589596
return nil
@@ -592,6 +599,19 @@ extension RunnerTests {
592599
#endif
593600
}
594601

602+
/// Whether a registered host is on screen. A registered host is an out-of-process service that only
603+
/// comes up because some app presented it, and `open` refuses to launch one, so no in-bundle test
604+
/// can make the system report one foreground; the named hosts answer that one question here and the
605+
/// registry order and the foreground condition above stay the production ones.
606+
private func systemSurfaceHostState(_ host: SystemSurfaceHost) -> XCUIApplication.State {
607+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
608+
if presentedSystemSurfaceForegroundOverrideForTesting?.contains(host.bundleId) == true {
609+
return .runningForeground
610+
}
611+
#endif
612+
return XCUIApplication(bundleIdentifier: host.bundleId).state
613+
}
614+
595615
func currentXCTestFailureCount() -> Int {
596616
return testRun?.failureCount ?? 0
597617
}

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,11 @@ enum CommandType: String, Codable, CaseIterable {
4444
/// decides whether a stopped app is started, so it is declared per command rather than inferred
4545
/// from whether the command may be replayed (#2890).
4646
enum CommandLaunchPolicy: Equatable {
47-
/// Never brings an app forward: the command answers from the runner's own capture and state, or
48-
/// drives the runner's own lifecycle.
47+
/// Preparation brings no app forward and binds no target: the command answers from the runner's own
48+
/// capture and state, or drives the runner's own lifecycle, so it is served the standing cached
49+
/// target. A surface that is genuinely presented is served in place instead, because that is the
50+
/// screen the command observes (#2438). Scoped to preparation: a command body may still go to the
51+
/// app it names, as macOS `screenshot` does.
4952
case noApp
5053
/// Answers from the surface that already has focus, where activating an app would cancel exactly
5154
/// what the command is about: an in-place system surface, or a press that belongs to the system.

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,12 @@ final class RunnerTests: XCTestCase {
202202
var blockingSystemModalPresenceOverrideForTesting: Bool?
203203
var alertResolutionOverrideForTesting: (@MainActor (Date) -> RunnerAlert?)?
204204
var alertButtonHittabilityProbeOverrideForTesting: (@MainActor (Date) -> Bool)?
205+
// Unit-test-only seam for the in-place surface probe (`presentedSystemSurfaceHost`): a registered
206+
// host is an out-of-process XPC service that only comes up because some app presented it, and
207+
// `open` refuses to launch one, so the foreground answer for the named registered hosts is
208+
// supplied here. The probe's registry order and its `.runningForeground` condition stay the
209+
// production ones. Production never compiles this property.
210+
var presentedSystemSurfaceForegroundOverrideForTesting: Set<String>?
205211
// Runs on the waiting thread after `runMainThreadWork`'s wait timed out and before it takes the
206212
// lock that decides between finished and abandoned, so a test can finish the work in that window.
207213
var mainThreadWorkTimedOutForTesting: (@Sendable () -> Void)?

‎apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+CommandDispatchTests.swift‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,85 @@ extension RunnerTests {
143143
}
144144
}
145145

146+
/// `.noApp` owes both of the merge-base's answers: a registered host presented over the session is
147+
/// served in place (#2438), and with nothing presented the standing cached target is served rather
148+
/// than a target resolved from the request. The policy axis dropped both. The request names a bundle
149+
/// the session never bound because that is the only shape separating the two targets — when the
150+
/// request agrees with the cache, both answers name the same app. The seam is the host's foreground
151+
/// state alone: the probe's registry walk and foreground condition stay the production ones, and the
152+
/// `snapshot`/`querySelector` route that consumes this preparation is pinned live by the replay
153+
/// layers, which need a host something actually presented.
154+
@MainActor
155+
func testNoAppCommandStillServesAPresentedSurfaceInPlaceAndOtherwiseTheStandingTarget() throws {
156+
let cachedBundleId = "com.example.session"
157+
let requestedBundleId = "com.example.requested-but-never-bound"
158+
defer {
159+
presentedSystemSurfaceForegroundOverrideForTesting = nil
160+
invalidateCachedTarget(reason: "unit_test_cleanup")
161+
}
162+
163+
let host = try XCTUnwrap(SystemSurfaceHostRegistry.hosts.first)
164+
let screenshot = try runnerCommandFixture(
165+
#"{"command":"screenshot","commandId":"capture-1","appBundleId":"\#(requestedBundleId)"}"#
166+
)
167+
mainOwned.app = app
168+
mainOwned.bundleId = cachedBundleId
169+
170+
presentedSystemSurfaceForegroundOverrideForTesting = [host.bundleId]
171+
guard case .context(let presented) = prepareActiveCommandContext(command: screenshot) else {
172+
return XCTFail("screenshot must be prepared, not refused")
173+
}
174+
XCTAssertEqual(
175+
presented.systemSurface,
176+
host,
177+
"a capture taken under a presented surface must carry that surface's provenance (#2438)"
178+
)
179+
XCTAssertFalse(
180+
presented.app === app,
181+
"the capture target is the presented host, not the standing session target"
182+
)
183+
XCTAssertFalse(
184+
presented.app === springboard,
185+
"a presented surface is served in place, never through SpringBoard"
186+
)
187+
XCTAssertNil(pendingTargetActivation, "serving a surface in place may not record an activation")
188+
XCTAssertEqual(
189+
mainOwned.bundleId,
190+
cachedBundleId,
191+
"serving a surface in place may not rebind the session target"
192+
)
193+
194+
// A second host reported instead of the first: an arm that returned the registry's first entry
195+
// rather than walking it would pass everything above and fail here.
196+
let secondHost = SystemSurfaceHostRegistry.hosts[1]
197+
presentedSystemSurfaceForegroundOverrideForTesting = [secondHost.bundleId]
198+
guard case .context(let other) = prepareActiveCommandContext(command: screenshot) else {
199+
return XCTFail("screenshot must be prepared, not refused")
200+
}
201+
XCTAssertEqual(
202+
other.systemSurface,
203+
secondHost,
204+
"the probe must serve the host that is reported foreground, not the registry's first entry"
205+
)
206+
207+
// The other half, with nothing presented. Without this the arm could pass by serving a surface
208+
// that is not there.
209+
presentedSystemSurfaceForegroundOverrideForTesting = nil
210+
guard case .context(let standing) = prepareActiveCommandContext(command: screenshot) else {
211+
return XCTFail("screenshot must be prepared, not refused")
212+
}
213+
XCTAssertNil(
214+
standing.systemSurface,
215+
"no surface is presented, so nothing may be disclosed as one"
216+
)
217+
XCTAssertTrue(
218+
standing.app === app,
219+
"naming another bundle is no licence to point the observation away from the standing target"
220+
)
221+
XCTAssertNil(pendingTargetActivation)
222+
XCTAssertEqual(mainOwned.bundleId, cachedBundleId, "preparing a capture binds nothing")
223+
}
224+
146225
@MainActor
147226
func testSkipAppActivationPreflightIncludesForegroundCachedCoordinateOnlyTaps() throws {
148227
app.launch()

0 commit comments

Comments
 (0)