update-dependencies-mocked.test.js
1 /** 2 * Mocked unit tests for scripts/update-dependencies.js 3 * 4 * Tests the main update flow (lines 218-367) which includes: 5 * - main() function: safety checks, filtering, dry-run, update loop 6 * - Per-package update with lint/test verification 7 * - Rollback on failure 8 * - Commit creation for successful updates 9 * - Backup cleanup 10 * 11 * Strategy: Mock child_process.execSync and fs, then set process.argv 12 * before dynamically importing the script to trigger main(). 13 */ 14 15 import { test, describe, mock, beforeEach, afterEach } from 'node:test'; 16 import assert from 'node:assert'; 17 import path from 'path'; 18 import { fileURLToPath } from 'url'; 19 20 const __filename = fileURLToPath(import.meta.url); 21 const __dirname = path.dirname(__filename); 22 23 // Track all calls to mocked execSync 24 let execSyncCalls = []; 25 let execSyncHandler = () => ''; 26 27 // Track fs operations 28 let fsState = {}; 29 let fsCopyFileCalls = []; 30 let fsMkdirCalls = []; 31 let fsUnlinkCalls = []; 32 let fsReaddirResult = []; 33 34 // Track process.exit calls 35 let exitCalls = []; 36 let consoleLogCalls = []; 37 let consoleErrorCalls = []; 38 39 // Save originals 40 const originalArgv = process.argv; 41 const originalExit = process.exit; 42 const originalConsoleLog = console.log; 43 const originalConsoleError = console.error; 44 45 // Mock execSync 46 const mockExecSync = mock.fn((command, options) => { 47 execSyncCalls.push({ command, options }); 48 return execSyncHandler(command, options); 49 }); 50 51 // Set up module-level mocks 52 mock.module('child_process', { 53 namedExports: { 54 execSync: mockExecSync, 55 }, 56 }); 57 58 // Mock fs 59 mock.module('fs', { 60 defaultExport: { 61 existsSync: mock.fn(filepath => { 62 // .dependency-updates dir 63 if (filepath.includes('.dependency-updates')) return true; 64 // package-lock.json 65 if (filepath.endsWith('package-lock.json')) return true; 66 return fsState[filepath] !== undefined ? fsState[filepath] : true; 67 }), 68 mkdirSync: mock.fn((_dir, _opts) => { 69 fsMkdirCalls.push({ dir: _dir, opts: _opts }); 70 }), 71 copyFileSync: mock.fn((src, dst) => { 72 fsCopyFileCalls.push({ src, dst }); 73 }), 74 readdirSync: mock.fn(_dir => { 75 return fsReaddirResult; 76 }), 77 unlinkSync: mock.fn(filepath => { 78 fsUnlinkCalls.push(filepath); 79 }), 80 }, 81 }); 82 83 /** 84 * Helper: import the script fresh with given argv. 85 * Each import triggers main() which we test through side effects. 86 * 87 * The script calls main().catch(...) which can call process.exit(1). 88 * We mock process.exit as a no-op (just record calls) rather than throwing, 89 * because throwing from process.exit creates unhandled promise rejections 90 * from the script's own .catch() handler. 91 */ 92 async function runScript(extraArgs = []) { 93 // Set process.argv 94 process.argv = ['node', 'scripts/update-dependencies.js', ...extraArgs]; 95 96 // Mock process.exit as a no-op that records calls. 97 // We cannot throw here because the script's .catch() handler also calls 98 // process.exit, which would create an unhandled rejection. 99 process.exit = code => { 100 exitCalls.push(code); 101 }; 102 103 // Capture console output 104 console.log = (...args) => { 105 consoleLogCalls.push(args.join(' ')); 106 }; 107 console.error = (...args) => { 108 consoleErrorCalls.push(args.join(' ')); 109 }; 110 111 // Dynamic import with cache bust to get fresh module 112 const cacheBust = `?t=${Date.now()}-${Math.random()}`; 113 await import(`../../scripts/update-dependencies.js${cacheBust}`); 114 // Give the async main() time to settle (it's fire-and-forget from the import) 115 await new Promise(resolve => setTimeout(resolve, 100)); 116 } 117 118 /** 119 * Helper: set up execSync to respond differently to different commands. 120 */ 121 function setupExecSync(handlers = {}) { 122 execSyncHandler = (command, options) => { 123 for (const [pattern, handler] of Object.entries(handlers)) { 124 if (command.includes(pattern)) { 125 const result = handler(command, options); 126 if (result instanceof Error) throw result; 127 return result; 128 } 129 } 130 return ''; 131 }; 132 } 133 134 describe('update-dependencies main() flow', () => { 135 beforeEach(() => { 136 execSyncCalls = []; 137 exitCalls = []; 138 consoleLogCalls = []; 139 consoleErrorCalls = []; 140 fsCopyFileCalls = []; 141 fsMkdirCalls = []; 142 fsUnlinkCalls = []; 143 fsReaddirResult = []; 144 fsState = {}; 145 mockExecSync.mock.resetCalls(); 146 }); 147 148 afterEach(() => { 149 process.argv = originalArgv; 150 process.exit = originalExit; 151 console.log = originalConsoleLog; 152 console.error = originalConsoleError; 153 mock.restoreAll(); 154 }); 155 156 // Re-register mocks after restoreAll in afterEach would clear them, 157 // so we use individual describe blocks with their own mock setup. 158 }); 159 160 describe('main(): dirty working directory aborts', () => { 161 beforeEach(() => { 162 execSyncCalls = []; 163 exitCalls = []; 164 consoleLogCalls = []; 165 consoleErrorCalls = []; 166 fsCopyFileCalls = []; 167 fsUnlinkCalls = []; 168 fsReaddirResult = []; 169 mockExecSync.mock.resetCalls(); 170 }); 171 172 afterEach(() => { 173 process.argv = originalArgv; 174 process.exit = originalExit; 175 console.log = originalConsoleLog; 176 console.error = originalConsoleError; 177 }); 178 179 test('exits with code 1 when working directory is dirty (no --dry-run)', async () => { 180 setupExecSync({ 181 'git status --porcelain': () => 'M package.json\n', 182 }); 183 184 await runScript(['--level=patches']); 185 186 assert.ok(exitCalls.includes(1), 'Should call process.exit(1)'); 187 const allOutput = [...consoleLogCalls, ...consoleErrorCalls].join('\n'); 188 assert.ok(allOutput.includes('not clean'), 'Should warn about dirty working directory'); 189 }); 190 191 test('does NOT exit when working directory is dirty in dry-run mode', async () => { 192 setupExecSync({ 193 'git status --porcelain': () => 'M package.json\n', 194 'npm outdated --json': () => '{}', 195 }); 196 197 await runScript(['--dry-run']); 198 199 assert.ok(!exitCalls.includes(1), 'Should not exit in dry-run mode'); 200 const allOutput = consoleLogCalls.join('\n'); 201 assert.ok( 202 allOutput.includes('up to date') || 203 allOutput.includes('DRY RUN') || 204 allOutput.includes('No packages'), 205 'Should proceed normally' 206 ); 207 }); 208 }); 209 210 describe('main(): no outdated packages', () => { 211 beforeEach(() => { 212 execSyncCalls = []; 213 exitCalls = []; 214 consoleLogCalls = []; 215 consoleErrorCalls = []; 216 fsCopyFileCalls = []; 217 fsUnlinkCalls = []; 218 fsReaddirResult = []; 219 mockExecSync.mock.resetCalls(); 220 }); 221 222 afterEach(() => { 223 process.argv = originalArgv; 224 process.exit = originalExit; 225 console.log = originalConsoleLog; 226 console.error = originalConsoleError; 227 }); 228 229 test('reports all up to date when npm outdated returns empty', async () => { 230 setupExecSync({ 231 'git status --porcelain': () => '', 232 'npm outdated --json': () => '', 233 }); 234 235 await runScript([]); 236 237 const allOutput = consoleLogCalls.join('\n'); 238 assert.ok(allOutput.includes('up to date'), 'Should indicate all dependencies are current'); 239 }); 240 241 test('reports all up to date when npm outdated returns empty object', async () => { 242 setupExecSync({ 243 'git status --porcelain': () => '', 244 'npm outdated --json': () => '{}', 245 }); 246 247 await runScript([]); 248 249 const allOutput = consoleLogCalls.join('\n'); 250 assert.ok(allOutput.includes('up to date'), 'Should indicate all dependencies are current'); 251 }); 252 }); 253 254 describe('main(): dry-run displays packages without updating', () => { 255 beforeEach(() => { 256 execSyncCalls = []; 257 exitCalls = []; 258 consoleLogCalls = []; 259 consoleErrorCalls = []; 260 fsCopyFileCalls = []; 261 fsUnlinkCalls = []; 262 fsReaddirResult = []; 263 mockExecSync.mock.resetCalls(); 264 }); 265 266 afterEach(() => { 267 process.argv = originalArgv; 268 process.exit = originalExit; 269 console.log = originalConsoleLog; 270 console.error = originalConsoleError; 271 }); 272 273 test('dry-run shows packages to update but makes no changes', async () => { 274 const outdated = { 275 lodash: { current: '4.17.20', wanted: '4.17.21', latest: '4.17.21', location: '' }, 276 axios: { current: '1.5.0', wanted: '1.6.0', latest: '1.6.0', location: '' }, 277 }; 278 279 setupExecSync({ 280 'git status --porcelain': () => 'M package.json\n', // dirty - ok in dry-run 281 'npm outdated --json': () => JSON.stringify(outdated), 282 }); 283 284 await runScript(['--dry-run', '--level=all']); 285 286 const allOutput = consoleLogCalls.join('\n'); 287 assert.ok(allOutput.includes('DRY RUN'), 'Should indicate dry run'); 288 assert.ok(allOutput.includes('lodash'), 'Should list lodash'); 289 assert.ok(allOutput.includes('axios'), 'Should list axios'); 290 291 // Should NOT have called npm install 292 const installCalls = execSyncCalls.filter(c => c.command.includes('npm install')); 293 assert.strictEqual(installCalls.length, 0, 'Should not run npm install in dry-run'); 294 }); 295 296 test('dry-run with --package filter shows only that package', async () => { 297 const outdated = { 298 lodash: { current: '4.17.20', wanted: '4.17.21', latest: '4.17.21', location: '' }, 299 axios: { current: '1.5.0', wanted: '1.6.0', latest: '1.6.0', location: '' }, 300 }; 301 302 setupExecSync({ 303 'git status --porcelain': () => '', 304 'npm outdated --json': () => JSON.stringify(outdated), 305 }); 306 307 await runScript(['--dry-run', '--level=all', '--package=lodash']); 308 309 const allOutput = consoleLogCalls.join('\n'); 310 assert.ok(allOutput.includes('DRY RUN'), 'Should indicate dry run'); 311 assert.ok(allOutput.includes('lodash'), 'Should show lodash'); 312 assert.ok(allOutput.includes('1'), 'Should show 1 package to update'); 313 }); 314 }); 315 316 describe('main(): --package filter for non-existent package exits', () => { 317 beforeEach(() => { 318 execSyncCalls = []; 319 exitCalls = []; 320 consoleLogCalls = []; 321 consoleErrorCalls = []; 322 fsCopyFileCalls = []; 323 fsUnlinkCalls = []; 324 fsReaddirResult = []; 325 mockExecSync.mock.resetCalls(); 326 }); 327 328 afterEach(() => { 329 process.argv = originalArgv; 330 process.exit = originalExit; 331 console.log = originalConsoleLog; 332 console.error = originalConsoleError; 333 }); 334 335 test('exits with code 1 when --package not found in outdated list', async () => { 336 const outdated = { 337 lodash: { current: '4.17.20', wanted: '4.17.21', latest: '4.17.21', location: '' }, 338 }; 339 340 setupExecSync({ 341 'git status --porcelain': () => '', 342 'npm outdated --json': () => JSON.stringify(outdated), 343 }); 344 345 await runScript(['--level=all', '--package=nonexistent']); 346 347 assert.ok(exitCalls.includes(1), 'Should exit with code 1'); 348 const allOutput = [...consoleLogCalls, ...consoleErrorCalls].join('\n'); 349 assert.ok(allOutput.includes('not found'), 'Should indicate package not found'); 350 }); 351 }); 352 353 describe('main(): no packages match the selected level', () => { 354 beforeEach(() => { 355 execSyncCalls = []; 356 exitCalls = []; 357 consoleLogCalls = []; 358 consoleErrorCalls = []; 359 fsCopyFileCalls = []; 360 fsUnlinkCalls = []; 361 fsReaddirResult = []; 362 mockExecSync.mock.resetCalls(); 363 }); 364 365 afterEach(() => { 366 process.argv = originalArgv; 367 process.exit = originalExit; 368 console.log = originalConsoleLog; 369 console.error = originalConsoleError; 370 }); 371 372 test('reports no packages when level filter excludes all', async () => { 373 // Only major updates, but level is patches 374 const outdated = { 375 lodash: { current: '3.0.0', wanted: '4.0.0', latest: '4.0.0', location: '' }, 376 }; 377 378 setupExecSync({ 379 'git status --porcelain': () => '', 380 'npm outdated --json': () => JSON.stringify(outdated), 381 }); 382 383 await runScript(['--level=patches']); 384 385 const allOutput = consoleLogCalls.join('\n'); 386 assert.ok( 387 allOutput.includes('No packages to update'), 388 'Should indicate no packages at this level' 389 ); 390 }); 391 }); 392 393 describe('main(): successful single package update', () => { 394 beforeEach(() => { 395 execSyncCalls = []; 396 exitCalls = []; 397 consoleLogCalls = []; 398 consoleErrorCalls = []; 399 fsCopyFileCalls = []; 400 fsMkdirCalls = []; 401 fsUnlinkCalls = []; 402 fsReaddirResult = ['package.json.2026-01-01', 'package-lock.json.2026-01-01']; 403 mockExecSync.mock.resetCalls(); 404 }); 405 406 afterEach(() => { 407 process.argv = originalArgv; 408 process.exit = originalExit; 409 console.log = originalConsoleLog; 410 console.error = originalConsoleError; 411 }); 412 413 test('updates package, runs lint and tests, creates commit', async () => { 414 const outdated = { 415 axios: { current: '1.5.0', wanted: '1.6.0', latest: '1.6.0', location: '' }, 416 }; 417 418 setupExecSync({ 419 'git status --porcelain': () => '', 420 'npm outdated --json': () => JSON.stringify(outdated), 421 'npm install axios@1.6.0': () => 'added 1 package', 422 'npm run lint:fix': () => 'All lint checks passed', 423 'npm test': () => 'All tests passed', 424 'git add': () => '', 425 }); 426 427 await runScript(['--level=all']); 428 429 const allOutput = [...consoleLogCalls, ...consoleErrorCalls].join('\n'); 430 431 // Should show successful update 432 assert.ok(allOutput.includes('Successfully updated axios'), 'Should report success'); 433 assert.ok(allOutput.includes('Successful: 1'), 'Should show 1 successful'); 434 assert.ok(allOutput.includes('Failed: 0'), 'Should show 0 failed'); 435 assert.ok(allOutput.includes('Commit created'), 'Should create commit'); 436 437 // Verify npm install was called for the package 438 const installCalls = execSyncCalls.filter(c => c.command.includes('npm install axios@1.6.0')); 439 assert.ok(installCalls.length > 0, 'Should call npm install for axios'); 440 441 // Verify tests were run 442 const testCalls = execSyncCalls.filter(c => c.command === 'npm test'); 443 assert.ok(testCalls.length > 0, 'Should run npm test'); 444 445 // Verify lint was run 446 const lintCalls = execSyncCalls.filter(c => c.command === 'npm run lint:fix'); 447 assert.ok(lintCalls.length > 0, 'Should run lint:fix'); 448 449 // Verify git commit was attempted 450 const commitCalls = execSyncCalls.filter( 451 c => c.command.includes('git add') && c.command.includes('git commit') 452 ); 453 assert.ok(commitCalls.length > 0, 'Should create git commit'); 454 455 // Verify backup was created (copyFileSync called) 456 assert.ok(fsCopyFileCalls.length >= 2, 'Should backup package.json and package-lock.json'); 457 }); 458 459 test('successful update with --no-commit skips git commit', async () => { 460 const outdated = { 461 axios: { current: '1.5.0', wanted: '1.6.0', latest: '1.6.0', location: '' }, 462 }; 463 464 setupExecSync({ 465 'git status --porcelain': () => '', 466 'npm outdated --json': () => JSON.stringify(outdated), 467 'npm install axios@1.6.0': () => 'added 1 package', 468 'npm run lint:fix': () => 'passed', 469 'npm test': () => 'passed', 470 }); 471 472 await runScript(['--level=all', '--no-commit']); 473 474 const allOutput = consoleLogCalls.join('\n'); 475 476 assert.ok(allOutput.includes('Successfully updated axios'), 'Should report success'); 477 // Should NOT create a commit 478 const commitCalls = execSyncCalls.filter(c => c.command.includes('git commit')); 479 assert.strictEqual(commitCalls.length, 0, 'Should not create git commit with --no-commit'); 480 }); 481 }); 482 483 describe('main(): package update fails npm install', () => { 484 beforeEach(() => { 485 execSyncCalls = []; 486 exitCalls = []; 487 consoleLogCalls = []; 488 consoleErrorCalls = []; 489 fsCopyFileCalls = []; 490 fsMkdirCalls = []; 491 fsUnlinkCalls = []; 492 fsReaddirResult = ['package.json.2026-01-01', 'package-lock.json.2026-01-01']; 493 mockExecSync.mock.resetCalls(); 494 }); 495 496 afterEach(() => { 497 process.argv = originalArgv; 498 process.exit = originalExit; 499 console.log = originalConsoleLog; 500 console.error = originalConsoleError; 501 }); 502 503 test('rolls back when npm install fails', async () => { 504 const outdated = { 505 'broken-pkg': { current: '1.0.0', wanted: '2.0.0', latest: '2.0.0', location: '' }, 506 }; 507 508 setupExecSync({ 509 'git status --porcelain': () => '', 510 'npm outdated --json': () => JSON.stringify(outdated), 511 'npm install broken-pkg@2.0.0': () => { 512 throw new Error('npm ERR! 404 Not Found'); 513 }, 514 'npm install': () => 'reinstalled', // rollback npm install 515 'npm run lint:fix': () => 'passed', 516 'npm test': () => 'passed', 517 }); 518 519 await runScript(['--level=all', '--no-commit']); 520 521 const allOutput = [...consoleLogCalls, ...consoleErrorCalls].join('\n'); 522 assert.ok( 523 allOutput.includes('Failed to update broken-pkg') || allOutput.includes('npm install failed'), 524 'Should report the failure' 525 ); 526 assert.ok(allOutput.includes('Rolling back'), 'Should trigger rollback'); 527 assert.ok(allOutput.includes('Failed: 1'), 'Should show 1 failed'); 528 }); 529 }); 530 531 describe('main(): tests fail after update triggers rollback', () => { 532 beforeEach(() => { 533 execSyncCalls = []; 534 exitCalls = []; 535 consoleLogCalls = []; 536 consoleErrorCalls = []; 537 fsCopyFileCalls = []; 538 fsMkdirCalls = []; 539 fsUnlinkCalls = []; 540 fsReaddirResult = ['package.json.2026-01-01', 'package-lock.json.2026-01-01']; 541 mockExecSync.mock.resetCalls(); 542 }); 543 544 afterEach(() => { 545 process.argv = originalArgv; 546 process.exit = originalExit; 547 console.log = originalConsoleLog; 548 console.error = originalConsoleError; 549 }); 550 551 test('rolls back when tests fail after successful install', async () => { 552 const outdated = { 553 axios: { current: '1.5.0', wanted: '2.0.0', latest: '2.0.0', location: '' }, 554 }; 555 556 setupExecSync({ 557 'git status --porcelain': () => '', 558 'npm outdated --json': () => JSON.stringify(outdated), 559 'npm install axios@2.0.0': () => 'installed', 560 'npm run lint:fix': () => 'passed', 561 'npm test': () => { 562 throw new Error('Test suite failed'); 563 }, 564 'npm install': () => 'reinstalled', // rollback npm install 565 }); 566 567 await runScript(['--level=all', '--no-commit']); 568 569 const allOutput = [...consoleLogCalls, ...consoleErrorCalls].join('\n'); 570 assert.ok( 571 allOutput.includes('Tests failed') || allOutput.includes('tests failed'), 572 'Should report test failure' 573 ); 574 assert.ok(allOutput.includes('Rolling back'), 'Should trigger rollback'); 575 assert.ok(allOutput.includes('Rollback complete'), 'Should complete rollback'); 576 assert.ok(allOutput.includes('Failed: 1'), 'Should show 1 failed'); 577 }); 578 }); 579 580 describe('main(): multiple packages - mixed success and failure', () => { 581 beforeEach(() => { 582 execSyncCalls = []; 583 exitCalls = []; 584 consoleLogCalls = []; 585 consoleErrorCalls = []; 586 fsCopyFileCalls = []; 587 fsMkdirCalls = []; 588 fsUnlinkCalls = []; 589 fsReaddirResult = ['package.json.2026-01-01', 'package-lock.json.2026-01-01']; 590 mockExecSync.mock.resetCalls(); 591 }); 592 593 afterEach(() => { 594 process.argv = originalArgv; 595 process.exit = originalExit; 596 console.log = originalConsoleLog; 597 console.error = originalConsoleError; 598 }); 599 600 test('processes multiple packages: one succeeds, one fails', async () => { 601 const outdated = { 602 axios: { current: '1.5.0', wanted: '1.6.0', latest: '1.6.0', location: '' }, 603 lodash: { current: '4.17.0', wanted: '4.18.0', latest: '4.18.0', location: '' }, 604 }; 605 606 let testCallCount = 0; 607 608 setupExecSync({ 609 'git status --porcelain': () => '', 610 'npm outdated --json': () => JSON.stringify(outdated), 611 'npm install axios@1.6.0': () => 'installed', 612 'npm install lodash@4.18.0': () => 'installed', 613 'npm run lint:fix': () => 'passed', 614 'npm test': () => { 615 testCallCount++; 616 if (testCallCount === 1) return 'passed'; // axios tests pass 617 throw new Error('lodash broke tests'); // lodash tests fail 618 }, 619 'npm install': () => 'reinstalled', // rollback 620 }); 621 622 await runScript(['--level=all', '--no-commit']); 623 624 const allOutput = [...consoleLogCalls, ...consoleErrorCalls].join('\n'); 625 assert.ok(allOutput.includes('Successfully updated axios'), 'axios should succeed'); 626 assert.ok( 627 allOutput.includes('Tests failed after updating lodash') || 628 allOutput.includes('tests failed'), 629 'lodash should fail' 630 ); 631 assert.ok(allOutput.includes('Successful: 1'), 'Should show 1 successful'); 632 assert.ok(allOutput.includes('Failed: 1'), 'Should show 1 failed'); 633 }); 634 }); 635 636 describe('main(): lint failure is non-blocking', () => { 637 beforeEach(() => { 638 execSyncCalls = []; 639 exitCalls = []; 640 consoleLogCalls = []; 641 consoleErrorCalls = []; 642 fsCopyFileCalls = []; 643 fsMkdirCalls = []; 644 fsUnlinkCalls = []; 645 fsReaddirResult = ['package.json.2026-01-01']; 646 mockExecSync.mock.resetCalls(); 647 }); 648 649 afterEach(() => { 650 process.argv = originalArgv; 651 process.exit = originalExit; 652 console.log = originalConsoleLog; 653 console.error = originalConsoleError; 654 }); 655 656 test('continues even when lint fails (lint is non-blocking)', async () => { 657 const outdated = { 658 axios: { current: '1.5.0', wanted: '1.6.0', latest: '1.6.0', location: '' }, 659 }; 660 661 setupExecSync({ 662 'git status --porcelain': () => '', 663 'npm outdated --json': () => JSON.stringify(outdated), 664 'npm install axios@1.6.0': () => 'installed', 665 'npm run lint:fix': () => { 666 throw new Error('Lint failed'); 667 }, 668 'npm test': () => 'passed', 669 }); 670 671 await runScript(['--level=all', '--no-commit']); 672 673 const allOutput = [...consoleLogCalls, ...consoleErrorCalls].join('\n'); 674 // Despite lint failure, update should succeed because tests pass 675 assert.ok( 676 allOutput.includes('Successfully updated axios'), 677 'Should succeed despite lint failure' 678 ); 679 assert.ok( 680 allOutput.includes('Lint failed') || allOutput.includes('continuing anyway'), 681 'Should note lint failure' 682 ); 683 assert.ok(allOutput.includes('Successful: 1'), 'Should show 1 successful'); 684 }); 685 }); 686 687 describe('main(): backup cleanup removes old files', () => { 688 beforeEach(() => { 689 execSyncCalls = []; 690 exitCalls = []; 691 consoleLogCalls = []; 692 consoleErrorCalls = []; 693 fsCopyFileCalls = []; 694 fsMkdirCalls = []; 695 fsUnlinkCalls = []; 696 mockExecSync.mock.resetCalls(); 697 }); 698 699 afterEach(() => { 700 process.argv = originalArgv; 701 process.exit = originalExit; 702 console.log = originalConsoleLog; 703 console.error = originalConsoleError; 704 }); 705 706 test('cleans up old backup files when more than 10 exist', async () => { 707 // Create 12 backup files (should clean up 2) 708 fsReaddirResult = [ 709 'package.json.2026-01-12', 710 'package.json.2026-01-11', 711 'package.json.2026-01-10', 712 'package.json.2026-01-09', 713 'package.json.2026-01-08', 714 'package.json.2026-01-07', 715 'package.json.2026-01-06', 716 'package.json.2026-01-05', 717 'package.json.2026-01-04', 718 'package.json.2026-01-03', 719 'package.json.2026-01-02', 720 'package.json.2026-01-01', 721 ]; 722 723 const outdated = { 724 axios: { current: '1.5.0', wanted: '1.6.0', latest: '1.6.0', location: '' }, 725 }; 726 727 setupExecSync({ 728 'git status --porcelain': () => '', 729 'npm outdated --json': () => JSON.stringify(outdated), 730 'npm install axios@1.6.0': () => 'installed', 731 'npm run lint:fix': () => 'passed', 732 'npm test': () => 'passed', 733 }); 734 735 await runScript(['--level=all', '--no-commit']); 736 737 const allOutput = consoleLogCalls.join('\n'); 738 // Should clean up 2 old backup files (12 - 10 = 2) 739 assert.strictEqual(fsUnlinkCalls.length, 2, 'Should delete 2 old backup files'); 740 assert.ok(allOutput.includes('Cleaned up 2'), 'Should report cleanup of 2 files'); 741 }); 742 743 test('does not clean up when 10 or fewer backup files exist', async () => { 744 fsReaddirResult = [ 745 'package.json.2026-01-05', 746 'package-lock.json.2026-01-05', 747 'package.json.2026-01-04', 748 'package-lock.json.2026-01-04', 749 ]; 750 751 const outdated = { 752 axios: { current: '1.5.0', wanted: '1.6.0', latest: '1.6.0', location: '' }, 753 }; 754 755 setupExecSync({ 756 'git status --porcelain': () => '', 757 'npm outdated --json': () => JSON.stringify(outdated), 758 'npm install axios@1.6.0': () => 'installed', 759 'npm run lint:fix': () => 'passed', 760 'npm test': () => 'passed', 761 }); 762 763 await runScript(['--level=all', '--no-commit']); 764 765 assert.strictEqual(fsUnlinkCalls.length, 0, 'Should not delete any backups'); 766 }); 767 }); 768 769 describe('main(): verbose flag shows debug output', () => { 770 beforeEach(() => { 771 execSyncCalls = []; 772 exitCalls = []; 773 consoleLogCalls = []; 774 consoleErrorCalls = []; 775 fsCopyFileCalls = []; 776 fsMkdirCalls = []; 777 fsUnlinkCalls = []; 778 fsReaddirResult = []; 779 mockExecSync.mock.resetCalls(); 780 }); 781 782 afterEach(() => { 783 process.argv = originalArgv; 784 process.exit = originalExit; 785 console.log = originalConsoleLog; 786 console.error = originalConsoleError; 787 }); 788 789 test('--verbose shows DEBUG messages with command details', async () => { 790 setupExecSync({ 791 'git status --porcelain': () => '', 792 'npm outdated --json': () => '{}', 793 }); 794 795 await runScript(['--dry-run', '--verbose']); 796 797 const allOutput = consoleLogCalls.join('\n'); 798 assert.ok(allOutput.includes('[DEBUG]'), 'Should show DEBUG messages'); 799 assert.ok(allOutput.includes('Running:'), 'Should show command being run'); 800 }); 801 }); 802 803 describe('main(): --package option targeting a specific package', () => { 804 beforeEach(() => { 805 execSyncCalls = []; 806 exitCalls = []; 807 consoleLogCalls = []; 808 consoleErrorCalls = []; 809 fsCopyFileCalls = []; 810 fsMkdirCalls = []; 811 fsUnlinkCalls = []; 812 fsReaddirResult = ['package.json.2026-01-01']; 813 mockExecSync.mock.resetCalls(); 814 }); 815 816 afterEach(() => { 817 process.argv = originalArgv; 818 process.exit = originalExit; 819 console.log = originalConsoleLog; 820 console.error = originalConsoleError; 821 }); 822 823 test('logs target package name when --package is set', async () => { 824 const outdated = { 825 axios: { current: '1.5.0', wanted: '1.6.0', latest: '1.6.0', location: '' }, 826 lodash: { current: '4.17.0', wanted: '4.18.0', latest: '4.18.0', location: '' }, 827 }; 828 829 setupExecSync({ 830 'git status --porcelain': () => '', 831 'npm outdated --json': () => JSON.stringify(outdated), 832 'npm install axios@1.6.0': () => 'installed', 833 'npm run lint:fix': () => 'passed', 834 'npm test': () => 'passed', 835 }); 836 837 await runScript(['--level=all', '--package=axios', '--no-commit']); 838 839 const allOutput = consoleLogCalls.join('\n'); 840 assert.ok(allOutput.includes('Target package: axios'), 'Should log target package'); 841 assert.ok(allOutput.includes('Successfully updated axios'), 'Should update axios'); 842 assert.ok(allOutput.includes('Successful: 1'), 'Should show 1 successful'); 843 844 // Should NOT have attempted to install lodash 845 const lodashCalls = execSyncCalls.filter(c => c.command.includes('lodash')); 846 assert.strictEqual(lodashCalls.length, 0, 'Should not touch lodash'); 847 }); 848 }); 849 850 describe('main(): git status check failure', () => { 851 beforeEach(() => { 852 execSyncCalls = []; 853 exitCalls = []; 854 consoleLogCalls = []; 855 consoleErrorCalls = []; 856 fsCopyFileCalls = []; 857 fsUnlinkCalls = []; 858 fsReaddirResult = []; 859 mockExecSync.mock.resetCalls(); 860 }); 861 862 afterEach(() => { 863 process.argv = originalArgv; 864 process.exit = originalExit; 865 console.log = originalConsoleLog; 866 console.error = originalConsoleError; 867 }); 868 869 test('treats git status failure as dirty directory', async () => { 870 setupExecSync({ 871 'git status --porcelain': () => { 872 throw new Error('git not found'); 873 }, 874 }); 875 876 await runScript(['--level=patches']); 877 878 assert.ok(exitCalls.includes(1), 'Should exit with code 1 when git status fails'); 879 }); 880 }); 881 882 describe('main(): npm outdated returns invalid JSON', () => { 883 beforeEach(() => { 884 execSyncCalls = []; 885 exitCalls = []; 886 consoleLogCalls = []; 887 consoleErrorCalls = []; 888 fsCopyFileCalls = []; 889 fsUnlinkCalls = []; 890 fsReaddirResult = []; 891 mockExecSync.mock.resetCalls(); 892 }); 893 894 afterEach(() => { 895 process.argv = originalArgv; 896 process.exit = originalExit; 897 console.log = originalConsoleLog; 898 console.error = originalConsoleError; 899 }); 900 901 test('handles invalid JSON from npm outdated gracefully', async () => { 902 setupExecSync({ 903 'git status --porcelain': () => '', 904 'npm outdated --json': () => 'not valid json {{{{', 905 }); 906 907 await runScript([]); 908 909 const allOutput = [...consoleLogCalls, ...consoleErrorCalls].join('\n'); 910 // Should handle parse error and treat as no outdated packages 911 assert.ok( 912 allOutput.includes('Failed to parse') || allOutput.includes('up to date'), 913 'Should handle JSON parse error' 914 ); 915 }); 916 }); 917 918 describe('main(): categorizeUpdate edge cases', () => { 919 beforeEach(() => { 920 execSyncCalls = []; 921 exitCalls = []; 922 consoleLogCalls = []; 923 consoleErrorCalls = []; 924 fsCopyFileCalls = []; 925 fsMkdirCalls = []; 926 fsUnlinkCalls = []; 927 fsReaddirResult = ['package.json.2026-01-01']; 928 mockExecSync.mock.resetCalls(); 929 }); 930 931 afterEach(() => { 932 process.argv = originalArgv; 933 process.exit = originalExit; 934 console.log = originalConsoleLog; 935 console.error = originalConsoleError; 936 }); 937 938 test('correctly categorizes major, minor, and patch updates', async () => { 939 const outdated = { 940 'major-pkg': { current: '1.0.0', wanted: '2.0.0', latest: '2.0.0', location: '' }, 941 'minor-pkg': { current: '1.0.0', wanted: '1.1.0', latest: '1.1.0', location: '' }, 942 'patch-pkg': { current: '1.0.0', wanted: '1.0.1', latest: '1.0.1', location: '' }, 943 }; 944 945 setupExecSync({ 946 'git status --porcelain': () => '', 947 'npm outdated --json': () => JSON.stringify(outdated), 948 }); 949 950 await runScript(['--dry-run', '--level=all']); 951 952 const allOutput = consoleLogCalls.join('\n'); 953 assert.ok( 954 allOutput.includes('major-pkg') && allOutput.includes('major'), 955 'Should categorize major' 956 ); 957 assert.ok( 958 allOutput.includes('minor-pkg') && allOutput.includes('minor'), 959 'Should categorize minor' 960 ); 961 assert.ok( 962 allOutput.includes('patch-pkg') && allOutput.includes('patch'), 963 'Should categorize patch' 964 ); 965 }); 966 967 test('level=minors includes minor and major but not patch', async () => { 968 const outdated = { 969 'major-pkg': { current: '1.0.0', wanted: '2.0.0', latest: '2.0.0', location: '' }, 970 'minor-pkg': { current: '1.0.0', wanted: '1.1.0', latest: '1.1.0', location: '' }, 971 'patch-pkg': { current: '1.0.0', wanted: '1.0.1', latest: '1.0.1', location: '' }, 972 }; 973 974 setupExecSync({ 975 'git status --porcelain': () => '', 976 'npm outdated --json': () => JSON.stringify(outdated), 977 }); 978 979 await runScript(['--dry-run', '--level=minors']); 980 981 const allOutput = consoleLogCalls.join('\n'); 982 assert.ok(allOutput.includes('major-pkg'), 'minors level should include major updates'); 983 assert.ok(allOutput.includes('minor-pkg'), 'minors level should include minor updates'); 984 // Patch should be excluded 985 assert.ok(allOutput.includes('2)'), 'Should show 2 packages to update'); 986 }); 987 988 test('level=majors includes only major updates', async () => { 989 const outdated = { 990 'major-pkg': { current: '1.0.0', wanted: '2.0.0', latest: '2.0.0', location: '' }, 991 'minor-pkg': { current: '1.0.0', wanted: '1.1.0', latest: '1.1.0', location: '' }, 992 'patch-pkg': { current: '1.0.0', wanted: '1.0.1', latest: '1.0.1', location: '' }, 993 }; 994 995 setupExecSync({ 996 'git status --porcelain': () => '', 997 'npm outdated --json': () => JSON.stringify(outdated), 998 }); 999 1000 await runScript(['--dry-run', '--level=majors']); 1001 1002 const allOutput = consoleLogCalls.join('\n'); 1003 assert.ok(allOutput.includes('major-pkg'), 'majors level should include major updates'); 1004 assert.ok(allOutput.includes('1)'), 'Should show 1 package to update'); 1005 }); 1006 }); 1007 1008 describe('main(): commit message format', () => { 1009 beforeEach(() => { 1010 execSyncCalls = []; 1011 exitCalls = []; 1012 consoleLogCalls = []; 1013 consoleErrorCalls = []; 1014 fsCopyFileCalls = []; 1015 fsMkdirCalls = []; 1016 fsUnlinkCalls = []; 1017 fsReaddirResult = ['package.json.2026-01-01']; 1018 mockExecSync.mock.resetCalls(); 1019 }); 1020 1021 afterEach(() => { 1022 process.argv = originalArgv; 1023 process.exit = originalExit; 1024 console.log = originalConsoleLog; 1025 console.error = originalConsoleError; 1026 }); 1027 1028 test('commit message includes package names and version changes', async () => { 1029 const outdated = { 1030 axios: { current: '1.5.0', wanted: '1.6.0', latest: '1.6.0', location: '' }, 1031 }; 1032 1033 setupExecSync({ 1034 'git status --porcelain': () => '', 1035 'npm outdated --json': () => JSON.stringify(outdated), 1036 'npm install axios@1.6.0': () => 'installed', 1037 'npm run lint:fix': () => 'passed', 1038 'npm test': () => 'passed', 1039 'git add': () => '', 1040 }); 1041 1042 await runScript(['--level=all']); 1043 1044 // Find the git commit command 1045 const commitCall = execSyncCalls.find(c => c.command.includes('git commit')); 1046 assert.ok(commitCall, 'Should have a git commit call'); 1047 assert.ok( 1048 commitCall.command.includes('chore: update dependencies'), 1049 'Should have standard commit message' 1050 ); 1051 assert.ok(commitCall.command.includes('axios'), 'Should mention updated package'); 1052 assert.ok(commitCall.command.includes('1.5.0'), 'Should mention old version'); 1053 assert.ok(commitCall.command.includes('1.6.0'), 'Should mention new version'); 1054 }); 1055 }); 1056 1057 describe('main(): failed summary output', () => { 1058 beforeEach(() => { 1059 execSyncCalls = []; 1060 exitCalls = []; 1061 consoleLogCalls = []; 1062 consoleErrorCalls = []; 1063 fsCopyFileCalls = []; 1064 fsMkdirCalls = []; 1065 fsUnlinkCalls = []; 1066 fsReaddirResult = ['package.json.2026-01-01']; 1067 mockExecSync.mock.resetCalls(); 1068 }); 1069 1070 afterEach(() => { 1071 process.argv = originalArgv; 1072 process.exit = originalExit; 1073 console.log = originalConsoleLog; 1074 console.error = originalConsoleError; 1075 }); 1076 1077 test('shows detailed failure summary with reasons', async () => { 1078 const outdated = { 1079 'bad-pkg': { current: '1.0.0', wanted: '2.0.0', latest: '2.0.0', location: '' }, 1080 }; 1081 1082 setupExecSync({ 1083 'git status --porcelain': () => '', 1084 'npm outdated --json': () => JSON.stringify(outdated), 1085 'npm install bad-pkg@2.0.0': () => { 1086 throw new Error('npm ERR!'); 1087 }, 1088 'npm install': () => 'reinstalled', 1089 }); 1090 1091 await runScript(['--level=all', '--no-commit']); 1092 1093 const allOutput = [...consoleLogCalls, ...consoleErrorCalls].join('\n'); 1094 assert.ok(allOutput.includes('Failed updates'), 'Should show failed updates section'); 1095 assert.ok(allOutput.includes('bad-pkg'), 'Should mention the failed package'); 1096 assert.ok(allOutput.includes('npm install failed'), 'Should show failure reason'); 1097 }); 1098 }); 1099 1100 describe('main(): all packages fail - no commit created', () => { 1101 beforeEach(() => { 1102 execSyncCalls = []; 1103 exitCalls = []; 1104 consoleLogCalls = []; 1105 consoleErrorCalls = []; 1106 fsCopyFileCalls = []; 1107 fsMkdirCalls = []; 1108 fsUnlinkCalls = []; 1109 fsReaddirResult = ['package.json.2026-01-01']; 1110 mockExecSync.mock.resetCalls(); 1111 }); 1112 1113 afterEach(() => { 1114 process.argv = originalArgv; 1115 process.exit = originalExit; 1116 console.log = originalConsoleLog; 1117 console.error = originalConsoleError; 1118 }); 1119 1120 test('does not create commit when all updates fail', async () => { 1121 const outdated = { 1122 'bad-pkg': { current: '1.0.0', wanted: '2.0.0', latest: '2.0.0', location: '' }, 1123 }; 1124 1125 setupExecSync({ 1126 'git status --porcelain': () => '', 1127 'npm outdated --json': () => JSON.stringify(outdated), 1128 'npm install bad-pkg@2.0.0': () => { 1129 throw new Error('npm ERR!'); 1130 }, 1131 'npm install': () => 'reinstalled', 1132 }); 1133 1134 await runScript(['--level=all']); 1135 1136 const commitCalls = execSyncCalls.filter(c => c.command.includes('git commit')); 1137 assert.strictEqual(commitCalls.length, 0, 'Should not create commit when all updates fail'); 1138 assert.ok( 1139 !consoleLogCalls.join('\n').includes('Commit created'), 1140 'Should not report commit creation' 1141 ); 1142 }); 1143 });