@@ -6,6 +6,7 @@ import type { CommandFlags } from '../../../core/dispatch.ts';
66import { attachRefs , type SnapshotBackend } from '../../../kernel/snapshot.ts' ;
77import { AppError } from '../../../kernel/errors.ts' ;
88import { buildSnapshotState } from '../snapshot-capture.ts' ;
9+ import { setSessionSnapshot , STALE_SNAPSHOT_REFS_WARNING } from '../../session-snapshot.ts' ;
910import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts' ;
1011import {
1112 makeIosSession ,
@@ -106,7 +107,9 @@ async function emulateCaptureSnapshotForSession(
106107 contextFromFlags ( effectiveFlags , session . appBundleId , session . trace ?. outPath ) ,
107108 ) ) as { nodes ?: never [ ] ; truncated ?: boolean ; backend ?: SnapshotBackend } ;
108109 const snapshot = buildSnapshotState ( snapshotData ?? { } , effectiveFlags ) ;
109- session . snapshot = snapshot ;
110+ // Mirror the real captureSnapshotForSession: session snapshot writes go
111+ // through setSessionSnapshot so snapshotRefsStale tracking (#1076) applies.
112+ setSessionSnapshot ( session , snapshot ) ;
110113 sessionStore . set ( session . name , session ) ;
111114 return snapshot ;
112115}
@@ -3101,3 +3104,210 @@ test('is reports Android permission dialog blocker when app content assertion fa
31013104 } ) ;
31023105 }
31033106} ) ;
3107+
3108+ // --- Stale @ref warnings (#1076) ---
3109+
3110+ function makeTwoButtonNodes ( ) {
3111+ return [
3112+ {
3113+ index : 0 ,
3114+ type : 'Application' ,
3115+ rect : { x : 0 , y : 0 , width : 390 , height : 844 } ,
3116+ } ,
3117+ {
3118+ index : 1 ,
3119+ parentIndex : 0 ,
3120+ type : 'XCUIElementTypeButton' ,
3121+ label : 'Continue' ,
3122+ rect : { x : 10 , y : 20 , width : 100 , height : 40 } ,
3123+ enabled : true ,
3124+ hittable : true ,
3125+ } ,
3126+ {
3127+ index : 2 ,
3128+ parentIndex : 0 ,
3129+ type : 'XCUIElementTypeButton' ,
3130+ label : 'Cancel' ,
3131+ rect : { x : 10 , y : 80 , width : 100 , height : 40 } ,
3132+ enabled : true ,
3133+ hittable : true ,
3134+ } ,
3135+ ] ;
3136+ }
3137+
3138+ function makeStaleRefSession ( sessionName : string ) : SessionState {
3139+ const session = makeSession ( sessionName ) ;
3140+ session . snapshot = {
3141+ nodes : attachRefs ( makeTwoButtonNodes ( ) as never ) ,
3142+ createdAt : Date . now ( ) ,
3143+ backend : 'xctest' ,
3144+ } ;
3145+ // As if the snapshot command just returned these refs to the client.
3146+ session . snapshotRefsStale = false ;
3147+ return session ;
3148+ }
3149+
3150+ async function runInteraction (
3151+ sessionStore : SessionStore ,
3152+ sessionName : string ,
3153+ command : string ,
3154+ positionals : string [ ] ,
3155+ flags : Record < string , unknown > = { } ,
3156+ ) {
3157+ return await handleInteractionCommands ( {
3158+ req : { token : 't' , session : sessionName , command, positionals, flags } ,
3159+ sessionName,
3160+ sessionStore,
3161+ contextFromFlags,
3162+ } ) ;
3163+ }
3164+
3165+ test ( 'press selector then press @ref warns that refs outlived the stored snapshot' , async ( ) => {
3166+ const sessionStore = makeSessionStore ( ) ;
3167+ const sessionName = 'stale-ref-warns' ;
3168+ const session = makeStaleRefSession ( sessionName ) ;
3169+ sessionStore . set ( sessionName , session ) ;
3170+ mockDispatch . mockImplementation ( async ( _device , command ) =>
3171+ command === 'snapshot' ? { nodes : makeTwoButtonNodes ( ) , backend : 'xctest' } : { } ,
3172+ ) ;
3173+
3174+ // Selector press: its resolution capture replaces the stored snapshot
3175+ // without handing the new refs back to the client.
3176+ const selectorPress = await runInteraction ( sessionStore , sessionName , 'press' , [
3177+ 'label=Continue' ,
3178+ ] ) ;
3179+ if ( ! selectorPress ?. ok ) {
3180+ throw new Error ( `selector press failed: ${ JSON . stringify ( selectorPress ) } ` ) ;
3181+ }
3182+ expect ( selectorPress . data ?. warning ) . toBeUndefined ( ) ;
3183+ expect ( sessionStore . get ( sessionName ) ?. snapshotRefsStale ) . toBe ( true ) ;
3184+
3185+ const refPress = await runInteraction ( sessionStore , sessionName , 'press' , [ '@e1' ] ) ;
3186+ expect ( refPress ?. ok ) . toBe ( true ) ;
3187+ if ( refPress ?. ok ) {
3188+ expect ( refPress . data ?. warning ) . toBe ( STALE_SNAPSHOT_REFS_WARNING ) ;
3189+ }
3190+ } ) ;
3191+
3192+ test ( 'press @ref directly after refs were issued does not warn' , async ( ) => {
3193+ const sessionStore = makeSessionStore ( ) ;
3194+ const sessionName = 'fresh-ref-no-warning' ;
3195+ const session = makeStaleRefSession ( sessionName ) ;
3196+ sessionStore . set ( sessionName , session ) ;
3197+
3198+ const response = await runInteraction ( sessionStore , sessionName , 'press' , [ '@e1' ] ) ;
3199+ expect ( response ?. ok ) . toBe ( true ) ;
3200+ if ( response ?. ok ) {
3201+ expect ( response . data ?. warning ) . toBeUndefined ( ) ;
3202+ }
3203+ } ) ;
3204+
3205+ test ( 're-issuing refs clears the stale marker so press @ref does not warn' , async ( ) => {
3206+ const sessionStore = makeSessionStore ( ) ;
3207+ const sessionName = 'reissued-refs-no-warning' ;
3208+ const session = makeStaleRefSession ( sessionName ) ;
3209+ sessionStore . set ( sessionName , session ) ;
3210+ mockDispatch . mockImplementation ( async ( _device , command ) =>
3211+ command === 'snapshot' ? { nodes : makeTwoButtonNodes ( ) , backend : 'xctest' } : { } ,
3212+ ) ;
3213+
3214+ const selectorPress = await runInteraction ( sessionStore , sessionName , 'press' , [
3215+ 'label=Continue' ,
3216+ ] ) ;
3217+ expect ( selectorPress ?. ok ) . toBe ( true ) ;
3218+ expect ( sessionStore . get ( sessionName ) ?. snapshotRefsStale ) . toBe ( true ) ;
3219+
3220+ // Simulate the snapshot command re-issuing refs (its handler clears the
3221+ // marker through buildNextSnapshotSession; covered in snapshot-handler tests).
3222+ const stored = sessionStore . get ( sessionName ) ! ;
3223+ stored . snapshotRefsStale = false ;
3224+ sessionStore . set ( sessionName , stored ) ;
3225+
3226+ const refPress = await runInteraction ( sessionStore , sessionName , 'press' , [ '@e1' ] ) ;
3227+ expect ( refPress ?. ok ) . toBe ( true ) ;
3228+ if ( refPress ?. ok ) {
3229+ expect ( refPress . data ?. warning ) . toBeUndefined ( ) ;
3230+ }
3231+ } ) ;
3232+
3233+ test ( 'fill @ref warns while refs are stale' , async ( ) => {
3234+ const sessionStore = makeSessionStore ( ) ;
3235+ const sessionName = 'stale-ref-fill' ;
3236+ const session = makeStaleRefSession ( sessionName ) ;
3237+ session . snapshot = {
3238+ nodes : attachRefs ( [
3239+ {
3240+ index : 0 ,
3241+ type : 'XCUIElementTypeTextField' ,
3242+ label : 'Email' ,
3243+ rect : { x : 10 , y : 20 , width : 200 , height : 40 } ,
3244+ enabled : true ,
3245+ hittable : true ,
3246+ } ,
3247+ ] ) ,
3248+ createdAt : Date . now ( ) ,
3249+ backend : 'xctest' ,
3250+ } ;
3251+ session . snapshotRefsStale = true ;
3252+ sessionStore . set ( sessionName , session ) ;
3253+
3254+ const response = await runInteraction ( sessionStore , sessionName , 'fill' , [
3255+ '@e1' ,
3256+ 'hello@example.com' ,
3257+ ] ) ;
3258+ expect ( response ?. ok ) . toBe ( true ) ;
3259+ if ( response ?. ok ) {
3260+ expect ( response . data ?. warning ) . toBe ( STALE_SNAPSHOT_REFS_WARNING ) ;
3261+ }
3262+ } ) ;
3263+
3264+ test ( 'get text @ref warns while refs are stale' , async ( ) => {
3265+ const sessionStore = makeSessionStore ( ) ;
3266+ const sessionName = 'stale-ref-get-text' ;
3267+ const session = makeStaleRefSession ( sessionName ) ;
3268+ session . snapshotRefsStale = true ;
3269+ sessionStore . set ( sessionName , session ) ;
3270+ mockDispatch . mockRejectedValue (
3271+ new Error ( 'dispatch should not be called for snapshot-derived get text' ) ,
3272+ ) ;
3273+
3274+ const response = await runInteraction ( sessionStore , sessionName , 'get' , [ 'text' , '@e1' ] ) ;
3275+ expect ( response ?. ok ) . toBe ( true ) ;
3276+ if ( response ?. ok ) {
3277+ expect ( response . data ?. warning ) . toBe ( STALE_SNAPSHOT_REFS_WARNING ) ;
3278+ expect ( response . data ?. ref ) . toBe ( 'e1' ) ;
3279+ }
3280+ } ) ;
3281+
3282+ test ( 'stale-ref warning appends to an existing interaction warning' , async ( ) => {
3283+ const sessionStore = makeSessionStore ( ) ;
3284+ const sessionName = 'stale-ref-appends' ;
3285+ const session = makeStaleRefSession ( sessionName ) ;
3286+ session . snapshot = {
3287+ nodes : attachRefs ( [
3288+ {
3289+ index : 0 ,
3290+ // Non-fillable type produces the runtime fill warning the stale-ref
3291+ // warning must append to, not replace.
3292+ type : 'XCUIElementTypeStaticText' ,
3293+ label : 'Read-only' ,
3294+ rect : { x : 10 , y : 20 , width : 200 , height : 40 } ,
3295+ enabled : true ,
3296+ hittable : true ,
3297+ } ,
3298+ ] ) ,
3299+ createdAt : Date . now ( ) ,
3300+ backend : 'xctest' ,
3301+ } ;
3302+ session . snapshotRefsStale = true ;
3303+ sessionStore . set ( sessionName , session ) ;
3304+
3305+ const response = await runInteraction ( sessionStore , sessionName , 'fill' , [ '@e1' , 'nope' ] ) ;
3306+ expect ( response ?. ok ) . toBe ( true ) ;
3307+ if ( response ?. ok ) {
3308+ const warning = String ( response . data ?. warning ?? '' ) ;
3309+ expect ( warning ) . toContain ( 'attempting fill anyway' ) ;
3310+ expect ( warning ) . toContain ( STALE_SNAPSHOT_REFS_WARNING ) ;
3311+ expect ( warning . endsWith ( STALE_SNAPSHOT_REFS_WARNING ) ) . toBe ( true ) ;
3312+ }
3313+ } ) ;
0 commit comments