11import fs from "node:fs/promises" ;
22import os from "node:os" ;
33import path from "node:path" ;
4+ import { FsSafeError } from "openclaw/plugin-sdk/security-runtime" ;
45import { afterEach , describe , expect , it , vi } from "vitest" ;
56import { applyMemoryWikiMutation } from "./apply.js" ;
67import { importChatGptConversations } from "./chatgpt-import.js" ;
@@ -11,6 +12,9 @@ import { createMemoryWikiTestHarness } from "./test-helpers.js";
1112
1213const securityRuntimeMock = vi . hoisted ( ( ) => ( {
1314 failReadTextOnceFor : undefined as string | undefined ,
15+ failReadTextAlwaysFor : undefined as string | undefined ,
16+ readTextOnceError : new Error ( "transient existing-page read failure" ) ,
17+ readTextError : new Error ( "persistent existing-page read failure" ) ,
1418 readTextFailureInjected : false ,
1519} ) ) ;
1620
@@ -26,12 +30,16 @@ vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
2630 return Reflect . get ( target , prop , receiver ) ;
2731 }
2832 return async ( relativePath : string ) => {
33+ if ( securityRuntimeMock . failReadTextAlwaysFor === relativePath ) {
34+ securityRuntimeMock . readTextFailureInjected = true ;
35+ throw securityRuntimeMock . readTextError ;
36+ }
2937 if (
3038 securityRuntimeMock . failReadTextOnceFor === relativePath &&
3139 ! securityRuntimeMock . readTextFailureInjected
3240 ) {
3341 securityRuntimeMock . readTextFailureInjected = true ;
34- throw new Error ( "transient existing-page read failure" ) ;
42+ throw securityRuntimeMock . readTextOnceError ;
3543 }
3644 return target . readText ( relativePath ) ;
3745 } ;
@@ -67,10 +75,63 @@ function buildSourcePage(raw: string, updatedAt: string): string {
6775 } ) ;
6876}
6977
78+ async function createChatGptImportFixture ( prefix : string ) {
79+ const { rootDir, config } = await createVault ( { prefix } ) ;
80+ const exportDir = path . join ( rootDir , "chatgpt-export" ) ;
81+ await fs . mkdir ( exportDir , { recursive : true } ) ;
82+ await fs . writeFile (
83+ path . join ( exportDir , "conversations.json" ) ,
84+ `${ JSON . stringify ( [
85+ {
86+ conversation_id : "12345678-1234-1234-1234-1234567890ab" ,
87+ title : "Travel preference check" ,
88+ create_time : 1_712_363_200 ,
89+ update_time : 1_712_366_800 ,
90+ current_node : "assistant-1" ,
91+ mapping : {
92+ root : { } ,
93+ "user-1" : {
94+ parent : "root" ,
95+ message : {
96+ author : { role : "user" } ,
97+ content : { parts : [ "I prefer aisle seats." ] } ,
98+ } ,
99+ } ,
100+ "assistant-1" : {
101+ parent : "user-1" ,
102+ message : {
103+ author : { role : "assistant" } ,
104+ content : { parts : [ "Noted." ] } ,
105+ } ,
106+ } ,
107+ } ,
108+ } ,
109+ ] ) } \n`,
110+ "utf8" ,
111+ ) ;
112+ await importChatGptConversations ( {
113+ config,
114+ exportPath : exportDir ,
115+ nowMs : Date . UTC ( 2026 , 3 , 5 , 12 , 0 , 0 ) ,
116+ } ) ;
117+ const sourceFiles = ( await fs . readdir ( path . join ( rootDir , "sources" ) ) ) . filter (
118+ ( entry ) => entry !== "index.md" ,
119+ ) ;
120+ expect ( sourceFiles ) . toHaveLength ( 1 ) ;
121+ return {
122+ config,
123+ exportDir,
124+ pagePath : path . join ( rootDir , "sources" , sourceFiles [ 0 ] ) ,
125+ } ;
126+ }
127+
70128describe ( "memory-wiki existing-page read retry" , ( ) => {
71129 afterEach ( ( ) => {
72130 vi . restoreAllMocks ( ) ;
73131 securityRuntimeMock . failReadTextOnceFor = undefined ;
132+ securityRuntimeMock . failReadTextAlwaysFor = undefined ;
133+ securityRuntimeMock . readTextOnceError = new Error ( "transient existing-page read failure" ) ;
134+ securityRuntimeMock . readTextError = new Error ( "persistent existing-page read failure" ) ;
74135 securityRuntimeMock . readTextFailureInjected = false ;
75136 } ) ;
76137
@@ -200,6 +261,10 @@ describe("memory-wiki existing-page read retry", () => {
200261 await fs . writeFile ( pagePath , edited , "utf8" ) ;
201262
202263 securityRuntimeMock . failReadTextOnceFor = "syntheses/release-plan.md" ;
264+ securityRuntimeMock . readTextOnceError = new FsSafeError (
265+ "not-found" ,
266+ "page temporarily missing" ,
267+ ) ;
203268
204269 await applyMemoryWikiMutation ( {
205270 config,
@@ -218,53 +283,47 @@ describe("memory-wiki existing-page read retry", () => {
218283 expect ( after ) . toContain ( "privacyTier: sensitive" ) ;
219284 } ) ;
220285
221- it ( "preserves chatgpt conversation notes after a transient existing-page read failure" , async ( ) => {
222- const { rootDir, config } = await createVault ( { prefix : "memory-wiki-chatgpt-read-retry-" } ) ;
223- const exportDir = path . join ( rootDir , "chatgpt-export" ) ;
224- await fs . mkdir ( exportDir , { recursive : true } ) ;
225- const conversations = [
226- {
227- conversation_id : "12345678-1234-1234-1234-1234567890ab" ,
228- title : "Travel preference check" ,
229- create_time : 1_712_363_200 ,
230- update_time : 1_712_366_800 ,
231- current_node : "assistant-1" ,
232- mapping : {
233- root : { } ,
234- "user-1" : {
235- parent : "root" ,
236- message : {
237- author : { role : "user" } ,
238- content : { parts : [ "I prefer aisle seats." ] } ,
239- } ,
240- } ,
241- "assistant-1" : {
242- parent : "user-1" ,
243- message : {
244- author : { role : "assistant" } ,
245- content : { parts : [ "Noted." ] } ,
246- } ,
247- } ,
248- } ,
249- } ,
250- ] ;
251- await fs . writeFile (
252- path . join ( exportDir , "conversations.json" ) ,
253- `${ JSON . stringify ( conversations , null , 2 ) } \n` ,
254- "utf8" ,
255- ) ;
286+ it ( "does not treat a path-alias policy failure as a missing synthesis page" , async ( ) => {
287+ const { rootDir, config } = await createVault ( { prefix : "memory-wiki-apply-path-alias-" } ) ;
256288
257- await importChatGptConversations ( {
289+ await applyMemoryWikiMutation ( {
258290 config,
259- exportPath : exportDir ,
260- nowMs : Date . UTC ( 2026 , 3 , 5 , 12 , 0 , 0 ) ,
291+ mutation : {
292+ op : "create_synthesis" ,
293+ title : "Release Plan" ,
294+ body : "Initial summary." ,
295+ sourceIds : [ "source.alpha" ] ,
296+ } ,
261297 } ) ;
262298
263- const sourceFiles = ( await fs . readdir ( path . join ( rootDir , "sources" ) ) ) . filter (
264- ( entry ) => entry !== "index.md" ,
299+ const pagePath = path . join ( rootDir , "syntheses" , "release-plan.md" ) ;
300+ const before = await fs . readFile ( pagePath , "utf8" ) ;
301+ securityRuntimeMock . failReadTextAlwaysFor = "syntheses/release-plan.md" ;
302+ securityRuntimeMock . readTextError = new FsSafeError (
303+ "path-alias" ,
304+ "page resolved outside the vault root" ,
305+ ) ;
306+
307+ await expect (
308+ applyMemoryWikiMutation ( {
309+ config,
310+ mutation : {
311+ op : "create_synthesis" ,
312+ title : "Release Plan" ,
313+ body : "Replacement summary." ,
314+ sourceIds : [ "source.alpha" ] ,
315+ } ,
316+ } ) ,
317+ ) . rejects . toMatchObject ( { code : "path-alias" } ) ;
318+
319+ expect ( securityRuntimeMock . readTextFailureInjected ) . toBe ( true ) ;
320+ await expect ( fs . readFile ( pagePath , "utf8" ) ) . resolves . toBe ( before ) ;
321+ } ) ;
322+
323+ it ( "preserves chatgpt conversation notes after a transient existing-page read failure" , async ( ) => {
324+ const { config, exportDir, pagePath } = await createChatGptImportFixture (
325+ "memory-wiki-chatgpt-read-retry-" ,
265326 ) ;
266- expect ( sourceFiles ) . toHaveLength ( 1 ) ;
267- const pagePath = path . join ( rootDir , "sources" , sourceFiles [ 0 ] ) ;
268327 const userNote = "HUMAN NOTE: verified against the airline booking." ;
269328 const edited = ( await fs . readFile ( pagePath , "utf8" ) ) . replace (
270329 "<!-- openclaw:human:start -->\n<!-- openclaw:human:end -->" ,
@@ -278,7 +337,7 @@ describe("memory-wiki existing-page read retry", () => {
278337 async ( ...args : Parameters < typeof fs . readFile > ) : ReturnType < typeof fs . readFile > => {
279338 if ( ! injectedFailure && args [ 0 ] === pagePath && args [ 1 ] === "utf8" ) {
280339 injectedFailure = true ;
281- throw new Error ( "transient existing- page read failure" ) ;
340+ throw Object . assign ( new Error ( "page temporarily missing" ) , { code : "ENOENT" } ) ;
282341 }
283342 return originalReadFile ( ...args ) ;
284343 } ,
@@ -295,4 +354,30 @@ describe("memory-wiki existing-page read retry", () => {
295354 expect ( second . createdCount ) . toBe ( 0 ) ;
296355 expect ( after ) . toContain ( userNote ) ;
297356 } ) ;
357+
358+ it ( "leaves a ChatGPT page unchanged after a persistent existing-page read failure" , async ( ) => {
359+ const { config, exportDir, pagePath } = await createChatGptImportFixture (
360+ "memory-wiki-chatgpt-persistent-read-" ,
361+ ) ;
362+ const before = await fs . readFile ( pagePath , "utf8" ) ;
363+ const originalReadFile = fs . readFile . bind ( fs ) ;
364+ vi . spyOn ( fs , "readFile" ) . mockImplementation (
365+ async ( ...args : Parameters < typeof fs . readFile > ) : ReturnType < typeof fs . readFile > => {
366+ if ( args [ 0 ] === pagePath && args [ 1 ] === "utf8" ) {
367+ throw Object . assign ( new Error ( "resource busy" ) , { code : "EBUSY" } ) ;
368+ }
369+ return originalReadFile ( ...args ) ;
370+ } ,
371+ ) ;
372+
373+ await expect (
374+ importChatGptConversations ( {
375+ config,
376+ exportPath : exportDir ,
377+ nowMs : Date . UTC ( 2026 , 3 , 6 , 12 , 0 , 0 ) ,
378+ } ) ,
379+ ) . rejects . toMatchObject ( { code : "EBUSY" } ) ;
380+
381+ await expect ( originalReadFile ( pagePath , "utf8" ) ) . resolves . toBe ( before ) ;
382+ } ) ;
298383} ) ;
0 commit comments