@@ -15,18 +15,38 @@ import {
1515 dismissFailedSnapShot ,
1616 resolveExistingSnapShotTarget ,
1717 resolveSnapShotTargetOnce ,
18+ resolveSnapShotDeliveryTarget ,
1819} from "./SnapShotCoordinator" ;
1920import {
2021 beginSnapShotAnimation ,
2122 dismissAllSnapShotAnimations ,
2223 getPendingSnapShotAnimations ,
2324 setSnapShotAnimationDestination ,
25+ scheduleSnapShotAnimationDestination ,
2426} from "../../lib/snapShotAnimation" ;
2527
28+ const storage = vi . hoisted ( ( ) => {
29+ const values = new Map < string , string > ( ) ;
30+ const storage = {
31+ getItem : ( key : string ) => values . get ( key ) ?? null ,
32+ setItem : vi . fn ( ( key : string , value : string ) => {
33+ values . set ( key , value ) ;
34+ } ) ,
35+ removeItem : ( key : string ) => {
36+ values . delete ( key ) ;
37+ } ,
38+ clear : ( ) => values . clear ( ) ,
39+ } ;
40+ vi . stubGlobal ( "localStorage" , storage ) ;
41+ return storage ;
42+ } ) ;
43+
2644const environmentId = EnvironmentId . make ( "snap-shot-environment" ) ;
2745const projectRef = scopeProjectRef ( environmentId , ProjectId . make ( "snap-shot-project" ) ) ;
2846
2947beforeEach ( ( ) => {
48+ storage . clear ( ) ;
49+ vi . stubGlobal ( "localStorage" , storage ) ;
3050 useComposerDraftStore . setState ( {
3151 draftsByThreadKey : { } ,
3252 draftThreadsByThreadKey : { } ,
@@ -49,6 +69,7 @@ describe("window capture failures", () => {
4969 const soundedIds = new Set ( [ "older" , "newer" ] ) ;
5070 const dismissSnapShotAnimation = vi . fn ( async ( ) => undefined ) ;
5171 vi . stubGlobal ( "window" , {
72+ localStorage : storage ,
5273 desktopBridge : {
5374 requestSnapShotPermissions : vi . fn ( ) ,
5475 getSnapShotState : vi . fn ( ) ,
@@ -170,6 +191,7 @@ describe("window capture delivery", () => {
170191 } ,
171192 } ;
172193 vi . stubGlobal ( "window" , {
194+ localStorage : storage ,
173195 desktopBridge : bridge ,
174196 setTimeout,
175197 clearTimeout,
@@ -268,3 +290,93 @@ describe("window capture target resolution", () => {
268290 expect ( resolveExistingSnapShotTarget ( routeThreadRef , routeThreadRef ) ) . toEqual ( routeThreadRef ) ;
269291 } ) ;
270292} ) ;
293+
294+ describe ( "durable snapshot delivery" , ( ) => {
295+ it . each ( [ false , true ] ) (
296+ "retains a capture on quota failure and retries without duplicates (staged: %s)" ,
297+ async ( staged ) => {
298+ const target = scopeThreadRef ( environmentId , ThreadId . make ( "quota-thread" ) ) ;
299+ const capture = {
300+ id : "12345678-1234-1234-1234-123456789abc" ,
301+ name : "window.png" ,
302+ mimeType : "image/png" as const ,
303+ sizeBytes : 3 ,
304+ dataUrl : "data:image/png;base64,AQID" ,
305+ source : {
306+ kind : "snap-shot" as const ,
307+ capturedAt : "2026-09-01T00:00:00.000Z" ,
308+ appName : "Editor" ,
309+ windowTitle : "main.ts" ,
310+ } ,
311+ } ;
312+ const acknowledgeSnapShot = vi . fn ( async ( ) => undefined ) ;
313+ const bridge = {
314+ readSnapShot : async ( ) => capture ,
315+ acknowledgeSnapShot,
316+ } as unknown as DesktopSnapShotBridge ;
317+ vi . stubGlobal ( "window" , { localStorage : storage , dispatchEvent : vi . fn ( ) } ) ;
318+ const write = storage . setItem . getMockImplementation ( ) ! ;
319+ storage . setItem . mockImplementation ( ( ) => {
320+ throw new Error ( "QuotaExceededError" ) ;
321+ } ) ;
322+ try {
323+ if ( staged ) {
324+ const store = useComposerDraftStore . getState ( ) ;
325+ store . addImage ( target , {
326+ type : "image" ,
327+ ...capture ,
328+ previewUrl : capture . dataUrl ,
329+ file : new File ( [ new Uint8Array ( [ 1 , 2 , 3 ] ) ] , capture . name , { type : capture . mimeType } ) ,
330+ } ) ;
331+ void store . syncPersistedAttachments ( target , [ capture ] ) ;
332+ }
333+ await expect ( deliverSnapShot ( bridge , capture , target ) ) . rejects . toThrow (
334+ "could not be saved" ,
335+ ) ;
336+ expect ( acknowledgeSnapShot ) . not . toHaveBeenCalled ( ) ;
337+ expect (
338+ useComposerDraftStore . getState ( ) . getComposerDraft ( target ) ?. nonPersistedImageIds ,
339+ ) . toContain ( capture . id ) ;
340+ storage . setItem . mockImplementation ( write ) ;
341+ await deliverSnapShot ( bridge , capture , target ) ;
342+ expect ( acknowledgeSnapShot ) . toHaveBeenCalledExactlyOnceWith ( capture . id ) ;
343+ expect ( useComposerDraftStore . getState ( ) . getComposerDraft ( target ) ?. images ) . toHaveLength ( 1 ) ;
344+ expect (
345+ useComposerDraftStore . getState ( ) . getComposerDraft ( target ) ?. persistedAttachments ,
346+ ) . toHaveLength ( 1 ) ;
347+ } finally {
348+ storage . setItem . mockImplementation ( write ) ;
349+ }
350+ } ,
351+ ) ;
352+ } ) ;
353+
354+ describe ( "snapshot destination ownership" , ( ) => {
355+ it . each ( [ "unmount" , "blur" , "disabled animations" ] ) (
356+ "keeps the original environment and thread after %s" ,
357+ async ( reason ) => {
358+ const original = scopeThreadRef ( environmentId , ThreadId . make ( "original" ) ) ;
359+ const next = scopeThreadRef ( EnvironmentId . make ( "another-environment" ) , ThreadId . make ( "next" ) ) ;
360+ const targets = new Map < string , Promise < typeof original | null > > ( ) ;
361+ let current = original ;
362+ const resolveTarget = async ( ) => current ;
363+ const requested = resolveSnapShotDeliveryTarget ( targets , "capture" , resolveTarget ) ;
364+ if ( reason !== "disabled animations" ) {
365+ beginSnapShotAnimation ( "capture" , original ) ;
366+ if ( reason === "unmount" ) {
367+ const unmount = scheduleSnapShotAnimationDestination ( "capture" , ( ) => undefined ) ;
368+ unmount ( ) ;
369+ } else dismissAllSnapShotAnimations ( ) ;
370+ }
371+ current = next ;
372+ await requested ;
373+ expect ( getPendingSnapShotAnimations ( ) ) . toHaveLength ( 0 ) ;
374+ expect ( await resolveSnapShotDeliveryTarget ( targets , "capture" , resolveTarget ) ) . toEqual (
375+ original ,
376+ ) ;
377+ expect ( await resolveSnapShotDeliveryTarget ( targets , "later-capture" , resolveTarget ) ) . toEqual (
378+ next ,
379+ ) ;
380+ } ,
381+ ) ;
382+ } ) ;
0 commit comments