@@ -2,17 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22
33import {
44 downloadFile ,
5- extractFilenameFromContentDisposition
5+ extractFilenameFromContentDisposition ,
6+ openFileInNewTab
67} from '@/base/common/downloadUtil'
78
8- let mockIsCloud = false
9+ const { mockIsCloud } = vi . hoisted ( ( ) => ( {
10+ mockIsCloud : { value : false }
11+ } ) )
912
1013vi . mock ( '@/platform/distribution/types' , ( ) => ( {
1114 get isCloud ( ) {
12- return mockIsCloud
15+ return mockIsCloud . value
1316 }
1417} ) )
1518
19+ vi . mock ( '@/i18n' , ( ) => ( {
20+ t : ( key : string ) => key
21+ } ) )
22+
23+ vi . mock ( '@/platform/updates/common/toastStore' , ( ) => ( {
24+ useToastStore : vi . fn ( ( ) => ( { addAlert : vi . fn ( ) } ) )
25+ } ) )
26+
1627// Global stubs
1728const createObjectURLSpy = vi
1829 . spyOn ( URL , 'createObjectURL' )
@@ -26,7 +37,7 @@ describe('downloadUtil', () => {
2637 let fetchMock : ReturnType < typeof vi . fn >
2738
2839 beforeEach ( ( ) => {
29- mockIsCloud = false
40+ mockIsCloud . value = false
3041 fetchMock = vi . fn ( )
3142 vi . stubGlobal ( 'fetch' , fetchMock )
3243 createObjectURLSpy . mockClear ( ) . mockReturnValue ( 'blob:mock-url' )
@@ -154,7 +165,7 @@ describe('downloadUtil', () => {
154165 } )
155166
156167 it ( 'streams downloads via blob when running in cloud' , async ( ) => {
157- mockIsCloud = true
168+ mockIsCloud . value = true
158169 const testUrl = 'https://storage.googleapis.com/bucket/file.bin'
159170 const blob = new Blob ( [ 'test' ] )
160171 const blobFn = vi . fn ( ) . mockResolvedValue ( blob )
@@ -173,6 +184,7 @@ describe('downloadUtil', () => {
173184 expect ( fetchMock ) . toHaveBeenCalledWith ( testUrl )
174185 const fetchPromise = fetchMock . mock . results [ 0 ] . value as Promise < Response >
175186 await fetchPromise
187+ await Promise . resolve ( ) // let fetchAsBlob return
176188 const blobPromise = blobFn . mock . results [ 0 ] . value as Promise < Blob >
177189 await blobPromise
178190 await Promise . resolve ( )
@@ -183,7 +195,7 @@ describe('downloadUtil', () => {
183195 } )
184196
185197 it ( 'logs an error when cloud fetch fails' , async ( ) => {
186- mockIsCloud = true
198+ mockIsCloud . value = true
187199 const testUrl = 'https://storage.googleapis.com/bucket/missing.bin'
188200 const consoleSpy = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } )
189201 fetchMock . mockResolvedValue ( {
@@ -197,14 +209,15 @@ describe('downloadUtil', () => {
197209 expect ( fetchMock ) . toHaveBeenCalledWith ( testUrl )
198210 const fetchPromise = fetchMock . mock . results [ 0 ] . value as Promise < Response >
199211 await fetchPromise
200- await Promise . resolve ( )
212+ await Promise . resolve ( ) // let fetchAsBlob throw
213+ await Promise . resolve ( ) // let .catch handler run
201214 expect ( consoleSpy ) . toHaveBeenCalled ( )
202215 expect ( createObjectURLSpy ) . not . toHaveBeenCalled ( )
203216 consoleSpy . mockRestore ( )
204217 } )
205218
206219 it ( 'uses filename from Content-Disposition header in cloud mode' , async ( ) => {
207- mockIsCloud = true
220+ mockIsCloud . value = true
208221 const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
209222 const blob = new Blob ( [ 'test' ] )
210223 const blobFn = vi . fn ( ) . mockResolvedValue ( blob )
@@ -223,6 +236,7 @@ describe('downloadUtil', () => {
223236 expect ( fetchMock ) . toHaveBeenCalledWith ( testUrl )
224237 const fetchPromise = fetchMock . mock . results [ 0 ] . value as Promise < Response >
225238 await fetchPromise
239+ await Promise . resolve ( ) // let fetchAsBlob return
226240 const blobPromise = blobFn . mock . results [ 0 ] . value as Promise < Blob >
227241 await blobPromise
228242 await Promise . resolve ( )
@@ -231,7 +245,7 @@ describe('downloadUtil', () => {
231245 } )
232246
233247 it ( 'uses RFC 5987 filename from Content-Disposition header' , async ( ) => {
234- mockIsCloud = true
248+ mockIsCloud . value = true
235249 const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
236250 const blob = new Blob ( [ 'test' ] )
237251 const blobFn = vi . fn ( ) . mockResolvedValue ( blob )
@@ -253,14 +267,15 @@ describe('downloadUtil', () => {
253267
254268 const fetchPromise = fetchMock . mock . results [ 0 ] . value as Promise < Response >
255269 await fetchPromise
270+ await Promise . resolve ( ) // let fetchAsBlob return
256271 const blobPromise = blobFn . mock . results [ 0 ] . value as Promise < Blob >
257272 await blobPromise
258273 await Promise . resolve ( )
259274 expect ( mockLink . download ) . toBe ( '中文.png' )
260275 } )
261276
262277 it ( 'falls back to provided filename when Content-Disposition is missing' , async ( ) => {
263- mockIsCloud = true
278+ mockIsCloud . value = true
264279 const testUrl = 'https://storage.googleapis.com/bucket/abc123.png'
265280 const blob = new Blob ( [ 'test' ] )
266281 const blobFn = vi . fn ( ) . mockResolvedValue ( blob )
@@ -278,13 +293,107 @@ describe('downloadUtil', () => {
278293
279294 const fetchPromise = fetchMock . mock . results [ 0 ] . value as Promise < Response >
280295 await fetchPromise
296+ await Promise . resolve ( ) // let fetchAsBlob return
281297 const blobPromise = blobFn . mock . results [ 0 ] . value as Promise < Blob >
282298 await blobPromise
283299 await Promise . resolve ( )
284300 expect ( mockLink . download ) . toBe ( 'my-fallback.png' )
285301 } )
286302 } )
287303
304+ describe ( 'openFileInNewTab' , ( ) => {
305+ let windowOpenSpy : ReturnType < typeof vi . spyOn >
306+
307+ beforeEach ( ( ) => {
308+ vi . useFakeTimers ( )
309+ windowOpenSpy = vi . spyOn ( window , 'open' ) . mockImplementation ( ( ) => null )
310+ } )
311+
312+ afterEach ( ( ) => {
313+ vi . useRealTimers ( )
314+ } )
315+
316+ it ( 'opens URL directly when not in cloud mode' , async ( ) => {
317+ mockIsCloud . value = false
318+ const testUrl = 'https://example.com/image.png'
319+
320+ await openFileInNewTab ( testUrl )
321+
322+ expect ( windowOpenSpy ) . toHaveBeenCalledWith ( testUrl , '_blank' )
323+ expect ( fetchMock ) . not . toHaveBeenCalled ( )
324+ } )
325+
326+ it ( 'opens blank tab synchronously then navigates to blob URL in cloud mode' , async ( ) => {
327+ mockIsCloud . value = true
328+ const testUrl = 'https://storage.googleapis.com/bucket/image.png'
329+ const blob = new Blob ( [ 'test' ] , { type : 'image/png' } )
330+ const mockTab = { location : { href : '' } , closed : false , close : vi . fn ( ) }
331+ windowOpenSpy . mockReturnValue ( mockTab as unknown as Window )
332+ fetchMock . mockResolvedValue ( {
333+ ok : true ,
334+ blob : vi . fn ( ) . mockResolvedValue ( blob )
335+ } as unknown as Response )
336+
337+ await openFileInNewTab ( testUrl )
338+
339+ expect ( windowOpenSpy ) . toHaveBeenCalledWith ( '' , '_blank' )
340+ expect ( fetchMock ) . toHaveBeenCalledWith ( testUrl )
341+ expect ( createObjectURLSpy ) . toHaveBeenCalledWith ( blob )
342+ expect ( mockTab . location . href ) . toBe ( 'blob:mock-url' )
343+ } )
344+
345+ it ( 'revokes blob URL after timeout in cloud mode' , async ( ) => {
346+ mockIsCloud . value = true
347+ const blob = new Blob ( [ 'test' ] , { type : 'image/png' } )
348+ const mockTab = { location : { href : '' } , closed : false , close : vi . fn ( ) }
349+ windowOpenSpy . mockReturnValue ( mockTab as unknown as Window )
350+ fetchMock . mockResolvedValue ( {
351+ ok : true ,
352+ blob : vi . fn ( ) . mockResolvedValue ( blob )
353+ } as unknown as Response )
354+
355+ await openFileInNewTab ( 'https://example.com/image.png' )
356+
357+ expect ( revokeObjectURLSpy ) . not . toHaveBeenCalled ( )
358+ vi . advanceTimersByTime ( 60_000 )
359+ expect ( revokeObjectURLSpy ) . toHaveBeenCalledWith ( 'blob:mock-url' )
360+ } )
361+
362+ it ( 'closes blank tab and logs error when cloud fetch fails' , async ( ) => {
363+ mockIsCloud . value = true
364+ const testUrl = 'https://storage.googleapis.com/bucket/missing.png'
365+ const consoleSpy = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } )
366+ const mockTab = { location : { href : '' } , closed : false , close : vi . fn ( ) }
367+ windowOpenSpy . mockReturnValue ( mockTab as unknown as Window )
368+ fetchMock . mockResolvedValue ( {
369+ ok : false ,
370+ status : 404
371+ } as unknown as Response )
372+
373+ await openFileInNewTab ( testUrl )
374+
375+ expect ( mockTab . close ) . toHaveBeenCalled ( )
376+ expect ( consoleSpy ) . toHaveBeenCalled ( )
377+ consoleSpy . mockRestore ( )
378+ } )
379+
380+ it ( 'revokes blob URL immediately if tab was closed by user' , async ( ) => {
381+ mockIsCloud . value = true
382+ const blob = new Blob ( [ 'test' ] , { type : 'image/png' } )
383+ const mockTab = { location : { href : '' } , closed : true , close : vi . fn ( ) }
384+ windowOpenSpy . mockReturnValue ( mockTab as unknown as Window )
385+ fetchMock . mockResolvedValue ( {
386+ ok : true ,
387+ blob : vi . fn ( ) . mockResolvedValue ( blob )
388+ } as unknown as Response )
389+
390+ await openFileInNewTab ( 'https://example.com/image.png' )
391+
392+ expect ( revokeObjectURLSpy ) . toHaveBeenCalledWith ( 'blob:mock-url' )
393+ expect ( mockTab . location . href ) . toBe ( '' )
394+ } )
395+ } )
396+
288397 describe ( 'extractFilenameFromContentDisposition' , ( ) => {
289398 it ( 'returns null for null header' , ( ) => {
290399 expect ( extractFilenameFromContentDisposition ( null ) ) . toBeNull ( )
0 commit comments