Skip to content

Commit f3b8ab3

Browse files
authored
fix(ios): refuse observe-only snapshots that find a system alert (#4219)
* fix(ios): refuse observe-only snapshots that find a system alert The system-alert tier answered an observe-only capture with the SpringBoard alert tree, and the response still carried observation metadata that asserts the tree came from the session app. The docs already promise no substitution, so an observe-only capture now refuses with OBSERVATION_UNAVAILABLE and a hint naming the alert. Regular snapshots keep returning the alert tree. Fold the fast and raw alert tiers into one function so the refusal has a single owner. Closes #3348 * docs(snapshot): state the observe-only system alert refusal in help Versioned help owns command semantics, so it now names the refusal. The website wording also stops implying that a success proves no alert is present. * test(ios): assert the observe-only system alert refusal hint
1 parent f8e9c8f commit f3b8ab3

7 files changed

Lines changed: 85 additions & 19 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -694,11 +694,13 @@ extension RunnerTests {
694694
return XCUIApplication(bundleIdentifier: host.bundleId).state
695695
}
696696

697-
func observeOnlyUnavailableResponse() -> Response {
697+
func observeOnlyUnavailableResponse(
698+
hint: String = "XCUIApplication must report runningForeground before and after capture. This state report does not prove screen ownership (#2696)."
699+
) -> Response {
698700
Response(ok: false, error: ErrorPayload(
699701
code: "OBSERVATION_UNAVAILABLE",
700702
message: "The session app cannot be observed without activation.",
701-
hint: "XCUIApplication must report runningForeground before and after capture. This state report does not prove screen ownership (#2696)."
703+
hint: hint
702704
))
703705
}
704706

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

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -168,20 +168,11 @@ extension RunnerTests {
168168
// in RunnerTests.swift), so reverting this entry point to bypass the bounded probe fails the
169169
// regression test.
170170
func snapshotFast(target: SnapshotCaptureTarget, options: PresentationOptions) throws -> DataPayload {
171-
let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget)
172-
if let blocking = boundedBlockingSystemAlertSnapshot(
173-
deadline: deadline,
174-
penaltyTarget: .prepared(bundleId: target.bundleId),
175-
options: options
176-
) {
177-
return blocking
178-
}
179-
return try runSnapshotCapturePlan(
180-
Self.regularVisiblePlan,
181-
target: target,
182-
options: options,
171+
try snapshotAfterSystemAlertTier(
172+
plan: Self.regularVisiblePlan,
183173
terminal: .sparseWithFatalOnAXFailure,
184-
deadline: deadline
174+
target: target,
175+
options: options
185176
)
186177
}
187178

@@ -336,19 +327,36 @@ extension RunnerTests {
336327

337328
// See `snapshotFast` above: the single production entry point, no unit-test overload.
338329
func snapshotRaw(target: SnapshotCaptureTarget, options: PresentationOptions) throws -> DataPayload {
330+
try snapshotAfterSystemAlertTier(
331+
plan: Self.rawDiagnosticPlan,
332+
terminal: .throwOnAXFailure,
333+
target: target,
334+
options: options
335+
)
336+
}
337+
338+
private func snapshotAfterSystemAlertTier(
339+
plan: [SnapshotBackendKind],
340+
terminal: SnapshotCaptureTerminalPolicy,
341+
target: SnapshotCaptureTarget,
342+
options: PresentationOptions
343+
) throws -> DataPayload {
339344
let deadline = Date().addingTimeInterval(Self.snapshotPlanBudget)
340345
if let blocking = boundedBlockingSystemAlertSnapshot(
341346
deadline: deadline,
342347
penaltyTarget: .prepared(bundleId: target.bundleId),
343348
options: options
344349
) {
350+
if target.refusesSystemAlertSubstitution {
351+
throw SystemAlertSubstitutionRefused()
352+
}
345353
return blocking
346354
}
347355
return try runSnapshotCapturePlan(
348-
Self.rawDiagnosticPlan,
356+
plan,
349357
target: target,
350358
options: options,
351-
terminal: .throwOnAXFailure,
359+
terminal: terminal,
352360
deadline: deadline
353361
)
354362
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,14 @@ struct SnapshotCaptureTarget {
1414
let processIdentifier: Int?
1515
/// The lit panel's window the request booked; set only on a multi-panel request.
1616
var litPanelSurface: LitPanelSurface? = nil
17+
/// Set on an observe-only capture, whose answer asserts app provenance: the system-alert tier then
18+
/// refuses the capture instead of answering with the SpringBoard tree (#3348).
19+
var refusesSystemAlertSubstitution = false
1720
}
1821

22+
/// The system-alert tier would have answered a capture that must come from the app.
23+
struct SystemAlertSubstitutionRefused: Error {}
24+
1925
/// What snapshot command preparation hands the off-main capture.
2026
enum SnapshotCommandPreparation {
2127
case response(Response)

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ extension RunnerTests {
3232
processIdentifier: Self.processIdentifier(of: context.app)
3333
)
3434
target.litPanelSurface = self.litPanelState.surface
35+
target.refusesSystemAlertSubstitution = context.observation != nil
3536
return .capture(
3637
target,
3738
systemSurface: context.systemSurface,
@@ -138,6 +139,10 @@ extension RunnerTests {
138139
return Response(ok: true, data: payload)
139140
} catch let refusal as RunnerAppScreenCaptureFailure {
140141
return refusal.response
142+
} catch is SystemAlertSubstitutionRefused {
143+
return observeOnlyUnavailableResponse(
144+
hint: "A system alert is presented over the session app. Resolve it with alert, or read it with a regular snapshot."
145+
)
141146
} catch let failure as SnapshotCaptureFailure {
142147
invalidateCachedTargetAfterSnapshotFailure()
143148
return Response(

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,49 @@ extension RunnerTests {
108108
XCTAssertNil(pendingTargetActivation)
109109
}
110110

111+
@MainActor
112+
func testObserveOnlySnapshotRefusesOnlyASubstitutedSystemAlertTree() throws {
113+
app.launch()
114+
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 10))
115+
invalidateCachedTarget(reason: "unit_test_setup")
116+
defer {
117+
systemModalProbeOverrideForTesting = nil
118+
invalidateCachedTarget(reason: "unit_test_cleanup")
119+
app.terminate()
120+
}
121+
let appField = "\"appBundleId\":\"com.callstack.agentdevice.runner\""
122+
123+
let alertTreeMessage = "substituted system alert tree"
124+
systemModalProbeOverrideForTesting = { _ in DataPayload(message: alertTreeMessage) }
125+
for rawField in ["", ",\"raw\":true"] {
126+
let observeOnly = try executeSnapshotDispatched(command: runnerCommandFixture(
127+
"{\"command\":\"snapshot\",\(appField),\"observeOnly\":true\(rawField)}"
128+
))
129+
XCTAssertFalse(observeOnly.ok, "request suffix: \(rawField)")
130+
XCTAssertEqual(observeOnly.error?.code, "OBSERVATION_UNAVAILABLE", "request suffix: \(rawField)")
131+
XCTAssertEqual(
132+
observeOnly.error?.hint,
133+
"A system alert is presented over the session app. Resolve it with alert, or read it with a regular snapshot.",
134+
"request suffix: \(rawField)"
135+
)
136+
XCTAssertNil(observeOnly.data, "request suffix: \(rawField)")
137+
138+
let regular = try executeSnapshotDispatched(command: runnerCommandFixture(
139+
"{\"command\":\"snapshot\",\(appField)\(rawField)}"
140+
))
141+
XCTAssertTrue(regular.ok, "request suffix: \(rawField)")
142+
XCTAssertEqual(regular.data?.message, alertTreeMessage, "request suffix: \(rawField)")
143+
XCTAssertNil(regular.data?.observation, "request suffix: \(rawField)")
144+
}
145+
146+
systemModalProbeOverrideForTesting = { _ in nil }
147+
let noAlert = try executeSnapshotDispatched(command: runnerCommandFixture(
148+
"{\"command\":\"snapshot\",\(appField),\"observeOnly\":true}"
149+
))
150+
XCTAssertTrue(noAlert.ok)
151+
XCTAssertEqual(noAlert.data?.observation?.mode, "observe-only")
152+
}
153+
111154
@MainActor
112155
func testRegularSnapshotStillRepairsBackgroundAndDisclosesPriorState() throws {
113156
app.launch()

‎packages/command-surface/src/capture/snapshot.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export const snapshotCommandFacet = defineCommandFacet({
7575
name: SNAPSHOT_COMMAND_NAME,
7676
text: {
7777
summary: 'Capture or diff the accessibility tree',
78-
cliDetail: `Regular iOS snapshots clip overflowing elements to their visible area and omit fully outside elements. --json reports clipping counts in snapshotQualityNotes with code regular-geometry-clipped; --raw retains reported frames. --observe-only is local iOS snapshot-only: an already-ready compatible runner reads the session app without activation or foreground repair, refuses unavailable observation, and reports XCUIApplication state rather than verified foreground ownership (#2696). Regular targetActivation disclosure stays unchanged (#2682, #2694). Repeated equivalent unfiltered Android snapshots return a compact unchanged acknowledgement. Use --force-full to re-emit the tree; --json and --raw retain full output. Android Appium JSON snapshots publish a display viewport only when measurements before and after the tree agree, independent of tree extent or keyboard content resizing. On Android, JSON value carries field content rather than static labels; saved selectors and replay scripts using value= for static labels must use label= instead; a reported showing hint means value is empty, and text equal to the placeholder is preserved when hint state is unknown. For iOS raw-coordinate fallback after a no-op ref press, inspect rects with snapshot -i --json, press the rect center, then verify with diff snapshot -i or snapshot --diff. iOS backend capability contract: ${snapshotBackendCapabilityHelp}.`,
78+
cliDetail: `Regular iOS snapshots clip overflowing elements to their visible area and omit fully outside elements. --json reports clipping counts in snapshotQualityNotes with code regular-geometry-clipped; --raw retains reported frames. --observe-only is local iOS snapshot-only: an already-ready compatible runner reads the session app without activation or foreground repair, refuses unavailable observation or a readable system alert over the app, and reports XCUIApplication state rather than verified foreground ownership (#2696). Regular targetActivation disclosure stays unchanged (#2682, #2694). Repeated equivalent unfiltered Android snapshots return a compact unchanged acknowledgement. Use --force-full to re-emit the tree; --json and --raw retain full output. Android Appium JSON snapshots publish a display viewport only when measurements before and after the tree agree, independent of tree extent or keyboard content resizing. On Android, JSON value carries field content rather than static labels; saved selectors and replay scripts using value= for static labels must use label= instead; a reported showing hint means value is empty, and text equal to the placeholder is preserved when hint state is unknown. For iOS raw-coordinate fallback after a no-op ref press, inspect rects with snapshot -i --json, press the rect center, then verify with diff snapshot -i or snapshot --diff. iOS backend capability contract: ${snapshotBackendCapabilityHelp}.`,
7979
},
8080
metadata: snapshotCommandMetadata,
8181
run: (client, input) => client.capture.snapshot(input),

‎website/docs/docs/snapshots.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ left exactly as it was. Other platforms, provider-owned sessions, and `--diff` a
5151

5252
The runner refuses with `OBSERVATION_UNAVAILABLE` (also named by `details.runnerErrorCode`) unless
5353
the app reports `runningForeground` before and after the capture under one process identity. It does
54-
not substitute SpringBoard or a presented system surface. The host refuses with `COMMAND_FAILED` and
54+
not substitute SpringBoard or a presented system surface, and it refuses the same way when it can
55+
read a system alert over the app. A success does not prove that no alert is present: an alert the
56+
runner cannot read leaves the app tree as the answer. The host refuses with `COMMAND_FAILED` and
5557
`details.reason: "observation-unavailable"` when a runner answers an observe-only request without
5658
`observation` or with a `targetActivation` fact. Successful JSON includes:
5759

0 commit comments

Comments
 (0)