@@ -439,7 +439,7 @@ describe("safeWriteJson", () => {
439439
440440 // Test for rollback failure scenario
441441 test ( "should log error and re-throw original if rollback fails" , async ( ) => {
442- const initialData = { message : "Initial, should be lost if rollback fails" }
442+ const initialData = { message : "Initial, preserved if rollback fails" }
443443 const newData = { message : "New content" }
444444
445445 await fsPromisesActuals . writeFile ! ( currentTestFilePath , JSON . stringify ( initialData ) )
@@ -471,6 +471,16 @@ describe("safeWriteJson", () => {
471471 // is only logged.
472472 await expect ( safeWriteJson ( currentTestFilePath , newData ) ) . rejects . toThrow ( "Fallback replacement rename failed" )
473473
474+ const backups = ( await fs . readdir ( tempDir ) ) . filter ( ( entry ) => entry . includes ( ".bak_" ) )
475+ expect ( backups ) . toHaveLength ( 1 )
476+ expect ( await readFileContent ( path . join ( tempDir , backups [ 0 ] ) ) ) . toEqual ( initialData )
477+ await safeWriteJson ( currentTestFilePath , newData , {
478+ merge : ( existing ) => {
479+ expect ( existing ) . toEqual ( initialData )
480+ return newData
481+ } ,
482+ } )
483+
474484 // Verify console.error was called for the rollback failure
475485 expect ( consoleErrorSpy ) . toHaveBeenCalledWith (
476486 expect . stringContaining ( "Failed to restore backup" ) ,
@@ -595,21 +605,20 @@ describe("safeWriteJson", () => {
595605 const backupPath = path . join ( tempDir , `.${ path . basename ( currentTestFilePath ) } .bak_1_stale.tmp` )
596606 await fs . writeFile ( backupPath , JSON . stringify ( { stale : true } ) )
597607 // Directory access succeeds; only the canonical-path check fails.
598- vi . mocked ( fs . access )
599- . mockResolvedValueOnce ( undefined )
600- . mockRejectedValueOnce ( Object . assign ( new Error ( "Canonical access failed" ) , { code } ) )
608+ const error = Object . assign ( new Error ( "Canonical access failed" ) , { code } )
609+ vi . mocked ( fs . access ) . mockResolvedValueOnce ( undefined ) . mockRejectedValueOnce ( error )
601610 const merge = vi . fn ( ( existing : unknown ) => existing )
602611
603- await safeWriteJson ( currentTestFilePath , { } , { merge } )
612+ await expect ( safeWriteJson ( currentTestFilePath , { } , { merge } ) ) . rejects . toBe ( error )
604613
605- expect ( merge ) . toHaveBeenCalledWith ( { initial : "content" } , { } )
614+ expect ( merge ) . not . toHaveBeenCalled ( )
606615 expect ( await readFileContent ( currentTestFilePath ) ) . toEqual ( { initial : "content" } )
607- expect ( await fs . readdir ( tempDir ) ) . toEqual ( [ path . basename ( currentTestFilePath ) ] )
616+ expect ( await readFileContent ( backupPath ) ) . toEqual ( { stale : true } )
608617 } ,
609618 )
610619
611620 it . each ( [ "ENOENT" , "EACCES" , "EIO" ] ) (
612- "should recover from a readable backup when another backup stat fails with %s" ,
621+ "should preserve backups when another backup stat fails with %s" ,
613622 async ( code ) => {
614623 await fs . unlink ( currentTestFilePath )
615624 const unreadableBackup = path . join ( tempDir , `.${ path . basename ( currentTestFilePath ) } .bak_1_bad.tmp` )
@@ -625,14 +634,60 @@ describe("safeWriteJson", () => {
625634 } )
626635 const merge = vi . fn ( ( existing : unknown ) => existing )
627636
628- await safeWriteJson ( currentTestFilePath , { } , { merge } )
637+ if ( code === "ENOENT" ) {
638+ await safeWriteJson ( currentTestFilePath , { } , { merge } )
639+ expect ( merge ) . toHaveBeenCalledWith ( { committed : true } , { } )
640+ } else {
641+ await expect ( safeWriteJson ( currentTestFilePath , { } , { merge } ) ) . rejects . toMatchObject ( { code } )
642+ expect ( merge ) . not . toHaveBeenCalled ( )
643+ expect ( await readFileContent ( unreadableBackup ) ) . toEqual ( { stale : true } )
644+ expect ( await readFileContent ( readableBackup ) ) . toEqual ( { committed : true } )
645+ }
646+ } ,
647+ )
629648
630- expect ( merge ) . toHaveBeenCalledWith ( { committed : true } , { } )
631- expect ( await readFileContent ( currentTestFilePath ) ) . toEqual ( { committed : true } )
632- expect ( await fs . readdir ( tempDir ) ) . toEqual ( [ path . basename ( currentTestFilePath ) ] )
649+ it . each ( [ "rename" , "stat" ] as const ) (
650+ "should abort recovery and preserve history when backup %s fails" ,
651+ async ( operation ) => {
652+ await fs . unlink ( currentTestFilePath )
653+ const backupPath = path . join ( tempDir , `.${ path . basename ( currentTestFilePath ) } .bak_1_saved.tmp` )
654+ const committed = { history : [ "saved" ] }
655+ await fs . writeFile ( backupPath , JSON . stringify ( committed ) )
656+ const error = Object . assign ( new Error ( "Recovery failed" ) , { code : "EACCES" } )
657+ vi . mocked ( fs [ operation ] ) . mockRejectedValueOnce ( error )
658+ const merge = vi . fn ( )
659+
660+ await expect ( safeWriteJson ( currentTestFilePath , { } , { merge } ) ) . rejects . toBe ( error )
661+
662+ expect ( merge ) . not . toHaveBeenCalled ( )
663+ expect ( await readFileContent ( backupPath ) ) . toEqual ( committed )
664+ await expect ( fs . readFile ( currentTestFilePath ) ) . rejects . toMatchObject ( { code : "ENOENT" } )
665+ await safeWriteJson ( currentTestFilePath , { } , { merge : ( existing ) => existing } )
666+ expect ( await readFileContent ( currentTestFilePath ) ) . toEqual ( committed )
633667 } ,
634668 )
635669
670+ test ( "should restore by mtime and preserve unrelated siblings" , async ( ) => {
671+ await fs . unlink ( currentTestFilePath )
672+ const prefix = `.${ path . basename ( currentTestFilePath ) } `
673+ const newest = path . join ( tempDir , `${ prefix } .bak_1_newest.tmp` )
674+ const older = path . join ( tempDir , `${ prefix } .bak_9_older.tmp` )
675+ const unrelated = path . join ( tempDir , ".other.json.bak_1_saved.tmp" )
676+ const wrongSuffix = path . join ( tempDir , `${ prefix } .bak_1_saved.txt` )
677+ await fs . writeFile ( newest , JSON . stringify ( { newest : true } ) )
678+ await fs . writeFile ( older , JSON . stringify ( { older : true } ) )
679+ await fs . writeFile ( unrelated , "keep" )
680+ await fs . writeFile ( wrongSuffix , "keep" )
681+ await fs . utimes ( older , new Date ( 1000 ) , new Date ( 1000 ) )
682+ await fs . utimes ( newest , new Date ( 2000 ) , new Date ( 2000 ) )
683+
684+ await safeWriteJson ( currentTestFilePath , { } , { merge : ( existing ) => existing } )
685+
686+ expect ( await readFileContent ( currentTestFilePath ) ) . toEqual ( { newest : true } )
687+ expect ( await fs . readFile ( unrelated , "utf8" ) ) . toBe ( "keep" )
688+ expect ( await fs . readFile ( wrongSuffix , "utf8" ) ) . toBe ( "keep" )
689+ } )
690+
636691 test ( "should open the replacement with write access for flushing" , async ( ) => {
637692 const open = vi . mocked ( fs . open )
638693 open . mockClear ( )
0 commit comments