/ tests / utils / update-dependencies-mocked.test.js
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  });