11import fs from "node:fs" ;
22import path from "node:path" ;
3- import { describe , expect , it } from "vitest" ;
3+ import { afterEach , describe , expect , it , vi } from "vitest" ;
44import { withTempDir } from "../test-helpers/temp-dir.js" ;
55import { loadJsonFile , saveJsonFile } from "./json-file.js" ;
66
7+ const SAVED_PAYLOAD = { enabled : true , count : 2 } ;
8+ const PREVIOUS_JSON = '{"enabled":false}\n' ;
9+
10+ function escapeRegExp ( value : string ) : string {
11+ return value . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
12+ }
13+
14+ function writeExistingJson ( pathname : string ) {
15+ fs . writeFileSync ( pathname , PREVIOUS_JSON , "utf8" ) ;
16+ }
17+
718async function withJsonPath < T > (
819 run : ( params : { root : string ; pathname : string } ) => Promise < T > | T ,
920) : Promise < T > {
@@ -13,6 +24,10 @@ async function withJsonPath<T>(
1324}
1425
1526describe ( "json-file helpers" , ( ) => {
27+ afterEach ( ( ) => {
28+ vi . restoreAllMocks ( ) ;
29+ } ) ;
30+
1631 it . each ( [
1732 {
1833 name : "missing files" ,
@@ -40,11 +55,11 @@ describe("json-file helpers", () => {
4055 it ( "creates parent dirs, writes a trailing newline, and loads the saved object" , async ( ) => {
4156 await withTempDir ( { prefix : "openclaw-json-file-" } , async ( root ) => {
4257 const pathname = path . join ( root , "nested" , "config.json" ) ;
43- saveJsonFile ( pathname , { enabled : true , count : 2 } ) ;
58+ saveJsonFile ( pathname , SAVED_PAYLOAD ) ;
4459
4560 const raw = fs . readFileSync ( pathname , "utf8" ) ;
4661 expect ( raw . endsWith ( "\n" ) ) . toBe ( true ) ;
47- expect ( loadJsonFile ( pathname ) ) . toEqual ( { enabled : true , count : 2 } ) ;
62+ expect ( loadJsonFile ( pathname ) ) . toEqual ( SAVED_PAYLOAD ) ;
4863
4964 const fileMode = fs . statSync ( pathname ) . mode & 0o777 ;
5065 const dirMode = fs . statSync ( path . dirname ( pathname ) ) . mode & 0o777 ;
@@ -64,15 +79,103 @@ describe("json-file helpers", () => {
6479 } ,
6580 {
6681 name : "existing JSON files" ,
67- setup : ( pathname : string ) => {
68- fs . writeFileSync ( pathname , '{"enabled":false}\n' , "utf8" ) ;
69- } ,
82+ setup : writeExistingJson ,
7083 } ,
7184 ] ) ( "writes the latest payload for $name" , async ( { setup } ) => {
7285 await withJsonPath ( ( { pathname } ) => {
7386 setup ( pathname ) ;
74- saveJsonFile ( pathname , { enabled : true , count : 2 } ) ;
75- expect ( loadJsonFile ( pathname ) ) . toEqual ( { enabled : true , count : 2 } ) ;
87+ saveJsonFile ( pathname , SAVED_PAYLOAD ) ;
88+ expect ( loadJsonFile ( pathname ) ) . toEqual ( SAVED_PAYLOAD ) ;
89+ } ) ;
90+ } ) ;
91+
92+ it ( "writes through a sibling temp file before replacing the destination" , async ( ) => {
93+ await withJsonPath ( ( { pathname } ) => {
94+ writeExistingJson ( pathname ) ;
95+ const renameSpy = vi . spyOn ( fs , "renameSync" ) ;
96+
97+ saveJsonFile ( pathname , SAVED_PAYLOAD ) ;
98+
99+ const renameCall = renameSpy . mock . calls . find ( ( [ , target ] ) => target === pathname ) ;
100+ expect ( renameCall ?. [ 0 ] ) . toMatch ( new RegExp ( `^${ escapeRegExp ( pathname ) } \\..+\\.tmp$` ) ) ;
101+ expect ( renameSpy ) . toHaveBeenCalledWith ( renameCall ?. [ 0 ] , pathname ) ;
102+ expect ( loadJsonFile ( pathname ) ) . toEqual ( SAVED_PAYLOAD ) ;
103+ } ) ;
104+ } ) ;
105+
106+ it . runIf ( process . platform !== "win32" ) (
107+ "preserves symlink destinations when replacing existing JSON files" ,
108+ async ( ) => {
109+ await withTempDir ( { prefix : "openclaw-json-file-" } , async ( root ) => {
110+ const targetDir = path . join ( root , "target" ) ;
111+ const targetPath = path . join ( targetDir , "config.json" ) ;
112+ const linkPath = path . join ( root , "config-link.json" ) ;
113+ fs . mkdirSync ( targetDir , { recursive : true } ) ;
114+ writeExistingJson ( targetPath ) ;
115+ fs . symlinkSync ( targetPath , linkPath ) ;
116+
117+ saveJsonFile ( linkPath , SAVED_PAYLOAD ) ;
118+
119+ expect ( fs . lstatSync ( linkPath ) . isSymbolicLink ( ) ) . toBe ( true ) ;
120+ expect ( loadJsonFile ( targetPath ) ) . toEqual ( SAVED_PAYLOAD ) ;
121+ expect ( loadJsonFile ( linkPath ) ) . toEqual ( SAVED_PAYLOAD ) ;
122+ } ) ;
123+ } ,
124+ ) ;
125+
126+ it . runIf ( process . platform !== "win32" ) (
127+ "creates a missing target file through an existing symlink" ,
128+ async ( ) => {
129+ await withTempDir ( { prefix : "openclaw-json-file-" } , async ( root ) => {
130+ const targetDir = path . join ( root , "target" ) ;
131+ const targetPath = path . join ( targetDir , "config.json" ) ;
132+ const linkPath = path . join ( root , "config-link.json" ) ;
133+ fs . mkdirSync ( targetDir , { recursive : true } ) ;
134+ fs . symlinkSync ( targetPath , linkPath ) ;
135+
136+ saveJsonFile ( linkPath , SAVED_PAYLOAD ) ;
137+
138+ expect ( fs . lstatSync ( linkPath ) . isSymbolicLink ( ) ) . toBe ( true ) ;
139+ expect ( loadJsonFile ( targetPath ) ) . toEqual ( SAVED_PAYLOAD ) ;
140+ expect ( loadJsonFile ( linkPath ) ) . toEqual ( SAVED_PAYLOAD ) ;
141+ } ) ;
142+ } ,
143+ ) ;
144+
145+ it . runIf ( process . platform !== "win32" ) (
146+ "does not create missing target directories through an existing symlink" ,
147+ async ( ) => {
148+ await withTempDir ( { prefix : "openclaw-json-file-" } , async ( root ) => {
149+ const missingTargetDir = path . join ( root , "missing-target" ) ;
150+ const targetPath = path . join ( missingTargetDir , "config.json" ) ;
151+ const linkPath = path . join ( root , "config-link.json" ) ;
152+ fs . symlinkSync ( targetPath , linkPath ) ;
153+
154+ expect ( ( ) => saveJsonFile ( linkPath , SAVED_PAYLOAD ) ) . toThrow (
155+ expect . objectContaining ( { code : "ENOENT" } ) ,
156+ ) ;
157+ expect ( fs . existsSync ( missingTargetDir ) ) . toBe ( false ) ;
158+ expect ( fs . lstatSync ( linkPath ) . isSymbolicLink ( ) ) . toBe ( true ) ;
159+ } ) ;
160+ } ,
161+ ) ;
162+
163+ it ( "falls back to copy when rename-based overwrite fails" , async ( ) => {
164+ await withJsonPath ( ( { root, pathname } ) => {
165+ writeExistingJson ( pathname ) ;
166+ const copySpy = vi . spyOn ( fs , "copyFileSync" ) ;
167+ const renameSpy = vi . spyOn ( fs , "renameSync" ) . mockImplementationOnce ( ( ) => {
168+ const err = new Error ( "EPERM" ) as NodeJS . ErrnoException ;
169+ err . code = "EPERM" ;
170+ throw err ;
171+ } ) ;
172+
173+ saveJsonFile ( pathname , SAVED_PAYLOAD ) ;
174+
175+ expect ( renameSpy ) . toHaveBeenCalledOnce ( ) ;
176+ expect ( copySpy ) . toHaveBeenCalledOnce ( ) ;
177+ expect ( loadJsonFile ( pathname ) ) . toEqual ( SAVED_PAYLOAD ) ;
178+ expect ( fs . readdirSync ( root ) ) . toEqual ( [ "config.json" ] ) ;
76179 } ) ;
77180 } ) ;
78181} ) ;
0 commit comments