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