1import spawnAsync from '@expo/spawn-async'; 2import { vol } from 'memfs'; 3import path from 'path'; 4 5import { mockSpawnPromise, mockedSpawnAsync } from '../../__tests__/spawn-utils'; 6import { PNPM_WORKSPACE_FILE } from '../../utils/nodeWorkspaces'; 7import { PnpmPackageManager } from '../PnpmPackageManager'; 8 9jest.mock('@expo/spawn-async'); 10jest.mock('fs'); 11 12const originalCI = process.env.CI; 13 14beforeAll(() => { 15 // Disable logging to clean up test ouput 16 jest.spyOn(console, 'log').mockImplementation(); 17}); 18 19beforeEach(() => { 20 // Pnpm changes behavior based on `CI=true`. 21 // Disable this behavior by default, to make these tests pass on CI. 22 // Some tests test this behavior and may override this value. 23 delete process.env.CI; 24}); 25 26afterEach(() => { 27 process.env.CI = originalCI; 28}); 29 30describe('PnpmPackageManager', () => { 31 const projectRoot = '/project/with-pnpm'; 32 33 it('name is set to pnpm', () => { 34 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 35 expect(pnpm.name).toBe('pnpm'); 36 }); 37 38 describe('getDefaultEnvironment', () => { 39 it('runs npm with ADBLOCK=1 and DISABLE_OPENCOLLECTIVE=1', async () => { 40 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 41 await pnpm.installAsync(); 42 43 expect(spawnAsync).toBeCalledWith( 44 expect.anything(), 45 expect.anything(), 46 expect.objectContaining({ 47 env: expect.objectContaining({ ADBLOCK: '1', DISABLE_OPENCOLLECTIVE: '1' }), 48 }) 49 ); 50 }); 51 52 it('runs with overwritten default environment', async () => { 53 const pnpm = new PnpmPackageManager({ cwd: projectRoot, env: { ADBLOCK: '0' } }); 54 await pnpm.installAsync(); 55 56 expect(spawnAsync).toBeCalledWith( 57 expect.anything(), 58 expect.anything(), 59 expect.objectContaining({ 60 env: { ADBLOCK: '0', DISABLE_OPENCOLLECTIVE: '1' }, 61 }) 62 ); 63 }); 64 }); 65 66 describe('runAsync', () => { 67 it('logs executed command', async () => { 68 const log = jest.fn(); 69 const pnpm = new PnpmPackageManager({ cwd: projectRoot, log }); 70 await pnpm.runAsync(['install', '--some-flag']); 71 expect(log).toHaveBeenCalledWith('> pnpm install --some-flag'); 72 }); 73 74 it('inherits stdio output without silent', async () => { 75 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 76 await pnpm.runAsync(['install']); 77 78 expect(spawnAsync).toBeCalledWith( 79 expect.anything(), 80 expect.anything(), 81 expect.objectContaining({ stdio: 'inherit' }) 82 ); 83 }); 84 85 it('does not inherit stdio with silent', async () => { 86 const pnpm = new PnpmPackageManager({ cwd: projectRoot, silent: true }); 87 await pnpm.runAsync(['install']); 88 89 expect(spawnAsync).toBeCalledWith( 90 expect.anything(), 91 expect.anything(), 92 expect.objectContaining({ stdio: undefined }) 93 ); 94 }); 95 96 it('adds a single package with custom parameters', async () => { 97 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 98 await pnpm.runAsync(['add', '--save-peer', '@babel/core']); 99 100 expect(spawnAsync).toBeCalledWith( 101 'pnpm', 102 ['add', '--save-peer', '@babel/core'], 103 expect.objectContaining({ cwd: projectRoot }) 104 ); 105 }); 106 107 it('adds multiple packages with custom parameters', async () => { 108 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 109 await pnpm.runAsync(['add', '--save-peer', '@babel/core', '@babel/runtime']); 110 111 expect(spawnAsync).toBeCalledWith( 112 'pnpm', 113 ['add', '--save-peer', '@babel/core', '@babel/runtime'], 114 expect.objectContaining({ cwd: projectRoot }) 115 ); 116 }); 117 }); 118 119 describe('versionAsync', () => { 120 it('returns version from pnpm', async () => { 121 mockedSpawnAsync.mockImplementation(() => 122 mockSpawnPromise(Promise.resolve({ stdout: '7.0.0\n' })) 123 ); 124 125 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 126 127 expect(await pnpm.versionAsync()).toBe('7.0.0'); 128 expect(spawnAsync).toBeCalledWith('pnpm', ['--version'], expect.anything()); 129 }); 130 }); 131 132 describe('getConfigAsync', () => { 133 it('returns a configuration key from pnpm', async () => { 134 mockedSpawnAsync.mockImplementation(() => 135 mockSpawnPromise(Promise.resolve({ stdout: 'https://custom.registry.org/\n' })) 136 ); 137 138 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 139 140 expect(await pnpm.getConfigAsync('registry')).toBe('https://custom.registry.org/'); 141 expect(spawnAsync).toBeCalledWith('pnpm', ['config', 'get', 'registry'], expect.anything()); 142 }); 143 }); 144 145 describe('installAsync', () => { 146 it('runs normal installation', async () => { 147 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 148 await pnpm.installAsync(); 149 150 expect(spawnAsync).toBeCalledWith( 151 'pnpm', 152 ['install'], 153 expect.objectContaining({ cwd: projectRoot }) 154 ); 155 }); 156 157 it('runs installation with flags', async () => { 158 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 159 await pnpm.installAsync(['--ignore-scripts']); 160 161 expect(spawnAsync).toBeCalledWith( 162 'pnpm', 163 ['install', '--ignore-scripts'], 164 expect.objectContaining({ cwd: projectRoot }) 165 ); 166 }); 167 168 describe('frozen lockfile', () => { 169 it('does not add --no-frozen-lockfile when not in CI', async () => { 170 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 171 await pnpm.installAsync(); 172 173 expect(spawnAsync).toBeCalledWith( 174 'pnpm', 175 ['install'], 176 expect.objectContaining({ cwd: projectRoot }) 177 ); 178 }); 179 180 it('adds --no-frozen-lockfile when in CI', async () => { 181 process.env.CI = 'true'; 182 183 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 184 await pnpm.installAsync(); 185 186 expect(spawnAsync).toBeCalledWith( 187 'pnpm', 188 ['install', '--no-frozen-lockfile'], 189 expect.objectContaining({ cwd: projectRoot }) 190 ); 191 }); 192 193 it('does not add --no-frozen-lockfile if passed as flag in CI', async () => { 194 process.env.CI = 'true'; 195 196 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 197 await pnpm.installAsync(['--frozen-lockfile']); 198 199 expect(spawnAsync).toBeCalledWith( 200 'pnpm', 201 ['install', '--frozen-lockfile'], 202 expect.objectContaining({ cwd: projectRoot }) 203 ); 204 }); 205 }); 206 }); 207 208 describe('uninstallAsync', () => { 209 afterEach(() => vol.reset()); 210 211 it('removes node_modules folder relative to cwd', async () => { 212 vol.fromJSON( 213 { 214 'package.json': '{}', 215 'node_modules/expo/package.json': '{}', 216 }, 217 projectRoot 218 ); 219 220 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 221 await pnpm.uninstallAsync(); 222 223 expect(vol.existsSync(path.join(projectRoot, 'node_modules'))).toBe(false); 224 }); 225 226 it('skips removing non-existing node_modules folder', async () => { 227 vol.fromJSON({ 'package.json': '{}' }, projectRoot); 228 229 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 230 await pnpm.uninstallAsync(); 231 232 expect(vol.existsSync(path.join(projectRoot, 'node_modules'))).toBe(false); 233 }); 234 235 it('fails when no cwd is provided', async () => { 236 const pnpm = new PnpmPackageManager({ cwd: undefined }); 237 await expect(pnpm.uninstallAsync()).rejects.toThrow('cwd is required'); 238 }); 239 }); 240 241 describe('addAsync', () => { 242 it('installs project without packages', async () => { 243 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 244 await pnpm.addAsync(); 245 246 expect(spawnAsync).toBeCalledWith( 247 'pnpm', 248 ['install'], 249 expect.objectContaining({ cwd: projectRoot }) 250 ); 251 }); 252 253 it('adds a single package to dependencies', async () => { 254 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 255 await pnpm.addAsync(['@react-navigation/native']); 256 257 expect(spawnAsync).toBeCalledWith( 258 'pnpm', 259 ['add', '@react-navigation/native'], 260 expect.objectContaining({ cwd: projectRoot }) 261 ); 262 }); 263 264 it('adds multiple packages to dependencies', async () => { 265 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 266 await pnpm.addAsync(['@react-navigation/native', '@react-navigation/drawer']); 267 268 expect(spawnAsync).toBeCalledWith( 269 'pnpm', 270 ['add', '@react-navigation/native', '@react-navigation/drawer'], 271 expect.objectContaining({ cwd: projectRoot }) 272 ); 273 }); 274 }); 275 276 describe('addDevAsync', () => { 277 it('installs project without packages', async () => { 278 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 279 await pnpm.addDevAsync(); 280 281 expect(spawnAsync).toBeCalledWith( 282 'pnpm', 283 ['install'], 284 expect.objectContaining({ cwd: projectRoot }) 285 ); 286 }); 287 288 it('adds a single package to dev dependencies', async () => { 289 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 290 await pnpm.addDevAsync(['eslint']); 291 292 expect(spawnAsync).toBeCalledWith( 293 'pnpm', 294 ['add', '--save-dev', 'eslint'], 295 expect.objectContaining({ cwd: projectRoot }) 296 ); 297 }); 298 299 it('adds multiple packages to dev dependencies', async () => { 300 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 301 await pnpm.addDevAsync(['eslint', 'prettier']); 302 303 expect(spawnAsync).toBeCalledWith( 304 'pnpm', 305 ['add', '--save-dev', 'eslint', 'prettier'], 306 expect.objectContaining({ cwd: projectRoot }) 307 ); 308 }); 309 }); 310 311 describe('addGlobalAsync', () => { 312 it('installs project without packages', async () => { 313 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 314 await pnpm.addGlobalAsync(); 315 316 expect(spawnAsync).toBeCalledWith( 317 'pnpm', 318 ['install'], 319 expect.objectContaining({ cwd: projectRoot }) 320 ); 321 }); 322 323 it('adds a single package globally', async () => { 324 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 325 await pnpm.addGlobalAsync(['expo-cli@^5']); 326 327 expect(spawnAsync).toBeCalledWith( 328 'pnpm', 329 ['add', '--global', 'expo-cli@^5'], 330 expect.anything() 331 ); 332 }); 333 334 it('adds multiple packages globally', async () => { 335 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 336 await pnpm.addGlobalAsync(['expo-cli@^5', 'eas-cli']); 337 338 expect(spawnAsync).toBeCalledWith( 339 'pnpm', 340 ['add', '--global', 'expo-cli@^5', 'eas-cli'], 341 expect.anything() 342 ); 343 }); 344 }); 345 346 describe('removeAsync', () => { 347 it('removes a single package', async () => { 348 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 349 await pnpm.removeAsync(['metro']); 350 351 expect(spawnAsync).toBeCalledWith( 352 'pnpm', 353 ['remove', 'metro'], 354 expect.objectContaining({ cwd: projectRoot }) 355 ); 356 }); 357 358 it('removes multiple packages', async () => { 359 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 360 await pnpm.removeAsync(['metro', 'jest-haste-map']); 361 362 expect(spawnAsync).toBeCalledWith( 363 'pnpm', 364 ['remove', 'metro', 'jest-haste-map'], 365 expect.objectContaining({ cwd: projectRoot }) 366 ); 367 }); 368 }); 369 370 describe('removeDevAsync', () => { 371 it('removes a single package', async () => { 372 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 373 await pnpm.removeDevAsync(['metro']); 374 375 expect(spawnAsync).toBeCalledWith( 376 'pnpm', 377 ['remove', '--save-dev', 'metro'], 378 expect.objectContaining({ cwd: projectRoot }) 379 ); 380 }); 381 382 it('removes multiple packages', async () => { 383 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 384 await pnpm.removeDevAsync(['metro', 'jest-haste-map']); 385 386 expect(spawnAsync).toBeCalledWith( 387 'pnpm', 388 ['remove', '--save-dev', 'metro', 'jest-haste-map'], 389 expect.objectContaining({ cwd: projectRoot }) 390 ); 391 }); 392 }); 393 394 describe('removeGlobalAsync', () => { 395 it('removes a single package', async () => { 396 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 397 await pnpm.removeGlobalAsync(['expo-cli']); 398 399 expect(spawnAsync).toBeCalledWith( 400 'pnpm', 401 ['remove', '--global', 'expo-cli'], 402 expect.objectContaining({ cwd: projectRoot }) 403 ); 404 }); 405 406 it('removes multiple packages', async () => { 407 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 408 await pnpm.removeGlobalAsync(['expo-cli', 'eas-cli']); 409 410 expect(spawnAsync).toBeCalledWith( 411 'pnpm', 412 ['remove', '--global', 'expo-cli', 'eas-cli'], 413 expect.objectContaining({ cwd: projectRoot }) 414 ); 415 }); 416 }); 417 418 describe('workspaceRoot', () => { 419 const workspaceRoot = '/monorepo'; 420 const projectRoot = '/monorepo/packages/test'; 421 422 it('returns null for non-monorepo project', () => { 423 vol.fromJSON( 424 { 425 'package.json': JSON.stringify({ name: 'project' }), 426 }, 427 projectRoot 428 ); 429 430 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 431 expect(pnpm.workspaceRoot()).toBeNull(); 432 }); 433 434 it('returns new instance for monorepo project', () => { 435 vol.fromJSON( 436 { 437 'packages/test/package.json': JSON.stringify({ name: 'project' }), 438 'package.json': JSON.stringify({ 439 name: 'monorepo', 440 }), 441 [PNPM_WORKSPACE_FILE]: 'packages:\n - packages/*', 442 }, 443 workspaceRoot 444 ); 445 446 const pnpm = new PnpmPackageManager({ cwd: projectRoot }); 447 const root = pnpm.workspaceRoot(); 448 expect(root).toBeInstanceOf(PnpmPackageManager); 449 expect(root).not.toBe(pnpm); 450 }); 451 }); 452}); 453