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