1import spawnAsync from '@expo/spawn-async'; 2import { vol } from 'memfs'; 3import path from 'path'; 4 5import { mockSpawnPromise, mockedSpawnAsync, STUB_SPAWN_CHILD } from '../../__tests__/spawn-utils'; 6import { NpmPackageManager } from '../NpmPackageManager'; 7 8jest.mock('@expo/spawn-async'); 9jest.mock('fs'); 10 11beforeAll(() => { 12 // Disable logging to clean up test ouput 13 jest.spyOn(console, 'log').mockImplementation(); 14}); 15 16describe('NpmPackageManager', () => { 17 const projectRoot = '/project/with-npm'; 18 19 it('name is set to npm', () => { 20 const npm = new NpmPackageManager({ cwd: projectRoot }); 21 expect(npm.name).toBe('npm'); 22 }); 23 24 describe('getDefaultEnvironment', () => { 25 it('runs npm with ADBLOCK=1 and DISABLE_OPENCOLLECTIVE=1', async () => { 26 const npm = new NpmPackageManager({ cwd: projectRoot }); 27 await npm.installAsync(); 28 29 expect(spawnAsync).toBeCalledWith( 30 expect.anything(), 31 expect.anything(), 32 expect.objectContaining({ 33 env: expect.objectContaining({ ADBLOCK: '1', DISABLE_OPENCOLLECTIVE: '1' }), 34 }) 35 ); 36 }); 37 38 it('runs with overwritten default environment', async () => { 39 const npm = new NpmPackageManager({ cwd: projectRoot, env: { ADBLOCK: '0' } }); 40 await npm.installAsync(); 41 42 expect(spawnAsync).toBeCalledWith( 43 expect.anything(), 44 expect.anything(), 45 expect.objectContaining({ 46 env: { ADBLOCK: '0', DISABLE_OPENCOLLECTIVE: '1' }, 47 }) 48 ); 49 }); 50 }); 51 52 describe('runAsync', () => { 53 it('logs executed command', async () => { 54 const log = jest.fn(); 55 const npm = new NpmPackageManager({ cwd: projectRoot, log }); 56 await npm.runAsync(['install', '--some-flag']); 57 expect(log).toHaveBeenCalledWith('> npm install --some-flag'); 58 }); 59 60 it('inherits stdio output without silent', async () => { 61 const npm = new NpmPackageManager({ cwd: projectRoot }); 62 await npm.runAsync(['install']); 63 64 expect(spawnAsync).toBeCalledWith( 65 expect.anything(), 66 expect.anything(), 67 expect.objectContaining({ stdio: 'inherit' }) 68 ); 69 }); 70 71 it('does not inherit stdio with silent', async () => { 72 const npm = new NpmPackageManager({ cwd: projectRoot, silent: true }); 73 await npm.runAsync(['install']); 74 75 expect(spawnAsync).toBeCalledWith( 76 expect.anything(), 77 expect.anything(), 78 expect.objectContaining({ stdio: undefined }) 79 ); 80 }); 81 82 it('returns spawn promise with child', () => { 83 const npm = new NpmPackageManager({ cwd: projectRoot }); 84 expect(npm.runAsync(['install'])).toHaveProperty( 85 'child', 86 expect.objectContaining(STUB_SPAWN_CHILD) 87 ); 88 }); 89 90 it('adds a single package with custom parameters', async () => { 91 const npm = new NpmPackageManager({ cwd: projectRoot }); 92 await npm.runAsync(['install', '--save-peer', '@babel/core']); 93 94 expect(spawnAsync).toBeCalledWith( 95 'npm', 96 ['install', '--save-peer', '@babel/core'], 97 expect.objectContaining({ cwd: projectRoot }) 98 ); 99 }); 100 101 it('adds multiple packages with custom parameters', async () => { 102 const npm = new NpmPackageManager({ cwd: projectRoot }); 103 await npm.runAsync(['install', '--save-peer', '@babel/core', '@babel/runtime']); 104 105 expect(spawnAsync).toBeCalledWith( 106 'npm', 107 ['install', '--save-peer', '@babel/core', '@babel/runtime'], 108 expect.objectContaining({ cwd: projectRoot }) 109 ); 110 }); 111 }); 112 113 describe('versionAsync', () => { 114 it('returns version from npm', async () => { 115 mockedSpawnAsync.mockImplementation(() => 116 mockSpawnPromise(Promise.resolve({ stdout: '7.0.0\n' })) 117 ); 118 119 const npm = new NpmPackageManager({ cwd: projectRoot }); 120 121 expect(await npm.versionAsync()).toBe('7.0.0'); 122 expect(spawnAsync).toBeCalledWith('npm', ['--version'], expect.anything()); 123 }); 124 }); 125 126 describe('getConfigAsync', () => { 127 it('returns a configuration key from npm', async () => { 128 mockedSpawnAsync.mockImplementation(() => 129 mockSpawnPromise(Promise.resolve({ stdout: 'https://custom.registry.org/\n' })) 130 ); 131 132 const npm = new NpmPackageManager({ cwd: projectRoot }); 133 134 expect(await npm.getConfigAsync('registry')).toBe('https://custom.registry.org/'); 135 expect(spawnAsync).toBeCalledWith('npm', ['config', 'get', 'registry'], expect.anything()); 136 }); 137 }); 138 139 describe('installAsync', () => { 140 it('runs normal installation', async () => { 141 const npm = new NpmPackageManager({ cwd: projectRoot }); 142 await npm.installAsync(); 143 144 expect(spawnAsync).toBeCalledWith( 145 'npm', 146 ['install'], 147 expect.objectContaining({ cwd: projectRoot }) 148 ); 149 }); 150 151 it('runs installation with flags', async () => { 152 const npm = new NpmPackageManager({ cwd: projectRoot }); 153 await npm.installAsync(['--ignore-scripts']); 154 155 expect(spawnAsync).toBeCalledWith( 156 'npm', 157 ['install', '--ignore-scripts'], 158 expect.objectContaining({ cwd: projectRoot }) 159 ); 160 }); 161 }); 162 163 describe('uninstallAsync', () => { 164 afterEach(() => vol.reset()); 165 166 it('removes node_modules folder relative to cwd', async () => { 167 vol.fromJSON( 168 { 169 'package.json': '{}', 170 'node_modules/expo/package.json': '{}', 171 }, 172 projectRoot 173 ); 174 175 const npm = new NpmPackageManager({ cwd: projectRoot }); 176 await npm.uninstallAsync(); 177 178 expect(vol.existsSync(path.join(projectRoot, 'node_modules'))).toBe(false); 179 }); 180 181 it('skips removing non-existing node_modules folder', async () => { 182 vol.fromJSON({ 'package.json': '{}' }, projectRoot); 183 184 const npm = new NpmPackageManager({ cwd: projectRoot }); 185 await npm.uninstallAsync(); 186 187 expect(vol.existsSync(path.join(projectRoot, 'node_modules'))).toBe(false); 188 }); 189 190 it('fails when no cwd is provided', async () => { 191 const npm = new NpmPackageManager({ cwd: undefined }); 192 await expect(npm.uninstallAsync()).rejects.toThrow('cwd is required'); 193 }); 194 }); 195 196 describe('addAsync', () => { 197 it('installs project without packages', async () => { 198 const npm = new NpmPackageManager({ cwd: projectRoot }); 199 await npm.addAsync(); 200 201 expect(spawnAsync).toBeCalledWith( 202 'npm', 203 ['install'], 204 expect.objectContaining({ cwd: projectRoot }) 205 ); 206 }); 207 208 it('returns pending spawn promise with child', async () => { 209 const npm = new NpmPackageManager({ cwd: projectRoot }); 210 const pending = npm.addAsync(['expo']); 211 212 expect(pending).toHaveProperty('child', expect.any(Promise)); 213 await expect(pending.child).resolves.toMatchObject(STUB_SPAWN_CHILD); 214 }); 215 216 it('adds a single unversioned package to dependencies', async () => { 217 const npm = new NpmPackageManager({ cwd: projectRoot }); 218 await npm.addAsync(['expo']); 219 220 expect(spawnAsync).toBeCalledWith( 221 'npm', 222 ['install', '--save', 'expo'], 223 expect.objectContaining({ cwd: projectRoot }) 224 ); 225 }); 226 227 it('adds a multiple unversioned package to dependencies', async () => { 228 const npm = new NpmPackageManager({ cwd: projectRoot }); 229 await npm.addAsync(['expo', 'react-native']); 230 231 expect(spawnAsync).toBeCalledWith( 232 'npm', 233 ['install', '--save', 'expo', 'react-native'], 234 expect.objectContaining({ cwd: projectRoot }) 235 ); 236 }); 237 238 it('installs multiple versioned dependencies by updating package.json', async () => { 239 vol.fromJSON({ 'package.json': '{}' }, projectRoot); 240 241 const npm = new NpmPackageManager({ cwd: projectRoot }); 242 await npm.addAsync(['expo@^46', '[email protected]']); 243 244 const packageFile = JSON.parse( 245 vol.readFileSync(path.join(projectRoot, 'package.json')).toString() 246 ); 247 248 expect(packageFile).toHaveProperty( 249 'dependencies', 250 expect.objectContaining({ expo: '^46', 'react-native': '0.69.3' }) 251 ); 252 expect(spawnAsync).toBeCalledWith( 253 'npm', 254 ['install'], 255 expect.objectContaining({ cwd: projectRoot }) 256 ); 257 }); 258 259 it('installs mixed dependencies with flags by updating package.json', async () => { 260 vol.fromJSON({ 'package.json': '{}' }, projectRoot); 261 262 const npm = new NpmPackageManager({ cwd: projectRoot }); 263 await npm.addAsync(['expo@^46', '[email protected]', 'jest', '--ignore-scripts']); 264 265 const packageFile = JSON.parse( 266 vol.readFileSync(path.join(projectRoot, 'package.json')).toString() 267 ); 268 269 expect(packageFile).toHaveProperty( 270 'dependencies', 271 expect.objectContaining({ expo: '^46', 'react-native': '0.69.3' }) 272 ); 273 expect(spawnAsync).toBeCalledWith( 274 'npm', 275 ['install', '--save', '--ignore-scripts', 'jest'], 276 expect.objectContaining({ cwd: projectRoot }) 277 ); 278 }); 279 280 it('installs dist-tag versions with --save', async () => { 281 vol.fromJSON({ 'package.json': '{}' }, projectRoot); 282 283 const npm = new NpmPackageManager({ cwd: projectRoot }); 284 await npm.addAsync(['[email protected]', 'expo@next']); 285 286 const packageFile = JSON.parse( 287 vol.readFileSync(path.join(projectRoot, 'package.json')).toString() 288 ); 289 290 expect(packageFile).toHaveProperty( 291 'dependencies', 292 expect.objectContaining({ 'react-native': '0.69.3' }) 293 ); 294 expect(spawnAsync).toBeCalledWith( 295 'npm', 296 ['install', '--save', 'expo@next'], 297 expect.objectContaining({ cwd: projectRoot }) 298 ); 299 }); 300 }); 301 302 describe('addDevAsync', () => { 303 it('installs project without packages', async () => { 304 const npm = new NpmPackageManager({ cwd: projectRoot }); 305 await npm.addDevAsync(); 306 307 expect(spawnAsync).toBeCalledWith( 308 'npm', 309 ['install'], 310 expect.objectContaining({ cwd: projectRoot }) 311 ); 312 }); 313 314 it('returns pending spawn promise with child', async () => { 315 const npm = new NpmPackageManager({ cwd: projectRoot }); 316 const pending = npm.addDevAsync(['expo']); 317 318 expect(pending).toHaveProperty('child', expect.any(Promise)); 319 await expect(pending.child).resolves.toMatchObject(STUB_SPAWN_CHILD); 320 }); 321 322 it('adds a single unversioned package to dependencies', async () => { 323 const npm = new NpmPackageManager({ cwd: projectRoot }); 324 await npm.addDevAsync(['expo']); 325 326 expect(spawnAsync).toBeCalledWith( 327 'npm', 328 ['install', '--save-dev', 'expo'], 329 expect.objectContaining({ cwd: projectRoot }) 330 ); 331 }); 332 333 it('adds a multiple unversioned package to dependencies', async () => { 334 const npm = new NpmPackageManager({ cwd: projectRoot }); 335 await npm.addDevAsync(['expo', 'react-native']); 336 337 expect(spawnAsync).toBeCalledWith( 338 'npm', 339 ['install', '--save-dev', 'expo', 'react-native'], 340 expect.objectContaining({ cwd: projectRoot }) 341 ); 342 }); 343 344 it('installs multiple versioned dependencies by updating package.json', async () => { 345 vol.fromJSON({ 'package.json': '{}' }, projectRoot); 346 347 const npm = new NpmPackageManager({ cwd: projectRoot }); 348 await npm.addDevAsync(['expo@^46', '[email protected]']); 349 350 const packageFile = JSON.parse( 351 vol.readFileSync(path.join(projectRoot, 'package.json')).toString() 352 ); 353 354 expect(packageFile).toHaveProperty( 355 'devDependencies', 356 expect.objectContaining({ expo: '^46', 'react-native': '0.69.3' }) 357 ); 358 expect(spawnAsync).toBeCalledWith( 359 'npm', 360 ['install'], 361 expect.objectContaining({ cwd: projectRoot }) 362 ); 363 }); 364 365 it('installs mixed dependencies with flags by updating package.json', async () => { 366 vol.fromJSON({ 'package.json': '{}' }, projectRoot); 367 368 const npm = new NpmPackageManager({ cwd: projectRoot }); 369 await npm.addDevAsync(['expo@^46', '[email protected]', 'jest', '--ignore-scripts']); 370 371 const packageFile = JSON.parse( 372 vol.readFileSync(path.join(projectRoot, 'package.json')).toString() 373 ); 374 375 expect(packageFile).toHaveProperty( 376 'devDependencies', 377 expect.objectContaining({ expo: '^46', 'react-native': '0.69.3' }) 378 ); 379 expect(spawnAsync).toBeCalledWith( 380 'npm', 381 ['install', '--save-dev', '--ignore-scripts', 'jest'], 382 expect.objectContaining({ cwd: projectRoot }) 383 ); 384 }); 385 386 it('installs dist-tag versions with --save', async () => { 387 vol.fromJSON({ 'package.json': '{}' }, projectRoot); 388 389 const npm = new NpmPackageManager({ cwd: projectRoot }); 390 await npm.addDevAsync(['[email protected]', 'expo@next']); 391 392 const packageFile = JSON.parse( 393 vol.readFileSync(path.join(projectRoot, 'package.json')).toString() 394 ); 395 396 expect(packageFile).toHaveProperty( 397 'devDependencies', 398 expect.objectContaining({ 'react-native': '0.69.3' }) 399 ); 400 expect(spawnAsync).toBeCalledWith( 401 'npm', 402 ['install', '--save-dev', 'expo@next'], 403 expect.objectContaining({ cwd: projectRoot }) 404 ); 405 }); 406 }); 407 408 describe('addGlobalAsync', () => { 409 it('installs project without packages', async () => { 410 const npm = new NpmPackageManager({ cwd: projectRoot }); 411 await npm.addGlobalAsync(); 412 413 expect(spawnAsync).toBeCalledWith( 414 'npm', 415 ['install'], 416 expect.objectContaining({ cwd: projectRoot }) 417 ); 418 }); 419 420 it('adds a single package globally', async () => { 421 const npm = new NpmPackageManager({ cwd: projectRoot }); 422 await npm.addGlobalAsync(['expo-cli@^5']); 423 424 expect(spawnAsync).toBeCalledWith( 425 'npm', 426 ['install', '--global', 'expo-cli@^5'], 427 expect.anything() 428 ); 429 }); 430 431 it('adds multiple packages globally', async () => { 432 const npm = new NpmPackageManager({ cwd: projectRoot }); 433 await npm.addGlobalAsync(['expo-cli@^5', 'eas-cli']); 434 435 expect(spawnAsync).toBeCalledWith( 436 'npm', 437 ['install', '--global', 'expo-cli@^5', 'eas-cli'], 438 expect.anything() 439 ); 440 }); 441 }); 442 443 describe('removeAsync', () => { 444 it('removes a single package', async () => { 445 const npm = new NpmPackageManager({ cwd: projectRoot }); 446 await npm.removeAsync(['metro']); 447 448 expect(spawnAsync).toBeCalledWith( 449 'npm', 450 ['uninstall', 'metro'], 451 expect.objectContaining({ cwd: projectRoot }) 452 ); 453 }); 454 455 it('removes multiple packages', async () => { 456 const npm = new NpmPackageManager({ cwd: projectRoot }); 457 await npm.removeAsync(['metro', 'jest-haste-map']); 458 459 expect(spawnAsync).toBeCalledWith( 460 'npm', 461 ['uninstall', 'metro', 'jest-haste-map'], 462 expect.objectContaining({ cwd: projectRoot }) 463 ); 464 }); 465 }); 466 467 describe('removeDevAsync', () => { 468 it('removes a single package', async () => { 469 const npm = new NpmPackageManager({ cwd: projectRoot }); 470 await npm.removeDevAsync(['metro']); 471 472 expect(spawnAsync).toBeCalledWith( 473 'npm', 474 ['uninstall', '--save-dev', 'metro'], 475 expect.objectContaining({ cwd: projectRoot }) 476 ); 477 }); 478 479 it('removes multiple packages', async () => { 480 const npm = new NpmPackageManager({ cwd: projectRoot }); 481 await npm.removeDevAsync(['metro', 'jest-haste-map']); 482 483 expect(spawnAsync).toBeCalledWith( 484 'npm', 485 ['uninstall', '--save-dev', 'metro', 'jest-haste-map'], 486 expect.objectContaining({ cwd: projectRoot }) 487 ); 488 }); 489 }); 490 491 describe('removeGlobalAsync', () => { 492 it('removes a single package', async () => { 493 const npm = new NpmPackageManager({ cwd: projectRoot }); 494 await npm.removeGlobalAsync(['expo-cli']); 495 496 expect(spawnAsync).toBeCalledWith( 497 'npm', 498 ['uninstall', '--global', 'expo-cli'], 499 expect.objectContaining({ cwd: projectRoot }) 500 ); 501 }); 502 503 it('removes multiple packages', async () => { 504 const npm = new NpmPackageManager({ cwd: projectRoot }); 505 await npm.removeGlobalAsync(['expo-cli', 'eas-cli']); 506 507 expect(spawnAsync).toBeCalledWith( 508 'npm', 509 ['uninstall', '--global', 'expo-cli', 'eas-cli'], 510 expect.objectContaining({ cwd: projectRoot }) 511 ); 512 }); 513 }); 514 515 describe('workspaceRoot', () => { 516 const workspaceRoot = '/monorepo'; 517 const projectRoot = '/monorepo/packages/test'; 518 519 it('returns null for non-monorepo project', () => { 520 vol.fromJSON( 521 { 522 'package.json': JSON.stringify({ name: 'project' }), 523 }, 524 projectRoot 525 ); 526 527 const npm = new NpmPackageManager({ cwd: projectRoot }); 528 expect(npm.workspaceRoot()).toBeNull(); 529 }); 530 531 it('returns new instance for monorepo project', () => { 532 vol.fromJSON( 533 { 534 'packages/test/package.json': JSON.stringify({ name: 'project' }), 535 'package.json': JSON.stringify({ 536 name: 'monorepo', 537 workspaces: ['packages/*'], 538 }), 539 }, 540 workspaceRoot 541 ); 542 543 const npm = new NpmPackageManager({ cwd: projectRoot }); 544 const root = npm.workspaceRoot(); 545 expect(root).toBeInstanceOf(NpmPackageManager); 546 expect(root).not.toBe(npm); 547 }); 548 }); 549}); 550