@@ -616,6 +616,105 @@ test('a content-quality capture failure that never recovers still fails closed a
616616 expect ( mockDispatchCommand . mock . calls . length ) . toBeGreaterThan ( 1 ) ;
617617} ) ;
618618
619+ // #1385: the iOS side of the same launch race — the capture doesn't throw,
620+ // it returns a structurally-valid tree flagged `sparse` (the private-AX
621+ // fallback engaging under load). `isSparseSnapshotQualityVerdict` treats this
622+ // as always-retryable, unlike the Android thrown-error branch, which is
623+ // gated on the narrower content-quality taxonomy — so this needs its own
624+ // regression, not just coverage by the Android capture-failed tests above.
625+ function iosSparseCapture ( ) : {
626+ nodes : never [ ] ;
627+ truncated : false ;
628+ backend : 'xctest' ;
629+ quality : object ;
630+ } {
631+ return {
632+ nodes : [ ] ,
633+ truncated : false ,
634+ backend : 'xctest' ,
635+ quality : { state : 'sparse' , backend : 'private-ax' , reason : 'AX query rejected mid-transition' } ,
636+ } ;
637+ }
638+
639+ test ( 'an iOS sparse-snapshot verdict right after launch recovers within the bounded retry, and the action still dispatches' , async ( ) => {
640+ const root = fs . mkdtempSync (
641+ path . join ( os . tmpdir ( ) , 'agent-device-replay-target-verify-launch-race-sparse-recover-' ) ,
642+ ) ;
643+ const { sessionStore, sessionName } = setupSession ( root ) ;
644+ const filePath = writeReplayFile ( root , [ SAVE_ANNOTATION , 'click id="save"' ] ) ;
645+
646+ // First two captures return a structurally-valid but sparse-flagged tree
647+ // (mirrors the private-AX fallback engaging mid-launch); the third lands
648+ // after the app settles and finds the recorded target.
649+ mockDispatchCommand
650+ . mockImplementationOnce ( async ( ) => iosSparseCapture ( ) )
651+ . mockImplementationOnce ( async ( ) => iosSparseCapture ( ) )
652+ . mockResolvedValue ( {
653+ nodes : [
654+ {
655+ index : 0 ,
656+ depth : 0 ,
657+ type : 'Button' ,
658+ identifier : 'save' ,
659+ label : 'Save' ,
660+ rect : { x : 10 , y : 10 , width : 40 , height : 20 } ,
661+ } ,
662+ ] ,
663+ truncated : false ,
664+ backend : 'xctest' ,
665+ } ) ;
666+
667+ const invoked : DaemonRequest [ ] = [ ] ;
668+ const response = await runReplayScriptFile ( {
669+ req : baseReq ( { positionals : [ filePath ] } ) ,
670+ sessionName,
671+ logPath : path . join ( root , 'daemon.log' ) ,
672+ sessionStore,
673+ invoke : async ( req ) => {
674+ invoked . push ( req ) ;
675+ return { ok : true , data : { } } ;
676+ } ,
677+ } ) ;
678+
679+ expect ( response . ok ) . toBe ( true ) ;
680+ expect ( invoked . map ( ( req ) => req . command ) ) . toEqual ( [ 'click' ] ) ;
681+ expect ( mockDispatchCommand ) . toHaveBeenCalledTimes ( 3 ) ;
682+ } ) ;
683+
684+ test ( 'an iOS sparse-snapshot verdict that never recovers still fails closed as identity-unverifiable once the bounded retry is exhausted' , async ( ) => {
685+ const root = fs . mkdtempSync (
686+ path . join ( os . tmpdir ( ) , 'agent-device-replay-target-verify-launch-race-sparse-exhausted-' ) ,
687+ ) ;
688+ const { sessionStore, sessionName } = setupSession ( root ) ;
689+ const filePath = writeReplayFile ( root , [ SAVE_ANNOTATION , 'click id="save"' ] ) ;
690+
691+ mockDispatchCommand . mockImplementation ( async ( ) => iosSparseCapture ( ) ) ;
692+
693+ const invoked : DaemonRequest [ ] = [ ] ;
694+ const response = await runReplayScriptFile ( {
695+ req : baseReq ( { positionals : [ filePath ] } ) ,
696+ sessionName,
697+ logPath : path . join ( root , 'daemon.log' ) ,
698+ sessionStore,
699+ invoke : async ( req ) => {
700+ invoked . push ( req ) ;
701+ return { ok : true , data : { } } ;
702+ } ,
703+ } ) ;
704+
705+ expect ( invoked . length ) . toBe ( 0 ) ;
706+ expect ( response . ok ) . toBe ( false ) ;
707+ if ( response . ok ) return ;
708+ expect ( response . error . code ) . toBe ( 'REPLAY_DIVERGENCE' ) ;
709+ const divergence = response . error . details ?. divergence as Record < string , unknown > ;
710+ expect ( divergence . kind ) . toBe ( 'identity-unverifiable' ) ;
711+ const cause = divergence . cause as { code : string ; message : string } ;
712+ expect ( cause . code ) . toBe ( 'IDENTITY_UNVERIFIABLE' ) ;
713+ expect ( cause . message ) . toContain ( 'sparse-snapshot' ) ;
714+ // Bounded: the retry gives up after its fixed delay list, not forever.
715+ expect ( mockDispatchCommand . mock . calls . length ) . toBeGreaterThan ( 1 ) ;
716+ } ) ;
717+
619718test ( 'a permanent (non-content-quality) capture failure fails fast without retrying' , async ( ) => {
620719 const root = fs . mkdtempSync (
621720 path . join ( os . tmpdir ( ) , 'agent-device-replay-target-verify-launch-race-permanent-' ) ,
0 commit comments