@@ -404,6 +404,76 @@ test('the host’s device set is back before the lock is', async () => {
404404 } ) ;
405405} ) ;
406406
407+ test ( 'a restore that half-finished names the backup it was refused, not an older leftover' , async ( ) => {
408+ await withTempDir ( 'device-set-half-restored-legacy-' , async ( root ) => {
409+ const paths = makeRedirectPaths ( root ) ;
410+ // The default backup path, so the older version's leftover prefix is the one a real host sees.
411+ const backupPath = `${ paths . xctestDeviceSetPath } .agent-device-backup` ;
412+ const legacyBackupPath = path . join (
413+ path . dirname ( backupPath ) ,
414+ '.agent-device-xctestdevices-backup-1600000000000' ,
415+ ) ;
416+ fs . mkdirSync ( paths . requestedSetPath , { recursive : true } ) ;
417+ fs . mkdirSync ( backupPath , { recursive : true } ) ;
418+ fs . writeFileSync ( path . join ( backupPath , 'host-device.txt' ) , 'the host owns this' ) ;
419+ fs . mkdirSync ( legacyBackupPath , { recursive : true } ) ;
420+ fs . writeFileSync ( path . join ( legacyBackupPath , 'stale-device.txt' ) , 'an older interruption' ) ;
421+ fs . symlinkSync ( paths . requestedSetPath , paths . xctestDeviceSetPath , 'dir' ) ;
422+
423+ // An interrupted build left the symlink, this run's backup holds the host's set, and an older
424+ // version's leftover sits beside it. The first restore is refused after it took the symlink down, so
425+ // the hand-back is the one that puts the host's set back — and it is refused while deleting the
426+ // leftover. The report must name what is still renamed aside, which by then is nothing: sending the
427+ // reader to the older leftover would have them copy a stale set over the one now in place.
428+ const realRename = fs . renameSync ;
429+ let restoreAttempts = 0 ;
430+ const renameSpy = vi . spyOn ( fs , 'renameSync' ) . mockImplementation ( ( ( from , to ) => {
431+ if ( String ( to ) === paths . xctestDeviceSetPath ) {
432+ restoreAttempts += 1 ;
433+ if ( restoreAttempts === 1 ) {
434+ throw Object . assign ( new Error ( 'EACCES: permission denied' ) , { code : 'EACCES' } ) ;
435+ }
436+ }
437+ return realRename ( from , to ) ;
438+ } ) as typeof fs . renameSync ) ;
439+ const realRm = fs . rmSync ;
440+ const rmSpy = vi . spyOn ( fs , 'rmSync' ) . mockImplementation ( ( ( target , options ) => {
441+ if ( String ( target ) === legacyBackupPath ) {
442+ throw Object . assign ( new Error ( 'EPERM: operation not permitted' ) , { code : 'EPERM' } ) ;
443+ }
444+ return realRm ( target as Parameters < typeof fs . rmSync > [ 0 ] , options ) ;
445+ } ) as typeof fs . rmSync ) ;
446+
447+ try {
448+ await assert . rejects (
449+ ( ) =>
450+ acquireXcodebuildSimulatorSetRedirect ( makeScopedSimulator ( paths ) , redirectOptions ( paths ) ) ,
451+ ( error : unknown ) => {
452+ assert . ok ( error instanceof AppError ) ;
453+ assert . equal ( error . message , 'Failed to redirect XCTest device set path' ) ;
454+ assert . match ( String ( error . details ?. error ) , / E A C C E S / ) ;
455+ assert . match ( String ( error . details ?. restoreError ) , / E P E R M / ) ;
456+ assert . equal ( error . details ?. hint , undefined ) ;
457+ return true ;
458+ } ,
459+ ) ;
460+ assert . equal ( fs . existsSync ( legacyBackupPath ) , true , 'the leftover stays where it is' ) ;
461+ assert . equal (
462+ fs . readFileSync ( path . join ( paths . xctestDeviceSetPath , 'host-device.txt' ) , 'utf8' ) ,
463+ 'the host owns this' ,
464+ ) ;
465+ assert . equal (
466+ fs . existsSync ( paths . lockDirPath ) ,
467+ false ,
468+ 'the lock must not outlive the failure' ,
469+ ) ;
470+ } finally {
471+ rmSpy . mockRestore ( ) ;
472+ renameSpy . mockRestore ( ) ;
473+ }
474+ } ) ;
475+ } ) ;
476+
407477test ( 'a redirect that failed before it moved anything names no backup that is not there' , async ( ) => {
408478 await withTempDir ( 'device-set-failed-before-rename-' , async ( root ) => {
409479 const paths = makeRedirectPaths ( root ) ;
0 commit comments