@@ -10,6 +10,7 @@ import {
1010 formatUpdateNotice ,
1111 getUpgradeCommand ,
1212 readUpdateCache ,
13+ refreshUpdateCache ,
1314 type UpdateCache ,
1415 writeUpdateCache ,
1516} from './update-notifier.ts' ;
@@ -223,10 +224,9 @@ describe('update-notifier', () => {
223224 } ) ;
224225 } ) ;
225226
226- describe ( 'checkForUpdate' , ( ) => {
227+ describe ( 'checkForUpdate (cache-only) ' , ( ) => {
227228 let testCacheDir : string ;
228229 let originalXdgCacheHome : string | undefined ;
229- let originalFetch : typeof globalThis . fetch ;
230230 let originalCI : string | undefined ;
231231 let originalNoUpdateCheck : string | undefined ;
232232
@@ -235,7 +235,6 @@ describe('update-notifier', () => {
235235 originalXdgCacheHome = process . env . XDG_CACHE_HOME ;
236236 process . env . XDG_CACHE_HOME = testCacheDir ;
237237
238- originalFetch = globalThis . fetch ;
239238 originalCI = process . env . CI ;
240239 originalNoUpdateCheck = process . env . MUX_NO_UPDATE_CHECK ;
241240
@@ -250,8 +249,6 @@ describe('update-notifier', () => {
250249 process . env . XDG_CACHE_HOME = originalXdgCacheHome ;
251250 }
252251
253- globalThis . fetch = originalFetch ;
254-
255252 if ( originalCI === undefined ) {
256253 delete process . env . CI ;
257254 } else {
@@ -280,135 +277,206 @@ describe('update-notifier', () => {
280277 } ) ;
281278
282279 it ( 'should return null when not a TTY' , async ( ) => {
283- globalThis . fetch = mock ( ( ) =>
284- Promise . resolve (
285- new Response ( JSON . stringify ( { version : '2.0.0' } ) , { status : 200 } ) ,
286- ) ,
287- ) as unknown as typeof fetch ;
288280 const result = await checkForUpdate ( '1.0.0' , { isTTY : false } ) ;
289281 expect ( result ) . toBeNull ( ) ;
290282 } ) ;
291283
292- it ( 'should return notice when newer version is available' , async ( ) => {
293- globalThis . fetch = mock ( ( ) =>
294- Promise . resolve (
295- new Response ( JSON . stringify ( { version : '2.0.0' } ) , { status : 200 } ) ,
296- ) ,
297- ) as unknown as typeof fetch ;
284+ it ( 'should return null when no cache exists' , async ( ) => {
285+ const result = await checkForUpdate ( '1.0.0' , { isTTY : true } ) ;
286+ expect ( result ) . toBeNull ( ) ;
287+ } ) ;
288+
289+ it ( 'should return notice when cache has newer version' , async ( ) => {
290+ await writeUpdateCache ( {
291+ latestVersion : '2.0.0' ,
292+ lastChecked : Date . now ( ) ,
293+ firstSeenAt : Date . now ( ) - 49 * 60 * 60 * 1000 ,
294+ } ) ;
298295 const result = await checkForUpdate ( '1.0.0' , { isTTY : true } ) ;
299296 expect ( result ) . not . toBeNull ( ) ;
300297 expect ( result ) . toContain ( '2.0.0' ) ;
301298 } ) ;
302299
303- it ( 'should return null when already on latest version' , async ( ) => {
300+ it ( 'should return null when cache version equals current' , async ( ) => {
301+ await writeUpdateCache ( {
302+ latestVersion : '1.0.0' ,
303+ lastChecked : Date . now ( ) ,
304+ firstSeenAt : Date . now ( ) ,
305+ } ) ;
306+ const result = await checkForUpdate ( '1.0.0' , { isTTY : true } ) ;
307+ expect ( result ) . toBeNull ( ) ;
308+ } ) ;
309+
310+ it ( 'should suppress notification for Homebrew when version is recent' , async ( ) => {
311+ await writeUpdateCache ( {
312+ latestVersion : '2.0.0' ,
313+ lastChecked : Date . now ( ) ,
314+ firstSeenAt : Date . now ( ) , // just discovered
315+ } ) ;
316+ const result = await checkForUpdate ( '1.0.0' , {
317+ isTTY : true ,
318+ execPath : '/opt/homebrew/bin/mux' ,
319+ } ) ;
320+ expect ( result ) . toBeNull ( ) ;
321+ } ) ;
322+
323+ it ( 'should show notification for Homebrew when version is old enough' , async ( ) => {
324+ await writeUpdateCache ( {
325+ latestVersion : '2.0.0' ,
326+ lastChecked : Date . now ( ) ,
327+ firstSeenAt : Date . now ( ) - 49 * 60 * 60 * 1000 , // 49 hours ago
328+ } ) ;
329+ const result = await checkForUpdate ( '1.0.0' , {
330+ isTTY : true ,
331+ execPath : '/opt/homebrew/bin/mux' ,
332+ } ) ;
333+ expect ( result ) . not . toBeNull ( ) ;
334+ expect ( result ) . toContain ( '2.0.0' ) ;
335+ } ) ;
336+
337+ it ( 'should not apply Homebrew delay for npm installs' , async ( ) => {
338+ await writeUpdateCache ( {
339+ latestVersion : '2.0.0' ,
340+ lastChecked : Date . now ( ) ,
341+ firstSeenAt : Date . now ( ) , // just discovered, but npm — no delay
342+ } ) ;
343+ const result = await checkForUpdate ( '1.0.0' , {
344+ isTTY : true ,
345+ execPath : '/usr/local/lib/node_modules/@mux/cli/bin/mux' ,
346+ } ) ;
347+ expect ( result ) . not . toBeNull ( ) ;
348+ expect ( result ) . toContain ( '2.0.0' ) ;
349+ } ) ;
350+ } ) ;
351+
352+ describe ( 'refreshUpdateCache' , ( ) => {
353+ let testCacheDir : string ;
354+ let originalXdgCacheHome : string | undefined ;
355+ let originalFetch : typeof globalThis . fetch ;
356+
357+ beforeEach ( async ( ) => {
358+ testCacheDir = join ( tmpdir ( ) , `mux-cli-test-cache-${ Date . now ( ) } ` ) ;
359+ originalXdgCacheHome = process . env . XDG_CACHE_HOME ;
360+ process . env . XDG_CACHE_HOME = testCacheDir ;
361+ originalFetch = globalThis . fetch ;
362+ } ) ;
363+
364+ afterEach ( async ( ) => {
365+ if ( originalXdgCacheHome === undefined ) {
366+ delete process . env . XDG_CACHE_HOME ;
367+ } else {
368+ process . env . XDG_CACHE_HOME = originalXdgCacheHome ;
369+ }
370+ globalThis . fetch = originalFetch ;
371+ await rm ( testCacheDir , { recursive : true , force : true } ) ;
372+ } ) ;
373+
374+ it ( 'should fetch and write cache when no cache exists' , async ( ) => {
304375 globalThis . fetch = mock ( ( ) =>
305376 Promise . resolve (
306- new Response ( JSON . stringify ( { version : '1 .0.0' } ) , { status : 200 } ) ,
377+ new Response ( JSON . stringify ( { version : '2 .0.0' } ) , { status : 200 } ) ,
307378 ) ,
308379 ) as unknown as typeof fetch ;
309- const result = await checkForUpdate ( '1.0.0' , { isTTY : true } ) ;
310- expect ( result ) . toBeNull ( ) ;
380+
381+ await refreshUpdateCache ( ) ;
382+
383+ const cache = await readUpdateCache ( ) ;
384+ expect ( cache ) . not . toBeNull ( ) ;
385+ expect ( cache ?. latestVersion ) . toBe ( '2.0.0' ) ;
311386 } ) ;
312387
313- it ( 'should use cached version when cache is fresh' , async ( ) => {
314- const now = Date . now ( ) ;
315- const cache : UpdateCache = {
316- latestVersion : '3.0.0' ,
317- lastChecked : now ,
318- firstSeenAt : now - 49 * 60 * 60 * 1000 , // 49 hours ago (past Homebrew delay)
319- } ;
320- await writeUpdateCache ( cache ) ;
388+ it ( 'should skip fetch when cache is fresh' , async ( ) => {
389+ await writeUpdateCache ( {
390+ latestVersion : '2.0.0' ,
391+ lastChecked : Date . now ( ) ,
392+ firstSeenAt : Date . now ( ) ,
393+ } ) ;
321394
322- // fetch should NOT be called
323395 const fetchMock = mock ( ( ) =>
324396 Promise . resolve (
325- new Response ( JSON . stringify ( { version : '4 .0.0' } ) , { status : 200 } ) ,
397+ new Response ( JSON . stringify ( { version : '3 .0.0' } ) , { status : 200 } ) ,
326398 ) ,
327399 ) as unknown as typeof fetch ;
328400 globalThis . fetch = fetchMock ;
329401
330- const result = await checkForUpdate ( '1.0.0' , { isTTY : true } ) ;
331- expect ( result ) . toContain ( '3.0.0' ) ; // cached version, not 4.0.0
402+ await refreshUpdateCache ( ) ;
403+
332404 expect ( fetchMock ) . not . toHaveBeenCalled ( ) ;
405+ const cache = await readUpdateCache ( ) ;
406+ expect ( cache ?. latestVersion ) . toBe ( '2.0.0' ) ;
333407 } ) ;
334408
335409 it ( 'should fetch when cache is stale' , async ( ) => {
336- const staleTime = Date . now ( ) - 25 * 60 * 60 * 1000 ; // 25 hours ago
337- const cache : UpdateCache = {
410+ const staleTime = Date . now ( ) - 25 * 60 * 60 * 1000 ;
411+ await writeUpdateCache ( {
338412 latestVersion : '2.0.0' ,
339413 lastChecked : staleTime ,
340414 firstSeenAt : staleTime ,
341- } ;
342- await writeUpdateCache ( cache ) ;
415+ } ) ;
343416
344417 globalThis . fetch = mock ( ( ) =>
345418 Promise . resolve (
346419 new Response ( JSON . stringify ( { version : '3.0.0' } ) , { status : 200 } ) ,
347420 ) ,
348421 ) as unknown as typeof fetch ;
349422
350- const result = await checkForUpdate ( '1.0.0' , { isTTY : true } ) ;
351- expect ( result ) . toContain ( '3.0.0' ) ; // fetched version
352- } ) ;
423+ await refreshUpdateCache ( ) ;
353424
354- it ( 'should return null when fetch fails and no cache exists' , async ( ) => {
355- globalThis . fetch = mock ( ( ) =>
356- Promise . reject ( new Error ( 'network error' ) ) ,
357- ) as unknown as typeof fetch ;
358- const result = await checkForUpdate ( '1.0.0' , { isTTY : true } ) ;
359- expect ( result ) . toBeNull ( ) ;
425+ const cache = await readUpdateCache ( ) ;
426+ expect ( cache ?. latestVersion ) . toBe ( '3.0.0' ) ;
360427 } ) ;
361428
362- it ( 'should suppress notification for Homebrew when version is recent' , async ( ) => {
429+ it ( 'should preserve firstSeenAt when version is unchanged' , async ( ) => {
430+ const originalFirstSeen = Date . now ( ) - 10 * 60 * 60 * 1000 ;
431+ const staleTime = Date . now ( ) - 25 * 60 * 60 * 1000 ;
432+ await writeUpdateCache ( {
433+ latestVersion : '2.0.0' ,
434+ lastChecked : staleTime ,
435+ firstSeenAt : originalFirstSeen ,
436+ } ) ;
437+
363438 globalThis . fetch = mock ( ( ) =>
364439 Promise . resolve (
365440 new Response ( JSON . stringify ( { version : '2.0.0' } ) , { status : 200 } ) ,
366441 ) ,
367442 ) as unknown as typeof fetch ;
368- const result = await checkForUpdate ( '1.0.0' , {
369- isTTY : true ,
370- execPath : '/opt/homebrew/bin/mux' ,
371- } ) ;
372- // Version was just discovered (firstSeenAt = now), so Homebrew delay kicks in
373- expect ( result ) . toBeNull ( ) ;
443+
444+ await refreshUpdateCache ( ) ;
445+
446+ const cache = await readUpdateCache ( ) ;
447+ expect ( cache ?. firstSeenAt ) . toBe ( originalFirstSeen ) ;
374448 } ) ;
375449
376- it ( 'should show notification for Homebrew when version is old enough ' , async ( ) => {
377- const oldTime = Date . now ( ) - 49 * 60 * 60 * 1000 ; // 49 hours ago
378- const cache : UpdateCache = {
450+ it ( 'should reset firstSeenAt when version changes ' , async ( ) => {
451+ const staleTime = Date . now ( ) - 25 * 60 * 60 * 1000 ;
452+ await writeUpdateCache ( {
379453 latestVersion : '2.0.0' ,
380- lastChecked : Date . now ( ) ,
381- firstSeenAt : oldTime ,
382- } ;
383- await writeUpdateCache ( cache ) ;
454+ lastChecked : staleTime ,
455+ firstSeenAt : staleTime ,
456+ } ) ;
384457
385458 globalThis . fetch = mock ( ( ) =>
386459 Promise . resolve (
387- new Response ( JSON . stringify ( { version : '2 .0.0' } ) , { status : 200 } ) ,
460+ new Response ( JSON . stringify ( { version : '3 .0.0' } ) , { status : 200 } ) ,
388461 ) ,
389462 ) as unknown as typeof fetch ;
390463
391- const result = await checkForUpdate ( '1.0.0' , {
392- isTTY : true ,
393- execPath : '/opt/homebrew/bin/mux' ,
394- } ) ;
395- expect ( result ) . not . toBeNull ( ) ;
396- expect ( result ) . toContain ( '2.0.0' ) ;
464+ const before = Date . now ( ) ;
465+ await refreshUpdateCache ( ) ;
466+
467+ const cache = await readUpdateCache ( ) ;
468+ expect ( cache ?. firstSeenAt ) . toBeGreaterThanOrEqual ( before ) ;
397469 } ) ;
398470
399- it ( 'should not apply Homebrew delay for npm installs ' , async ( ) => {
471+ it ( 'should not write cache when fetch fails ' , async ( ) => {
400472 globalThis . fetch = mock ( ( ) =>
401- Promise . resolve (
402- new Response ( JSON . stringify ( { version : '2.0.0' } ) , { status : 200 } ) ,
403- ) ,
473+ Promise . reject ( new Error ( 'network error' ) ) ,
404474 ) as unknown as typeof fetch ;
405- const result = await checkForUpdate ( '1.0.0' , {
406- isTTY : true ,
407- execPath : '/usr/local/lib/node_modules/@mux/cli/bin/mux' ,
408- } ) ;
409- // npm install — no delay, should show notice immediately
410- expect ( result ) . not . toBeNull ( ) ;
411- expect ( result ) . toContain ( '2.0.0' ) ;
475+
476+ await refreshUpdateCache ( ) ;
477+
478+ const cache = await readUpdateCache ( ) ;
479+ expect ( cache ) . toBeNull ( ) ;
412480 } ) ;
413481 } ) ;
414482} ) ;
0 commit comments