@@ -596,7 +596,105 @@ test('record stop leaves a short visual tail after iOS simulator gestures', asyn
596596 expect ( kill ) . toHaveBeenCalledWith ( 'SIGINT' ) ;
597597} ) ;
598598
599- test ( 'record stop escalates stale iOS simulator recordVideo processes' , async ( ) => {
599+ test ( 'record start stores iOS simulator recorder pid for scoped cleanup' , async ( ) => {
600+ const sessionStore = makeSessionStore ( ) ;
601+ const sessionName = 'ios-sim-recorder-pid' ;
602+ sessionStore . set (
603+ sessionName ,
604+ makeSession ( sessionName , {
605+ platform : 'ios' ,
606+ id : 'sim-1' ,
607+ name : 'Simulator' ,
608+ kind : 'simulator' ,
609+ booted : true ,
610+ } ) ,
611+ ) ;
612+ mockRunCmdBackground . mockImplementation ( ( ) => ( {
613+ child : { kill : ( ) => { } , pid : 5151 } as any ,
614+ wait : Promise . resolve ( { stdout : '' , stderr : '' , exitCode : 0 } ) ,
615+ } ) ) ;
616+
617+ const response = await runRecordCommand ( {
618+ sessionStore,
619+ sessionName,
620+ positionals : [ 'start' , './sim-recorder-pid.mp4' ] ,
621+ flags : { hideTouches : true } ,
622+ } ) ;
623+
624+ expect ( response ?. ok ) . toBe ( true ) ;
625+ expect ( sessionStore . get ( sessionName ) ?. recording ?. recorderPid ) . toBe ( 5151 ) ;
626+ } ) ;
627+
628+ test ( 'record stop prefers session-owned iOS recorder processes before path fallback' , async ( ) => {
629+ vi . useFakeTimers ( ) ;
630+ const processKill = vi . spyOn ( process , 'kill' ) . mockImplementation ( ( ) => true ) ;
631+ const sessionStore = makeSessionStore ( ) ;
632+ const sessionName = 'ios-sim-owned-recorder' ;
633+ const kill = vi . fn ( ) ;
634+ const session = makeSession ( sessionName , {
635+ platform : 'ios' ,
636+ id : 'sim-1' ,
637+ name : 'Simulator' ,
638+ kind : 'simulator' ,
639+ booted : true ,
640+ } ) ;
641+ session . recording = {
642+ platform : 'ios' ,
643+ outPath : '/tmp/owned-recorder.mp4' ,
644+ startedAt : Date . now ( ) ,
645+ showTouches : true ,
646+ gestureEvents : [ ] ,
647+ recorderPid : 1111 ,
648+ child : { kill, pid : 1111 } ,
649+ wait : new Promise ( ( ) => { } ) ,
650+ } ;
651+ sessionStore . set ( sessionName , session ) ;
652+ mockRunCmd . mockImplementation ( async ( cmd , args ) => {
653+ if ( cmd === 'pgrep' && args [ 0 ] === '-P' ) {
654+ expect ( args ) . toEqual ( [ '-P' , '1111' ] ) ;
655+ return { stdout : '2222\n' , stderr : '' , exitCode : 0 } ;
656+ }
657+ if ( cmd === 'pgrep' && args [ 0 ] === '-f' ) {
658+ return { stdout : '3333\n' , stderr : '' , exitCode : 0 } ;
659+ }
660+ return { stdout : '' , stderr : '' , exitCode : 0 } ;
661+ } ) ;
662+
663+ try {
664+ const responsePromise = runRecordCommand ( {
665+ sessionStore,
666+ sessionName,
667+ positionals : [ 'stop' ] ,
668+ } ) ;
669+
670+ await vi . advanceTimersByTimeAsync ( 12_000 ) ;
671+ const response = await responsePromise ;
672+
673+ expect ( response ?. ok ) . toBe ( false ) ;
674+ expect ( ( response as any ) . error ?. message ) . toMatch ( / d i d n o t e x i t / ) ;
675+ expect ( kill . mock . calls . map ( ( call ) => call [ 0 ] ) ) . toEqual ( [ 'SIGINT' , 'SIGTERM' , 'SIGKILL' ] ) ;
676+ expect ( mockRunCmd . mock . calls . map ( ( call ) => call [ 1 ] ) ) . toEqual ( [
677+ [ '-P' , '1111' ] ,
678+ [ '-P' , '1111' ] ,
679+ [ '-P' , '1111' ] ,
680+ ] ) ;
681+ expect ( processKill . mock . calls . map ( ( call ) => call [ 0 ] ) ) . toEqual ( [
682+ 1111 , 2222 , 1111 , 2222 , 1111 , 2222 ,
683+ ] ) ;
684+ expect ( processKill . mock . calls . map ( ( call ) => call [ 1 ] ) ) . toEqual ( [
685+ 'SIGINT' ,
686+ 'SIGINT' ,
687+ 'SIGTERM' ,
688+ 'SIGTERM' ,
689+ 'SIGKILL' ,
690+ 'SIGKILL' ,
691+ ] ) ;
692+ } finally {
693+ processKill . mockRestore ( ) ;
694+ }
695+ } ) ;
696+
697+ test ( 'record stop falls back to path matching for stale iOS simulator recordVideo processes' , async ( ) => {
600698 vi . useFakeTimers ( ) ;
601699 const processKill = vi . spyOn ( process , 'kill' ) . mockImplementation ( ( ) => true ) ;
602700 const sessionStore = makeSessionStore ( ) ;
@@ -1229,7 +1327,7 @@ test('record stop force-kills Android screenrecord when SIGINT fails but process
12291327 ) . toBe ( true ) ;
12301328} ) ;
12311329
1232- test ( 'record stop reports invalidated recording after cleanup ' , async ( ) => {
1330+ test ( 'record stop keeps iOS simulator video when touch overlay recording was invalidated ' , async ( ) => {
12331331 const sessionStore = makeSessionStore ( ) ;
12341332 const sessionName = 'ios-invalidated-recording' ;
12351333 const session = makeSession ( sessionName , {
@@ -1257,10 +1355,12 @@ test('record stop reports invalidated recording after cleanup', async () => {
12571355 positionals : [ 'stop' ] ,
12581356 } ) ;
12591357
1260- expect ( response ?. ok ) . toBe ( false ) ;
1261- if ( response ?. ok === false ) {
1262- expect ( response . error . code ) . toBe ( 'COMMAND_FAILED' ) ;
1263- expect ( response . error . message ) . toBe ( 'iOS runner session exited during recording' ) ;
1358+ expect ( response ?. ok ) . toBe ( true ) ;
1359+ if ( response ?. ok === true ) {
1360+ expect ( response . data ?. outPath ) . toBe ( path . resolve ( './invalidated.mp4' ) ) ;
1361+ expect ( response . data ?. overlayWarning ) . toBe (
1362+ 'overlay unavailable: iOS runner session exited during recording' ,
1363+ ) ;
12641364 }
12651365 expect ( sessionStore . get ( sessionName ) ?. recording ) . toBeUndefined ( ) ;
12661366} ) ;
0 commit comments