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