@@ -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