@@ -84,6 +84,125 @@ beforeEach(() => {
8484 mockRunnerCommand . mockResolvedValue ( { } ) ;
8585} ) ;
8686
87+ async function runWaitCommand (
88+ sessionName : string ,
89+ device : SessionState [ 'device' ] ,
90+ positionals : string [ ] ,
91+ ) {
92+ const sessionStore = makeSessionStore ( ) ;
93+ sessionStore . set ( sessionName , makeSession ( sessionName , device ) ) ;
94+ return await handleSnapshotCommands ( {
95+ req : {
96+ token : 't' ,
97+ session : sessionName ,
98+ command : 'wait' ,
99+ positionals,
100+ flags : { } ,
101+ } ,
102+ sessionName,
103+ logPath : '/tmp/daemon.log' ,
104+ sessionStore,
105+ } ) ;
106+ }
107+
108+ const locationPermissionNodes = [
109+ {
110+ index : 0 ,
111+ depth : 0 ,
112+ type : 'android.widget.FrameLayout' ,
113+ label : 'Location permission' ,
114+ rect : { x : 0 , y : 0 , width : 390 , height : 844 } ,
115+ } ,
116+ {
117+ index : 1 ,
118+ depth : 1 ,
119+ parentIndex : 0 ,
120+ type : 'android.widget.TextView' ,
121+ label : 'Allow location access?' ,
122+ rect : { x : 24 , y : 210 , width : 342 , height : 40 } ,
123+ } ,
124+ {
125+ index : 2 ,
126+ depth : 1 ,
127+ parentIndex : 0 ,
128+ type : 'android.widget.Button' ,
129+ label : 'Not now' ,
130+ rect : { x : 24 , y : 320 , width : 140 , height : 48 } ,
131+ hittable : true ,
132+ } ,
133+ {
134+ index : 3 ,
135+ depth : 1 ,
136+ parentIndex : 0 ,
137+ type : 'android.widget.Button' ,
138+ label : 'Continue' ,
139+ rect : { x : 180 , y : 320 , width : 160 , height : 48 } ,
140+ hittable : true ,
141+ } ,
142+ ] ;
143+
144+ const locationRequiredNodes = [
145+ {
146+ index : 0 ,
147+ depth : 0 ,
148+ type : 'android.widget.TextView' ,
149+ label : 'Location required' ,
150+ rect : { x : 24 , y : 180 , width : 342 , height : 40 } ,
151+ } ,
152+ {
153+ index : 1 ,
154+ depth : 0 ,
155+ type : 'android.widget.Button' ,
156+ label : 'Dismiss' ,
157+ rect : { x : 24 , y : 260 , width : 342 , height : 48 } ,
158+ } ,
159+ ] ;
160+
161+ const iosSurfaceSummaryNodes = [
162+ {
163+ index : 0 ,
164+ depth : 0 ,
165+ type : 'XCUIElementTypeApplication' ,
166+ label : 'Expo Go' ,
167+ rect : { x : 0 , y : 0 , width : 393 , height : 852 } ,
168+ } ,
169+ {
170+ index : 1 ,
171+ depth : 1 ,
172+ type : 'XCUIElementTypeImage' ,
173+ label : 'gearshape.fill' ,
174+ rect : { x : 12 , y : 54 , width : 24 , height : 24 } ,
175+ } ,
176+ {
177+ index : 2 ,
178+ depth : 1 ,
179+ type : 'XCUIElementTypeOther' ,
180+ label : 'Tab Bar' ,
181+ rect : { x : 0 , y : 760 , width : 393 , height : 92 } ,
182+ } ,
183+ {
184+ index : 3 ,
185+ depth : 1 ,
186+ type : 'XCUIElementTypeStaticText' ,
187+ label : 'Confirm catalog refresh' ,
188+ rect : { x : 48 , y : 280 , width : 297 , height : 36 } ,
189+ } ,
190+ {
191+ index : 4 ,
192+ depth : 1 ,
193+ type : 'XCUIElementTypeButton' ,
194+ label : 'Keep browsing' ,
195+ rect : { x : 48 , y : 360 , width : 297 , height : 48 } ,
196+ } ,
197+ {
198+ index : 5 ,
199+ depth : 1 ,
200+ type : 'XCUIElementTypeButton' ,
201+ identifier : 'host.exp.exponent:id/reload_button' ,
202+ rect : { x : 260 , y : 54 , width : 48 , height : 48 } ,
203+ } ,
204+ ] ;
205+
87206test ( 'snapshot rejects @ref scope without existing session snapshot' , async ( ) => {
88207 const sessionStore = makeSessionStore ( ) ;
89208 const sessionName = 'ios-sim' ;
@@ -574,6 +693,98 @@ test('wait text on Android uses freshness-aware capture instead of one-shot snap
574693 ) ;
575694} ) ;
576695
696+ test ( 'wait text timeout includes compact current-surface labels and buttons' , async ( ) => {
697+ const sessionName = 'android-wait-timeout-surface' ;
698+ mockDispatch . mockResolvedValue ( {
699+ nodes : locationPermissionNodes ,
700+ truncated : false ,
701+ backend : 'android' ,
702+ analysis : { rawNodeCount : 4 , maxDepth : 1 } ,
703+ } ) ;
704+
705+ const response = await runWaitCommand ( sessionName , androidDevice , [ 'Receipt uploaded' , '0' ] ) ;
706+
707+ expect ( response ?. ok ) . toBe ( false ) ;
708+ if ( response && ! response . ok ) {
709+ expect ( response . error . message ) . toBe (
710+ 'wait timed out for text: Receipt uploaded. Current surface: Location permission, Allow location access?, Not now, Continue.' ,
711+ ) ;
712+ expect ( response . error . details ?. currentSurface ) . toEqual ( {
713+ labels : [ 'Location permission' , 'Allow location access?' , 'Not now' , 'Continue' ] ,
714+ buttons : [ 'Not now' , 'Continue' ] ,
715+ } ) ;
716+ }
717+ } ) ;
718+
719+ test ( 'wait selector timeout includes compact current-surface details' , async ( ) => {
720+ const sessionName = 'android-wait-selector-timeout-surface' ;
721+ mockDispatch . mockResolvedValue ( {
722+ nodes : locationRequiredNodes ,
723+ truncated : false ,
724+ backend : 'android' ,
725+ analysis : { rawNodeCount : 2 , maxDepth : 0 } ,
726+ } ) ;
727+
728+ const response = await runWaitCommand ( sessionName , androidDevice , [ 'id=receipt-uploaded' , '0' ] ) ;
729+
730+ expect ( response ?. ok ) . toBe ( false ) ;
731+ if ( response && ! response . ok ) {
732+ expect ( response . error . message ) . toBe (
733+ 'wait timed out for selector: id=receipt-uploaded. Current surface: Location required, Dismiss.' ,
734+ ) ;
735+ expect ( response . error . details ?. currentSurface ) . toEqual ( {
736+ labels : [ 'Location required' , 'Dismiss' ] ,
737+ buttons : [ 'Dismiss' ] ,
738+ } ) ;
739+ }
740+ } ) ;
741+
742+ test ( 'wait timeout summary prefers content labels over chrome and identifier noise' , async ( ) => {
743+ const sessionName = 'ios-wait-timeout-surface-summary' ;
744+ mockRunnerCommand . mockResolvedValue ( { found : false } ) ;
745+ mockDispatch . mockResolvedValue ( {
746+ nodes : iosSurfaceSummaryNodes ,
747+ truncated : false ,
748+ backend : 'xctest' ,
749+ } ) ;
750+
751+ const response = await runWaitCommand ( sessionName , iosSimulatorDevice , [
752+ 'Impossible success text' ,
753+ '0' ,
754+ ] ) ;
755+
756+ expect ( response ?. ok ) . toBe ( false ) ;
757+ if ( response && ! response . ok ) {
758+ expect ( response . error . message ) . toBe (
759+ 'wait timed out for text: Impossible success text. Current surface: Confirm catalog refresh, Keep browsing.' ,
760+ ) ;
761+ expect ( response . error . details ?. currentSurface ) . toEqual ( {
762+ labels : [
763+ 'Confirm catalog refresh' ,
764+ 'Keep browsing' ,
765+ 'host.exp.exponent:id/reload_button' ,
766+ 'Expo Go' ,
767+ 'gearshape.fill' ,
768+ 'Tab Bar' ,
769+ ] ,
770+ buttons : [ 'Keep browsing' , 'host.exp.exponent:id/reload_button' ] ,
771+ } ) ;
772+ }
773+ } ) ;
774+
775+ test ( 'wait timeout preserves current behavior when current-surface inspection fails' , async ( ) => {
776+ const sessionName = 'android-wait-timeout-surface-fails' ;
777+ mockDispatch . mockRejectedValue ( new Error ( 'snapshot unavailable' ) ) ;
778+
779+ const response = await runWaitCommand ( sessionName , androidDevice , [ 'Receipt uploaded' , '0' ] ) ;
780+
781+ expect ( response ?. ok ) . toBe ( false ) ;
782+ if ( response && ! response . ok ) {
783+ expect ( response . error . message ) . toBe ( 'wait timed out for text: Receipt uploaded' ) ;
784+ expect ( response . error . details ) . toBeUndefined ( ) ;
785+ }
786+ } ) ;
787+
577788test ( 'settings rejects unsupported iOS physical devices' , async ( ) => {
578789 const sessionStore = makeSessionStore ( ) ;
579790 const sessionName = 'ios-device' ;
0 commit comments