@@ -20,15 +20,65 @@ vi.mock("./useEditorSave", () => ({
2020 useEditorSave : ( ) => ( {
2121 saveRafRef : { current : null } ,
2222 handleContentChange : vi . fn ( ) ,
23+ getPendingCandidate : vi . fn ( ( ) => null ) ,
24+ flushPendingSave : vi . fn ( async ( ) => ( { status : "clean" as const } ) ) ,
25+ discardPendingSave : vi . fn ( ) ,
2326 } ) ,
2427} ) ) ;
2528
2629import { useFileManager } from "./useFileManager" ;
30+ import { resetStudioWriteTokens , studioFileContentVersion } from "../utils/studioFileVersion" ;
31+ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics" ;
2732
2833( globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT : boolean } ) . IS_REACT_ACT_ENVIRONMENT = true ;
2934
35+ function useTestFileManager ( projectId : string ) {
36+ return useFileManager ( {
37+ projectId,
38+ showToast : vi . fn ( ) ,
39+ recordEdit : vi . fn ( async ( ) => { } ) ,
40+ domEditSaveTimestampRef : { current : 0 } ,
41+ setRefreshKey : vi . fn ( ) ,
42+ } ) ;
43+ }
44+
45+ async function mountTestFileManager ( projectId = "project-a" ) {
46+ const captured : { manager : ReturnType < typeof useFileManager > | null } = { manager : null } ;
47+ function Probe ( ) {
48+ captured . manager = useTestFileManager ( projectId ) ;
49+ return null ;
50+ }
51+ const root = createRoot ( document . createElement ( "div" ) ) ;
52+ await act ( async ( ) => root . render ( < Probe /> ) ) ;
53+ const manager = captured . manager ;
54+ if ( ! manager ) throw new Error ( "file manager did not render" ) ;
55+ return { manager, root } ;
56+ }
57+
58+ async function mountOverwriteRequest ( response : Response ) {
59+ const fetchMock = vi . fn ( ( _url : string , init ?: RequestInit ) => {
60+ if ( init ?. method !== "PUT" ) {
61+ throw new Error ( "overwrite unexpectedly performed a preflight read" ) ;
62+ }
63+ return Promise . resolve ( response ) ;
64+ } ) ;
65+ vi . stubGlobal ( "fetch" , fetchMock ) ;
66+ return { ...( await mountTestFileManager ( ) ) , fetchMock } ;
67+ }
68+
69+ function createOverwriteConflict ( currentVersion : string | null , currentContent : string | null ) {
70+ return new StudioFileConflictError ( {
71+ filePath : "index.html" ,
72+ currentVersion,
73+ currentContent,
74+ attemptedContent : "STUDIO" ,
75+ } ) ;
76+ }
77+
3078describe ( "useFileManager project ownership" , ( ) => {
3179 afterEach ( ( ) => {
80+ resetStudioWriteTokens ( ) ;
81+ vi . useRealTimers ( ) ;
3282 vi . unstubAllGlobals ( ) ;
3383 } ) ;
3484
@@ -54,13 +104,7 @@ describe("useFileManager project ownership", () => {
54104
55105 const captured : { manager : ReturnType < typeof useFileManager > | null } = { manager : null } ;
56106 function Probe ( { projectId } : { projectId : string } ) {
57- captured . manager = useFileManager ( {
58- projectId,
59- showToast : vi . fn ( ) ,
60- recordEdit : vi . fn ( async ( ) => { } ) ,
61- domEditSaveTimestampRef : { current : 0 } ,
62- setRefreshKey : vi . fn ( ) ,
63- } ) ;
107+ captured . manager = useTestFileManager ( projectId ) ;
64108 return null ;
65109 }
66110
@@ -106,4 +150,92 @@ describe("useFileManager project ownership", () => {
106150
107151 await act ( async ( ) => root . unmount ( ) ) ;
108152 } ) ;
153+
154+ it ( "uses a fresh write token when a lost response retries a committed save" , async ( ) => {
155+ vi . useFakeTimers ( ) ;
156+ let putAttempt = 0 ;
157+ const fetchMock = vi . fn ( ( _url : string , init ?: RequestInit ) => {
158+ if ( ! init ?. method ) {
159+ return Promise . resolve ( {
160+ ok : true ,
161+ json : async ( ) => ( { content : "BEFORE" , version : "v1" } ) ,
162+ } as Response ) ;
163+ }
164+ putAttempt += 1 ;
165+ if ( putAttempt === 1 ) return Promise . reject ( new TypeError ( "response lost" ) ) ;
166+ return Promise . resolve ( { ok : true , json : async ( ) => ( { version : "v2" } ) } as Response ) ;
167+ } ) ;
168+ vi . stubGlobal ( "fetch" , fetchMock ) ;
169+
170+ const { manager, root } = await mountTestFileManager ( ) ;
171+ await manager . readProjectFile ( "index.html" ) ;
172+
173+ const write = manager . writeProjectFile ( "index.html" , "AFTER" ) ;
174+ await vi . runAllTimersAsync ( ) ;
175+ await write ;
176+
177+ const writeTokens = fetchMock . mock . calls
178+ . filter ( ( [ , init ] ) => init ?. method === "PUT" )
179+ . map ( ( [ , init ] ) => new Headers ( init ?. headers ) . get ( "X-Hyperframes-Write-Token" ) ) ;
180+ expect ( writeTokens ) . toHaveLength ( 2 ) ;
181+ expect ( writeTokens [ 0 ] ) . toBeTruthy ( ) ;
182+ expect ( writeTokens [ 1 ] ) . not . toBe ( writeTokens [ 0 ] ) ;
183+
184+ await act ( async ( ) => root . unmount ( ) ) ;
185+ } ) ;
186+
187+ it ( "overwrites the exact external content version with an If-Match precondition" , async ( ) => {
188+ const { manager, root, fetchMock } = await mountOverwriteRequest ( {
189+ ok : true ,
190+ json : async ( ) => ( { version : "v3" } ) ,
191+ } as Response ) ;
192+ const conflict = createOverwriteConflict ( "v2" , "EXTERNAL" ) ;
193+
194+ await manager . overwriteExternalConflict ( conflict ) ;
195+
196+ const [ , init ] = fetchMock . mock . calls [ 0 ] ?? [ ] ;
197+ const headers = new Headers ( init ?. headers ) ;
198+ expect ( init ) . toMatchObject ( { method : "PUT" , body : "STUDIO" } ) ;
199+ expect ( headers . get ( "If-Match" ) ) . toBe ( await studioFileContentVersion ( "EXTERNAL" ) ) ;
200+ expect ( headers . get ( "If-None-Match" ) ) . toBeNull ( ) ;
201+ await act ( async ( ) => root . unmount ( ) ) ;
202+ } ) ;
203+
204+ it ( "preserves a newer third-party edit when a content-less conflict version is stale" , async ( ) => {
205+ const { manager, root, fetchMock } = await mountOverwriteRequest ( {
206+ ok : false ,
207+ status : 409 ,
208+ json : async ( ) => ( { currentVersion : "v3" , currentContent : "THIRD PARTY" } ) ,
209+ } as Response ) ;
210+ const conflict = createOverwriteConflict ( "v2" , null ) ;
211+
212+ await expect ( manager . overwriteExternalConflict ( conflict ) ) . rejects . toMatchObject ( {
213+ name : "StudioFileConflictError" ,
214+ currentVersion : "v3" ,
215+ currentContent : "THIRD PARTY" ,
216+ attemptedContent : "STUDIO" ,
217+ } ) ;
218+
219+ const [ , init ] = fetchMock . mock . calls [ 0 ] ?? [ ] ;
220+ const headers = new Headers ( init ?. headers ) ;
221+ expect ( headers . get ( "If-Match" ) ) . toBe ( "v2" ) ;
222+ expect ( headers . get ( "If-None-Match" ) ) . toBeNull ( ) ;
223+ await act ( async ( ) => root . unmount ( ) ) ;
224+ } ) ;
225+
226+ it ( "uses create-only semantics when the conflicted file was deleted" , async ( ) => {
227+ const { manager, root, fetchMock } = await mountOverwriteRequest ( {
228+ ok : true ,
229+ json : async ( ) => ( { version : "v1" } ) ,
230+ } as Response ) ;
231+ const conflict = createOverwriteConflict ( null , null ) ;
232+
233+ await manager . overwriteExternalConflict ( conflict ) ;
234+
235+ const [ , init ] = fetchMock . mock . calls [ 0 ] ?? [ ] ;
236+ const headers = new Headers ( init ?. headers ) ;
237+ expect ( headers . get ( "If-Match" ) ) . toBeNull ( ) ;
238+ expect ( headers . get ( "If-None-Match" ) ) . toBe ( "*" ) ;
239+ await act ( async ( ) => root . unmount ( ) ) ;
240+ } ) ;
109241} ) ;
0 commit comments