@@ -8,6 +8,7 @@ const autoUpdaterMock = {
88 allowDowngrade : false ,
99 autoDownload : true ,
1010 autoInstallOnAppQuit : false ,
11+ autoRunAppAfterInstall : true ,
1112 logger : null as unknown ,
1213 on : vi . fn ( ) ,
1314 setFeedURL : vi . fn ( ) ,
@@ -25,6 +26,7 @@ import {
2526 isNewerVersion ,
2627 parseSemver ,
2728 resolveUpdateChannel ,
29+ type UpdaterHandle ,
2830 updateCheckIntervalMs ,
2931} from '@/main/updater'
3032
@@ -35,15 +37,20 @@ describe('resolveUpdateChannel', () => {
3537 } )
3638
3739 it ( 'maps prerelease versions to their channel' , ( ) => {
38- expect ( resolveUpdateChannel ( '1.2.3-beta.1' ) ) . toBe ( 'beta' )
39- expect ( resolveUpdateChannel ( '1.2.3-alpha.2' ) ) . toBe ( 'alpha' )
40+ expect ( resolveUpdateChannel ( '1.2.3-dev.2' ) ) . toBe ( 'dev' )
41+ expect ( resolveUpdateChannel ( '1.2.3-staging.1' ) ) . toBe ( 'staging' )
42+ } )
43+
44+ it ( 'keeps legacy alpha and beta builds on their environment streams' , ( ) => {
45+ expect ( resolveUpdateChannel ( '1.2.3-alpha.2' ) ) . toBe ( 'dev' )
46+ expect ( resolveUpdateChannel ( '1.2.3-beta.1' ) ) . toBe ( 'staging' )
4047 } )
4148} )
4249
4350describe ( 'updateCheckIntervalMs' , ( ) => {
4451 it ( 'checks dev and staging builds every five minutes' , ( ) => {
45- expect ( updateCheckIntervalMs ( '1.2.3-alpha .2' ) ) . toBe ( 5 * 60 * 1000 )
46- expect ( updateCheckIntervalMs ( '1.2.3-beta .1' ) ) . toBe ( 5 * 60 * 1000 )
52+ expect ( updateCheckIntervalMs ( '1.2.3-dev .2' ) ) . toBe ( 5 * 60 * 1000 )
53+ expect ( updateCheckIntervalMs ( '1.2.3-staging .1' ) ) . toBe ( 5 * 60 * 1000 )
4754 } )
4855
4956 it ( 'checks production builds every thirty minutes' , ( ) => {
@@ -61,6 +68,7 @@ describe('parseSemver', () => {
6168 it ( 'returns null for garbage' , ( ) => {
6269 expect ( parseSemver ( 'latest' ) ) . toBeNull ( )
6370 expect ( parseSemver ( '1.2' ) ) . toBeNull ( )
71+ expect ( parseSemver ( '1.2.3garbage' ) ) . toBeNull ( )
6472 expect ( parseSemver ( '' ) ) . toBeNull ( )
6573 } )
6674} )
@@ -137,7 +145,11 @@ describe('initUpdater state machine', () => {
137145 }
138146 }
139147
140- async function createUpdater ( options ?: { autoDownload ?: boolean ; feedAvailable ?: boolean } ) {
148+ async function createUpdater ( options ?: {
149+ autoDownload ?: boolean
150+ feedAvailable ?: boolean | 'no-release'
151+ probeOriginFeed ?: ( feedUrl : string ) => Promise < boolean | 'no-release' >
152+ } ) {
141153 const states : DesktopUpdateState [ ] = [ ]
142154 const handle = initUpdater ( {
143155 getWindow : ( ) => null ,
@@ -147,7 +159,7 @@ describe('initUpdater state machine', () => {
147159 onStateChange : ( state ) => states . push ( state ) ,
148160 loadAutoUpdater : ( ) =>
149161 autoUpdaterMock as unknown as typeof import ( 'electron-updater' ) [ 'autoUpdater' ] ,
150- probeOriginFeed : async ( ) => options ?. feedAvailable ?? false ,
162+ probeOriginFeed : options ?. probeOriginFeed ?? ( async ( ) => options ?. feedAvailable ?? false ) ,
151163 canSelfUpdate : async ( ) => true ,
152164 } )
153165 // Engine selection (signature detection) resolves asynchronously.
@@ -162,7 +174,7 @@ describe('initUpdater state machine', () => {
162174 autoUpdaterMock . checkForUpdates . mockClear ( )
163175 autoUpdaterMock . downloadUpdate . mockClear ( )
164176 autoUpdaterMock . quitAndInstall . mockClear ( )
165- // Keep the update-downloaded dialog from resolving into quitAndInstall.
177+ autoUpdaterMock . autoRunAppAfterInstall = false
166178 vi . mocked ( dialog . showMessageBox ) . mockResolvedValue ( { response : 1 , checkboxChecked : false } )
167179 } )
168180
@@ -188,12 +200,14 @@ describe('initUpdater state machine', () => {
188200 { status : 'downloading' , version : '2.0.0' , percent : 42 } ,
189201 { status : 'ready' , version : '2.0.0' } ,
190202 ] )
203+ expect ( dialog . showMessageBox ) . not . toHaveBeenCalled ( )
204+ expect ( autoUpdaterMock . quitAndInstall ) . not . toHaveBeenCalled ( )
191205
192206 handle . install ( )
193207 expect ( autoUpdaterMock . quitAndInstall ) . toHaveBeenCalledTimes ( 1 )
194208 } )
195209
196- it ( 'stops at available and downloads on demand when auto-download is off ' , async ( ) => {
210+ it ( 'downloads, installs, and relaunches from one Update action ' , async ( ) => {
197211 autoUpdaterMock . autoDownload = false
198212 const { handle } = await createUpdater ( { autoDownload : false } )
199213
@@ -203,6 +217,11 @@ describe('initUpdater state machine', () => {
203217 handle . check ( )
204218 expect ( autoUpdaterMock . downloadUpdate ) . toHaveBeenCalledTimes ( 1 )
205219 expect ( autoUpdaterMock . checkForUpdates ) . not . toHaveBeenCalled ( )
220+
221+ emit ( 'update-downloaded' , { version : '2.0.0' } )
222+ expect ( dialog . showMessageBox ) . not . toHaveBeenCalled ( )
223+ expect ( autoUpdaterMock . autoRunAppAfterInstall ) . toBe ( true )
224+ expect ( autoUpdaterMock . quitAndInstall ) . toHaveBeenCalledTimes ( 1 )
206225 } )
207226
208227 it ( 'checks from idle and ignores re-entrant checks while busy' , async ( ) => {
@@ -216,6 +235,31 @@ describe('initUpdater state machine', () => {
216235 expect ( autoUpdaterMock . checkForUpdates ) . toHaveBeenCalledTimes ( 1 )
217236 } )
218237
238+ it ( 'does not lose an interactive check while updater capability is initializing' , async ( ) => {
239+ let resolveCapability : ( ( capable : boolean ) => void ) | undefined
240+ const capability = new Promise < boolean > ( ( resolve ) => {
241+ resolveCapability = resolve
242+ } )
243+ const states : DesktopUpdateState [ ] = [ ]
244+ const handle = initUpdater ( {
245+ getWindow : ( ) => null ,
246+ events,
247+ appOrigin : ( ) => 'https://www.dev.sim.ai' ,
248+ onStateChange : ( state ) => states . push ( state ) ,
249+ loadAutoUpdater : ( ) =>
250+ autoUpdaterMock as unknown as typeof import ( 'electron-updater' ) [ 'autoUpdater' ] ,
251+ probeOriginFeed : async ( ) => true ,
252+ canSelfUpdate : ( ) => capability ,
253+ } )
254+
255+ handle . check ( )
256+ expect ( states ) . toEqual ( [ { status : 'checking' } ] )
257+
258+ resolveCapability ?.( true )
259+ await vi . advanceTimersByTimeAsync ( 0 )
260+ expect ( autoUpdaterMock . checkForUpdates ) . toHaveBeenCalledTimes ( 1 )
261+ } )
262+
219263 it ( 'resets to idle when a downloaded update is a blocked downgrade' , async ( ) => {
220264 const { handle } = await createUpdater ( )
221265 emit ( 'update-downloaded' , { version : '0.0.1' } )
@@ -224,6 +268,17 @@ describe('initUpdater state machine', () => {
224268 expect ( autoUpdaterMock . quitAndInstall ) . not . toHaveBeenCalled ( )
225269 } )
226270
271+ it ( 'never exposes an equal, older, malformed, or cross-stream candidate as an update' , async ( ) => {
272+ const { handle } = await createUpdater ( )
273+
274+ for ( const version of [ '1.0.0' , '0.9.9' , 'nightly' , '2.0.0-dev.1' ] ) {
275+ emit ( 'update-available' , { version } )
276+ expect ( handle . getState ( ) ) . toEqual ( { status : 'idle' } )
277+ }
278+
279+ expect ( autoUpdaterMock . downloadUpdate ) . not . toHaveBeenCalled ( )
280+ } )
281+
227282 it ( 'surfaces updater errors and recovers via update-not-available' , async ( ) => {
228283 const { handle } = await createUpdater ( )
229284 emit ( 'error' , new Error ( 'feed unreachable' ) )
@@ -233,7 +288,8 @@ describe('initUpdater state machine', () => {
233288 } )
234289
235290 it ( 'switches to the per-env origin feed when the origin serves one' , async ( ) => {
236- await createUpdater ( { feedAvailable : true } )
291+ const { handle } = await createUpdater ( { feedAvailable : true } )
292+ handle . check ( )
237293 await vi . advanceTimersByTimeAsync ( 0 )
238294 expect ( autoUpdaterMock . setFeedURL ) . toHaveBeenCalledWith ( {
239295 provider : 'generic' ,
@@ -244,27 +300,70 @@ describe('initUpdater state machine', () => {
244300 } )
245301
246302 it ( 'keeps the packaged GitHub feed when the origin has no feed' , async ( ) => {
247- await createUpdater ( { feedAvailable : false } )
303+ const { handle } = await createUpdater ( { feedAvailable : false } )
304+ handle . check ( )
248305 await vi . advanceTimersByTimeAsync ( 0 )
249306 expect ( autoUpdaterMock . setFeedURL ) . not . toHaveBeenCalled ( )
250307 } )
251308
252- it ( 'skips checks on prerelease builds when the origin feed is down' , async ( ) => {
309+ it ( 'completes an interactive check immediately when the environment has no release' , async ( ) => {
310+ const { handle, states } = await createUpdater ( { feedAvailable : 'no-release' } )
311+
312+ handle . check ( )
313+ await vi . advanceTimersByTimeAsync ( 0 )
314+
315+ expect ( states ) . toEqual ( [ { status : 'checking' } , { status : 'idle' } ] )
316+ expect ( autoUpdaterMock . checkForUpdates ) . not . toHaveBeenCalled ( )
317+ } )
318+
319+ it ( 're-probes a no-release environment so a newly published update appears without restart' , async ( ) => {
320+ const probeOriginFeed = vi
321+ . fn < ( feedUrl : string ) => Promise < boolean | 'no-release' > > ( )
322+ . mockResolvedValueOnce ( 'no-release' )
323+ . mockResolvedValueOnce ( true )
324+ const { handle } = await createUpdater ( { probeOriginFeed } )
325+
326+ handle . check ( )
327+ await vi . advanceTimersByTimeAsync ( 0 )
328+ expect ( handle . getState ( ) ) . toEqual ( { status : 'idle' } )
329+ expect ( autoUpdaterMock . checkForUpdates ) . not . toHaveBeenCalled ( )
330+
331+ handle . check ( )
332+ await vi . advanceTimersByTimeAsync ( 0 )
333+ expect ( probeOriginFeed ) . toHaveBeenCalledTimes ( 2 )
334+ expect ( autoUpdaterMock . setFeedURL ) . toHaveBeenCalledTimes ( 1 )
335+ expect ( autoUpdaterMock . checkForUpdates ) . toHaveBeenCalledTimes ( 1 )
336+ } )
337+
338+ it ( 'recovers an interactive check when the feed probe times out' , async ( ) => {
339+ const probeOriginFeed = vi . fn ( ( ) => new Promise < boolean > ( ( ) => { } ) )
340+ const { handle } = await createUpdater ( { probeOriginFeed } )
341+
342+ handle . check ( )
343+ expect ( handle . getState ( ) ) . toEqual ( { status : 'checking' } )
344+ await vi . advanceTimersByTimeAsync ( 10_000 )
345+
346+ expect ( handle . getState ( ) ) . toEqual ( { status : 'error' } )
347+ expect ( autoUpdaterMock . checkForUpdates ) . not . toHaveBeenCalled ( )
348+ } )
349+
350+ it ( 'fails interactive checks promptly on prerelease builds when the origin feed is down' , async ( ) => {
253351 // The GitHub fallback is stable-only: a Sim Dev shell can never apply a
254352 // prod-identity artifact, so it must not check against it.
255- vi . mocked ( app . getVersion ) . mockReturnValue ( '1.0.1-alpha .7' )
353+ vi . mocked ( app . getVersion ) . mockReturnValue ( '1.0.1-dev .7' )
256354 try {
257355 const { handle } = await createUpdater ( { feedAvailable : false } )
258356 handle . check ( )
259357 await vi . advanceTimersByTimeAsync ( 0 )
260358 expect ( autoUpdaterMock . checkForUpdates ) . not . toHaveBeenCalled ( )
359+ expect ( handle . getState ( ) ) . toEqual ( { status : 'error' } )
261360 } finally {
262361 vi . mocked ( app . getVersion ) . mockReturnValue ( '1.0.0' )
263362 }
264363 } )
265364
266365 it ( 'checks prerelease builds normally through the origin feed' , async ( ) => {
267- vi . mocked ( app . getVersion ) . mockReturnValue ( '1.0.1-alpha .7' )
366+ vi . mocked ( app . getVersion ) . mockReturnValue ( '1.0.1-dev .7' )
268367 try {
269368 const { handle } = await createUpdater ( { feedAvailable : true } )
270369 handle . check ( )
@@ -276,8 +375,8 @@ describe('initUpdater state machine', () => {
276375 } )
277376
278377 it . each ( [
279- [ '1.0.1-alpha .7' , 5 * 60 * 1000 ] ,
280- [ '1.0.1-beta .7' , 5 * 60 * 1000 ] ,
378+ [ '1.0.1-dev .7' , 5 * 60 * 1000 ] ,
379+ [ '1.0.1-staging .7' , 5 * 60 * 1000 ] ,
281380 [ '1.0.1' , 30 * 60 * 1000 ] ,
282381 ] ) ( 'schedules %s update polling every %i milliseconds' , async ( version , interval ) => {
283382 vi . mocked ( app . getVersion ) . mockReturnValue ( version )
@@ -505,11 +604,34 @@ describe('checkForUpdatesInteractive', () => {
505604 await vi . advanceTimersByTimeAsync ( 0 )
506605
507606 expect ( dialog . showMessageBox ) . toHaveBeenCalledWith (
508- expect . objectContaining ( { message : 'Sim is up to date' } )
607+ expect . objectContaining ( {
608+ type : 'info' ,
609+ buttons : [ 'OK' ] ,
610+ defaultId : 0 ,
611+ message : 'You’re up to date!' ,
612+ detail : `Sim ${ app . getVersion ( ) } is currently the newest version available.` ,
613+ } )
509614 )
510615 expect ( shell . openExternal ) . not . toHaveBeenCalled ( )
511616 } )
512617
618+ it ( 'fails a hung interactive check after twelve seconds instead of waiting thirty' , async ( ) => {
619+ const handle : UpdaterHandle = {
620+ setAutoDownload : ( ) => { } ,
621+ getState : ( ) => ( { status : 'checking' } ) ,
622+ check : vi . fn ( ) ,
623+ install : vi . fn ( ) ,
624+ onState : ( ) => ( ) => { } ,
625+ }
626+
627+ checkForUpdatesInteractive ( { getWindow : ( ) => null , events, handle } )
628+ await vi . advanceTimersByTimeAsync ( 12_000 )
629+
630+ expect ( dialog . showMessageBox ) . toHaveBeenCalledWith (
631+ expect . objectContaining ( { message : 'Could not check for updates' } )
632+ )
633+ } )
634+
513635 it ( 'only explains packaged-build updates when unpackaged' , async ( ) => {
514636 ; ( app as unknown as { isPackaged : boolean } ) . isPackaged = false
515637 checkForUpdatesInteractive ( { getWindow : ( ) => null , events, handle : null } )
0 commit comments