22import { act } from "react" ;
33import { createRoot } from "react-dom/client" ;
44import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
5- import { useEditorSave , type EditorSaveHandle } from "./useEditorSave" ;
5+
6+ const trackStudioSaveFailure = vi . hoisted ( ( ) => vi . fn ( ) ) ;
7+ vi . mock ( "../utils/studioSaveDiagnostics" , async ( importOriginal ) => ( {
8+ ...( await importOriginal < typeof import ( "../utils/studioSaveDiagnostics" ) > ( ) ) ,
9+ trackStudioSaveFailure,
10+ } ) ) ;
11+
612import { StudioFileConflictError } from "../utils/studioSaveDiagnostics" ;
13+ import { useEditorSave , type EditorSaveHandle } from "./useEditorSave" ;
714
815( globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT : boolean } ) . IS_REACT_ACT_ENVIRONMENT = true ;
916
1017type WriteProjectFile = ( path : string , content : string , expectedContent ?: string ) => Promise < void > ;
1118
1219async function mountEditorSave ( writeProjectFile : WriteProjectFile ) {
1320 const captured : { handle : EditorSaveHandle | null } = { handle : null } ;
21+ const showToast = vi . fn ( ) ;
1422
1523 function Probe ( ) {
1624 captured . handle = useEditorSave ( {
@@ -21,7 +29,7 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
2129 recordEdit : vi . fn ( async ( ) => undefined ) ,
2230 domEditSaveTimestampRef : { current : 0 } ,
2331 setRefreshKey : vi . fn ( ) ,
24- showToast : vi . fn ( ) ,
32+ showToast,
2533 } ) ;
2634 return null ;
2735 }
@@ -32,20 +40,25 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
3240
3341 return {
3442 handle : captured . handle ,
43+ showToast,
3544 unmount : ( ) => act ( async ( ) => root . unmount ( ) ) ,
3645 } ;
3746}
3847
3948describe ( "useEditorSave pending work" , ( ) => {
4049 beforeEach ( ( ) => {
50+ trackStudioSaveFailure . mockClear ( ) ;
4151 vi . stubGlobal (
4252 "requestAnimationFrame" ,
4353 vi . fn ( ( ) => 41 ) ,
4454 ) ;
4555 vi . stubGlobal ( "cancelAnimationFrame" , vi . fn ( ) ) ;
4656 } ) ;
4757
48- afterEach ( ( ) => vi . unstubAllGlobals ( ) ) ;
58+ afterEach ( ( ) => {
59+ vi . restoreAllMocks ( ) ;
60+ vi . unstubAllGlobals ( ) ;
61+ } ) ;
4962
5063 it ( "exposes and flushes the latest rAF-buffered source candidate" , async ( ) => {
5164 const writeProjectFile = vi . fn ( async ( ) => undefined ) ;
@@ -111,7 +124,72 @@ describe("useEditorSave pending work", () => {
111124 status : "conflict" ,
112125 error : conflict ,
113126 } ) ;
127+ expect ( trackStudioSaveFailure ) . toHaveBeenCalledWith ( {
128+ source : "code_editor" ,
129+ error : conflict ,
130+ filePath : "index.html" ,
131+ } ) ;
132+
133+ await mounted . unmount ( ) ;
134+ } ) ;
135+
136+ it ( "emits one identical failure per five-second burst" , async ( ) => {
137+ vi . spyOn ( Date , "now" ) . mockReturnValue ( 1_000 ) ;
138+ const error = new Error ( "Load failed" ) ;
139+ const mounted = await mountEditorSave ( async ( ) => {
140+ throw error ;
141+ } ) ;
142+
143+ act ( ( ) => mounted . handle . handleContentChange ( "first candidate" ) ) ;
144+ await mounted . handle . flushPendingSave ( ) ;
145+ vi . spyOn ( Date , "now" ) . mockReturnValue ( 2_000 ) ;
146+ act ( ( ) => mounted . handle . handleContentChange ( "second candidate" ) ) ;
147+ await mounted . handle . flushPendingSave ( ) ;
148+
149+ expect ( trackStudioSaveFailure ) . toHaveBeenCalledOnce ( ) ;
150+ expect ( mounted . showToast ) . toHaveBeenCalledOnce ( ) ;
151+ await mounted . unmount ( ) ;
152+ } ) ;
153+
154+ it ( "emits a changed failure immediately and repeats after the burst window" , async ( ) => {
155+ const now = vi . spyOn ( Date , "now" ) . mockReturnValue ( 1_000 ) ;
156+ const writeProjectFile = vi
157+ . fn < WriteProjectFile > ( )
158+ . mockRejectedValueOnce ( new Error ( "Load failed" ) )
159+ . mockRejectedValueOnce ( new Error ( "Failed to fetch" ) )
160+ . mockRejectedValueOnce ( new Error ( "Failed to fetch" ) ) ;
161+ const mounted = await mountEditorSave ( writeProjectFile ) ;
162+
163+ act ( ( ) => mounted . handle . handleContentChange ( "first candidate" ) ) ;
164+ await mounted . handle . flushPendingSave ( ) ;
165+ now . mockReturnValue ( 2_000 ) ;
166+ act ( ( ) => mounted . handle . handleContentChange ( "second candidate" ) ) ;
167+ await mounted . handle . flushPendingSave ( ) ;
168+ now . mockReturnValue ( 8_000 ) ;
169+ act ( ( ) => mounted . handle . handleContentChange ( "third candidate" ) ) ;
170+ await mounted . handle . flushPendingSave ( ) ;
171+
172+ expect ( trackStudioSaveFailure ) . toHaveBeenCalledTimes ( 3 ) ;
173+ await mounted . unmount ( ) ;
174+ } ) ;
175+
176+ it ( "emits the same failure again after a successful save" , async ( ) => {
177+ vi . spyOn ( Date , "now" ) . mockReturnValue ( 1_000 ) ;
178+ const writeProjectFile = vi
179+ . fn < WriteProjectFile > ( )
180+ . mockRejectedValueOnce ( new Error ( "Load failed" ) )
181+ . mockResolvedValueOnce ( undefined )
182+ . mockRejectedValueOnce ( new Error ( "Load failed" ) ) ;
183+ const mounted = await mountEditorSave ( writeProjectFile ) ;
184+
185+ act ( ( ) => mounted . handle . handleContentChange ( "first candidate" ) ) ;
186+ await mounted . handle . flushPendingSave ( ) ;
187+ act ( ( ) => mounted . handle . handleContentChange ( "successful candidate" ) ) ;
188+ await mounted . handle . flushPendingSave ( ) ;
189+ act ( ( ) => mounted . handle . handleContentChange ( "third candidate" ) ) ;
190+ await mounted . handle . flushPendingSave ( ) ;
114191
192+ expect ( trackStudioSaveFailure ) . toHaveBeenCalledTimes ( 2 ) ;
115193 await mounted . unmount ( ) ;
116194 } ) ;
117195
0 commit comments