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