@@ -484,6 +484,116 @@ describe('strict update consent', () => {
484484 expect ( getLocalUpdateState ( ) ) . toMatchObject ( { status : 'available' , availableVersion : '1.2.3' } )
485485 expect ( getLocalUpdateState ( ) . message ) . toBeUndefined ( )
486486 } )
487+
488+ it ( 'retries a download requested before the cancelled one settled' , async ( ) => {
489+ vi . resetModules ( )
490+ const directory = temporaryDirectory ( )
491+ writeFileSync ( join ( directory , 'app-update.yml' ) , '' , 'utf8' )
492+ writeFileSync ( join ( directory , 'update-settings.json' ) , '{"autoUpdate":false}\n' , 'utf8' )
493+ const { app : localApp } = await import ( 'electron' )
494+ const { default : localElectronUpdater } = await import ( 'electron-updater' )
495+ const {
496+ cancelUpdateDownload : cancelLocalUpdateDownload ,
497+ getUpdateState : getLocalUpdateState ,
498+ initUpdater : initLocalUpdater ,
499+ startUpdateDownload : startLocalUpdateDownload ,
500+ } = await import ( '../src/updater' )
501+ const localAutoUpdater = localElectronUpdater . autoUpdater
502+ vi . mocked ( localApp . getPath ) . mockReturnValue ( directory )
503+ Object . defineProperty ( localApp , 'isPackaged' , { configurable : true , value : true } )
504+ Object . defineProperty ( process , 'resourcesPath' , { configurable : true , value : directory } )
505+
506+ // Mirrors AppUpdater.downloadUpdate: an in-flight download is handed back
507+ // to the next caller and the token it passes is ignored. The promise that
508+ // clears the slot is the one the caller receives, so the slot is already
509+ // free by the time the caller's own handlers run.
510+ let rejectDownload : ( ( error : Error ) => void ) | undefined
511+ let downloadPromise : Promise < string [ ] > | null = null
512+ vi . mocked ( localAutoUpdater . downloadUpdate ) . mockImplementation ( ( ) => {
513+ if ( downloadPromise !== null ) return downloadPromise
514+ const inner = new Promise < string [ ] > ( ( _resolve , reject ) => {
515+ rejectDownload = reject
516+ } )
517+ downloadPromise = inner . finally ( ( ) => {
518+ downloadPromise = null
519+ } )
520+ return downloadPromise
521+ } )
522+
523+ initLocalUpdater ( ( ) => undefined )
524+ const available = vi . mocked ( localAutoUpdater . on ) . mock . calls . find (
525+ ( [ event ] ) => event === 'update-available' ,
526+ ) ?. [ 1 ] as ( ( info : { version : string } ) => void ) | undefined
527+ available ?.( { version : '1.2.3' } )
528+
529+ startLocalUpdateDownload ( )
530+ cancelLocalUpdateDownload ( )
531+ startLocalUpdateDownload ( )
532+
533+ // The retry must not reach electron-updater yet: it would be handed the
534+ // cancelled download and inherit its rejection.
535+ expect ( localAutoUpdater . downloadUpdate ) . toHaveBeenCalledOnce ( )
536+
537+ rejectDownload ?.( new Error ( 'cancelled' ) )
538+ await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) )
539+
540+ expect ( localAutoUpdater . downloadUpdate ) . toHaveBeenCalledTimes ( 2 )
541+ const retryToken = vi . mocked ( localAutoUpdater . downloadUpdate ) . mock . calls [ 1 ] ?. [ 0 ] as
542+ | { cancelled : boolean }
543+ | undefined
544+ expect ( retryToken ?. cancelled ) . toBe ( false )
545+ expect ( getLocalUpdateState ( ) ) . toMatchObject ( { status : 'downloading' } )
546+ expect ( getLocalUpdateState ( ) . message ) . toBeUndefined ( )
547+ } )
548+
549+ it ( 'drops a deferred retry when the user cancels again' , async ( ) => {
550+ vi . resetModules ( )
551+ const directory = temporaryDirectory ( )
552+ writeFileSync ( join ( directory , 'app-update.yml' ) , '' , 'utf8' )
553+ writeFileSync ( join ( directory , 'update-settings.json' ) , '{"autoUpdate":false}\n' , 'utf8' )
554+ const { app : localApp } = await import ( 'electron' )
555+ const { default : localElectronUpdater } = await import ( 'electron-updater' )
556+ const {
557+ cancelUpdateDownload : cancelLocalUpdateDownload ,
558+ getUpdateState : getLocalUpdateState ,
559+ initUpdater : initLocalUpdater ,
560+ startUpdateDownload : startLocalUpdateDownload ,
561+ } = await import ( '../src/updater' )
562+ const localAutoUpdater = localElectronUpdater . autoUpdater
563+ vi . mocked ( localApp . getPath ) . mockReturnValue ( directory )
564+ Object . defineProperty ( localApp , 'isPackaged' , { configurable : true , value : true } )
565+ Object . defineProperty ( process , 'resourcesPath' , { configurable : true , value : directory } )
566+
567+ let rejectDownload : ( ( error : Error ) => void ) | undefined
568+ let downloadPromise : Promise < string [ ] > | null = null
569+ vi . mocked ( localAutoUpdater . downloadUpdate ) . mockImplementation ( ( ) => {
570+ if ( downloadPromise !== null ) return downloadPromise
571+ const inner = new Promise < string [ ] > ( ( _resolve , reject ) => {
572+ rejectDownload = reject
573+ } )
574+ downloadPromise = inner . finally ( ( ) => {
575+ downloadPromise = null
576+ } )
577+ return downloadPromise
578+ } )
579+
580+ initLocalUpdater ( ( ) => undefined )
581+ const available = vi . mocked ( localAutoUpdater . on ) . mock . calls . find (
582+ ( [ event ] ) => event === 'update-available' ,
583+ ) ?. [ 1 ] as ( ( info : { version : string } ) => void ) | undefined
584+ available ?.( { version : '1.2.3' } )
585+
586+ startLocalUpdateDownload ( )
587+ cancelLocalUpdateDownload ( )
588+ startLocalUpdateDownload ( )
589+ cancelLocalUpdateDownload ( )
590+
591+ rejectDownload ?.( new Error ( 'cancelled' ) )
592+ await new Promise ( ( resolve ) => setTimeout ( resolve , 0 ) )
593+
594+ expect ( localAutoUpdater . downloadUpdate ) . toHaveBeenCalledOnce ( )
595+ expect ( getLocalUpdateState ( ) ) . toMatchObject ( { status : 'available' , availableVersion : '1.2.3' } )
596+ } )
487597} )
488598
489599describe ( 'update prompt receipts' , ( ) => {
0 commit comments