/ __quarantined_tests__ / cron / process-reaper.test.js
process-reaper.test.js
  1  /**
  2   * Tests for process-reaper cron job
  3   *
  4   * runProcessReaper() uses execSync (ps, kill, free), os.freemem/totalmem,
  5   * and writes to a SQLite DB. We mock child_process and os to avoid
  6   * actual process killing, then verify the returned stats and DB write.
  7   */
  8  
  9  import { test, describe, mock } from 'node:test';
 10  import assert from 'node:assert/strict';
 11  import { join } from 'path';
 12  import { tmpdir } from 'os';
 13  import { unlinkSync, existsSync } from 'fs';
 14  import Database from 'better-sqlite3';
 15  import { readFileSync } from 'fs';
 16  
 17  // ─── Setup temp DB ────────────────────────────────────────────────────────────
 18  
 19  const TEST_DB = join(tmpdir(), `test-process-reaper-${Date.now()}.db`);
 20  process.env.DATABASE_PATH = TEST_DB;
 21  
 22  const schemaPath = join(import.meta.dirname, '..', '..', 'db', 'schema.sql');
 23  const schema = readFileSync(schemaPath, 'utf-8');
 24  const db = new Database(TEST_DB);
 25  db.exec(schema);
 26  db.close();
 27  
 28  // ─── Mock child_process and os ───────────────────────────────────────────────
 29  
 30  let mockExecSyncImpl = cmd => '';
 31  
 32  mock.module('child_process', {
 33    namedExports: {
 34      execSync: (cmd, opts) => mockExecSyncImpl(cmd, opts),
 35    },
 36  });
 37  
 38  mock.module('os', {
 39    namedExports: {
 40      freemem: () => 2 * 1024 * 1024 * 1024, // 2 GB free
 41      totalmem: () => 8 * 1024 * 1024 * 1024, // 8 GB total
 42      cpus: () => [],
 43      loadavg: () => [0.5, 0.5, 0.5],
 44    },
 45  });
 46  
 47  mock.module('../../src/utils/logger.js', {
 48    defaultExport: class {
 49      info() {}
 50      warn() {}
 51      error() {}
 52      success() {}
 53      debug() {}
 54    },
 55  });
 56  
 57  mock.module('../../src/utils/load-env.js', {
 58    defaultExport: {},
 59  });
 60  
 61  const { runProcessReaper } = await import('../../src/cron/process-reaper.js');
 62  
 63  // ─── Tests ────────────────────────────────────────────────────────────────────
 64  
 65  describe('runProcessReaper', () => {
 66    test('returns correct shape', async () => {
 67      mockExecSyncImpl = cmd => {
 68        if (cmd.includes('grep -c')) return '0\n';
 69        if (cmd.includes('free -m')) return '4096 512\n';
 70        if (cmd.includes('ps -eo')) return '\n';
 71        return '';
 72      };
 73  
 74      const result = await runProcessReaper();
 75      assert.ok(typeof result.zombie_count === 'number');
 76      assert.ok(typeof result.free_mem_mb === 'number');
 77      assert.ok(typeof result.stale_processes_killed === 'number');
 78      assert.ok(Array.isArray(result.killed));
 79      assert.ok(['ok', 'warning'].includes(result.status));
 80      assert.ok(typeof result.duration_seconds === 'number');
 81    });
 82  
 83    test('status is ok when memory is healthy and no zombies', async () => {
 84      mockExecSyncImpl = cmd => {
 85        if (cmd.includes('grep -c')) return '0\n'; // no zombies
 86        if (cmd.includes('free -m')) return '8192 0\n'; // plenty of swap free
 87        if (cmd.includes('ps -eo')) return '\n'; // no processes
 88        return '';
 89      };
 90  
 91      const result = await runProcessReaper();
 92      assert.strictEqual(result.zombie_count, 0);
 93      assert.strictEqual(result.stale_processes_killed, 0);
 94      assert.strictEqual(result.status, 'ok');
 95    });
 96  
 97    test('status is warning when zombie count exceeds default threshold (500)', async () => {
 98      mockExecSyncImpl = cmd => {
 99        if (cmd.includes('grep -c')) return '600\n'; // 600 zombies > default threshold 500
100        if (cmd.includes('free -m')) return '8192 0\n';
101        if (cmd.includes('ps -eo')) return '\n';
102        return '';
103      };
104  
105      const result = await runProcessReaper();
106      assert.strictEqual(result.zombie_count, 600);
107      assert.strictEqual(result.status, 'warning');
108    });
109  
110    test('status is warning when free memory is below default threshold (1024 MB)', async () => {
111      // os mock returns 2GB free which is > 1024 MB threshold — status should be ok
112      // We verify the threshold logic by checking zero-free scenario via swap
113      mockExecSyncImpl = cmd => {
114        if (cmd.includes('grep -c')) return '0\n';
115        // High swap usage (>70%) triggers warning
116        if (cmd.includes('free -m')) return '1024 800\n'; // 80% swap used > 70% threshold
117        if (cmd.includes('ps -eo')) return '\n';
118        return '';
119      };
120  
121      const result = await runProcessReaper();
122      // swap_pct = 800/1024 = 78% > SWAP_WARN_PCT (70%) → warning
123      assert.strictEqual(result.status, 'warning');
124    });
125  
126    test('returns stale_processes_killed=0 when no stale processes', async () => {
127      mockExecSyncImpl = cmd => {
128        if (cmd.includes('grep -c')) return '0\n';
129        if (cmd.includes('free -m')) return '8192 0\n';
130        if (cmd.includes('ps -eo')) return '\n'; // empty ps output
131        return '';
132      };
133  
134      const result = await runProcessReaper();
135      assert.strictEqual(result.stale_processes_killed, 0);
136      assert.deepStrictEqual(result.killed, []);
137    });
138  
139    test('handles execSync failures gracefully', async () => {
140      mockExecSyncImpl = () => {
141        throw new Error('execSync error');
142      };
143  
144      // Should not throw — all execSync failures are caught internally
145      const result = await runProcessReaper();
146      assert.ok(typeof result.zombie_count === 'number');
147      assert.ok(result.zombie_count === 0); // fallback on error
148    });
149  
150    test('kills stale processes when ps returns long-running process', async () => {
151      // A process with etimes > MAX_AGE_MINUTES * 60 (default 7200s) should be killed
152      // Format: PID PPID etimes stat comm
153      const stalePid = 99998;
154      const staleEtimes = 8000; // > 7200s threshold
155      mockExecSyncImpl = cmd => {
156        if (cmd.includes('grep -c')) return '0\n';
157        if (cmd.includes('free -m')) return '8192 0\n';
158        if (cmd.includes('ps -eo')) return `${stalePid} 1 ${staleEtimes} S claude\n`;
159        // kill -TERM / kill -KILL commands
160        if (cmd.includes('kill')) return '';
161        // /proc/PID/cmdline for npm processes (not needed for 'claude')
162        return '';
163      };
164  
165      const result = await runProcessReaper();
166      // Should have killed 1 stale claude process
167      assert.strictEqual(result.stale_processes_killed, 1);
168      assert.strictEqual(result.killed.length, 1);
169      assert.strictEqual(result.killed[0].pid, stalePid);
170    });
171  
172    test('skips zombie processes (stat starts with Z)', async () => {
173      const zombiePid = 99997;
174      mockExecSyncImpl = cmd => {
175        if (cmd.includes('grep -c')) return '0\n';
176        if (cmd.includes('free -m')) return '8192 0\n';
177        if (cmd.includes('ps -eo')) return `${zombiePid} 1 8000 Z claude\n`;
178        return '';
179      };
180  
181      const result = await runProcessReaper();
182      // Zombie should be skipped — can't kill already-dead processes
183      assert.strictEqual(result.stale_processes_killed, 0);
184      assert.strictEqual(result.killed.length, 0);
185    });
186  
187    test('skips processes younger than MAX_AGE threshold', async () => {
188      const youngPid = 99996;
189      mockExecSyncImpl = cmd => {
190        if (cmd.includes('grep -c')) return '0\n';
191        if (cmd.includes('free -m')) return '8192 0\n';
192        if (cmd.includes('ps -eo')) return `${youngPid} 1 60 S claude\n`; // 60s old, well under 7200s threshold
193        return '';
194      };
195  
196      const result = await runProcessReaper();
197      assert.strictEqual(result.stale_processes_killed, 0);
198    });
199  
200    test('writes to system_health table in DB', async () => {
201      mockExecSyncImpl = cmd => {
202        if (cmd.includes('grep -c')) return '0\n';
203        if (cmd.includes('free -m')) return '4096 512\n';
204        if (cmd.includes('ps -eo')) return '\n';
205        return '';
206      };
207  
208      await runProcessReaper();
209  
210      const db2 = new Database(TEST_DB);
211      const row = db2
212        .prepare(
213          `SELECT * FROM system_health WHERE check_type='process_reaper' ORDER BY id DESC LIMIT 1`
214        )
215        .get();
216      db2.close();
217  
218      assert.ok(row, 'Should have written a system_health row');
219      assert.strictEqual(row.check_type, 'process_reaper');
220      assert.ok(['ok', 'warning'].includes(row.status));
221    });
222  });