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