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 { YarnPackageManager } from '../YarnPackageManager';
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('YarnPackageManager', () => {
17  const projectRoot = '/project/with-yarn';
18
19  it('name is set to yarn', () => {
20    const yarn = new YarnPackageManager({ cwd: projectRoot });
21    expect(yarn.name).toBe('yarn');
22  });
23
24  describe('getDefaultEnvironment', () => {
25    it('runs npm with ADBLOCK=1 and DISABLE_OPENCOLLECTIVE=1', async () => {
26      const yarn = new YarnPackageManager({ cwd: projectRoot });
27      await yarn.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 yarn = new YarnPackageManager({ cwd: projectRoot, env: { ADBLOCK: '0' } });
40      await yarn.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 yarn = new YarnPackageManager({ cwd: projectRoot, log });
56      await yarn.runAsync(['install', '--some-flag']);
57      expect(log).toHaveBeenCalledWith('> yarn install --some-flag');
58    });
59
60    it('inherits stdio output without silent', async () => {
61      const yarn = new YarnPackageManager({ cwd: projectRoot });
62      await yarn.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 yarn = new YarnPackageManager({ cwd: projectRoot, silent: true });
73      await yarn.runAsync(['install']);
74
75      expect(spawnAsync).toBeCalledWith(
76        expect.anything(),
77        expect.anything(),
78        expect.objectContaining({ stdio: undefined })
79      );
80    });
81
82    it('adds a single package with custom parameters', async () => {
83      const yarn = new YarnPackageManager({ cwd: projectRoot });
84      await yarn.runAsync(['add', '--peer', '@babel/core']);
85
86      expect(spawnAsync).toBeCalledWith(
87        'yarnpkg',
88        ['add', '--peer', '@babel/core'],
89        expect.objectContaining({ cwd: projectRoot })
90      );
91    });
92
93    it('adds multiple packages with custom parameters', async () => {
94      const yarn = new YarnPackageManager({ cwd: projectRoot });
95      await yarn.runAsync(['add', '--peer', '@babel/core', '@babel/runtime']);
96
97      expect(spawnAsync).toBeCalledWith(
98        'yarnpkg',
99        ['add', '--peer', '@babel/core', '@babel/runtime'],
100        expect.objectContaining({ cwd: projectRoot })
101      );
102    });
103  });
104
105  describe('versionAsync', () => {
106    it('returns version from yarn', async () => {
107      mockedSpawnAsync.mockImplementation(() =>
108        mockSpawnPromise(Promise.resolve({ stdout: '4.2.0\n' }))
109      );
110
111      const yarn = new YarnPackageManager({ cwd: projectRoot });
112
113      expect(await yarn.versionAsync()).toBe('4.2.0');
114      expect(spawnAsync).toBeCalledWith('yarnpkg', ['--version'], expect.anything());
115    });
116  });
117
118  describe('getConfigAsync', () => {
119    it('returns a configuration key from yarn', async () => {
120      mockedSpawnAsync.mockImplementation(() =>
121        mockSpawnPromise(Promise.resolve({ stdout: 'https://custom.registry.org/\n' }))
122      );
123
124      const yarn = new YarnPackageManager({ cwd: projectRoot });
125
126      expect(await yarn.getConfigAsync('registry')).toBe('https://custom.registry.org/');
127      expect(spawnAsync).toBeCalledWith(
128        'yarnpkg',
129        ['config', 'get', 'registry'],
130        expect.anything()
131      );
132    });
133  });
134
135  describe('installAsync', () => {
136    it('runs normal installation', async () => {
137      const yarn = new YarnPackageManager({ cwd: projectRoot });
138      await yarn.installAsync();
139
140      expect(spawnAsync).toBeCalledWith(
141        'yarnpkg',
142        ['install'],
143        expect.objectContaining({ cwd: projectRoot })
144      );
145    });
146
147    it('runs installation with flags', async () => {
148      const yarn = new YarnPackageManager({ cwd: projectRoot });
149      await yarn.installAsync(['--ignore-scripts']);
150
151      expect(spawnAsync).toBeCalledWith(
152        'yarnpkg',
153        ['install', '--ignore-scripts'],
154        expect.objectContaining({ cwd: projectRoot })
155      );
156    });
157  });
158
159  describe('uninstallAsync', () => {
160    afterEach(() => vol.reset());
161
162    it('removes node_modules folder relative to cwd', async () => {
163      vol.fromJSON(
164        {
165          'package.json': '{}',
166          'node_modules/expo/package.json': '{}',
167        },
168        projectRoot
169      );
170
171      const yarn = new YarnPackageManager({ cwd: projectRoot });
172      await yarn.uninstallAsync();
173
174      expect(vol.existsSync(path.join(projectRoot, 'node_modules'))).toBe(false);
175    });
176
177    it('skips removing non-existing node_modules folder', async () => {
178      vol.fromJSON({ 'package.json': '{}' }, projectRoot);
179
180      const yarn = new YarnPackageManager({ cwd: projectRoot });
181      await yarn.uninstallAsync();
182
183      expect(vol.existsSync(path.join(projectRoot, 'node_modules'))).toBe(false);
184    });
185
186    it('fails when no cwd is provided', async () => {
187      const yarn = new YarnPackageManager({ cwd: undefined });
188      await expect(yarn.uninstallAsync()).rejects.toThrow('cwd is required');
189    });
190  });
191
192  describe('addAsync', () => {
193    it('installs project without packages', async () => {
194      const yarn = new YarnPackageManager({ cwd: projectRoot });
195      await yarn.addAsync();
196
197      expect(spawnAsync).toBeCalledWith(
198        'yarnpkg',
199        ['install'],
200        expect.objectContaining({ cwd: projectRoot })
201      );
202    });
203
204    it('returns pending spawn promise with child', async () => {
205      const yarn = new YarnPackageManager({ cwd: projectRoot });
206      const pending = yarn.addAsync(['expo']);
207
208      expect(pending).toHaveProperty('child', expect.any(Promise));
209      await expect(pending.child).resolves.toMatchObject(STUB_SPAWN_CHILD);
210    });
211
212    it('adds a single package to dependencies', async () => {
213      const yarn = new YarnPackageManager({ cwd: projectRoot });
214      await yarn.addAsync(['@react-navigation/native']);
215
216      expect(spawnAsync).toBeCalledWith(
217        'yarnpkg',
218        ['add', '@react-navigation/native'],
219        expect.objectContaining({ cwd: projectRoot })
220      );
221    });
222
223    it('adds multiple packages to dependencies', async () => {
224      const yarn = new YarnPackageManager({ cwd: projectRoot });
225      await yarn.addAsync(['@react-navigation/native', '@react-navigation/drawer']);
226
227      expect(spawnAsync).toBeCalledWith(
228        'yarnpkg',
229        ['add', '@react-navigation/native', '@react-navigation/drawer'],
230        expect.objectContaining({ cwd: projectRoot })
231      );
232    });
233  });
234
235  describe('addDevAsync', () => {
236    it('installs project without packages', async () => {
237      const yarn = new YarnPackageManager({ cwd: projectRoot });
238      await yarn.addDevAsync();
239
240      expect(spawnAsync).toBeCalledWith(
241        'yarnpkg',
242        ['install'],
243        expect.objectContaining({ cwd: projectRoot })
244      );
245    });
246
247    it('returns pending spawn promise with child', async () => {
248      const yarn = new YarnPackageManager({ cwd: projectRoot });
249      const pending = yarn.addDevAsync(['expo']);
250
251      expect(pending).toHaveProperty('child', expect.any(Promise));
252      await expect(pending.child).resolves.toMatchObject(STUB_SPAWN_CHILD);
253    });
254
255    it('adds a single package to dev dependencies', async () => {
256      const yarn = new YarnPackageManager({ cwd: projectRoot });
257      await yarn.addDevAsync(['eslint']);
258
259      expect(spawnAsync).toBeCalledWith(
260        'yarnpkg',
261        ['add', '--dev', 'eslint'],
262        expect.objectContaining({ cwd: projectRoot })
263      );
264    });
265
266    it('adds multiple packages to dev dependencies', async () => {
267      const yarn = new YarnPackageManager({ cwd: projectRoot });
268      await yarn.addDevAsync(['eslint', 'prettier']);
269
270      expect(spawnAsync).toBeCalledWith(
271        'yarnpkg',
272        ['add', '--dev', 'eslint', 'prettier'],
273        expect.objectContaining({ cwd: projectRoot })
274      );
275    });
276  });
277
278  describe('addGlobalAsync', () => {
279    it('installs project without packages', async () => {
280      const yarn = new YarnPackageManager({ cwd: projectRoot });
281      await yarn.addGlobalAsync();
282
283      expect(spawnAsync).toBeCalledWith(
284        'yarnpkg',
285        ['install'],
286        expect.objectContaining({ cwd: projectRoot })
287      );
288    });
289
290    it('returns pending spawn promise with child', async () => {
291      const yarn = new YarnPackageManager({ cwd: projectRoot });
292      const pending = yarn.addGlobalAsync(['expo']);
293
294      expect(pending).toHaveProperty('child', expect.any(Promise));
295      await expect(pending.child).resolves.toMatchObject(STUB_SPAWN_CHILD);
296    });
297
298    it('adds a single package globally', async () => {
299      const yarn = new YarnPackageManager({ cwd: projectRoot });
300      await yarn.addGlobalAsync(['expo-cli@^5']);
301
302      expect(spawnAsync).toBeCalledWith(
303        'yarnpkg',
304        ['global', 'add', 'expo-cli@^5'],
305        expect.anything()
306      );
307    });
308
309    it('adds multiple packages globally', async () => {
310      const yarn = new YarnPackageManager({ cwd: projectRoot });
311      await yarn.addGlobalAsync(['expo-cli@^5', 'eas-cli']);
312
313      expect(spawnAsync).toBeCalledWith(
314        'yarnpkg',
315        ['global', 'add', 'expo-cli@^5', 'eas-cli'],
316        expect.anything()
317      );
318    });
319  });
320
321  describe('removeAsync', () => {
322    it('removes a single package', async () => {
323      const yarn = new YarnPackageManager({ cwd: projectRoot });
324      await yarn.removeAsync(['metro']);
325
326      expect(spawnAsync).toBeCalledWith(
327        'yarnpkg',
328        ['remove', 'metro'],
329        expect.objectContaining({ cwd: projectRoot })
330      );
331    });
332
333    it('removes multiple packages', async () => {
334      const yarn = new YarnPackageManager({ cwd: projectRoot });
335      await yarn.removeAsync(['metro', 'jest-haste-map']);
336
337      expect(spawnAsync).toBeCalledWith(
338        'yarnpkg',
339        ['remove', 'metro', 'jest-haste-map'],
340        expect.objectContaining({ cwd: projectRoot })
341      );
342    });
343  });
344
345  describe('removeDevAsync', () => {
346    it('removes a single package', async () => {
347      const yarn = new YarnPackageManager({ cwd: projectRoot });
348      await yarn.removeDevAsync(['metro']);
349
350      expect(spawnAsync).toBeCalledWith(
351        'yarnpkg',
352        ['remove', 'metro'],
353        expect.objectContaining({ cwd: projectRoot })
354      );
355    });
356
357    it('removes multiple packages', async () => {
358      const yarn = new YarnPackageManager({ cwd: projectRoot });
359      await yarn.removeDevAsync(['metro', 'jest-haste-map']);
360
361      expect(spawnAsync).toBeCalledWith(
362        'yarnpkg',
363        ['remove', 'metro', 'jest-haste-map'],
364        expect.objectContaining({ cwd: projectRoot })
365      );
366    });
367  });
368
369  describe('removeGlobalAsync', () => {
370    it('removes a single package', async () => {
371      const yarn = new YarnPackageManager({ cwd: projectRoot });
372      await yarn.removeGlobalAsync(['expo-cli']);
373
374      expect(spawnAsync).toBeCalledWith(
375        'yarnpkg',
376        ['global', 'remove', 'expo-cli'],
377        expect.objectContaining({ cwd: projectRoot })
378      );
379    });
380
381    it('removes multiple packages', async () => {
382      const yarn = new YarnPackageManager({ cwd: projectRoot });
383      await yarn.removeGlobalAsync(['expo-cli', 'eas-cli']);
384
385      expect(spawnAsync).toBeCalledWith(
386        'yarnpkg',
387        ['global', 'remove', 'expo-cli', 'eas-cli'],
388        expect.objectContaining({ cwd: projectRoot })
389      );
390    });
391  });
392
393  describe('workspaceRoot', () => {
394    const workspaceRoot = '/monorepo';
395    const projectRoot = '/monorepo/packages/test';
396
397    it('returns null for non-monorepo project', () => {
398      vol.fromJSON(
399        {
400          'package.json': JSON.stringify({ name: 'project' }),
401        },
402        projectRoot
403      );
404
405      const yarn = new YarnPackageManager({ cwd: projectRoot });
406      expect(yarn.workspaceRoot()).toBeNull();
407    });
408
409    it('returns new instance for monorepo project', () => {
410      vol.fromJSON(
411        {
412          'packages/test/package.json': JSON.stringify({ name: 'project' }),
413          'package.json': JSON.stringify({
414            name: 'monorepo',
415            workspaces: ['packages/*'],
416          }),
417        },
418        workspaceRoot
419      );
420
421      const yarn = new YarnPackageManager({ cwd: projectRoot });
422      const root = yarn.workspaceRoot();
423      expect(root).toBeInstanceOf(YarnPackageManager);
424      expect(root).not.toBe(yarn);
425    });
426  });
427
428  // describe('offline support', () => {
429  //   // TODO
430  // });
431});
432