55 existsSync ,
66 mkdirSync ,
77 mkdtempSync ,
8+ readFileSync ,
89 rmSync ,
910 symlinkSync ,
1011 unlinkSync ,
@@ -13,7 +14,7 @@ import {
1314import { tmpdir } from 'node:os'
1415import { dirname , join } from 'node:path'
1516import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
16- import { installUpdate , type PackageManager } from '#sim-cli/update/install'
17+ import { CliUpdateError , installUpdate , type PackageManager } from '#sim-cli/update/install'
1718
1819let directory : string
1920let packageRoot : string
@@ -63,6 +64,13 @@ describe('installing a CLI update', () => {
6364 const run = vi
6465 . fn ( )
6566 . mockResolvedValueOnce ( globalDirectory )
67+ . mockResolvedValueOnce (
68+ packageManager === 'yarn'
69+ ? JSON . stringify ( { type : 'inspect' , data : '2.1.5' } ) +
70+ '\n' +
71+ JSON . stringify ( { type : 'finished' , data : 1 } )
72+ : JSON . stringify ( '2.1.5' )
73+ )
6674 . mockImplementationOnce ( ( ) => {
6775 writeVersion ( '2.1.5' )
6876 return Promise . resolve ( '' )
@@ -72,10 +80,10 @@ describe('installing a CLI update', () => {
7280
7381 const expectedArgs =
7482 packageManager === 'npm'
75- ? [ 'install' , '-g' , 'sim@latest ' ]
83+ ? [ 'install' , '-g' , 'sim@2.1.5 ' ]
7684 : packageManager === 'yarn'
77- ? [ 'global' , 'add' , 'sim@latest ' ]
78- : [ 'add' , '-g' , 'sim@latest ' ]
85+ ? [ 'global' , 'add' , 'sim@2.1.5 ' ]
86+ : [ 'add' , '-g' , 'sim@2.1.5 ' ]
7987 expect ( run ) . toHaveBeenLastCalledWith ( packageManager , expectedArgs , {
8088 env : { SIM_NO_UPDATE_CHECK : '1' } ,
8189 capture : false ,
@@ -91,14 +99,21 @@ describe('installing a CLI update', () => {
9199 [ '2.1.2-dev.123.1' , 'dev' ] ,
92100 ] ) ( 'preserves the release channel for %s' , async ( currentVersion , tag ) => {
93101 writeVersion ( currentVersion )
94- const run = vi . fn ( ) . mockResolvedValue ( join ( directory , 'node_modules' ) )
102+ const run = vi
103+ . fn ( )
104+ . mockResolvedValueOnce ( join ( directory , 'node_modules' ) )
105+ . mockResolvedValueOnce ( JSON . stringify ( currentVersion ) )
95106 await installUpdate ( { ...options ( ) , currentVersion, run } )
96- expect ( run . mock . calls [ 1 ] [ 1 ] ) . toEqual ( [ 'install' , '-g' , `sim@${ tag } ` ] )
107+ expect ( run . mock . calls [ 1 ] [ 1 ] ) . toEqual ( [ 'view' , `sim@${ tag } ` , 'version' , '--json' ] )
108+ expect ( run ) . toHaveBeenCalledTimes ( 2 )
97109 } )
98110
99111 it ( 'does not pass the Sim API key to the package manager or change the parent environment' , async ( ) => {
100112 const env = { SIM_API_KEY : 'private' , npm_config_registry : 'https://registry.example' }
101- const run = vi . fn ( ) . mockResolvedValue ( join ( directory , 'node_modules' ) )
113+ const run = vi
114+ . fn ( )
115+ . mockResolvedValueOnce ( join ( directory , 'node_modules' ) )
116+ . mockResolvedValueOnce ( JSON . stringify ( '2.1.2' ) )
102117 await installUpdate ( { ...options ( ) , env, run } )
103118 expect ( run . mock . calls [ 1 ] [ 2 ] . env ) . toEqual ( {
104119 npm_config_registry : 'https://registry.example' ,
@@ -110,11 +125,129 @@ describe('installing a CLI update', () => {
110125 it ( 'reports an already current installation' , async ( ) => {
111126 await installUpdate ( {
112127 ...options ( ) ,
113- run : vi . fn ( ) . mockResolvedValue ( join ( directory , 'node_modules' ) ) ,
128+ run : vi
129+ . fn ( )
130+ . mockResolvedValueOnce ( join ( directory , 'node_modules' ) )
131+ . mockResolvedValueOnce ( JSON . stringify ( '2.1.2' ) ) ,
114132 } )
115133 expect ( output . join ( '' ) ) . toContain ( 'already up to date' )
116134 } )
117135
136+ it . each ( [
137+ [ '2.1.5' , '2.1.2' ] ,
138+ [ '2.1.10' , '2.1.9' ] ,
139+ [ '3.0.0' , '2.99.99' ] ,
140+ [ '2.1.5-preview.10.1' , '2.1.5-preview.9.9' ] ,
141+ [ '2.1.5-dev.10.2' , '2.1.5-dev.10.1' ] ,
142+ ] ) ( 'refuses to downgrade %s to %s before installation' , async ( currentVersion , candidate ) => {
143+ writeVersion ( currentVersion )
144+ const run = vi
145+ . fn ( )
146+ . mockResolvedValueOnce ( join ( directory , 'node_modules' ) )
147+ . mockResolvedValueOnce ( JSON . stringify ( candidate ) )
148+ await expect ( installUpdate ( { ...options ( ) , currentVersion, run } ) ) . rejects . toThrow (
149+ 'Refusing to downgrade'
150+ )
151+ expect ( run ) . toHaveBeenCalledTimes ( 2 )
152+ expect ( readFileSync ( join ( packageRoot , 'package.json' ) , 'utf8' ) ) . toContain ( currentVersion )
153+ expect ( existsSync ( `${ packageRoot } .lock` ) ) . toBe ( false )
154+ } )
155+
156+ it . each ( [
157+ [ '2.1.9' , '2.1.10' ] ,
158+ [ '2.1.5-preview.9.9' , '2.1.5-preview.10.1' ] ,
159+ [ '2.1.5-dev.10.9' , '2.1.5-dev.10.10' ] ,
160+ ] ) (
161+ 'compares numeric release components when updating %s to %s' ,
162+ async ( currentVersion , candidate ) => {
163+ writeVersion ( currentVersion )
164+ const run = vi
165+ . fn ( )
166+ . mockResolvedValueOnce ( join ( directory , 'node_modules' ) )
167+ . mockResolvedValueOnce ( JSON . stringify ( candidate ) )
168+ . mockImplementationOnce ( async ( ) => {
169+ writeVersion ( candidate )
170+ return ''
171+ } )
172+ await installUpdate ( { ...options ( ) , currentVersion, run } )
173+ expect ( run . mock . calls [ 2 ] [ 1 ] ) . toEqual ( [ 'install' , '-g' , `sim@${ candidate } ` ] )
174+ }
175+ )
176+
177+ it ( 'does not reinstall versions that differ only in build metadata' , async ( ) => {
178+ writeVersion ( '2.1.2+local' )
179+ const run = vi
180+ . fn ( )
181+ . mockResolvedValueOnce ( join ( directory , 'node_modules' ) )
182+ . mockResolvedValueOnce ( JSON . stringify ( '2.1.2+registry' ) )
183+ await installUpdate ( { ...options ( ) , currentVersion : '2.1.2+local' , run } )
184+ expect ( run ) . toHaveBeenCalledTimes ( 2 )
185+ expect ( output . join ( '' ) ) . toContain ( 'already up to date' )
186+ } )
187+
188+ it . each ( [
189+ '"2.1.5-dev.1.1"' ,
190+ '"invalid"' ,
191+ '"2.1.5; echo unsafe"' ,
192+ '"2.1.05"' ,
193+ '{' ,
194+ '["2.1.5"]' ,
195+ 'null' ,
196+ ] ) (
197+ 'rejects invalid or wrong-channel registry metadata without installing: %s' ,
198+ async ( metadata ) => {
199+ const run = vi
200+ . fn ( )
201+ . mockResolvedValueOnce ( join ( directory , 'node_modules' ) )
202+ . mockResolvedValueOnce ( metadata )
203+ await expect ( installUpdate ( { ...options ( ) , run } ) ) . rejects . toBeInstanceOf ( CliUpdateError )
204+ expect ( run ) . toHaveBeenCalledTimes ( 2 )
205+ expect ( output ) . toEqual ( [ ] )
206+ }
207+ )
208+
209+ it ( 'rejects ambiguous Yarn version events' , async ( ) => {
210+ const event = JSON . stringify ( { type : 'inspect' , data : '2.1.5' } )
211+ const run = vi . fn ( ) . mockResolvedValueOnce ( directory ) . mockResolvedValueOnce ( `${ event } \n${ event } ` )
212+ await expect ( installUpdate ( { ...options ( ) , packageManager : 'yarn' , run } ) ) . rejects . toThrow (
213+ 'single Sim release'
214+ )
215+ expect ( run ) . toHaveBeenCalledTimes ( 2 )
216+ } )
217+
218+ it ( 'refuses to install if the current installation changed while locating it' , async ( ) => {
219+ const run = vi . fn ( ) . mockImplementationOnce ( async ( ) => {
220+ writeVersion ( '2.1.6' )
221+ return join ( directory , 'node_modules' )
222+ } )
223+ await expect ( installUpdate ( { ...options ( ) , run } ) ) . rejects . toThrow ( 'installation changed' )
224+ expect ( run ) . toHaveBeenCalledTimes ( 1 )
225+ } )
226+
227+ it ( 'normalizes a missing installation entry' , async ( ) => {
228+ const run = vi . fn ( ) . mockResolvedValue ( join ( directory , 'missing' ) )
229+ await expect ( installUpdate ( { ...options ( ) , run } ) ) . rejects . toMatchObject ( {
230+ constructor : CliUpdateError ,
231+ message : expect . stringContaining ( 'Cannot access the Sim installation' ) ,
232+ } )
233+ } )
234+
235+ it . each ( [ 'missing' , 'malformed' , 'directory' ] ) (
236+ 'normalizes a %s installed manifest' ,
237+ async ( failure ) => {
238+ const manifestPath = join ( packageRoot , 'package.json' )
239+ rmSync ( manifestPath )
240+ if ( failure === 'malformed' ) writeFileSync ( manifestPath , '{' )
241+ if ( failure === 'directory' ) mkdirSync ( manifestPath )
242+ const run = vi . fn ( ) . mockResolvedValue ( join ( directory , 'node_modules' ) )
243+ await expect ( installUpdate ( { ...options ( ) , run } ) ) . rejects . toMatchObject ( {
244+ constructor : CliUpdateError ,
245+ message : expect . stringContaining ( 'Cannot read the installed Sim manifest' ) ,
246+ } )
247+ expect ( existsSync ( `${ packageRoot } .lock` ) ) . toBe ( false )
248+ }
249+ )
250+
118251 it ( 'verifies the new pnpm symlink instead of reading the old version directory' , async ( ) => {
119252 const globalRoot = join ( directory , 'global/node_modules' )
120253 mkdirSync ( globalRoot , { recursive : true } )
@@ -127,6 +260,7 @@ describe('installing a CLI update', () => {
127260 const run = vi
128261 . fn ( )
129262 . mockResolvedValueOnce ( globalRoot )
263+ . mockResolvedValueOnce ( JSON . stringify ( '2.1.5' ) )
130264 . mockImplementationOnce ( ( ) => {
131265 unlinkSync ( link )
132266 symlinkSync ( nextRoot , link )
@@ -199,6 +333,7 @@ describe('installing a CLI update', () => {
199333 const run = vi
200334 . fn ( )
201335 . mockResolvedValueOnce ( join ( directory , 'node_modules' ) )
336+ . mockResolvedValueOnce ( JSON . stringify ( '2.1.5' ) )
202337 . mockRejectedValueOnce ( new Error ( 'permission denied' ) )
203338 await expect ( installUpdate ( { ...options ( ) , run } ) ) . rejects . toThrow ( 'permission denied' )
204339 expect ( existsSync ( `${ packageRoot } .lock` ) ) . toBe ( false )
@@ -211,19 +346,23 @@ describe('installing a CLI update', () => {
211346 const run = vi
212347 . fn ( )
213348 . mockResolvedValueOnce ( join ( directory , 'node_modules' ) )
349+ . mockResolvedValueOnce ( JSON . stringify ( '2.1.5' ) )
214350 . mockImplementationOnce ( ( ) => {
215351 writeVersion ( version )
216352 return Promise . resolve ( '' )
217353 } )
218- await expect ( installUpdate ( { ...options ( ) , run } ) ) . rejects . toThrow ( 'release channel ' )
354+ await expect ( installUpdate ( { ...options ( ) , run } ) ) . rejects . toThrow ( 'expected Sim version ' )
219355 expect ( output . join ( '' ) ) . not . toContain ( 'Updated Sim' )
220356 }
221357 )
222358
223359 it ( 'refuses concurrent updates before a second installer starts' , async ( ) => {
224360 mkdirSync ( `${ packageRoot } .lock` )
225361 const run = vi . fn ( ) . mockResolvedValue ( join ( directory , 'node_modules' ) )
226- await expect ( installUpdate ( { ...options ( ) , run } ) ) . rejects . toThrow ( 'already being held' )
362+ await expect ( installUpdate ( { ...options ( ) , run } ) ) . rejects . toMatchObject ( {
363+ constructor : CliUpdateError ,
364+ message : expect . stringContaining ( 'already being held' ) ,
365+ } )
227366 expect ( run ) . toHaveBeenCalledTimes ( 1 )
228367 } )
229368} )
0 commit comments