11import { OperativeSystem } from '../types/download.types'
2- import { FALLBACK_LOADER_HOLD_MS , streamOrFallback } from './streamOrFallback'
2+ import {
3+ ESTIMATE_MAX_HOLD_MS ,
4+ ESTIMATE_MIN_HOLD_MS ,
5+ FALLBACK_LOADER_HOLD_MS ,
6+ estimateDownloadHoldMs ,
7+ streamOrFallback
8+ } from './streamOrFallback'
39
410const mockTriggerFileDownload = jest . fn ( )
511const mockDownloadFileWithProgress = jest . fn ( )
@@ -9,20 +15,136 @@ jest.mock('./file', () => ({
915 downloadFileWithProgress : ( ...args : unknown [ ] ) => mockDownloadFileWithProgress ( ...args )
1016} ) )
1117
18+ const buildHeadResponse = ( contentLength : string | null , ok = true ) : Response =>
19+ ( {
20+ ok,
21+ headers : { get : ( name : string ) => ( name . toLowerCase ( ) === 'content-length' ? contentLength : null ) }
22+ } ) as unknown as Response
23+
24+ const setNavigatorConnection = ( downlink : number | undefined ) : void => {
25+ Object . defineProperty ( window . navigator , 'connection' , {
26+ configurable : true ,
27+ value : downlink !== undefined ? { downlink } : undefined
28+ } )
29+ }
30+
31+ const clearNavigatorConnection = ( ) : void => {
32+ Object . defineProperty ( window . navigator , 'connection' , { configurable : true , value : undefined } )
33+ }
34+
35+ describe ( 'estimateDownloadHoldMs' , ( ) => {
36+ let mockFetch : jest . Mock
37+
38+ beforeEach ( ( ) => {
39+ mockFetch = jest . fn ( )
40+ global . fetch = mockFetch as unknown as typeof fetch
41+ } )
42+
43+ afterEach ( ( ) => {
44+ jest . resetAllMocks ( )
45+ clearNavigatorConnection ( )
46+ } )
47+
48+ describe ( 'when the HEAD request returns a Content-Length and the browser exposes downlink' , ( ) => {
49+ beforeEach ( ( ) => {
50+ // 80 MB in bytes — the macOS DMG ballpark.
51+ mockFetch . mockResolvedValue ( buildHeadResponse ( String ( 80 * 1024 * 1024 ) ) )
52+ } )
53+
54+ it ( 'should compute a hold proportional to size / downlink with the safety margin applied' , async ( ) => {
55+ // 100 Mbps → 80MB ≈ 6.4s pure transfer + safety margin (~1.5s) → ~7.9s.
56+ setNavigatorConnection ( 100 )
57+ const ms = await estimateDownloadHoldMs ( 'https://example.com/file.dmg' )
58+ expect ( ms ) . toBeGreaterThanOrEqual ( 7000 )
59+ expect ( ms ) . toBeLessThanOrEqual ( 9000 )
60+ } )
61+
62+ it ( 'should produce a longer hold for a slower connection at the same size' , async ( ) => {
63+ // 5 Mbps → 80MB ≈ 128s pure transfer, way over ESTIMATE_MAX_HOLD_MS.
64+ setNavigatorConnection ( 5 )
65+ const ms = await estimateDownloadHoldMs ( 'https://example.com/file.dmg' )
66+ expect ( ms ) . toBe ( ESTIMATE_MAX_HOLD_MS )
67+ } )
68+ } )
69+
70+ describe ( 'when the HEAD request fails' , ( ) => {
71+ it ( 'should return the minimum hold instead of guessing' , async ( ) => {
72+ mockFetch . mockRejectedValue ( new TypeError ( 'CORS blocked' ) )
73+ setNavigatorConnection ( 50 )
74+
75+ const ms = await estimateDownloadHoldMs ( 'https://example.com/file.dmg' )
76+
77+ expect ( ms ) . toBe ( ESTIMATE_MIN_HOLD_MS )
78+ } )
79+ } )
80+
81+ describe ( 'when the HEAD response is non-2xx (403, 405, etc.)' , ( ) => {
82+ it ( 'should treat it like a missing Content-Length and return the minimum hold' , async ( ) => {
83+ mockFetch . mockResolvedValue ( buildHeadResponse ( String ( 80 * 1024 * 1024 ) , false ) )
84+ setNavigatorConnection ( 50 )
85+
86+ const ms = await estimateDownloadHoldMs ( 'https://example.com/file.dmg' )
87+
88+ expect ( ms ) . toBe ( ESTIMATE_MIN_HOLD_MS )
89+ } )
90+ } )
91+
92+ describe ( 'when the HEAD response omits Content-Length' , ( ) => {
93+ it ( 'should return the minimum hold' , async ( ) => {
94+ mockFetch . mockResolvedValue ( buildHeadResponse ( null ) )
95+ setNavigatorConnection ( 50 )
96+
97+ const ms = await estimateDownloadHoldMs ( 'https://example.com/file.dmg' )
98+
99+ expect ( ms ) . toBe ( ESTIMATE_MIN_HOLD_MS )
100+ } )
101+ } )
102+
103+ describe ( 'when the browser does not expose navigator.connection (Safari, Firefox)' , ( ) => {
104+ it ( 'should fall back to the default downlink and still produce an adaptive hold' , async ( ) => {
105+ // 10 MB at the default 25 Mbps ≈ 3.2s pure transfer + safety margin.
106+ // Sized to land between MIN and MAX so the assertion exercises the
107+ // adaptive arithmetic rather than the clamps.
108+ mockFetch . mockResolvedValue ( buildHeadResponse ( String ( 10 * 1024 * 1024 ) ) )
109+ clearNavigatorConnection ( )
110+
111+ const ms = await estimateDownloadHoldMs ( 'https://example.com/file.dmg' )
112+
113+ expect ( ms ) . toBeGreaterThan ( ESTIMATE_MIN_HOLD_MS )
114+ expect ( ms ) . toBeLessThan ( ESTIMATE_MAX_HOLD_MS )
115+ } )
116+ } )
117+
118+ describe ( 'when the file is tiny' , ( ) => {
119+ it ( 'should clamp to the minimum hold so the modal does not vanish instantly' , async ( ) => {
120+ mockFetch . mockResolvedValue ( buildHeadResponse ( '512' ) ) // 512 bytes
121+ setNavigatorConnection ( 100 )
122+
123+ const ms = await estimateDownloadHoldMs ( 'https://example.com/file.dmg' )
124+
125+ expect ( ms ) . toBe ( ESTIMATE_MIN_HOLD_MS )
126+ } )
127+ } )
128+ } )
129+
12130describe ( 'streamOrFallback' , ( ) => {
13131 let abortController : AbortController
14132 let onProgress : jest . Mock
133+ let mockFetch : jest . Mock
15134
16135 beforeEach ( ( ) => {
17- jest . useFakeTimers ( )
136+ jest . useFakeTimers ( { doNotFake : [ 'performance' , 'Date' ] } )
18137 abortController = new AbortController ( )
19138 onProgress = jest . fn ( )
139+ mockFetch = jest . fn ( ) . mockResolvedValue ( buildHeadResponse ( null ) )
140+ global . fetch = mockFetch as unknown as typeof fetch
20141 } )
21142
22143 afterEach ( ( ) => {
23144 jest . runOnlyPendingTimers ( )
24145 jest . useRealTimers ( )
25146 jest . resetAllMocks ( )
147+ clearNavigatorConnection ( )
26148 } )
27149
28150 describe ( 'when the OS is macOS' , ( ) => {
@@ -35,14 +157,32 @@ describe('streamOrFallback', () => {
35157 onProgress
36158 } )
37159
38- jest . advanceTimersByTime ( FALLBACK_LOADER_HOLD_MS )
160+ // Drain the HEAD round-trip and the timer.
161+ await jest . advanceTimersByTimeAsync ( ESTIMATE_MAX_HOLD_MS )
39162 await promise
40163
41164 expect ( mockTriggerFileDownload ) . toHaveBeenCalledWith ( 'https://example.com/file.dmg' , 'Decentraland-Installer.dmg' )
42165 expect ( mockDownloadFileWithProgress ) . not . toHaveBeenCalled ( )
43166 } )
44167
45- it ( 'should hold the backdrop for FALLBACK_LOADER_HOLD_MS before resolving' , async ( ) => {
168+ it ( 'should HEAD the URL to get Content-Length for the adaptive estimate' , async ( ) => {
169+ const promise = streamOrFallback ( {
170+ url : 'https://example.com/file.dmg' ,
171+ filename : 'Decentraland-Installer.dmg' ,
172+ os : OperativeSystem . MACOS ,
173+ signal : abortController . signal ,
174+ onProgress
175+ } )
176+
177+ await jest . advanceTimersByTimeAsync ( ESTIMATE_MAX_HOLD_MS )
178+ await promise
179+
180+ expect ( mockFetch ) . toHaveBeenCalledWith ( 'https://example.com/file.dmg' , expect . objectContaining ( { method : 'HEAD' } ) )
181+ } )
182+
183+ it ( 'should hold for at least ESTIMATE_MIN_HOLD_MS when HEAD provides no size signal' , async ( ) => {
184+ mockFetch . mockResolvedValue ( buildHeadResponse ( null ) )
185+
46186 let resolved = false
47187 streamOrFallback ( {
48188 url : 'https://example.com/file.dmg' ,
@@ -54,12 +194,41 @@ describe('streamOrFallback', () => {
54194 resolved = true
55195 } )
56196
57- // Drain the microtask that schedules the timer, then advance just
58- // below the threshold — the hold should still be pending.
59- await jest . advanceTimersByTimeAsync ( FALLBACK_LOADER_HOLD_MS - 1 )
197+ await jest . advanceTimersByTimeAsync ( ESTIMATE_MIN_HOLD_MS - 1 )
60198 expect ( resolved ) . toBe ( false )
61199
62- await jest . advanceTimersByTimeAsync ( 1 )
200+ await jest . advanceTimersByTimeAsync ( 2 )
201+ expect ( resolved ) . toBe ( true )
202+ } )
203+
204+ it ( 'should resolve promptly when the signal aborts mid-hold' , async ( ) => {
205+ // Long estimated hold so the test exercises a real wait window:
206+ // 80 MB at 5 Mbps caps at MAX_HOLD_MS.
207+ mockFetch . mockResolvedValue ( buildHeadResponse ( String ( 80 * 1024 * 1024 ) ) )
208+ setNavigatorConnection ( 5 )
209+
210+ let resolved = false
211+ streamOrFallback ( {
212+ url : 'https://example.com/file.dmg' ,
213+ filename : 'Decentraland-Installer.dmg' ,
214+ os : OperativeSystem . MACOS ,
215+ signal : abortController . signal ,
216+ onProgress
217+ } ) . then ( ( ) => {
218+ resolved = true
219+ } )
220+
221+ // Let the HEAD resolve and the sleep get scheduled, then verify we are
222+ // mid-hold (not yet resolved).
223+ await jest . advanceTimersByTimeAsync ( ESTIMATE_MIN_HOLD_MS )
224+ expect ( resolved ) . toBe ( false )
225+
226+ // Abort while still inside the hold.
227+ abortController . abort ( )
228+ await jest . advanceTimersByTimeAsync ( 0 )
229+
230+ // Should resolve now without needing to advance through the rest of
231+ // the long hold.
63232 expect ( resolved ) . toBe ( true )
64233 } )
65234 } )
@@ -158,10 +327,7 @@ describe('streamOrFallback', () => {
158327 onProgress
159328 } )
160329
161- // Drain the rejected fetch promise, then advance through the hold.
162- await Promise . resolve ( )
163- await Promise . resolve ( )
164- jest . advanceTimersByTime ( FALLBACK_LOADER_HOLD_MS )
330+ await jest . advanceTimersByTimeAsync ( FALLBACK_LOADER_HOLD_MS )
165331 await promise
166332
167333 expect ( mockTriggerFileDownload ) . toHaveBeenCalledWith ( 'https://example.com/file.exe' , 'Decentraland-Installer.exe' )
0 commit comments