Skip to content

Commit cd758bd

Browse files
committed
fix(ios): report capture refusals as typed reasons in back-fallback and recording
#2741 routed screenshots through the resolved app display but left two failure contracts papered over (#2728): - The in-app `back` coordinate fallback read a nil screenshot sample as "no visual change" and sampled SpringBoard when the app resolved no window, so a two-home-screen comparison reported "in-app back control is not available" after a real leading tap had already run. The check now observes only the app's own resolved window and reports the display refusal as an unverified outcome; an observable no-change still reads as unavailable. - `record start` collapsed every no-frame bootstrap into an untyped NSError. It now surfaces the shared APP_SCREEN_* reason, so a runtime with no resolvable window fails closed with a typed code. Refuse a zero-pixel capture at the capture type so a required consumer cannot mistake it for a usable frame. macOS/tvOS keep their host-display capture and generic bootstrap error. Focused runner unit tests pin the observation split, the refusal-selection, and the typed-vs-generic error mapping.
1 parent fde74ac commit cd758bd

8 files changed

Lines changed: 327 additions & 77 deletions

File tree

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerAppScreenCapture.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ extension RunnerTests {
140140
guard let cgImage = runnerCGImage(from: upright) else {
141141
return .failure(.unrenderableImage)
142142
}
143+
// A zero-pixel image is a capture that did not happen, not a tiny one. Refusing it here — at the
144+
// type that owns the fact — keeps a required consumer (a recording sizing its writer from this
145+
// frame) from mistaking it for a usable frame and falling back to an untyped error (#2728).
146+
guard cgImage.width > 0, cgImage.height > 0 else {
147+
return .failure(.unrenderableImage)
148+
}
143149
return .success(
144150
CapturedAppScreen(
145151
image: upright,

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1466,13 +1466,14 @@ extension RunnerTests {
14661466
fps: command.fps.map { Int32($0) }
14671467
)
14681468
try recorder.start { [weak self] in
1469-
return self?.captureRunnerFrame(app: activeApp)
1469+
guard let self else { return .failure(.unresolvedScreen) }
1470+
return self.captureRunnerFrameResult(app: activeApp)
14701471
}
14711472
activeRecording = recorder
14721473
return Response(ok: true, data: DataPayload(message: "recording started"))
14731474
} catch {
14741475
activeRecording = nil
1475-
return Response(ok: false, error: ErrorPayload(message: "failed to start recording: \(error.localizedDescription)"))
1476+
return Response(ok: false, error: Self.recordingStartErrorPayload(for: error))
14761477
}
14771478
case .recordStop:
14781479
guard let recorder = activeRecording else {
@@ -2120,11 +2121,20 @@ extension RunnerTests {
21202121
)
21212122
#endif
21222123
case .back, .backInApp:
2123-
if tapInAppBackControl(app: activeApp) {
2124+
switch tapInAppBackControl(app: activeApp) {
2125+
case .performed:
21242126
let message = command.command == .back ? "back" : "backInApp"
21252127
return Response(ok: true, data: DataPayload(message: message))
2128+
case .unavailable:
2129+
return Response(
2130+
ok: false,
2131+
error: ErrorPayload(message: "in-app back control is not available")
2132+
)
2133+
case .unverified(let error):
2134+
// The fallback gesture ran but the display refused to be sampled. Reporting the typed refusal
2135+
// keeps an unknown outcome from being laundered into a definitive "no back control" (#2728).
2136+
return Response(ok: false, error: error)
21262137
}
2127-
return Response(ok: false, error: ErrorPayload(message: "in-app back control is not available"))
21282138
case .backSystem:
21292139
if performSystemBackAction(app: activeApp) {
21302140
return Response(ok: true, data: DataPayload(message: "backSystem"))

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,31 +42,59 @@ enum RunnerInteractionIdleWaits {
4242
extension RunnerTests {
4343
// MARK: - Recording
4444

45-
/// One frame for the recording pump and the keyboard settle sample.
45+
/// One frame for a caller that tolerates a dropped one — keyboard settling, which skips a sample it
46+
/// cannot take and keeps polling. A frame that must exist goes through `captureRunnerFrameResult`,
47+
/// which says why it refused.
4648
///
4749
/// On iOS the frame comes from the display owning a window, because a foldable's
4850
/// `XCUIScreen.main` can be the dark outer panel while the app runs on the inner one — a stream of
49-
/// identical black frames would then read as a settled screen and as a finished recording (#2728).
50-
/// An observation with no session window falls to the system surface's window, which is what the
51-
/// home screen is. macOS keeps recording the host display the way it always has.
51+
/// identical black frames would then read as a settled screen (#2728). An observation with no
52+
/// session window falls to the system surface's window, which is what the home screen is. macOS
53+
/// keeps the host display it always recorded.
5254
func captureRunnerFrame(app: XCUIApplication) -> RunnerImage? {
53-
#if os(iOS)
54-
guard case .success(let captured) = captureObservedScreen(app: app) else {
55+
switch captureRunnerFrameResult(app: app) {
56+
case .success(let captured):
57+
return captured.image
58+
case .failure:
5559
return nil
5660
}
57-
return captured.image
61+
}
62+
63+
/// The same frame as `captureRunnerFrame`, but carrying the reason it refused, so a required first
64+
/// frame — a recording's bootstrap, which sizes the whole writer from it — fails closed with a
65+
/// typed code rather than a message. The ongoing pump reads the same result and ignores a refusal
66+
/// the way it ignored the `nil` it used to get; only a frame that must exist owes a reason (#2728).
67+
func captureRunnerFrameResult(
68+
app: XCUIApplication
69+
) -> Result<CapturedAppScreen, RunnerAppScreenCaptureFailure> {
70+
#if os(iOS)
71+
return captureObservedScreen(app: app)
5872
#else
59-
var image: RunnerImage?
73+
var outcome: Result<CapturedAppScreen, RunnerAppScreenCaptureFailure> = .failure(
74+
.unrenderableImage
75+
)
6076
let capture = {
61-
let screenshot = XCUIScreen.main.screenshot()
62-
image = screenshot.image
77+
let image = XCUIScreen.main.screenshot().image
78+
if let cgImage = runnerCGImage(from: image) {
79+
// The host display has no resolved-panel facts to report; the recorder reads only the image
80+
// and its pixel size, so these two are inert placeholders, not measurements the host scales by.
81+
outcome = .success(
82+
CapturedAppScreen(
83+
image: image,
84+
displayID: 0,
85+
pixelWidth: cgImage.width,
86+
pixelHeight: cgImage.height,
87+
pixelsPerPoint: 1
88+
)
89+
)
90+
}
6391
}
6492
if Thread.isMainThread {
6593
capture()
6694
} else {
6795
DispatchQueue.main.sync(execute: capture)
6896
}
69-
return image
97+
return outcome
7098
#endif
7199
}
72100

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Navigation.swift

Lines changed: 159 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,66 @@ extension RunnerTests {
44
static let navigationBackKeywords = ["back", "close", "cancel"]
55
static let navigationFallbackVerificationDelay: TimeInterval = 0.25
66

7-
func tapInAppBackControl(app: XCUIApplication) -> Bool {
7+
/// What one in-app `back` attempt concluded. `.unverified` carries the typed capture failure the
8+
/// display refused with, so an optional visual check that could not run reports an unknown instead
9+
/// of the false "no back control exists" that a `nil` sample would otherwise become (#2728).
10+
enum InAppBackOutcome {
11+
case performed
12+
case unavailable
13+
case unverified(ErrorPayload)
14+
}
15+
16+
/// The three answers a before/after visual comparison can give. `unobserved` is not evidence of
17+
/// "no change": a display that refuses to be sampled proves nothing either way (#2728).
18+
enum NavigationVisualObservation: Equatable {
19+
case changed
20+
case unchanged
21+
case unobserved
22+
23+
var logToken: String {
24+
switch self {
25+
case .changed: return "yes"
26+
case .unchanged: return "no"
27+
case .unobserved: return "unknown"
28+
}
29+
}
30+
}
31+
32+
/// One navigation-fallback capture: the encoded frame when the display answered, and the typed
33+
/// reason it refused otherwise. Only iOS produces a refusal; other platforms capture nothing here.
34+
struct NavigationVisualSample {
35+
let data: Data?
36+
let refusalCode: String?
37+
let refusalHint: String?
38+
39+
init(data: Data?, refusalCode: String? = nil, refusalHint: String? = nil) {
40+
self.data = data
41+
self.refusalCode = refusalCode
42+
self.refusalHint = refusalHint
43+
}
44+
}
45+
46+
func tapInAppBackControl(app: XCUIApplication) -> InAppBackOutcome {
847
#if os(macOS)
948
if let back = macOSNavigationBackElement(app: app) {
1049
tapElementCenter(app: app, element: back)
11-
return true
50+
return .performed
1251
}
13-
return false
52+
return .unavailable
1453
#elseif os(tvOS)
1554
_ = pressTvRemote(.menu)
16-
return true
55+
return .performed
1756
#else
1857
let buttons = app.navigationBars.buttons.allElementsBoundByIndex
1958
if let back = buttons.first(where: { $0.isHittable }) {
2059
back.tap()
21-
return true
60+
return .performed
2261
}
2362
if isSnapshotXCTestChannelPenalized(bundleId: currentBundleId) {
2463
NSLog("AGENT_DEVICE_RUNNER_IN_APP_BACK_SKIPPED_XCTEST_ENUMERATION bundle=%@", currentBundleId ?? "")
2564
} else if let back = topNavigationBackElement(app: app) {
2665
tapElementCenter(app: app, element: back)
27-
return true
66+
return .performed
2867
}
2968
return tapTopLeadingNavigationFallback(app: app)
3069
#endif
@@ -109,11 +148,11 @@ extension RunnerTests {
109148
return CGPoint(x: frame.minX + xOffset, y: frame.minY + yOffset)
110149
}
111150

112-
private func tapTopLeadingNavigationFallback(app: XCUIApplication) -> Bool {
151+
private func tapTopLeadingNavigationFallback(app: XCUIApplication) -> InAppBackOutcome {
113152
#if os(iOS)
114153
let frame = onScreenWindowFrame(app: app)
115154
guard let point = Self.topLeadingNavigationFallbackPoint(in: frame) else {
116-
return false
155+
return .unavailable
117156
}
118157
let before = captureNavigationFallbackVisualState(app: app)
119158
let context = synthesizedCoordinateContext(
@@ -124,54 +163,96 @@ extension RunnerTests {
124163
synthesizedTapAt(app: app, x: point.x, y: point.y, context: context)
125164
}
126165
if case .performed = synthesized.outcome {
127-
return didNavigationFallbackChangeVisualState(app: app, before: before)
166+
return verifyNavigationFallbackOutcome(app: app, before: before)
128167
}
129168
let fallback = performGesture(app) {
130169
tapAt(app: app, x: point.x, y: point.y)
131170
}
132171
if case .performed = fallback.outcome {
133-
return didNavigationFallbackChangeVisualState(app: app, before: before)
172+
return verifyNavigationFallbackOutcome(app: app, before: before)
134173
}
135174
#endif
136-
return false
175+
return .unavailable
137176
}
138177

139-
private func captureNavigationFallbackVisualState(app: XCUIApplication) -> Data? {
178+
private func captureNavigationFallbackVisualState(app: XCUIApplication) -> NavigationVisualSample {
140179
#if os(iOS)
141-
// A visual check is optional evidence: a display that owns no window reports unknown by returning
142-
// no sample, which the comparison below already reads as "nothing proved". It never reaches for
143-
// a screen nobody is on, whose stable black would then be read as evidence that the tap did
144-
// nothing (#2728).
145-
guard case .success(let captured) = captureObservedScreen(app: app) else {
146-
return nil
180+
// A visual check is optional evidence, and the check is about THIS app's screen after the tap.
181+
// It uses the app's own resolved screen, never the system-surface fallback: sampling SpringBoard's
182+
// home screen twice would read as "unchanged" for an app that resolved no window, laundering a
183+
// wrong-process sample into the same false "no back control" verdict a `nil` sample produces (#2728).
184+
switch captureResolvedAppScreen(app: app) {
185+
case .success(let captured):
186+
guard let png = runnerPngData(for: captured.image) else {
187+
// The display resolved but the image would not encode: name it as the capture failure it is,
188+
// not as an unnamed no-sample that would default to "no display resolved" (#2728).
189+
let refusal = RunnerAppScreenCaptureFailure.unrenderableImage
190+
return NavigationVisualSample(data: nil, refusalCode: refusal.rawValue, refusalHint: refusal.hint)
191+
}
192+
return NavigationVisualSample(data: png)
193+
case .failure(let failure):
194+
return NavigationVisualSample(
195+
data: nil,
196+
refusalCode: failure.rawValue,
197+
refusalHint: failure.hint
198+
)
147199
}
148-
return runnerPngData(for: captured.image)
149200
#else
150-
return nil
201+
return NavigationVisualSample(data: nil)
151202
#endif
152203
}
153204

154-
private func didNavigationFallbackChangeVisualState(
205+
private func verifyNavigationFallbackOutcome(
155206
app: XCUIApplication,
156-
before: Data?
157-
) -> Bool {
207+
before: NavigationVisualSample
208+
) -> InAppBackOutcome {
158209
sleepFor(Self.navigationFallbackVerificationDelay)
159210
let after = captureNavigationFallbackVisualState(app: app)
160-
let changed = Self.didNavigationFallbackChangeVisualState(before: before, after: after)
161-
// The sample sizes are what tells a refused capture apart from an unchanged screen, and the
162-
// fallback is rare enough that saying so every time costs nothing (#2728).
211+
let observation = Self.navigationVisualObservation(before: before.data, after: after.data)
212+
// The sample sizes and the observation name together tell a refused capture apart from an
213+
// unchanged screen, and the fallback is rare enough that saying so every time costs nothing.
163214
NSLog(
164215
"AGENT_DEVICE_RUNNER_IN_APP_BACK_VISUAL_VERIFICATION beforeBytes=%ld afterBytes=%ld changed=%@",
165-
before?.count ?? -1,
166-
after?.count ?? -1,
167-
changed ? "yes" : "no"
216+
before.data?.count ?? -1,
217+
after.data?.count ?? -1,
218+
observation.logToken
168219
)
169-
return changed
220+
switch observation {
221+
case .changed:
222+
return .performed
223+
case .unchanged:
224+
return .unavailable
225+
case .unobserved:
226+
// No sample is not evidence of no navigation change: the fallback ran, so report the display
227+
// refusal it hit rather than laundering an unobservable result into "back is not available".
228+
return .unverified(Self.navigationFallbackErrorPayload(after: after, before: before))
229+
}
230+
}
231+
232+
static func navigationVisualObservation(
233+
before: Data?,
234+
after: Data?
235+
) -> NavigationVisualObservation {
236+
guard let before, let after else { return .unobserved }
237+
return before != after ? .changed : .unchanged
170238
}
171239

172-
static func didNavigationFallbackChangeVisualState(before: Data?, after: Data?) -> Bool {
173-
guard let before, let after else { return false }
174-
return before != after
240+
/// The refusal the fallback most recently hit wins, so the code names the last thing it looked at
241+
/// before giving up. A missing reason on both sides is unreachable on iOS (every nil sample carries
242+
/// one) and defaults to the plain display-unresolved code.
243+
static func navigationFallbackErrorPayload(
244+
after: NavigationVisualSample,
245+
before: NavigationVisualSample
246+
) -> ErrorPayload {
247+
ErrorPayload(
248+
code:
249+
after.refusalCode
250+
?? before.refusalCode
251+
?? RunnerAppScreenCaptureFailure.unresolvedScreen.rawValue,
252+
message:
253+
"The in-app back fallback was dispatched, but no display could be sampled to confirm the result. This is an unknown outcome, not evidence that a back control is absent.",
254+
hint: after.refusalHint ?? before.refusalHint
255+
)
175256
}
176257

177258
private func macOSNavigationBackElement(app: XCUIApplication) -> XCUIElement? {
@@ -234,21 +315,53 @@ extension RunnerTests {
234315
XCTAssertFalse(Self.isTopNavigationControlFrame(.infinite, in: window))
235316
}
236317

237-
func testNavigationFallbackRequiresObservedVisualChange() {
238-
XCTAssertTrue(
239-
Self.didNavigationFallbackChangeVisualState(
240-
before: Data([1, 2, 3]),
241-
after: Data([1, 2, 4])
242-
)
318+
func testNavigationVisualVerificationSeparatesNoChangeFromNoSample() {
319+
XCTAssertEqual(
320+
Self.navigationVisualObservation(before: Data([1, 2, 3]), after: Data([1, 2, 4])),
321+
.changed
243322
)
244-
XCTAssertFalse(
245-
Self.didNavigationFallbackChangeVisualState(
246-
before: Data([1, 2, 3]),
247-
after: Data([1, 2, 3])
248-
)
323+
XCTAssertEqual(
324+
Self.navigationVisualObservation(before: Data([1, 2, 3]), after: Data([1, 2, 3])),
325+
.unchanged
326+
)
327+
// A missing sample is neither a change nor a no-change; treating it as "unchanged" would let a
328+
// capture that refused become the reason the `back` command claims no control exists (#2728).
329+
XCTAssertEqual(Self.navigationVisualObservation(before: nil, after: Data([1])), .unobserved)
330+
XCTAssertEqual(Self.navigationVisualObservation(before: Data([1]), after: nil), .unobserved)
331+
XCTAssertEqual(Self.navigationVisualObservation(before: nil, after: nil), .unobserved)
332+
}
333+
334+
func testNavigationFallbackReportsTheRefusalItHitNotADefaultCode() {
335+
// The refusal from the most recent sample wins, so the code names what the fallback last looked at
336+
// before giving up; an earlier refusal is reported only when the later sample carried none (#2728).
337+
let after = NavigationVisualSample(
338+
data: nil,
339+
refusalCode: "APP_SCREEN_WINDOW_UNRESOLVED",
340+
refusalHint: "after hint"
341+
)
342+
let before = NavigationVisualSample(
343+
data: nil,
344+
refusalCode: "APP_SCREEN_UNRESOLVED",
345+
refusalHint: "before hint"
346+
)
347+
let laterWins = Self.navigationFallbackErrorPayload(after: after, before: before)
348+
XCTAssertEqual(laterWins.code, "APP_SCREEN_WINDOW_UNRESOLVED")
349+
XCTAssertEqual(laterWins.hint, "after hint")
350+
351+
let onlyBefore = Self.navigationFallbackErrorPayload(
352+
after: NavigationVisualSample(data: nil),
353+
before: before
354+
)
355+
XCTAssertEqual(onlyBefore.code, "APP_SCREEN_UNRESOLVED")
356+
XCTAssertEqual(onlyBefore.hint, "before hint")
357+
358+
// Neither side named a reason (unreachable on iOS): a real capture code, never a bare failure.
359+
let unnamed = Self.navigationFallbackErrorPayload(
360+
after: NavigationVisualSample(data: nil),
361+
before: NavigationVisualSample(data: nil)
249362
)
250-
XCTAssertFalse(Self.didNavigationFallbackChangeVisualState(before: nil, after: Data([1])))
251-
XCTAssertFalse(Self.didNavigationFallbackChangeVisualState(before: Data([1]), after: nil))
363+
XCTAssertEqual(unnamed.code, "APP_SCREEN_UNRESOLVED")
364+
XCTAssertTrue(unnamed.message.contains("unknown outcome"))
252365
}
253366
#endif
254367
}

0 commit comments

Comments
 (0)