@@ -64,6 +64,7 @@ import {
6464 overlayRecordingTouches ,
6565} from '../../../recording/overlay.ts' ;
6666import { runCmd , runCmdBackground } from '../../../utils/exec.ts' ;
67+ import { isPlayableVideo , waitForStableFile } from '../../../utils/video.ts' ;
6768
6869type RunnerCall = {
6970 command : string ;
@@ -81,6 +82,8 @@ const mockRunIosRunnerCommand = vi.mocked(runIosRunnerCommand);
8182const mockResizeRecording = vi . mocked ( resizeRecording ) ;
8283const mockTrimRecordingStart = vi . mocked ( trimRecordingStart ) ;
8384const mockOverlayRecordingTouches = vi . mocked ( overlayRecordingTouches ) ;
85+ const mockWaitForStableFile = vi . mocked ( waitForStableFile ) ;
86+ const mockIsPlayableVideo = vi . mocked ( isPlayableVideo ) ;
8487
8588const overlaySupportWarning = getRecordingOverlaySupportWarning ( ) ;
8689
@@ -183,6 +186,8 @@ beforeEach(() => {
183186 mockResizeRecording . mockImplementation ( async ( ) => { } ) ;
184187 mockTrimRecordingStart . mockImplementation ( async ( ) => { } ) ;
185188 mockOverlayRecordingTouches . mockImplementation ( async ( ) => { } ) ;
189+ mockWaitForStableFile . mockImplementation ( async ( ) => { } ) ;
190+ mockIsPlayableVideo . mockImplementation ( async ( ) => true ) ;
186191} ) ;
187192
188193afterEach ( ( ) => {
@@ -596,6 +601,138 @@ test('record stop leaves a short visual tail after iOS simulator gestures', asyn
596601 expect ( kill ) . toHaveBeenCalledWith ( 'SIGINT' ) ;
597602} ) ;
598603
604+ test ( 'record stop reports too-short iOS simulator recordings without leaving invalid output' , async ( ) => {
605+ const sessionStore = makeSessionStore ( ) ;
606+ const sessionName = 'ios-sim-too-short' ;
607+ const outPath = path . join ( os . tmpdir ( ) , `agent-device-too-short-${ Date . now ( ) } .mp4` ) ;
608+ fs . writeFileSync ( outPath , 'not-a-video' ) ;
609+ const session = makeSession ( sessionName , {
610+ platform : 'ios' ,
611+ id : 'sim-1' ,
612+ name : 'Simulator' ,
613+ kind : 'simulator' ,
614+ booted : true ,
615+ } ) ;
616+ session . recording = {
617+ platform : 'ios' ,
618+ outPath,
619+ startedAt : Date . now ( ) ,
620+ showTouches : false ,
621+ gestureEvents : [ ] ,
622+ child : { kill : vi . fn ( ) } ,
623+ wait : Promise . resolve ( { stdout : '' , stderr : 'failed to finalize' , exitCode : 1 } ) ,
624+ } ;
625+ sessionStore . set ( sessionName , session ) ;
626+
627+ const response = await runRecordCommand ( {
628+ sessionStore,
629+ sessionName,
630+ positionals : [ 'stop' ] ,
631+ } ) ;
632+
633+ expect ( response ?. ok ) . toBe ( false ) ;
634+ expect ( ( response as any ) . error ?. message ) . toMatch ( / w a i t a t l e a s t 1 0 0 0 m s / i) ;
635+ expect ( ( response as any ) . error ?. message ) . toMatch ( / f a i l e d t o f i n a l i z e / i) ;
636+ expect ( fs . existsSync ( outPath ) ) . toBe ( false ) ;
637+ } ) ;
638+
639+ test ( 'record stop measures too-short iOS simulator recordings from stop request time' , async ( ) => {
640+ vi . useFakeTimers ( ) ;
641+ vi . setSystemTime ( 10_000 ) ;
642+ const sessionStore = makeSessionStore ( ) ;
643+ const sessionName = 'ios-sim-too-short-delayed-finalize' ;
644+ const outPath = path . join ( os . tmpdir ( ) , `agent-device-too-short-delayed-${ Date . now ( ) } .mp4` ) ;
645+ fs . writeFileSync ( outPath , 'not-a-video' ) ;
646+ const session = makeSession ( sessionName , {
647+ platform : 'ios' ,
648+ id : 'sim-1' ,
649+ name : 'Simulator' ,
650+ kind : 'simulator' ,
651+ booted : true ,
652+ } ) ;
653+ session . recording = {
654+ platform : 'ios' ,
655+ outPath,
656+ startedAt : Date . now ( ) - 500 ,
657+ showTouches : false ,
658+ gestureEvents : [ ] ,
659+ child : { kill : vi . fn ( ) } ,
660+ wait : Promise . resolve ( { stdout : '' , stderr : '' , exitCode : 0 } ) ,
661+ } ;
662+ sessionStore . set ( sessionName , session ) ;
663+ mockWaitForStableFile . mockImplementation ( async ( ) => {
664+ vi . setSystemTime ( 11_300 ) ;
665+ } ) ;
666+ mockIsPlayableVideo . mockImplementation ( async ( ) => false ) ;
667+
668+ const response = await runRecordCommand ( {
669+ sessionStore,
670+ sessionName,
671+ positionals : [ 'stop' ] ,
672+ } ) ;
673+
674+ expect ( response ?. ok ) . toBe ( false ) ;
675+ expect ( ( response as any ) . error ?. message ) . toMatch ( / R e c o r d i n g s t o p p e d a f t e r 5 0 0 m s / i) ;
676+ expect ( ( response as any ) . error ?. message ) . toMatch ( / w a i t a t l e a s t 1 0 0 0 m s / i) ;
677+ expect ( fs . existsSync ( outPath ) ) . toBe ( false ) ;
678+ } ) ;
679+
680+ test ( 'record stop measures too-short Android failures from stop request time' , async ( ) => {
681+ vi . useFakeTimers ( ) ;
682+ vi . setSystemTime ( 20_000 ) ;
683+ const sessionStore = makeSessionStore ( ) ;
684+ const sessionName = 'android-too-short-delayed-stop' ;
685+ const session = makeSession ( sessionName , {
686+ platform : 'android' ,
687+ id : 'emulator-5554' ,
688+ name : 'Android' ,
689+ kind : 'device' ,
690+ booted : true ,
691+ } ) ;
692+ session . recording = {
693+ platform : 'android' ,
694+ outPath : path . resolve ( './android-too-short.mp4' ) ,
695+ startedAt : Date . now ( ) - 500 ,
696+ showTouches : true ,
697+ gestureEvents : [ ] ,
698+ remotePath : '/sdcard/agent-device-recording-too-short.mp4' ,
699+ remotePid : '4322' ,
700+ chunks : [
701+ {
702+ index : 1 ,
703+ path : path . resolve ( './android-too-short.mp4' ) ,
704+ remotePath : '/sdcard/agent-device-recording-too-short.mp4' ,
705+ } ,
706+ ] ,
707+ } ;
708+ sessionStore . set ( sessionName , session ) ;
709+ mockRunCmd . mockImplementation ( async ( _cmd , args ) => {
710+ const command = args . join ( ' ' ) ;
711+ if ( command === '-s emulator-5554 shell ps -o pid= -p 4322' ) {
712+ return { stdout : '4322\n' , stderr : '' , exitCode : 0 } ;
713+ }
714+ if ( command === '-s emulator-5554 shell kill -2 4322' ) {
715+ vi . setSystemTime ( 21_300 ) ;
716+ return { stdout : '' , stderr : 'failed to stop' , exitCode : 1 } ;
717+ }
718+ if ( command === '-s emulator-5554 shell kill -9 4322' ) {
719+ return { stdout : '' , stderr : 'failed to force stop' , exitCode : 1 } ;
720+ }
721+ return { stdout : '' , stderr : '' , exitCode : 0 } ;
722+ } ) ;
723+
724+ const response = await runRecordCommand ( {
725+ sessionStore,
726+ sessionName,
727+ positionals : [ 'stop' ] ,
728+ } ) ;
729+
730+ expect ( response ?. ok ) . toBe ( false ) ;
731+ expect ( ( response as any ) . error ?. message ) . toMatch ( / R e c o r d i n g s t o p p e d a f t e r 5 0 0 m s / i) ;
732+ expect ( ( response as any ) . error ?. message ) . toMatch ( / w a i t a t l e a s t 1 0 0 0 m s / i) ;
733+ expect ( ( response as any ) . error ?. message ) . toMatch ( / f a i l e d t o s t o p / i) ;
734+ } ) ;
735+
599736test ( 'record start stores iOS simulator recorder pid for scoped cleanup' , async ( ) => {
600737 const sessionStore = makeSessionStore ( ) ;
601738 const sessionName = 'ios-sim-recorder-pid' ;
0 commit comments