11import type * as NodeFsPromises from 'node:fs/promises' ;
2- import { mkdirSync , mkdtempSync , readdirSync , readFileSync , writeFileSync } from 'node:fs' ;
2+ import {
3+ existsSync ,
4+ mkdirSync ,
5+ mkdtempSync ,
6+ readdirSync ,
7+ readFileSync ,
8+ writeFileSync ,
9+ } from 'node:fs' ;
310import { tmpdir } from 'node:os' ;
411import { join } from 'node:path' ;
512import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest' ;
@@ -35,8 +42,16 @@ describe('commitBundle', () => {
3542 rmMock . mockReset ( ) ;
3643 } ) ;
3744
38- it ( 'rolls back to the prior complete bundle when the staging swap fails' , async ( ) = > {
45+ async function withTempParent ( run : ( parent : string ) => Promise < void > ) : Promise < void > {
3946 const parent = mkdtempSync ( join ( tmpdir ( ) , 'bundle-commit-parent-' ) ) ;
47+ try {
48+ await run ( parent ) ;
49+ } finally {
50+ await realRm ( parent , { recursive : true , force : true } ) . catch ( ( ) => undefined ) ;
51+ }
52+ }
53+
54+ function seedBundleDirs ( parent : string ) : { dir : string ; tmpDir : string } {
4055 const dir = join ( parent , 'bundle' ) ;
4156 const tmpDir = join ( dir , '.tmp' ) ;
4257
@@ -49,48 +64,70 @@ describe('commitBundle', () => {
4964 writeFileSync ( join ( tmpDir , 'result.json' ) , '{}\n' , 'utf8' ) ;
5065 writeFileSync ( join ( tmpDir , 'steps' , '01-evidence.json' ) , '{"step":9}\n' , 'utf8' ) ;
5166
52- renameMock . mockImplementation ( async ( oldPath , newPath ) => {
53- const src = String ( oldPath ) ;
54- const dest = String ( newPath ) ;
55- if ( src . includes ( '.staging.' ) && ! dest . includes ( '.prior.' ) ) {
56- throw Object . assign ( new Error ( 'simulated atomic swap failure' ) , { code : 'EACCES' } ) ;
57- }
58- return realRename ( oldPath , newPath ) ;
59- } ) ;
60-
61- await expect ( commitBundle ( tmpDir , dir ) ) . rejects . toThrow ( 'simulated atomic swap failure' ) ;
67+ return { dir, tmpDir } ;
68+ }
6269
63- expect ( readFileSync ( join ( dir , 'meta.json' ) , 'utf8' ) ) . toBe ( '{"snapshotId":"snap_old"}\n' ) ;
64- expect ( readFileSync ( join ( dir , 'steps' , '01-evidence.json' ) , 'utf8' ) ) . toBe ( '{"step":1}\n' ) ;
65- const leftovers = readdirSync ( parent ) . filter (
66- name => name . includes ( '.staging.' ) || name . includes ( '.prior.' ) ,
67- ) ;
68- expect ( leftovers ) . toEqual ( [ ] ) ;
70+ it ( 'rolls back to the prior complete bundle when the staging swap fails' , async ( ) => {
71+ await withTempParent ( async parent => {
72+ const { dir, tmpDir } = seedBundleDirs ( parent ) ;
73+
74+ renameMock . mockImplementation ( async ( oldPath , newPath ) => {
75+ const src = String ( oldPath ) ;
76+ const dest = String ( newPath ) ;
77+ if ( src . includes ( '.staging.' ) && ! dest . includes ( '.prior.' ) ) {
78+ throw Object . assign ( new Error ( 'simulated atomic swap failure' ) , { code : 'EACCES' } ) ;
79+ }
80+ return realRename ( oldPath , newPath ) ;
81+ } ) ;
82+
83+ await expect ( commitBundle ( tmpDir , dir ) ) . rejects . toThrow ( 'simulated atomic swap failure' ) ;
84+
85+ expect ( readFileSync ( join ( dir , 'meta.json' ) , 'utf8' ) ) . toBe ( '{"snapshotId":"snap_old"}\n' ) ;
86+ expect ( readFileSync ( join ( dir , 'steps' , '01-evidence.json' ) , 'utf8' ) ) . toBe ( '{"step":1}\n' ) ;
87+ const leftovers = readdirSync ( parent ) . filter (
88+ name => name . includes ( '.staging.' ) || name . includes ( '.prior.' ) ,
89+ ) ;
90+ expect ( leftovers ) . toEqual ( [ ] ) ;
91+ } ) ;
6992 } ) ;
7093
7194 it ( 'keeps the new bundle when post-swap prior-dir cleanup fails' , async ( ) => {
72- const parent = mkdtempSync ( join ( tmpdir ( ) , 'bundle-commit-parent-' ) ) ;
73- const dir = join ( parent , 'bundle' ) ;
74- const tmpDir = join ( dir , '.tmp' ) ;
95+ await withTempParent ( async parent => {
96+ const { dir, tmpDir } = seedBundleDirs ( parent ) ;
7597
76- mkdirSync ( join ( dir , 'steps' ) , { recursive : true } ) ;
77- writeFileSync ( join ( dir , 'meta.json' ) , '{"snapshotId":"snap_old"}\n' , 'utf8' ) ;
78- writeFileSync ( join ( dir , 'steps' , '01-evidence.json' ) , '{"step":1}\n' , 'utf8' ) ;
98+ rmMock . mockImplementation ( async ( path , options ) => {
99+ if ( String ( path ) . includes ( '.prior.' ) ) {
100+ throw Object . assign ( new Error ( 'simulated prior cleanup failure' ) , { code : 'EACCES' } ) ;
101+ }
102+ return realRm ( path , options ) ;
103+ } ) ;
79104
80- mkdirSync ( join ( tmpDir , 'steps' ) , { recursive : true } ) ;
81- writeFileSync ( join ( tmpDir , 'meta.json' ) , '{"snapshotId":"snap_new"}\n' , 'utf8' ) ;
82- writeFileSync ( join ( tmpDir , 'steps' , '01-evidence.json' ) , '{"step":9}\n' , 'utf8' ) ;
105+ await expect ( commitBundle ( tmpDir , dir ) ) . resolves . toBeUndefined ( ) ;
83106
84- rmMock . mockImplementation ( async ( path , options ) => {
85- if ( String ( path ) . includes ( '.prior.' ) ) {
86- throw Object . assign ( new Error ( 'simulated prior cleanup failure' ) , { code : 'EACCES' } ) ;
87- }
88- return realRm ( path , options ) ;
107+ expect ( readFileSync ( join ( dir , 'meta.json' ) , 'utf8' ) ) . toBe ( '{"snapshotId":"snap_new"}\n' ) ;
108+ expect ( readFileSync ( join ( dir , 'steps' , '01-evidence.json' ) , 'utf8' ) ) . toBe ( '{"step":9}\n' ) ;
89109 } ) ;
110+ } ) ;
90111
91- await expect ( commitBundle ( tmpDir , dir ) ) . resolves . toBeUndefined ( ) ;
92-
93- expect ( readFileSync ( join ( dir , 'meta.json' ) , 'utf8' ) ) . toBe ( '{"snapshotId":"snap_new"}\n' ) ;
94- expect ( readFileSync ( join ( dir , 'steps' , '01-evidence.json' ) , 'utf8' ) ) . toBe ( '{"step":9}\n' ) ;
112+ it ( 'propagates when rollback rename fails after a staging swap error' , async ( ) => {
113+ await withTempParent ( async parent => {
114+ const { dir, tmpDir } = seedBundleDirs ( parent ) ;
115+
116+ renameMock . mockImplementation ( async ( oldPath , newPath ) => {
117+ const src = String ( oldPath ) ;
118+ const dest = String ( newPath ) ;
119+ if ( src . includes ( '.staging.' ) && ! dest . includes ( '.prior.' ) ) {
120+ throw Object . assign ( new Error ( 'simulated atomic swap failure' ) , { code : 'EACCES' } ) ;
121+ }
122+ if ( src . includes ( '.prior.' ) && dest === dir ) {
123+ throw Object . assign ( new Error ( 'simulated rollback rename failure' ) , { code : 'EACCES' } ) ;
124+ }
125+ return realRename ( oldPath , newPath ) ;
126+ } ) ;
127+
128+ await expect ( commitBundle ( tmpDir , dir ) ) . rejects . toThrow ( 'simulated rollback rename failure' ) ;
129+ // Known gap: double-failure can leave `dir` absent until manual recovery.
130+ expect ( existsSync ( dir ) ) . toBe ( false ) ;
131+ } ) ;
95132 } ) ;
96133} ) ;
0 commit comments