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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -536,12 +536,27 @@ extension RunnerTests {
#else
// The platform exception, written once: `SystemSurfaceHostRegistry` registers no hosts off iOS,
// so nothing is ever served in place there and such a command keeps the activation route this
// axis found it on.
// axis found it on. The host never sends either command here on macOS — `alert` answers through
// the helper and `action-button` is refused by its owner fact before dispatch — so off iOS this
// route is reached only by the tvOS and visionOS runners.
return prepareActivatedTarget(command: command)
#endif
case .existingApp:
// No request-dependent bypass here: it decides by querying the cached target's state, and a
// command that may bring nothing forward has nothing for it to settle.
#if os(macOS)
if let bundleId = command.appBundleId?.trimmedNonEmpty,
macReadMayBeServedInBackground(
command: command,
targetState: XCUIApplication(bundleIdentifier: bundleId).state
)
{
// Identity first, through the shared rule: a served read must answer the process a bound
// read would have read, not a pid an outside relaunch replaced.
refreshCachedTargetIfProcessChanged(bundleId: bundleId)
return .context(ActiveCommandContext(app: resolveAppWithoutActivation(command: command)))
}
#endif
return prepareActivatedTarget(command: command)
case .mayLaunch:
if shouldSkipAppActivationPreflight(command) {
Expand Down Expand Up @@ -775,4 +790,16 @@ extension RunnerTests {
}
return XCUIApplication(bundleIdentifier: bundleId)
}

/// A macOS `.existingApp` read is served in place only for `.runningBackground`: the one state
/// whose tree answers while the app sits behind other windows. Every other state keeps the
/// activating route, matching `targetNeedsActivation`'s macOS set, and serving in place books no
/// activation fact or marker — the fact channel records only a repair performed, so a served
/// read answers as an ordinary read (#3254; decision record in #3338).
///
/// The probe handle must be fresh, not cached: a cached handle can address a pid an outside
/// relaunch replaced, whose `.state` would answer for the dead process.
func macReadMayBeServedInBackground(command: Command, targetState: XCUIApplication.State) -> Bool {
command.appBundleId?.trimmedNonEmpty != nil && targetState == .runningBackground
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -428,18 +428,27 @@ extension RunnerTests {
return Response(ok: true, data: DataPayload(text: text))
case .screenshot:
#if os(macOS)
// macOS keeps the app-targeted capture behavior for window-level screenshots.
if let bundleId = command.appBundleId, !bundleId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
// An app-targeted window-level capture is a screen-region grab: from the background the
// occluding app's pixels land in the image, so the raise is what makes the pixels correct
// (measured; record in #3338). `--fullscreen` reads the whole display and needs no raise.
// The wait belongs to the raise: only a raised app has an activation animation to settle.
let screenshotBundleId = command.appBundleId?.trimmedNonEmpty
if let bundleId = screenshotBundleId {
let targetApp = XCUIApplication(bundleIdentifier: bundleId)
targetApp.activate()
if macAppCaptureNeedsRaise(
fullscreen: command.fullscreen,
targetState: targetApp.state
) {
targetApp.activate()
// Brief wait for the app transition animation to complete
sleepFor(0.5)
}
activeApp = targetApp
// Brief wait for the app transition animation to complete
sleepFor(0.5)
}
let screenshot: XCUIScreenshot
if command.fullscreen == true {
screenshot = XCUIScreen.main.screenshot()
} else if let bundleId = command.appBundleId, !bundleId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
} else if screenshotBundleId != nil {
screenshot = screenshotRoot(app: activeApp).screenshot()
} else {
screenshot = XCUIScreen.main.screenshot()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,14 @@ extension RunnerTests {
return false
}

/// The state rule for the macOS app-targeted capture raise: only a window-level grab of an app
/// that is not already foreground raises, and a stopped app raises in both shapes because there
/// the activation is the launch the capture has always performed (#3254; record in #3338).
func macAppCaptureNeedsRaise(fullscreen: Bool?, targetState: XCUIApplication.State) -> Bool {
guard targetState != .runningForeground else { return false }
return fullscreen != true || targetState == .notRunning
}

@MainActor
func canUseFastForegroundAppGuard(
activeApp: XCUIApplication,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ enum CommandLaunchPolicy: Equatable {
/// enforced on iOS only: `notRunningRefusal` is `#if os(iOS)`, the platform that can read an app's
/// state without launching it. Off iOS these commands keep the activation route they had before
/// this axis existed, and no refusal can occur there.
/// On macOS the policy also decides the foreground question: a read of an app that is running
/// behind other windows is served in place without raising it, while a stopped app keeps the
/// activating route's launch (#3254; record in #3338).
case existingApp
/// Brings the app forward, which bare-launches it when it is not running.
case mayLaunch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -690,3 +690,172 @@ extension RunnerTests {
}
}
#endif

#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(macOS)
extension RunnerTests {
/// Part of every macOS install, never the runner or the test host, and cheap to leave terminated.
/// Its windows sit over or behind the test host's own, which is exactly the desktop shape #3254
/// describes: the user's frontmost app is a different app from the session app.
private static let macBackgroundTargetBundleId = "com.apple.systempreferences"
private static let macForegroundStealBundleId = "com.apple.finder"

/// Brings Settings up, then hands the foreground to Finder, and waits until Settings reports
/// itself background. `activate()` on another app is how a desktop backgrounds an app without
/// quitting it, so this leaves the state a read must answer — not a launchable absence.
@MainActor
private func startMacTargetAndLoseTheForeground() throws -> XCUIApplication {
let target = XCUIApplication(bundleIdentifier: Self.macBackgroundTargetBundleId)
target.launch()
XCTAssertTrue(target.waitForExistence(timeout: appExistenceTimeout))
_ = XCUIApplication(bundleIdentifier: Self.macForegroundStealBundleId).activate()
XCTAssertTrue(
target.wait(for: .runningBackground, timeout: 10),
"Finder must take the foreground and the target must settle background before the read answers from it"
)
return target
}

/// The #3254 fix on the policy axis: on macOS a `.existingApp` read of a session app that is
/// running behind other windows is served from where it sits. Three things must be true at once:
/// the command is prepared against the app it names, no activation fact is booked (the response
/// therefore discloses no repair because none happened), and the app is still background when the
/// read is done — the user's frontmost app never changed. Covers the user-level read and the two
/// leading reads an interaction carries (gesture viewport, selector resolution), the same shapes
/// the iOS surface arm pins. Deleting the macOS arm of `.existingApp` fails every row.
@MainActor
func testMacReadOfABackgroundAppIsServedInBackgroundWithoutActivating() throws {
let target = try startMacTargetAndLoseTheForeground()
let bundleId = Self.macBackgroundTargetBundleId
defer {
invalidateCachedTarget(reason: "unit_test_cleanup")
pendingTargetActivation = nil
target.terminate()
}
for request in [
#"{"command":"snapshot","commandId":"read","appBundleId":"\#(bundleId)"}"#,
#"{"command":"gestureViewport","commandId":"read","appBundleId":"\#(bundleId)"}"#,
#"{"command":"querySelector","selectorKey":"label","selectorValue":"General","commandId":"read","appBundleId":"\#(bundleId)"}"#
] {
invalidateCachedTarget(reason: "unit_test_setup")
pendingTargetActivation = nil
let command = try runnerCommandFixture(request)

guard case .context(let prepared) = prepareActiveCommandContext(command: command) else {
return XCTFail("\(request) must be served, not refused")
}
XCTAssertEqual(
prepared.app.state,
.runningBackground,
"\(request) must be served against the background app it names"
)
XCTAssertNil(pendingTargetActivation, "\(request) may not book an activation fact")
// The disclosure decision, pinned with #3254: a background-served read answers as an ordinary
// read with no substitute marker (no observation payload, no fact). The tree is live, not
// degraded; a future disclosure must edit this line on purpose.
XCTAssertNil(prepared.observation, "\(request) must not invent an observation payload")
XCTAssertEqual(
target.state,
.runningBackground,
"\(request) may not take the user's frontmost app away"
)
}
}

/// The identity rule the served read must go through (#3254 review): with the cache bound to a
/// pid an outside relaunch replaced, a served read answers the process a bound read would have
/// read, not the dead handle the cache holds. The bogus cached pid makes the wiring observable:
/// the served preparation must leave the live pid behind, so deleting the
/// `refreshCachedTargetIfProcessChanged` call from the arm leaves the bogus value and fails here.
@MainActor
func testMacBackgroundReadRefreshesACachedHandleFromADeadPid() throws {
let target = try startMacTargetAndLoseTheForeground()
let bundleId = Self.macBackgroundTargetBundleId
defer {
invalidateCachedTarget(reason: "unit_test_cleanup")
pendingTargetActivation = nil
target.terminate()
}
let command = try runnerCommandFixture(
#"{"command":"snapshot","commandId":"read","appBundleId":"\#(bundleId)"}"#
)
// Plant the residue an outside relaunch leaves between two reads: the cache still names this
// app and still holds a handle, but the pid it recorded is dead.
mainOwned.app = target
mainOwned.bundleId = bundleId
mainOwned.processIdentifier = 999_999
pendingTargetActivation = nil

guard case .context(let prepared) = prepareActiveCommandContext(command: command) else {
return XCTFail("the read must be served")
}
XCTAssertEqual(prepared.app.state, .runningBackground, "still served in place")
XCTAssertNil(pendingTargetActivation, "the refresh must not activate")
XCTAssertNotEqual(
mainOwned.processIdentifier,
999_999,
"a served read must run the shared identity refresh, not answer from a dead pid"
)
}

/// The scope limit that keeps this from being a blanket no-activate rule: an interaction against
/// the same background app still comes forward through the same preparation, because on macOS the
/// XCTest path drives events into the foreground window and a background click would land on
/// whatever window sits on top. This is the same call with `.mayLaunch`; the two tests above and
/// this one together pin the boundary the policy axis draws.
@MainActor
func testMacInteractionOnABackgroundAppStillActivates() throws {
let target = try startMacTargetAndLoseTheForeground()
defer {
invalidateCachedTarget(reason: "unit_test_cleanup")
pendingTargetActivation = nil
target.terminate()
}
invalidateCachedTarget(reason: "unit_test_setup")
pendingTargetActivation = nil
let command = try runnerCommandFixture(
#"{"command":"tap","x":10,"y":10,"commandId":"tap-bg","appBundleId":"\#(Self.macBackgroundTargetBundleId)"}"#
)

guard case .context = prepareActiveCommandContext(command: command) else {
return XCTFail("the tap must be prepared")
}
XCTAssertNotNil(
pendingTargetActivation,
"an interaction on a background app must still book its activation"
)
XCTAssertEqual(target.state, .runningForeground, "the tap must run against the foreground app")
}

/// Stopped apps behave exactly as they did before this axis read macOS (#3254 correction): a read
/// of a not-running session app still goes through the activating route and the launch it performs,
/// because macOS has no refusal there and callers rely on `open`-then-read working. `snapshot` is
/// the read whose policy the background change touched, so it is the read that proves the change is
/// scoped to the running app only: the app comes up, the activation fact is booked, and nothing is
/// refused. Making `macReadMayBeServedInBackground` answer `.notRunning` too would turn this
/// context into a refusal and fail on the first assertion.
@MainActor
func testMacReadOfANotRunningAppStillLaunchesIt() throws {
let target = XCUIApplication(bundleIdentifier: Self.macBackgroundTargetBundleId)
target.terminate()
invalidateCachedTarget(reason: "unit_test_setup")
pendingTargetActivation = nil
defer {
invalidateCachedTarget(reason: "unit_test_cleanup")
pendingTargetActivation = nil
target.terminate()
}
let command = try runnerCommandFixture(
#"{"command":"snapshot","commandId":"read","appBundleId":"\#(Self.macBackgroundTargetBundleId)"}"#
)

guard case .context = prepareActiveCommandContext(command: command) else {
return XCTFail("a read of a stopped app must keep the launch route it has always had")
}
XCTAssertNotEqual(target.state, .notRunning, "the stopped app must come up as it did before")
XCTAssertNotNil(
pendingTargetActivation,
"the command that launched the app discloses the activation it paid for"
)
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,67 @@ extension RunnerTests {
}
#endif

#if AGENT_DEVICE_RUNNER_UNIT_TESTS && os(macOS)
extension RunnerTests {
/// Pins every state the macOS screenshot raise condition reads (#3254), together with the read
/// arm's answer for the same state, so one table owns the pair. Both predicates run their real
/// bodies: a drifted condition on either side lands as a red row rather than a silent behavior
/// change. The rows encode the two platform facts the split rests on: the macOS SDK declares no
/// suspended state (see `RunnerTests+ApplicationStateRawValueTests`), so these two predicates
/// cannot disagree on it here, and every state that is not `.runningBackground` keeps its
/// activating route for reads — which for a capture is also where the raise lives. The one
/// deliberate divergence is background itself: a read is served in place because the tree answers
/// from anywhere, while a window-level capture raises because its pixels are a region grab that
/// the occluding app spoils (#3254's measurement). Full-screen captures raise only a stopped app,
/// where the activation is the launch it has always performed.
func testMacBackgroundServingAndCaptureRaiseConditionsArePinnedPerState() throws {
let read = try runnerCommandFixture(#"{"command":"snapshot","commandId":"table","appBundleId":"com.example.any"}"#)
let noBundle = try runnerCommandFixture(#"{"command":"snapshot","commandId":"table"}"#)
let states: [(state: XCUIApplication.State, expected: Bool)] = [
(.unknown, false),
(.notRunning, false),
(.runningBackground, true),
(.runningForeground, false),
]
let foreground = XCUIApplication.State.runningForeground
let unknown = XCUIApplication.State.unknown
let notRunning = XCUIApplication.State.notRunning
let raiseRows: [(fullscreen: Bool?, state: XCUIApplication.State, expected: Bool)] = [
(nil, foreground, false),
(nil, .runningBackground, true),
(nil, unknown, true),
(nil, notRunning, true),
(false, foreground, false),
(false, .runningBackground, true),
(false, unknown, true),
(false, notRunning, true),
(true, foreground, false),
(true, .runningBackground, false),
(true, unknown, false),
(true, notRunning, true),
]
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
for row in states {
XCTAssertEqual(
macReadMayBeServedInBackground(command: read, targetState: row.state),
row.expected,
"read state=\(row.state.rawValue)"
)
}
XCTAssertFalse(
macReadMayBeServedInBackground(command: noBundle, targetState: .runningBackground),
"a read naming no app has no session app to serve in place"
)
for row in raiseRows {
XCTAssertEqual(
macAppCaptureNeedsRaise(fullscreen: row.fullscreen, targetState: row.state),
row.expected,
"capture fullscreen=\(String(describing: row.fullscreen)) state=\(row.state.rawValue)"
)
}
}
}
#endif

#if AGENT_DEVICE_RUNNER_UNIT_TESTS
extension RunnerTests {
@MainActor
Expand Down
3 changes: 2 additions & 1 deletion src/commands/schema/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading