@@ -24,7 +24,7 @@ const autoUpdaterMock = {
2424 quitAndInstall : vi . fn ( ) ,
2525}
2626
27- import { app , dialog , shell } from 'electron'
27+ import { app , dialog , shell , autoUpdater as squirrelUpdater } from 'electron'
2828import {
2929 checkForUpdatesInteractive ,
3030 feedUrlForOrigin ,
@@ -153,6 +153,24 @@ describe('initUpdater state machine', () => {
153153 }
154154 }
155155
156+ /** Replays a native Squirrel.Mac event, e.g. `update-downloaded` once a bundle is staged. */
157+ function emitSquirrel ( event : string ) {
158+ for ( const [ name , listener ] of vi . mocked ( squirrelUpdater . on ) . mock . calls ) {
159+ if ( name === event ) {
160+ ; ( listener as ( ) => void ) ( )
161+ }
162+ }
163+ }
164+
165+ /** Drives a fresh updater to a Squirrel-staged `ready` update for `version`. */
166+ async function stageUpdate ( handle : UpdaterHandle , version : string ) {
167+ handle . check ( )
168+ await vi . advanceTimersByTimeAsync ( 0 )
169+ emit ( 'update-available' , { version } )
170+ emit ( 'update-downloaded' , { version } )
171+ emitSquirrel ( 'update-downloaded' )
172+ }
173+
156174 async function createUpdater ( options ?: {
157175 autoDownload ?: boolean
158176 feedAvailable ?: boolean | 'no-release'
@@ -183,6 +201,7 @@ describe('initUpdater state machine', () => {
183201 beforeEach ( ( ) => {
184202 vi . useFakeTimers ( )
185203 autoUpdaterMock . on . mockClear ( )
204+ vi . mocked ( squirrelUpdater . on ) . mockClear ( )
186205 autoUpdaterMock . setFeedURL . mockClear ( )
187206 autoUpdaterMock . checkForUpdates . mockClear ( )
188207 autoUpdaterMock . checkForUpdates . mockImplementation ( ( ) => new Promise ( ( ) => { } ) )
@@ -445,6 +464,228 @@ describe('initUpdater state machine', () => {
445464 } )
446465 } )
447466
467+ it ( 'replaces a staged update with a newer release instead of installing the stale build' , async ( ) => {
468+ const { handle, states } = await createUpdater ( )
469+ await stageUpdate ( handle , '2.0.0' )
470+ expect ( handle . getState ( ) ) . toEqual ( { status : 'ready' , version : '2.0.0' } )
471+ states . length = 0
472+
473+ await vi . advanceTimersByTimeAsync ( 10_000 )
474+ expect ( autoUpdaterMock . checkForUpdates ) . toHaveBeenCalledTimes ( 2 )
475+ emit ( 'checking-for-update' )
476+ emit ( 'update-available' , { version : '2.1.0' } )
477+ emit ( 'download-progress' , { percent : 50 } )
478+ expect ( autoUpdaterMock . downloadUpdate ) . toHaveBeenCalledTimes ( 2 )
479+
480+ await vi . advanceTimersByTimeAsync ( 30 * 60 * 1000 )
481+ expect ( autoUpdaterMock . checkForUpdates ) . toHaveBeenCalledTimes ( 2 )
482+
483+ emit ( 'update-downloaded' , { version : '2.1.0' } )
484+ expect ( handle . getState ( ) ) . toEqual ( { status : 'ready' , version : '2.0.0' } )
485+ expect ( autoUpdaterMock . autoInstallOnAppQuit ) . toBe ( true )
486+
487+ emitSquirrel ( 'update-downloaded' )
488+ expect ( states ) . toEqual ( [ { status : 'ready' , version : '2.1.0' } ] )
489+ expect ( events . record ) . toHaveBeenCalledWith ( 'update_downloaded' , { version : '2.1.0' } )
490+ } )
491+
492+ it ( 'does not re-check a ready update until Squirrel has staged it' , async ( ) => {
493+ const { handle } = await createUpdater ( )
494+ handle . check ( )
495+ await vi . advanceTimersByTimeAsync ( 0 )
496+ emit ( 'update-available' , { version : '2.0.0' } )
497+ emit ( 'update-downloaded' , { version : '2.0.0' } )
498+
499+ await vi . advanceTimersByTimeAsync ( 10_000 )
500+ expect ( autoUpdaterMock . checkForUpdates ) . toHaveBeenCalledTimes ( 1 )
501+
502+ emitSquirrel ( 'update-downloaded' )
503+ await vi . advanceTimersByTimeAsync ( 30 * 60 * 1000 - 10_000 )
504+ expect ( autoUpdaterMock . checkForUpdates ) . toHaveBeenCalledTimes ( 2 )
505+ } )
506+
507+ it ( 'keeps a staged update when a background re-check finds nothing newer or fails' , async ( ) => {
508+ const { handle, states } = await createUpdater ( )
509+ await stageUpdate ( handle , '2.0.0' )
510+ states . length = 0
511+
512+ await vi . advanceTimersByTimeAsync ( 10_000 )
513+ emit ( 'update-available' , { version : '2.0.0' } )
514+ await vi . advanceTimersByTimeAsync ( 30 * 60 * 1000 - 10_000 )
515+ emit ( 'update-not-available' )
516+ await vi . advanceTimersByTimeAsync ( 30 * 60 * 1000 )
517+ emit ( 'error' , new Error ( 'net::ERR_NETWORK_CHANGED' ) )
518+ await vi . advanceTimersByTimeAsync ( 30 * 60 * 1000 )
519+ emit ( 'update-available' , { version : '2.1.0' } )
520+ emit ( 'error' , new Error ( 'download interrupted' ) )
521+ await vi . advanceTimersByTimeAsync ( 30 * 60 * 1000 )
522+ emit ( 'update-available' , { version : '2.2.0' } )
523+ emit ( 'update-downloaded' , { version : '2.2.0' } )
524+ emit ( 'error' , new Error ( 'Squirrel could not verify the replacement' ) )
525+
526+ expect ( autoUpdaterMock . checkForUpdates ) . toHaveBeenCalledTimes ( 6 )
527+ expect ( autoUpdaterMock . downloadUpdate ) . toHaveBeenCalledTimes ( 3 )
528+ expect ( states ) . toEqual ( [ ] )
529+ expect ( handle . getState ( ) ) . toEqual ( { status : 'ready' , version : '2.0.0' } )
530+ expect ( autoUpdaterMock . autoInstallOnAppQuit ) . toBe ( true )
531+
532+ emitSquirrel ( 'update-downloaded' )
533+ expect ( handle . getState ( ) ) . toEqual ( { status : 'ready' , version : '2.0.0' } )
534+ } )
535+
536+ it ( 'does not replace a staged update when background downloads are disabled' , async ( ) => {
537+ const { handle } = await createUpdater ( )
538+ await stageUpdate ( handle , '2.0.0' )
539+ handle . setAutoDownload ( false )
540+
541+ await vi . advanceTimersByTimeAsync ( 10_000 )
542+
543+ expect ( autoUpdaterMock . checkForUpdates ) . toHaveBeenCalledTimes ( 1 )
544+ expect ( autoUpdaterMock . downloadUpdate ) . toHaveBeenCalledTimes ( 1 )
545+ expect ( handle . getState ( ) ) . toEqual ( { status : 'ready' , version : '2.0.0' } )
546+ } )
547+
548+ it ( 'completes a confirmed restart when a background re-check fails during teardown' , async ( ) => {
549+ let finishTeardown : ( ( ) => void ) | undefined
550+ const setRelaunchPending = vi . fn ( )
551+ const { handle } = await createUpdater ( {
552+ beforeInstall : ( ) =>
553+ new Promise < void > ( ( resolve ) => {
554+ finishTeardown = resolve
555+ } ) ,
556+ setRelaunchPending,
557+ } )
558+ await stageUpdate ( handle , '2.0.0' )
559+ await vi . advanceTimersByTimeAsync ( 10_000 )
560+ expect ( autoUpdaterMock . checkForUpdates ) . toHaveBeenCalledTimes ( 2 )
561+
562+ vi . mocked ( dialog . showMessageBox ) . mockResolvedValueOnce ( {
563+ response : 1 ,
564+ checkboxChecked : false ,
565+ } )
566+ handle . install ( )
567+ await vi . advanceTimersByTimeAsync ( 0 )
568+ emit ( 'error' , new Error ( 'net::ERR_NETWORK_CHANGED' ) )
569+ finishTeardown ?.( )
570+ await vi . advanceTimersByTimeAsync ( 0 )
571+
572+ expect ( handle . getState ( ) ) . toEqual ( { status : 'ready' , version : '2.0.0' } )
573+ expect ( setRelaunchPending ) . toHaveBeenCalledWith ( true )
574+ expect ( autoUpdaterMock . quitAndInstall ) . toHaveBeenCalledTimes ( 1 )
575+ } )
576+
577+ it ( 'surfaces a relaunch failure after a confirmed restart' , async ( ) => {
578+ const setRelaunchPending = vi . fn ( )
579+ vi . mocked ( dialog . showMessageBox ) . mockResolvedValueOnce ( {
580+ response : 1 ,
581+ checkboxChecked : false ,
582+ } )
583+ const { handle } = await createUpdater ( { beforeInstall : async ( ) => { } , setRelaunchPending } )
584+ await stageUpdate ( handle , '2.0.0' )
585+ handle . install ( )
586+ await vi . advanceTimersByTimeAsync ( 0 )
587+ expect ( autoUpdaterMock . quitAndInstall ) . toHaveBeenCalledTimes ( 1 )
588+
589+ emit ( 'error' , new Error ( 'ShipIt could not launch' ) )
590+
591+ expect ( handle . getState ( ) ) . toEqual ( { status : 'error' , version : '2.0.0' } )
592+ expect ( setRelaunchPending ) . toHaveBeenLastCalledWith ( false )
593+ } )
594+
595+ it ( 'installs a replacement that finished staging while the restart prompt was open' , async ( ) => {
596+ let resolveConfirmation : ( result : { response : number ; checkboxChecked : boolean } ) => void =
597+ ( ) => {
598+ throw new Error ( 'Restart confirmation did not initialize' )
599+ }
600+ const { handle } = await createUpdater ( )
601+ await stageUpdate ( handle , '2.0.0' )
602+ await vi . advanceTimersByTimeAsync ( 10_000 )
603+ emit ( 'update-available' , { version : '2.1.0' } )
604+
605+ vi . mocked ( dialog . showMessageBox ) . mockImplementationOnce (
606+ ( ) =>
607+ new Promise ( ( resolve ) => {
608+ resolveConfirmation = resolve
609+ } )
610+ )
611+ handle . install ( )
612+ emit ( 'update-downloaded' , { version : '2.1.0' } )
613+ emitSquirrel ( 'update-downloaded' )
614+ expect ( handle . getState ( ) ) . toEqual ( { status : 'ready' , version : '2.1.0' } )
615+ resolveConfirmation ( { response : 1 , checkboxChecked : false } )
616+ await vi . advanceTimersByTimeAsync ( 0 )
617+
618+ expect ( autoUpdaterMock . quitAndInstall ) . toHaveBeenCalledTimes ( 1 )
619+ } )
620+
621+ it ( 'refreshes an offered update to a newer release before it is downloaded' , async ( ) => {
622+ const { handle } = await createUpdater ( { autoDownload : false } )
623+ handle . check ( )
624+ await vi . advanceTimersByTimeAsync ( 0 )
625+ emit ( 'update-available' , { version : '2.0.0' } )
626+ expect ( handle . getState ( ) ) . toEqual ( { status : 'available' , version : '2.0.0' } )
627+
628+ await vi . advanceTimersByTimeAsync ( 10_000 )
629+ emit ( 'checking-for-update' )
630+ emit ( 'error' , new Error ( 'net::ERR_INTERNET_DISCONNECTED' ) )
631+ expect ( handle . getState ( ) ) . toEqual ( { status : 'available' , version : '2.0.0' } )
632+
633+ await vi . advanceTimersByTimeAsync ( 30 * 60 * 1000 - 10_000 )
634+ emit ( 'update-not-available' )
635+ expect ( handle . getState ( ) ) . toEqual ( { status : 'available' , version : '2.0.0' } )
636+
637+ await vi . advanceTimersByTimeAsync ( 30 * 60 * 1000 )
638+ emit ( 'update-available' , { version : '2.1.0' } )
639+ expect ( handle . getState ( ) ) . toEqual ( { status : 'available' , version : '2.1.0' } )
640+ expect ( autoUpdaterMock . downloadUpdate ) . not . toHaveBeenCalled ( )
641+ } )
642+
643+ it ( 'resumes refreshing after a replacement download is cancelled without an error event' , async ( ) => {
644+ const { handle } = await createUpdater ( )
645+ await stageUpdate ( handle , '2.0.0' )
646+ await vi . advanceTimersByTimeAsync ( 10_000 )
647+ autoUpdaterMock . downloadUpdate . mockImplementationOnce ( ( ) =>
648+ Promise . reject ( new Error ( 'cancelled' ) )
649+ )
650+ emit ( 'update-available' , { version : '2.1.0' } )
651+ await vi . advanceTimersByTimeAsync ( 0 )
652+
653+ await vi . advanceTimersByTimeAsync ( 30 * 60 * 1000 - 10_000 )
654+ expect ( autoUpdaterMock . checkForUpdates ) . toHaveBeenCalledTimes ( 3 )
655+ emit ( 'update-available' , { version : '2.1.0' } )
656+ expect ( autoUpdaterMock . downloadUpdate ) . toHaveBeenCalledTimes ( 3 )
657+ expect ( handle . getState ( ) ) . toEqual ( { status : 'ready' , version : '2.0.0' } )
658+ } )
659+
660+ it ( 'follows the feed when a re-check rolls an offered release back' , async ( ) => {
661+ const { handle } = await createUpdater ( { autoDownload : false } )
662+ handle . check ( )
663+ await vi . advanceTimersByTimeAsync ( 0 )
664+ emit ( 'update-available' , { version : '2.2.0' } )
665+
666+ await vi . advanceTimersByTimeAsync ( 10_000 )
667+ emit ( 'update-available' , { version : '2.1.0' } )
668+ expect ( handle . getState ( ) ) . toEqual ( { status : 'available' , version : '2.1.0' } )
669+
670+ handle . check ( )
671+ emit ( 'update-downloaded' , { version : '2.1.0' } )
672+ expect ( handle . getState ( ) ) . toEqual ( { status : 'ready' , version : '2.1.0' } )
673+ } )
674+
675+ it ( 'withdraws an offered update once a re-check stores a blocked candidate' , async ( ) => {
676+ const { handle } = await createUpdater ( { autoDownload : false } )
677+ handle . check ( )
678+ await vi . advanceTimersByTimeAsync ( 0 )
679+ emit ( 'update-available' , { version : '2.0.0' } )
680+
681+ await vi . advanceTimersByTimeAsync ( 10_000 )
682+ emit ( 'update-available' , { version : '2.1.0-dev.1' } )
683+
684+ expect ( handle . getState ( ) ) . toEqual ( { status : 'idle' } )
685+ handle . check ( )
686+ expect ( autoUpdaterMock . downloadUpdate ) . not . toHaveBeenCalled ( )
687+ } )
688+
448689 it ( 'checks from idle and ignores re-entrant checks while busy' , async ( ) => {
449690 const { handle } = await createUpdater ( )
450691 handle . check ( )
@@ -1035,6 +1276,31 @@ describe('initUpdater manual mode (no Developer ID signature)', () => {
10351276 expect ( handle . getState ( ) ) . toEqual ( { status : 'error' , manual : true } )
10361277 } )
10371278
1279+ it ( 'replaces an offered manual download with a newer release' , async ( ) => {
1280+ let feedVersion : string | null = '2.0.0'
1281+ const fetchManifest = vi . fn ( async ( ) => {
1282+ if ( feedVersion === null ) throw new Error ( 'network down' )
1283+ return manifest ( feedVersion )
1284+ } )
1285+ const { handle } = await createManualUpdater ( fetchManifest )
1286+ handle . check ( )
1287+ await vi . advanceTimersByTimeAsync ( 0 )
1288+ expect ( handle . getState ( ) ) . toEqual ( { status : 'available' , version : '2.0.0' , manual : true } )
1289+
1290+ feedVersion = null
1291+ await vi . advanceTimersByTimeAsync ( 10_000 )
1292+ expect ( handle . getState ( ) ) . toEqual ( { status : 'available' , version : '2.0.0' , manual : true } )
1293+
1294+ feedVersion = '2.1.0'
1295+ await vi . advanceTimersByTimeAsync ( 30 * 60 * 1000 )
1296+ expect ( handle . getState ( ) ) . toEqual ( { status : 'available' , version : '2.1.0' , manual : true } )
1297+
1298+ handle . install ( )
1299+ expect ( shell . openExternal ) . toHaveBeenCalledWith (
1300+ 'https://github.com/simstudioai/sim/releases/download/v2.1.0/Sim-2.1.0-universal.dmg'
1301+ )
1302+ } )
1303+
10381304 it ( 'checks on the scheduled interval' , async ( ) => {
10391305 const fetchManifest = vi . fn ( async ( ) => manifest ( '9.9.9' ) )
10401306 await createManualUpdater ( fetchManifest )
0 commit comments