11import os from 'node:os'
2- import { vi , test , expect , afterEach } from 'vitest'
2+ import path from 'node:path'
3+ import fsp from 'node:fs/promises'
4+ import { vi , test , expect , describe , beforeEach , afterEach } from 'vitest'
35
46import { getDownloadUrl , parseParams , retryFetch } from '../src/utils.js'
7+ import { getBinaryFilename , download } from '../src/install.js'
58
9+ // All vi.mock calls must be at module scope so Vitest hoists them before
10+ // any imports — mocks inside test() bodies are not hoisted and the already-
11+ // bound module closures (e.g. pipeline, unpackTar in install.js) would not
12+ // see the mock.
613vi . mock ( 'node:os' , ( ) => ( {
714 default : {
815 arch : vi . fn ( ) ,
@@ -11,6 +18,43 @@ vi.mock('node:os', () => ({
1118 }
1219} ) )
1320
21+ vi . mock ( 'node:fs/promises' , ( ) => ( {
22+ default : {
23+ access : vi . fn ( ) ,
24+ mkdir : vi . fn ( ) . mockResolvedValue ( undefined ) ,
25+ mkdtemp : vi . fn ( ) ,
26+ rename : vi . fn ( ) . mockResolvedValue ( undefined ) ,
27+ chmod : vi . fn ( ) . mockResolvedValue ( undefined ) ,
28+ rm : vi . fn ( ) . mockResolvedValue ( undefined ) ,
29+ } ,
30+ writeFile : vi . fn ( ) . mockResolvedValue ( undefined ) ,
31+ } ) )
32+
33+ vi . mock ( '../src/utils.js' , async ( original ) => {
34+ const actual : any = await original ( )
35+ return {
36+ ...actual ,
37+ hasAccess : vi . fn ( ) ,
38+ }
39+ } )
40+
41+ vi . mock ( 'node:stream/promises' , ( ) => ( {
42+ pipeline : vi . fn ( ) . mockResolvedValue ( undefined ) ,
43+ } ) )
44+
45+ vi . mock ( 'modern-tar/fs' , ( ) => ( {
46+ unpackTar : vi . fn ( ) . mockReturnValue ( vi . fn ( ) ) ,
47+ } ) )
48+
49+ const zipState = vi . hoisted ( ( ) => ( { entries : [ ] as any [ ] } ) )
50+ vi . mock ( '@zip.js/zip.js' , ( ) => ( {
51+ BlobReader : class { } ,
52+ BlobWriter : class { } ,
53+ ZipReader : class {
54+ getEntries ( ) { return Promise . resolve ( zipState . entries ) }
55+ } ,
56+ } ) )
57+
1458vi . stubGlobal ( 'fetch' , vi . fn ( ) . mockResolvedValue ( {
1559 status : 400 ,
1660 text : ( ) => Promise . resolve ( 'foobar' ) ,
@@ -21,6 +65,23 @@ afterEach(() => {
2165 vi . mocked ( globalThis . fetch ) . mockReset ( )
2266} )
2367
68+ test ( 'getBinaryFilename includes version in the filename' , ( ) => {
69+ vi . mocked ( os . platform ) . mockReturnValue ( 'linux' )
70+ expect ( getBinaryFilename ( '0.36.0' ) ) . toBe ( 'geckodriver-0.36.0' )
71+
72+ vi . mocked ( os . platform ) . mockReturnValue ( 'darwin' )
73+ expect ( getBinaryFilename ( '0.36.0' ) ) . toBe ( 'geckodriver-0.36.0' )
74+
75+ vi . mocked ( os . platform ) . mockReturnValue ( 'win32' )
76+ expect ( getBinaryFilename ( '0.36.0' ) ) . toBe ( 'geckodriver-0.36.0.exe' )
77+ } )
78+
79+ test ( 'getBinaryFilename rejects version strings with path separators or traversal sequences' , ( ) => {
80+ expect ( ( ) => getBinaryFilename ( '../../../evil' ) ) . toThrow ( 'Invalid geckodriver version string' )
81+ expect ( ( ) => getBinaryFilename ( '0.36.0/etc/passwd' ) ) . toThrow ( 'Invalid geckodriver version string' )
82+ expect ( ( ) => getBinaryFilename ( '0.36.0\\evil' ) ) . toThrow ( 'Invalid geckodriver version string' )
83+ } )
84+
2485test ( 'getDownloadUrl' , ( ) => {
2586 vi . mocked ( os . arch ) . mockReturnValue ( 'arm' )
2687 vi . mocked ( os . platform ) . mockReturnValue ( 'linux' )
@@ -42,6 +103,142 @@ test('getDownloadUrl', () => {
42103 expect ( getDownloadUrl ( '0.33.0' ) ) . toMatchSnapshot ( )
43104} )
44105
106+ describe ( 'download caching behaviour' , ( ) => {
107+ const CACHE_DIR = path . resolve ( os . tmpdir ( ) , 'test-cache' )
108+ let hasAccess : ReturnType < typeof vi . fn >
109+
110+ beforeEach ( async ( ) => {
111+ const utils = await import ( '../src/utils.js' )
112+ hasAccess = vi . mocked ( utils . hasAccess )
113+
114+ delete process . env . GECKODRIVER_VERSION
115+ vi . mocked ( os . platform ) . mockReturnValue ( 'linux' )
116+ // default: cache miss
117+ hasAccess . mockResolvedValue ( false )
118+ vi . mocked ( fsp . rename ) . mockClear ( )
119+ vi . mocked ( fsp . chmod ) . mockClear ( )
120+ vi . mocked ( fsp . rm ) . mockClear ( )
121+ vi . mocked ( fsp . mkdtemp ) . mockClear ( )
122+ } )
123+
124+ test ( 'returns cached versioned binary without any network request' , async ( ) => {
125+ vi . mocked ( hasAccess ) . mockResolvedValue ( true ) // cache hit
126+
127+ const result = await download ( '0.36.0' , CACHE_DIR )
128+
129+ expect ( result ) . toBe ( path . resolve ( CACHE_DIR , 'geckodriver-0.36.0' ) )
130+ // zero network requests — the hot path must be purely local
131+ expect ( globalThis . fetch ) . not . toHaveBeenCalled ( )
132+ } )
133+
134+ test ( 'does not fetch Cargo.toml when explicit version is cached' , async ( ) => {
135+ vi . mocked ( hasAccess ) . mockResolvedValue ( true )
136+
137+ await download ( '0.36.0' , CACHE_DIR )
138+
139+ const cargoFetched = vi . mocked ( globalThis . fetch ) . mock . calls
140+ . some ( ( [ url ] ) => String ( url ) . includes ( 'Cargo.toml' ) )
141+ expect ( cargoFetched ) . toBe ( false )
142+ } )
143+
144+ test ( 'resolves latest version from Cargo.toml then hits versioned cache (no binary download)' , async ( ) => {
145+ vi . mocked ( globalThis . fetch ) . mockResolvedValue ( {
146+ status : 200 ,
147+ text : ( ) => Promise . resolve ( 'version = "0.36.0"\nother = "x"' ) ,
148+ } as any )
149+ // after Cargo.toml resolves, the versioned binary is already cached
150+ vi . mocked ( hasAccess ) . mockResolvedValue ( true )
151+
152+ const result = await download ( undefined , CACHE_DIR )
153+
154+ expect ( result ) . toBe ( path . resolve ( CACHE_DIR , 'geckodriver-0.36.0' ) )
155+ // only Cargo.toml fetched — no binary download
156+ expect ( globalThis . fetch ) . toHaveBeenCalledTimes ( 1 )
157+ expect ( vi . mocked ( globalThis . fetch ) . mock . calls [ 0 ] [ 0 ] ) . toContain ( 'Cargo.toml' )
158+ } )
159+
160+ test ( 'extracts into a unique mkdtemp staging dir, then renames to the final versioned path' , async ( ) => {
161+ const stagingDir = path . resolve ( CACHE_DIR , 'geckodriver-AbC123' )
162+ vi . mocked ( fsp . mkdtemp ) . mockResolvedValue ( stagingDir )
163+ vi . mocked ( globalThis . fetch ) . mockResolvedValue ( {
164+ status : 200 ,
165+ body : { } ,
166+ blob : vi . fn ( ) . mockResolvedValue ( new Blob ( [ ] ) ) ,
167+ } as any )
168+
169+ await download ( '0.36.0' , CACHE_DIR ) . catch ( ( ) => { } )
170+
171+ const finalPath = path . resolve ( CACHE_DIR , 'geckodriver-0.36.0' )
172+
173+ // staging dir must be created via mkdtemp (unique per operation), not a
174+ // deterministic path that parallel downloads of the same version share
175+ expect ( fsp . mkdtemp ) . toHaveBeenCalledWith ( path . join ( CACHE_DIR , 'geckodriver-' ) )
176+ // rename must move the extracted binary out of that unique staging dir
177+ expect ( fsp . rename ) . toHaveBeenCalledWith (
178+ path . resolve ( stagingDir , 'geckodriver' ) , // extracted name inside staging dir
179+ finalPath // versioned final destination
180+ )
181+ // staging dir must be cleaned up
182+ expect ( fsp . rm ) . toHaveBeenCalledWith ( stagingDir , { recursive : true , force : true } )
183+ } )
184+
185+ test ( 'concurrent downloads of the same version use isolated staging dirs' , async ( ) => {
186+ // each mkdtemp call returns a distinct directory, mirroring the real OS
187+ let counter = 0
188+ vi . mocked ( fsp . mkdtemp ) . mockImplementation ( async ( prefix ) =>
189+ `${ prefix } ${ ++ counter } `
190+ )
191+ vi . mocked ( globalThis . fetch ) . mockResolvedValue ( {
192+ status : 200 ,
193+ body : { } ,
194+ blob : vi . fn ( ) . mockResolvedValue ( new Blob ( [ ] ) ) ,
195+ } as any )
196+
197+ await Promise . all ( [
198+ download ( '0.36.0' , CACHE_DIR ) . catch ( ( ) => { } ) ,
199+ download ( '0.36.0' , CACHE_DIR ) . catch ( ( ) => { } ) ,
200+ ] )
201+
202+ const stagingDirs = vi . mocked ( fsp . rename ) . mock . calls . map ( ( [ src ] ) => path . dirname ( String ( src ) ) )
203+ expect ( stagingDirs ) . toHaveLength ( 2 )
204+ // the two concurrent operations must not share a staging directory
205+ expect ( stagingDirs [ 0 ] ) . not . toBe ( stagingDirs [ 1 ] )
206+ } )
207+
208+ test ( 'treats EEXIST on the final rename as success when the binary is already present' , async ( ) => {
209+ vi . mocked ( fsp . mkdtemp ) . mockResolvedValue ( path . resolve ( CACHE_DIR , 'geckodriver-xyz' ) )
210+ vi . mocked ( globalThis . fetch ) . mockResolvedValue ( {
211+ status : 200 , body : { } , blob : vi . fn ( ) . mockResolvedValue ( new Blob ( [ ] ) ) ,
212+ } as any )
213+ // cache miss on entry, but after the rename collision the binary exists
214+ hasAccess . mockResolvedValueOnce ( false ) . mockResolvedValue ( true )
215+ const renameErr : any = new Error ( 'exists' )
216+ renameErr . code = 'EEXIST'
217+ vi . mocked ( fsp . rename ) . mockRejectedValueOnce ( renameErr )
218+
219+ const result = await download ( '0.36.0' , CACHE_DIR )
220+
221+ // a concurrent winner produced the binary — must resolve, not throw
222+ expect ( result ) . toBe ( path . resolve ( CACHE_DIR , 'geckodriver-0.36.0' ) )
223+ } )
224+
225+ test ( 'rejects zip entries that escape the staging directory (Zip Slip)' , async ( ) => {
226+ const stagingDir = path . resolve ( CACHE_DIR , 'geckodriver-zip' )
227+ vi . mocked ( os . platform ) . mockReturnValue ( 'win32' ) // .zip download path
228+ vi . mocked ( os . arch ) . mockReturnValue ( 'x64' )
229+ vi . mocked ( fsp . mkdtemp ) . mockResolvedValue ( stagingDir )
230+ vi . mocked ( globalThis . fetch ) . mockResolvedValue ( {
231+ status : 200 , body : { } , blob : vi . fn ( ) . mockResolvedValue ( new Blob ( [ ] ) ) ,
232+ } as any )
233+
234+ zipState . entries = [
235+ { filename : '../../evil.exe' , directory : false , getData : vi . fn ( ) } ,
236+ ]
237+
238+ await expect ( download ( '0.36.0' , CACHE_DIR ) ) . rejects . toThrow ( 'resolves outside the staging directory' )
239+ } )
240+ } )
241+
45242test ( 'download with proxy support' , async ( ) => {
46243 // Ensure hasAccess always returns false so download always calls fetch
47244 vi . mock ( '../src/utils.js' , async ( ) => {
0 commit comments