@@ -3,6 +3,7 @@ const originalFsPromisesRename = actualFsPromises.rename
33const originalFsPromisesUnlink = actualFsPromises . unlink
44const originalFsPromisesWriteFile = actualFsPromises . writeFile
55const _originalFsPromisesAccess = actualFsPromises . access
6+ const originalFsPromisesMkdir = actualFsPromises . mkdir
67
78jest . mock ( "fs/promises" , ( ) => {
89 const actual = jest . requireActual ( "fs/promises" )
@@ -21,6 +22,7 @@ jest.mock("fs/promises", () => {
2122 mockedFs . mkdtemp = jest . fn ( actual . mkdtemp )
2223 mockedFs . rm = jest . fn ( actual . rm )
2324 mockedFs . readdir = jest . fn ( actual . readdir )
25+ mockedFs . mkdir = jest . fn ( actual . mkdir )
2426 // fs.stat and fs.lstat will be available via { ...actual }
2527
2628 return mockedFs
@@ -44,6 +46,30 @@ import { safeWriteJson } from "../safeWriteJson"
4446import { Writable } from "stream" // For typing mock stream
4547
4648describe ( "safeWriteJson" , ( ) => {
49+ let originalConsoleError : typeof console . error
50+
51+ beforeAll ( ( ) => {
52+ // Store original console.error
53+ originalConsoleError = console . error
54+
55+ // Replace with filtered version that suppresses output from the module
56+ console . error = function ( ...args ) {
57+ // Check if call originated from safeWriteJson.ts
58+ if ( new Error ( ) . stack ?. includes ( "safeWriteJson.ts" ) ) {
59+ // Suppress output but allow spy recording
60+ return
61+ }
62+
63+ // Pass through all other calls (from tests)
64+ return originalConsoleError . apply ( console , args )
65+ }
66+ } )
67+
68+ afterAll ( ( ) => {
69+ // Restore original behavior
70+ console . error = originalConsoleError
71+ } )
72+
4773 jest . useRealTimers ( ) // Use real timers for this test suite
4874
4975 let tempTestDir : string = ""
@@ -77,6 +103,7 @@ describe("safeWriteJson", () => {
77103 ; ( fs . mkdtemp as jest . Mock ) . mockImplementation ( actualFsPromises . mkdtemp )
78104 ; ( fs . rm as jest . Mock ) . mockImplementation ( actualFsPromises . rm )
79105 ; ( fs . readdir as jest . Mock ) . mockImplementation ( actualFsPromises . readdir )
106+ ; ( fs . mkdir as jest . Mock ) . mockImplementation ( actualFsPromises . mkdir )
80107 // Ensure all mocks are reset after each test
81108 jest . restoreAllMocks ( )
82109 } )
@@ -199,11 +226,9 @@ describe("safeWriteJson", () => {
199226 const oldPathStr = oldPath . toString ( )
200227 const newPathStr = newPath . toString ( )
201228 renameCallCountTest1 ++
202- console . log ( `[TEST 1] fs.rename spy call #${ renameCallCountTest1 } : ${ oldPathStr } -> ${ newPathStr } ` )
203229
204230 // First rename call by safeWriteJson (if target exists) is target -> .bak
205231 if ( renameCallCountTest1 === 1 && ! oldPathStr . includes ( ".new_" ) && newPathStr . includes ( ".bak_" ) ) {
206- console . log ( "[TEST 1] Spy: Call #1 (target->backup), executing original rename." )
207232 return originalFsPromisesRename ( oldPath , newPath )
208233 }
209234 // Second rename call by safeWriteJson is .new -> target
@@ -212,7 +237,6 @@ describe("safeWriteJson", () => {
212237 oldPathStr . includes ( ".new_" ) &&
213238 path . resolve ( newPathStr ) === path . resolve ( currentTestFilePath )
214239 ) {
215- console . log ( "[TEST 1] Spy: Call #2 (.new->target), THROWING SIMULATED ERROR." )
216240 throw new Error ( "Simulated FS Error: rename tempNewFilePath to filePath" )
217241 }
218242 // Fallback for unexpected calls or if the target file didn't exist (only one rename: .new -> target)
@@ -223,14 +247,8 @@ describe("safeWriteJson", () => {
223247 ) {
224248 // This case handles if the initial file didn't exist, so only one rename happens.
225249 // For this specific test, we expect two renames.
226- console . warn (
227- "[TEST 1] Spy: Call #1 was .new->target, (unexpected for this test scenario, but handling)" ,
228- )
229250 throw new Error ( "Simulated FS Error: rename tempNewFilePath to filePath" )
230251 }
231- console . warn (
232- `[TEST 1] Spy: Unexpected call #${ renameCallCountTest1 } or paths. Defaulting to original rename. ${ oldPathStr } -> ${ newPathStr } ` ,
233- )
234252 return originalFsPromisesRename ( oldPath , newPath )
235253 } )
236254
@@ -249,6 +267,118 @@ describe("safeWriteJson", () => {
249267 renameSpy . mockRestore ( )
250268 } )
251269
270+ // Tests for directory creation functionality
271+ test ( "should create parent directory if it doesn't exist" , async ( ) => {
272+ // Create a path in a non-existent subdirectory of the temp dir
273+ const nonExistentDir = path . join ( tempTestDir , "non-existent-dir" )
274+ const filePath = path . join ( nonExistentDir , "test-data.json" )
275+ const data = { message : "Hello from new directory" }
276+
277+ // Verify the directory doesn't exist yet
278+ const dirAccessError = await fs . access ( nonExistentDir ) . catch ( ( e ) => e )
279+ expect ( dirAccessError ) . toBeDefined ( )
280+ expect ( dirAccessError . code ) . toBe ( "ENOENT" )
281+
282+ // safeWriteJson should now create directories and initialize an empty file automatically
283+
284+ // safeWriteJson should write the file
285+ await safeWriteJson ( filePath , data )
286+
287+ // Verify file was written correctly
288+ const writtenData = await readJsonFile ( filePath )
289+ expect ( writtenData ) . toEqual ( data )
290+
291+ // Verify no temp files remain
292+ const tempFiles = await listTempFiles ( nonExistentDir , "test-data.json" )
293+ expect ( tempFiles . length ) . toBe ( 0 )
294+ } )
295+
296+ test ( "should handle multi-level directory creation" , async ( ) => {
297+ // Create a new non-existent subdirectory path with multiple levels
298+ const newDir = path . join ( tempTestDir , "new-test-dir" , "subdir" , "deeper" )
299+ const filePath = path . join ( newDir , "new-file.json" )
300+ const data = { message : "New directory test" }
301+
302+ // Verify directories don't exist initially
303+ const dirAccessError = await fs . access ( newDir ) . catch ( ( e ) => e )
304+ expect ( dirAccessError ) . toBeDefined ( )
305+ expect ( dirAccessError . code ) . toBe ( "ENOENT" )
306+
307+ // Don't create any directories - safeWriteJson should handle it all
308+
309+ // Call safeWriteJson - it should create all missing directories and the file
310+ await safeWriteJson ( filePath , data )
311+
312+ // Verify all directory levels now exist
313+ const dirExists = await fs
314+ . access ( newDir )
315+ . then ( ( ) => true )
316+ . catch ( ( ) => false )
317+ expect ( dirExists ) . toBe ( true )
318+
319+ // Verify file was written correctly
320+ const writtenData = await readJsonFile ( filePath )
321+ expect ( writtenData ) . toEqual ( data )
322+
323+ // Check that no temp files remain
324+ const tempFiles = await listTempFiles ( newDir , "new-file.json" )
325+ expect ( tempFiles . length ) . toBe ( 0 )
326+ } )
327+
328+ test ( "should handle directory creation permission errors" , async ( ) => {
329+ // Mock mkdir to simulate a permission error
330+ const mkdirSpy = jest . spyOn ( fs , "mkdir" )
331+ mkdirSpy . mockImplementationOnce ( async ( ) => {
332+ const permError = new Error ( "EACCES: permission denied" ) as NodeJS . ErrnoException
333+ permError . code = "EACCES"
334+ throw permError
335+ } )
336+
337+ // Create test file path in a directory that will fail with permission error
338+ const nonExistentDir = path . join ( tempTestDir , "permission-denied-dir" )
339+ const filePath = path . join ( nonExistentDir , "test-data.json" )
340+ const testData = { message : "Should not be written due to permission error" }
341+
342+ // Expect the function to fail with the permission error
343+ await expect ( safeWriteJson ( filePath , testData ) ) . rejects . toThrow ( / E A C C E S / )
344+
345+ // Verify the file was not created
346+ const fileExists = await fs
347+ . access ( filePath )
348+ . then ( ( ) => true )
349+ . catch ( ( ) => false )
350+ expect ( fileExists ) . toBe ( false )
351+
352+ mkdirSpy . mockRestore ( )
353+ } )
354+
355+ test ( "should successfully write to a non-existent file in an existing directory" , async ( ) => {
356+ // Create directory but not the file
357+ const existingDir = path . join ( tempTestDir , "existing-dir" )
358+ await fs . mkdir ( existingDir , { recursive : true } )
359+
360+ const filePath = path . join ( existingDir , "non-existent-file.json" )
361+ const data = { message : "Creating new file" }
362+
363+ // Verify file doesn't exist before the operation
364+ const accessError = await fs . access ( filePath ) . catch ( ( e ) => e )
365+ expect ( accessError ) . toBeDefined ( )
366+ expect ( accessError . code ) . toBe ( "ENOENT" )
367+
368+ // safeWriteJson should automatically create the empty file for lock acquisition
369+
370+ // Write to the file
371+ await safeWriteJson ( filePath , data )
372+
373+ // Verify file was created with correct content
374+ const writtenData = await readJsonFile ( filePath )
375+ expect ( writtenData ) . toEqual ( data )
376+
377+ // Verify no temp files remain
378+ const tempFiles = await listTempFiles ( existingDir , "non-existent-file.json" )
379+ expect ( tempFiles . length ) . toBe ( 0 )
380+ } )
381+
252382 test ( "should handle failure when deleting tempBackupFilePath (filePath exists, all renames succeed)" , async ( ) => {
253383 const initialData = { message : "Initial content" }
254384 await fs . writeFile ( currentTestFilePath , JSON . stringify ( initialData ) ) // Use mocked fs for setup
@@ -259,10 +389,8 @@ describe("safeWriteJson", () => {
259389 unlinkSpy . mockImplementationOnce ( async ( filePath : any ) => {
260390 const filePathStr = filePath . toString ( )
261391 if ( filePathStr . includes ( ".bak_" ) ) {
262- console . log ( "[TEST unlink bak] Mock: Simulating failure for unlink backup." )
263392 throw new Error ( "Simulated FS Error: delete tempBackupFilePath" )
264393 }
265- console . log ( "[TEST unlink bak] Mock: Condition NOT MET. Using originalFsPromisesUnlink." )
266394 return originalFsPromisesUnlink ( filePath )
267395 } )
268396
@@ -438,40 +566,26 @@ describe("safeWriteJson", () => {
438566 const resolvedOldPath = path . resolve ( oldPathStr )
439567 const resolvedNewPath = path . resolve ( newPathStr )
440568 const resolvedCurrentTFP = path . resolve ( currentTestFilePath )
441- console . log (
442- `[TEST 2] fs.promises.rename call #${ renameCallCountTest2 } : oldPath=${ oldPathStr } (resolved: ${ resolvedOldPath } ), newPath=${ newPathStr } (resolved: ${ resolvedNewPath } ), currentTFP (resolved: ${ resolvedCurrentTFP } )` ,
443- )
444569
445570 if ( renameCallCountTest2 === 1 ) {
446571 // Call 1: Original -> Backup (Succeeds)
447572 if ( resolvedOldPath === resolvedCurrentTFP && newPathStr . includes ( ".bak_" ) ) {
448- console . log ( "[TEST 2] Call #1 (Original->Backup): Condition MET. originalFsPromisesRename." )
449573 return originalFsPromisesRename ( oldPath , newPath )
450574 }
451- console . error ( "[TEST 2] Call #1: UNEXPECTED args." )
452575 throw new Error ( "Unexpected args for rename call #1 in test" )
453576 } else if ( renameCallCountTest2 === 2 ) {
454577 // Call 2: New -> Original (Fails - this is the "original error")
455578 if ( oldPathStr . includes ( ".new_" ) && resolvedNewPath === resolvedCurrentTFP ) {
456- console . log (
457- '[TEST 2] Call #2 (New->Original): Condition MET. Throwing "Simulated FS Error: new to original".' ,
458- )
459579 throw new Error ( "Simulated FS Error: new to original" )
460580 }
461- console . error ( "[TEST 2] Call #2: UNEXPECTED args." )
462581 throw new Error ( "Unexpected args for rename call #2 in test" )
463582 } else if ( renameCallCountTest2 === 3 ) {
464583 // Call 3: Backup -> Original (Rollback attempt - Fails)
465584 if ( oldPathStr . includes ( ".bak_" ) && resolvedNewPath === resolvedCurrentTFP ) {
466- console . log (
467- '[TEST 2] Call #3 (Backup->Original Rollback): Condition MET. Throwing "Simulated FS Error: backup to original (rollback)".' ,
468- )
469585 throw new Error ( "Simulated FS Error: backup to original (rollback)" )
470586 }
471- console . error ( "[TEST 2] Call #3: UNEXPECTED args." )
472587 throw new Error ( "Unexpected args for rename call #3 in test" )
473588 }
474- console . error ( `[TEST 2] Unexpected fs.promises.rename call count: ${ renameCallCountTest2 } ` )
475589 return originalFsPromisesRename ( oldPath , newPath )
476590 } )
477591
0 commit comments