@@ -596,7 +596,109 @@ 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+ const recording = sessionStore . get ( sessionName ) ?. recording ;
626+ expect ( recording ?. platform ) . toBe ( 'ios' ) ;
627+ if ( recording ?. platform === 'ios' ) {
628+ expect ( recording . recorderPid ) . toBe ( 5151 ) ;
629+ }
630+ } ) ;
631+
632+ test ( 'record stop prefers session-owned iOS recorder processes before path fallback' , async ( ) => {
633+ vi . useFakeTimers ( ) ;
634+ const processKill = vi . spyOn ( process , 'kill' ) . mockImplementation ( ( ) => true ) ;
635+ const sessionStore = makeSessionStore ( ) ;
636+ const sessionName = 'ios-sim-owned-recorder' ;
637+ const kill = vi . fn ( ) ;
638+ const session = makeSession ( sessionName , {
639+ platform : 'ios' ,
640+ id : 'sim-1' ,
641+ name : 'Simulator' ,
642+ kind : 'simulator' ,
643+ booted : true ,
644+ } ) ;
645+ session . recording = {
646+ platform : 'ios' ,
647+ outPath : '/tmp/owned-recorder.mp4' ,
648+ startedAt : Date . now ( ) ,
649+ showTouches : true ,
650+ gestureEvents : [ ] ,
651+ recorderPid : 1111 ,
652+ child : { kill, pid : 1111 } ,
653+ wait : new Promise ( ( ) => { } ) ,
654+ } ;
655+ sessionStore . set ( sessionName , session ) ;
656+ mockRunCmd . mockImplementation ( async ( cmd , args ) => {
657+ if ( cmd === 'pgrep' && args [ 0 ] === '-P' ) {
658+ expect ( args ) . toEqual ( [ '-P' , '1111' ] ) ;
659+ return { stdout : '2222\n' , stderr : '' , exitCode : 0 } ;
660+ }
661+ if ( cmd === 'pgrep' && args [ 0 ] === '-f' ) {
662+ throw new Error ( 'path fallback should not run when owned recorder cleanup matches' ) ;
663+ }
664+ return { stdout : '' , stderr : '' , exitCode : 0 } ;
665+ } ) ;
666+
667+ try {
668+ const responsePromise = runRecordCommand ( {
669+ sessionStore,
670+ sessionName,
671+ positionals : [ 'stop' ] ,
672+ } ) ;
673+
674+ await vi . advanceTimersByTimeAsync ( 12_000 ) ;
675+ const response = await responsePromise ;
676+
677+ expect ( response ?. ok ) . toBe ( false ) ;
678+ expect ( ( response as any ) . error ?. message ) . toMatch ( / d i d n o t e x i t / ) ;
679+ expect ( kill . mock . calls . map ( ( call ) => call [ 0 ] ) ) . toEqual ( [ 'SIGINT' , 'SIGTERM' , 'SIGKILL' ] ) ;
680+ expect ( mockRunCmd . mock . calls . map ( ( call ) => call [ 1 ] ) ) . toEqual ( [
681+ [ '-P' , '1111' ] ,
682+ [ '-P' , '1111' ] ,
683+ [ '-P' , '1111' ] ,
684+ ] ) ;
685+ expect ( processKill . mock . calls . map ( ( call ) => call [ 0 ] ) ) . toEqual ( [
686+ 1111 , 2222 , 1111 , 2222 , 1111 , 2222 ,
687+ ] ) ;
688+ expect ( processKill . mock . calls . map ( ( call ) => call [ 1 ] ) ) . toEqual ( [
689+ 'SIGINT' ,
690+ 'SIGINT' ,
691+ 'SIGTERM' ,
692+ 'SIGTERM' ,
693+ 'SIGKILL' ,
694+ 'SIGKILL' ,
695+ ] ) ;
696+ } finally {
697+ processKill . mockRestore ( ) ;
698+ }
699+ } ) ;
700+
701+ test ( 'record stop falls back to path matching for stale iOS simulator recordVideo processes' , async ( ) => {
600702 vi . useFakeTimers ( ) ;
601703 const processKill = vi . spyOn ( process , 'kill' ) . mockImplementation ( ( ) => true ) ;
602704 const sessionStore = makeSessionStore ( ) ;
@@ -1229,7 +1331,7 @@ test('record stop force-kills Android screenrecord when SIGINT fails but process
12291331 ) . toBe ( true ) ;
12301332} ) ;
12311333
1232- test ( 'record stop reports invalidated recording after cleanup ' , async ( ) => {
1334+ test ( 'record stop keeps iOS simulator video when touch overlay recording was invalidated ' , async ( ) => {
12331335 const sessionStore = makeSessionStore ( ) ;
12341336 const sessionName = 'ios-invalidated-recording' ;
12351337 const session = makeSession ( sessionName , {
@@ -1257,10 +1359,12 @@ test('record stop reports invalidated recording after cleanup', async () => {
12571359 positionals : [ 'stop' ] ,
12581360 } ) ;
12591361
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' ) ;
1362+ expect ( response ?. ok ) . toBe ( true ) ;
1363+ if ( response ?. ok === true ) {
1364+ expect ( response . data ?. outPath ) . toBe ( path . resolve ( './invalidated.mp4' ) ) ;
1365+ expect ( response . data ?. overlayWarning ) . toBe (
1366+ 'overlay unavailable: iOS runner session exited during recording' ,
1367+ ) ;
12641368 }
12651369 expect ( sessionStore . get ( sessionName ) ?. recording ) . toBeUndefined ( ) ;
12661370} ) ;
0 commit comments