1- import { mkdtempSync , writeFileSync } from 'node:fs'
1+ import { existsSync , mkdirSync , mkdtempSync , readdirSync , writeFileSync } from 'node:fs'
22import { tmpdir } from 'node:os'
3- import { join } from 'node:path'
3+ import { basename , dirname , join } from 'node:path'
44import type { MenuItemConstructorOptions , WebContents } from 'electron'
55import { beforeEach , describe , expect , it , vi } from 'vitest'
66
@@ -304,11 +304,32 @@ function mockDownloadItem({
304304 const handler = item . once . mock . calls . find ( ( [ eventName ] ) => eventName === 'done' ) ?. [ 1 ] as
305305 | ( ( event : unknown , nextState : MockDownloadDoneState ) => void )
306306 | undefined
307+ const savePath = item . setSavePath . mock . calls . at ( - 1 ) ?. [ 0 ] as string | undefined
308+ if ( state === 'completed' && savePath ) writeFileSync ( savePath , filename )
307309 handler ?.( { } , state )
308310 } ,
309311 }
310312}
311313
314+ /** Asserts Electron was only ever given the hidden staging file in `directory`. */
315+ function expectOnlyStagingSavePath (
316+ item : { setSavePath : ReturnType < typeof vi . fn > } ,
317+ directory : string
318+ ) {
319+ expect ( item . setSavePath ) . toHaveBeenCalledOnce ( )
320+ const savePath = item . setSavePath . mock . calls [ 0 ] ?. [ 0 ] as string
321+ expect ( dirname ( savePath ) ) . toBe ( directory )
322+ expect ( basename ( savePath ) ) . toMatch ( / ^ \. s i m - d o w n l o a d - / )
323+ return savePath
324+ }
325+
326+ /** Visible files in a download directory, excluding in-flight staging files. */
327+ function finishedDownloadFiles ( directory : string ) : string [ ] {
328+ return readdirSync ( directory )
329+ . filter ( ( name ) => ! name . startsWith ( '.' ) )
330+ . sort ( )
331+ }
332+
312333function startMockDownload ( contents : MockView [ 'webContents' ] , download : MockDownloadHarness ) : void {
313334 const webSession = contents . session as typeof contents . session & {
314335 on : ReturnType < typeof vi . fn >
@@ -3357,7 +3378,7 @@ describe('browser-agent session', () => {
33573378 expect ( item . resume ) . not . toHaveBeenCalled ( )
33583379 await vi . waitFor ( ( ) => expect ( item . resume ) . toHaveBeenCalledOnce ( ) )
33593380 expect ( item . cancel ) . not . toHaveBeenCalled ( )
3360- expect ( item . setSavePath ) . toHaveBeenCalledWith ( join ( directory , 'report.csv' ) )
3381+ const stagingPath = expectOnlyStagingSavePath ( item , directory )
33613382 expect ( item . once ) . toHaveBeenCalledWith ( 'done' , expect . any ( Function ) )
33623383 expect ( onDownloadsChanged ) . toHaveBeenLastCalledWith ( {
33633384 scopeId : 'chat-test' ,
@@ -3375,13 +3396,17 @@ describe('browser-agent session', () => {
33753396 const done = item . once . mock . calls . find ( ( [ eventName ] ) => eventName === 'done' ) ?. [ 1 ] as
33763397 | ( ( event : unknown , state : 'completed' ) => void )
33773398 | undefined
3378- writeFileSync ( join ( directory , 'report.csv' ) , 'report' )
3399+ writeFileSync ( stagingPath , 'report' )
33793400 done ?.( { } , 'completed' )
33803401
3381- expect ( session . getBrowserDownloadsState ( 'chat-test' ) . downloads [ 0 ] ) . toMatchObject ( {
3382- filename : 'report.csv' ,
3383- state : 'completed' ,
3384- } )
3402+ await vi . waitFor ( ( ) =>
3403+ expect ( session . getBrowserDownloadsState ( 'chat-test' ) . downloads [ 0 ] ) . toMatchObject ( {
3404+ filename : 'report.csv' ,
3405+ state : 'completed' ,
3406+ } )
3407+ )
3408+ expect ( existsSync ( stagingPath ) ) . toBe ( false )
3409+ expect ( finishedDownloadFiles ( directory ) ) . toEqual ( [ 'report.csv' ] )
33853410 expect ( snapshots . get ( 'chat-test' ) ?. downloads [ 0 ] ) . toMatchObject ( {
33863411 filename : 'report.csv' ,
33873412 state : 'completed' ,
@@ -3410,6 +3435,131 @@ describe('browser-agent session', () => {
34103435 } )
34113436 } )
34123437
3438+ it ( 'sets a staging save path before will-download returns so Electron never prompts' , ( ) => {
3439+ const directory = mkdtempSync ( join ( tmpdir ( ) , 'sim-browser-downloads-' ) )
3440+ session = freshSession ( win , { } , undefined , {
3441+ getDirectory : ( ) => directory ,
3442+ getFreeDiskBytes : ( ) => Number . MAX_SAFE_INTEGER ,
3443+ } )
3444+ const contents = ( session . ensureTab ( ) . view as unknown as MockView ) . webContents
3445+ const download = mockDownloadItem ( { filename : 'report.csv' , totalBytes : 100 } )
3446+
3447+ startMockDownload ( contents , download )
3448+
3449+ const stagingPath = expectOnlyStagingSavePath ( download . item , directory )
3450+ expect ( stagingPath ) . not . toBe ( join ( directory , 'report.csv' ) )
3451+ expect ( download . item . cancel ) . not . toHaveBeenCalled ( )
3452+ } )
3453+
3454+ it ( 'waits for a slow filename allocation before publishing the completed file' , async ( ) => {
3455+ const directory = mkdtempSync ( join ( tmpdir ( ) , 'sim-browser-downloads-' ) )
3456+ const { persistence, snapshots } = memoryBrowserPersistence ( )
3457+ const pathProbe = deferred < boolean > ( )
3458+ session = freshSession ( win , { } , persistence , {
3459+ getDirectory : ( ) => directory ,
3460+ getFreeDiskBytes : ( ) => Number . MAX_SAFE_INTEGER ,
3461+ pathExists : ( ) => pathProbe . promise ,
3462+ } )
3463+ const contents = ( session . ensureTab ( ) . view as unknown as MockView ) . webContents
3464+ const download = mockDownloadItem ( { filename : 'slow.bin' , totalBytes : 100 } )
3465+
3466+ startMockDownload ( contents , download )
3467+ const stagingPath = expectOnlyStagingSavePath ( download . item , directory )
3468+ download . emitDone ( 'completed' )
3469+ await Promise . resolve ( )
3470+
3471+ expect ( session . getBrowserDownloadsState ( 'chat-test' ) . downloads [ 0 ] ) . toMatchObject ( {
3472+ filename : 'slow.bin' ,
3473+ state : 'progressing' ,
3474+ } )
3475+ expect ( existsSync ( stagingPath ) ) . toBe ( true )
3476+
3477+ pathProbe . resolve ( false )
3478+
3479+ await vi . waitFor ( ( ) =>
3480+ expect ( session . getBrowserDownloadsState ( 'chat-test' ) . downloads [ 0 ] ) . toMatchObject ( {
3481+ filename : 'slow.bin' ,
3482+ state : 'completed' ,
3483+ } )
3484+ )
3485+ expect ( existsSync ( stagingPath ) ) . toBe ( false )
3486+ expect ( finishedDownloadFiles ( directory ) ) . toEqual ( [ 'slow.bin' ] )
3487+ const { id } = session . getBrowserDownloadsState ( 'chat-test' ) . downloads [ 0 ]
3488+ expect ( session . completedBrowserDownload ( 'chat-test' , id ) ) . toEqual ( {
3489+ filename : 'slow.bin' ,
3490+ savePath : join ( directory , 'slow.bin' ) ,
3491+ } )
3492+ expect ( snapshots . get ( 'chat-test' ) ?. downloads [ 0 ] ) . toMatchObject ( {
3493+ state : 'completed' ,
3494+ savePath : join ( directory , 'slow.bin' ) ,
3495+ } )
3496+ } )
3497+
3498+ it ( 'interrupts a completed download whose staging file cannot be moved into place' , async ( ) => {
3499+ const directory = mkdtempSync ( join ( tmpdir ( ) , 'sim-browser-downloads-' ) )
3500+ session = freshSession ( win , { } , undefined , {
3501+ getDirectory : ( ) => directory ,
3502+ getFreeDiskBytes : ( ) => Number . MAX_SAFE_INTEGER ,
3503+ } )
3504+ const contents = ( session . ensureTab ( ) . view as unknown as MockView ) . webContents
3505+ const download = mockDownloadItem ( { filename : 'blocked.bin' , totalBytes : 100 } )
3506+
3507+ startMockDownload ( contents , download )
3508+ const stagingPath = expectOnlyStagingSavePath ( download . item , directory )
3509+ await vi . waitFor ( ( ) => expect ( download . item . resume ) . toHaveBeenCalledOnce ( ) )
3510+ mkdirSync ( join ( directory , 'blocked.bin' ) )
3511+ download . emitDone ( 'completed' )
3512+
3513+ await vi . waitFor ( ( ) =>
3514+ expect ( session . getBrowserDownloadsState ( 'chat-test' ) . downloads [ 0 ] ) . toMatchObject ( {
3515+ filename : 'blocked.bin' ,
3516+ state : 'interrupted' ,
3517+ } )
3518+ )
3519+ await vi . waitFor ( ( ) => expect ( existsSync ( stagingPath ) ) . toBe ( false ) )
3520+ const { id } = session . getBrowserDownloadsState ( 'chat-test' ) . downloads [ 0 ]
3521+ expect ( session . completedBrowserDownload ( 'chat-test' , id ) ) . toBeNull ( )
3522+
3523+ vi . mocked ( Menu . buildFromTemplate ) . mockClear ( )
3524+ session . showBrowserDownloadsMenu ( 'chat-test' , win , { x : 10 , y : 20 } )
3525+ const template = vi . mocked ( Menu . buildFromTemplate ) . mock . calls [ 0 ] ?. [ 0 ] as
3526+ | MenuItemConstructorOptions [ ]
3527+ | undefined
3528+ expect ( template ?. [ 0 ] ?. sublabel ) . toContain (
3529+ 'the finished download could not be moved to its destination'
3530+ )
3531+ } )
3532+
3533+ it ( 'removes the staging file when a download is cancelled or interrupted' , async ( ) => {
3534+ const directory = mkdtempSync ( join ( tmpdir ( ) , 'sim-browser-downloads-' ) )
3535+ session = freshSession ( win , { } , undefined , {
3536+ getDirectory : ( ) => directory ,
3537+ getFreeDiskBytes : ( ) => Number . MAX_SAFE_INTEGER ,
3538+ } )
3539+ const contents = ( session . ensureTab ( ) . view as unknown as MockView ) . webContents
3540+ const cancelled = mockDownloadItem ( { filename : 'cancelled.bin' , totalBytes : 100 } )
3541+ const interrupted = mockDownloadItem ( { filename : 'interrupted.bin' , totalBytes : 100 } )
3542+
3543+ startMockDownload ( contents , cancelled )
3544+ startMockDownload ( contents , interrupted )
3545+ await vi . waitFor ( ( ) => expect ( cancelled . item . resume ) . toHaveBeenCalledOnce ( ) )
3546+ await vi . waitFor ( ( ) => expect ( interrupted . item . resume ) . toHaveBeenCalledOnce ( ) )
3547+ const stagingPaths = [
3548+ expectOnlyStagingSavePath ( cancelled . item , directory ) ,
3549+ expectOnlyStagingSavePath ( interrupted . item , directory ) ,
3550+ ]
3551+ for ( const stagingPath of stagingPaths ) writeFileSync ( stagingPath , 'partial' )
3552+
3553+ cancelled . emitDone ( 'cancelled' )
3554+ interrupted . emitDone ( 'interrupted' )
3555+
3556+ await vi . waitFor ( ( ) => expect ( readdirSync ( directory ) ) . toEqual ( [ ] ) )
3557+ expect ( session . getBrowserDownloadsState ( 'chat-test' ) . downloads ) . toEqual ( [
3558+ expect . objectContaining ( { filename : 'interrupted.bin' , state : 'interrupted' } ) ,
3559+ expect . objectContaining ( { filename : 'cancelled.bin' , state : 'cancelled' } ) ,
3560+ ] )
3561+ } )
3562+
34133563 it ( 'does not let a pre-allocation progress event consume the admission probe' , async ( ) => {
34143564 const directory = mkdtempSync ( join ( tmpdir ( ) , 'sim-browser-downloads-' ) )
34153565 const getFreeDiskBytes = vi . fn ( ( ) => Number . MAX_SAFE_INTEGER )
@@ -3610,19 +3760,18 @@ describe('browser-agent session', () => {
36103760 firstProbe . resolve ( false )
36113761 secondProbe . reject ( new Error ( 'late path lookup failure' ) )
36123762 await vi . advanceTimersByTimeAsync ( 0 )
3613- expect ( first . item . setSavePath ) . not . toHaveBeenCalled ( )
3614- expect ( second . item . setSavePath ) . not . toHaveBeenCalled ( )
3763+ expectOnlyStagingSavePath ( first . item , directory )
3764+ expectOnlyStagingSavePath ( second . item , directory )
36153765 expect ( first . item . resume ) . not . toHaveBeenCalled ( )
36163766 expect ( second . item . resume ) . not . toHaveBeenCalled ( )
36173767
36183768 first . emitDone ( 'cancelled' )
36193769 second . emitDone ( 'cancelled' )
36203770 const replacement = mockDownloadItem ( { filename : 'hung-path.bin' , totalBytes : 100 } )
36213771 startMockDownload ( contents , replacement )
3622- await vi . waitFor ( ( ) =>
3623- expect ( replacement . item . setSavePath ) . toHaveBeenCalledWith ( join ( directory , 'hung-path.bin' ) )
3624- )
36253772 await vi . waitFor ( ( ) => expect ( replacement . item . resume ) . toHaveBeenCalledOnce ( ) )
3773+ replacement . emitDone ( 'completed' )
3774+ await vi . waitFor ( ( ) => expect ( finishedDownloadFiles ( directory ) ) . toEqual ( [ 'hung-path.bin' ] ) )
36263775 } finally {
36273776 vi . useRealTimers ( )
36283777 }
@@ -3694,7 +3843,7 @@ describe('browser-agent session', () => {
36943843 startMockDownload ( contents , download )
36953844
36963845 await vi . waitFor ( ( ) => expect ( download . item . cancel ) . toHaveBeenCalledOnce ( ) )
3697- expect ( download . item . setSavePath ) . not . toHaveBeenCalled ( )
3846+ expectOnlyStagingSavePath ( download . item , notDirectory )
36983847 expect ( download . item . resume ) . not . toHaveBeenCalled ( )
36993848 expect ( session . getBrowserDownloadsState ( 'chat-test' ) . downloads [ 0 ] ) . toMatchObject ( {
37003849 filename : 'cannot-save.bin' ,
@@ -3735,7 +3884,6 @@ describe('browser-agent session', () => {
37353884
37363885 startMockDownload ( contents , download )
37373886 await vi . waitFor ( ( ) => expect ( download . item . resume ) . toHaveBeenCalledOnce ( ) )
3738- const firstSavePath = download . item . setSavePath . mock . calls [ 0 ] ?. [ 0 ]
37393887 download . setReceivedBytes ( 2 * 1024 ** 3 + 1 )
37403888 download . emitUpdated ( )
37413889
@@ -3750,7 +3898,9 @@ describe('browser-agent session', () => {
37503898 const replacement = mockDownloadItem ( { filename : 'stream.bin' , totalBytes : 100 } )
37513899 startMockDownload ( contents , replacement )
37523900 expect ( replacement . item . cancel ) . not . toHaveBeenCalled ( )
3753- await vi . waitFor ( ( ) => expect ( replacement . item . setSavePath ) . toHaveBeenCalledWith ( firstSavePath ) )
3901+ await vi . waitFor ( ( ) => expect ( replacement . item . resume ) . toHaveBeenCalledOnce ( ) )
3902+ replacement . emitDone ( 'completed' )
3903+ await vi . waitFor ( ( ) => expect ( finishedDownloadFiles ( directory ) ) . toEqual ( [ 'stream.bin' ] ) )
37543904 } )
37553905
37563906 it ( 'throttles free-disk checks while stopping promptly after the interval' , async ( ) => {
@@ -3867,9 +4017,11 @@ describe('browser-agent session', () => {
38674017 . mockReturnValueOnce ( firstProbe . promise )
38684018 . mockReturnValueOnce ( secondProbe . promise )
38694019 . mockReturnValue ( Number . MAX_SAFE_INTEGER )
4020+ const pathExists = vi . fn ( ( _path : string ) => false )
38704021 session = freshSession ( win , { } , persistence , {
38714022 getDirectory : ( ) => directory ,
38724023 getFreeDiskBytes,
4024+ pathExists,
38734025 } )
38744026 const contents = ( session . ensureTab ( ) . view as unknown as MockView ) . webContents
38754027 const first = mockDownloadItem ( { filename : 'same-name.bin' , totalBytes : 100 } )
@@ -3878,12 +4030,11 @@ describe('browser-agent session', () => {
38784030 startMockDownload ( contents , first )
38794031 startMockDownload ( contents , second )
38804032 await vi . waitFor ( ( ) => expect ( getFreeDiskBytes ) . toHaveBeenCalledTimes ( 2 ) )
3881- const allocatedPaths = [
3882- first . item . setSavePath . mock . calls [ 0 ] ?. [ 0 ] ,
3883- second . item . setSavePath . mock . calls [ 0 ] ?. [ 0 ] ,
3884- ]
3885- expect ( new Set ( allocatedPaths ) . size ) . toBe ( 2 )
3886- expect ( allocatedPaths ) . toContain ( join ( directory , 'same-name.bin' ) )
4033+ expect ( pathExists . mock . calls . map ( ( [ candidate ] ) => candidate ) ) . toEqual ( [
4034+ join ( directory , 'same-name.bin' ) ,
4035+ join ( directory , 'same-name.bin' ) ,
4036+ expect . stringMatching ( / s a m e - n a m e \( .+ \) \. b i n $ / ) ,
4037+ ] )
38874038
38884039 await session . clearProfileStorage ( )
38894040
@@ -3903,18 +4054,24 @@ describe('browser-agent session', () => {
39034054 const nextContents = ( session . ensureTab ( ) . view as unknown as MockView ) . webContents
39044055 const replacement = mockDownloadItem ( { filename : 'same-name.bin' , totalBytes : 100 } )
39054056 startMockDownload ( nextContents , replacement )
3906- await vi . waitFor ( ( ) =>
3907- expect ( replacement . item . setSavePath ) . toHaveBeenCalledWith ( join ( directory , 'same-name.bin' ) )
3908- )
39094057 await vi . waitFor ( ( ) => expect ( replacement . item . resume ) . toHaveBeenCalledOnce ( ) )
39104058 expect ( replacement . item . cancel ) . not . toHaveBeenCalled ( )
39114059
39124060 first . emitDone ( 'completed' )
39134061 second . emitDone ( 'cancelled' )
39144062 const concurrent = mockDownloadItem ( { filename : 'same-name.bin' , totalBytes : 100 } )
39154063 startMockDownload ( nextContents , concurrent )
3916- await vi . waitFor ( ( ) => expect ( concurrent . item . setSavePath ) . toHaveBeenCalledOnce ( ) )
3917- expect ( concurrent . item . setSavePath ) . not . toHaveBeenCalledWith ( join ( directory , 'same-name.bin' ) )
4064+ await vi . waitFor ( ( ) => expect ( concurrent . item . resume ) . toHaveBeenCalledOnce ( ) )
4065+ concurrent . emitDone ( 'completed' )
4066+ await vi . waitFor ( ( ) =>
4067+ expect ( finishedDownloadFiles ( directory ) ) . toEqual ( [
4068+ expect . stringMatching ( / ^ s a m e - n a m e \( .+ \) \. b i n $ / ) ,
4069+ ] )
4070+ )
4071+ replacement . emitDone ( 'completed' )
4072+ await vi . waitFor ( ( ) => expect ( finishedDownloadFiles ( directory ) ) . toHaveLength ( 2 ) )
4073+ expect ( finishedDownloadFiles ( directory ) ) . toContain ( 'same-name.bin' )
4074+ await vi . waitFor ( ( ) => expect ( readdirSync ( directory ) ) . toHaveLength ( 2 ) )
39184075 } )
39194076
39204077 it ( 'does not reserve a late filename after profile teardown starts' , async ( ) => {
@@ -3931,7 +4088,7 @@ describe('browser-agent session', () => {
39314088 await Promise . resolve ( )
39324089
39334090 expect ( download . item . cancel ) . toHaveBeenCalledOnce ( )
3934- expect ( download . item . setSavePath ) . not . toHaveBeenCalled ( )
4091+ expectOnlyStagingSavePath ( download . item , directory )
39354092 expect ( download . item . resume ) . not . toHaveBeenCalled ( )
39364093 } )
39374094
@@ -3970,15 +4127,21 @@ describe('browser-agent session', () => {
39704127 queueMicrotask ( ( ) => session . disposeBrowserScope ( 'chat-first' ) )
39714128 secondPathProbe . resolve ( false )
39724129
3973- await vi . waitFor ( ( ) =>
3974- expect ( second . item . setSavePath ) . toHaveBeenCalledWith ( join ( directory , 'shared.bin' ) )
3975- )
3976- expect ( first . item . setSavePath ) . not . toHaveBeenCalled ( )
4130+ await vi . waitFor ( ( ) => expect ( second . item . resume ) . toHaveBeenCalledOnce ( ) )
4131+ expectOnlyStagingSavePath ( first . item , directory )
4132+ expect ( first . item . resume ) . not . toHaveBeenCalled ( )
39774133
39784134 const third = mockDownloadItem ( { filename : 'shared.bin' , totalBytes : 100 } )
39794135 startMockDownload ( thirdContents , third )
3980- await vi . waitFor ( ( ) => expect ( third . item . setSavePath ) . toHaveBeenCalledOnce ( ) )
3981- expect ( third . item . setSavePath ) . not . toHaveBeenCalledWith ( join ( directory , 'shared.bin' ) )
4136+ await vi . waitFor ( ( ) => expect ( third . item . resume ) . toHaveBeenCalledOnce ( ) )
4137+ third . emitDone ( 'completed' )
4138+ await vi . waitFor ( ( ) =>
4139+ expect ( finishedDownloadFiles ( directory ) ) . toEqual ( [
4140+ expect . stringMatching ( / ^ s h a r e d \( .+ \) \. b i n $ / ) ,
4141+ ] )
4142+ )
4143+ second . emitDone ( 'completed' )
4144+ await vi . waitFor ( ( ) => expect ( finishedDownloadFiles ( directory ) ) . toContain ( 'shared.bin' ) )
39824145 } )
39834146
39844147 it ( 'cancels only the disposed scope and ignores its late download callbacks' , async ( ) => {
@@ -4017,7 +4180,7 @@ describe('browser-agent session', () => {
40174180 disposedDownload . emitUpdated ( )
40184181 disposedDownload . emitDone ( 'cancelled' )
40194182
4020- expect ( disposedDownload . item . setSavePath ) . not . toHaveBeenCalled ( )
4183+ expectOnlyStagingSavePath ( disposedDownload . item , directory )
40214184 expect ( disposedDownload . item . resume ) . not . toHaveBeenCalled ( )
40224185 expect ( session . getBrowserDownloadsState ( 'chat-disposed' ) . downloads ) . toEqual ( [ ] )
40234186 expect ( onDownloadsChanged ) . not . toHaveBeenCalledWith (
@@ -4101,10 +4264,11 @@ describe('browser-agent session', () => {
41014264 expect ( rejected . item . cancel ) . toHaveBeenCalledOnce ( )
41024265
41034266 first . emitDone ( 'completed' )
4267+ await vi . waitFor ( ( ) => expect ( finishedDownloadFiles ( directory ) ) . toEqual ( [ 'first.txt' ] ) )
41044268 const replacement = mockDownloadItem ( { filename : 'fourth.txt' , totalBytes : 100 } )
41054269 startMockDownload ( contents , replacement )
41064270 expect ( replacement . item . cancel ) . not . toHaveBeenCalled ( )
4107- await vi . waitFor ( ( ) => expect ( replacement . item . setSavePath ) . toHaveBeenCalledOnce ( ) )
4271+ await vi . waitFor ( ( ) => expect ( replacement . item . resume ) . toHaveBeenCalledOnce ( ) )
41084272 } )
41094273
41104274 it ( 'bounds active browser downloads across tasks' , ( ) => {
0 commit comments