sonnet-overseer.test.js
1 /** 2 * Tests for src/cron/sonnet-overseer.js (stub version) 3 * 4 * The overseer is now a stub — LLM analysis is delegated to claude-orchestrator.sh. 5 * Tests cover the remaining functionality: 6 * - collectServiceStatus: checks systemctl for each service 7 * - collectRecentErrors: reads log file, filters by ERROR/WARN, deduplicates 8 * - runSonnetOverseer: stub that returns { summary: 'Delegated to orchestrator', ... } 9 * 10 * NOTE: requires --experimental-test-module-mocks 11 */ 12 13 import { test, describe, mock, before, after } from 'node:test'; 14 import assert from 'node:assert/strict'; 15 import { join, dirname } from 'path'; 16 import { fileURLToPath } from 'url'; 17 import { mkdirSync, writeFileSync, existsSync, unlinkSync } from 'fs'; 18 import Database from 'better-sqlite3'; 19 import { createPgMock } from '../helpers/pg-mock.js'; 20 21 const __filename = fileURLToPath(import.meta.url); 22 const __dirname = dirname(__filename); 23 // PROJECT_ROOT in sonnet-overseer.js = join(__dirname, '../..') from src/cron/ 24 const PROJECT_ROOT = join(__dirname, '../..'); // tests/cron/ → 333Method/ 25 const PROJECT_LOGS = join(PROJECT_ROOT, 'logs'); 26 27 // ─── Create in-memory test DB ───────────────────────────────────────────────── 28 29 const db = new Database(':memory:'); 30 31 db.exec(` 32 CREATE TABLE IF NOT EXISTS sites ( 33 id INTEGER PRIMARY KEY AUTOINCREMENT, 34 domain TEXT NOT NULL DEFAULT 'test.com', 35 status TEXT DEFAULT 'found', 36 score REAL, 37 error_message TEXT, 38 updated_at TEXT DEFAULT CURRENT_TIMESTAMP, 39 rescored_at DATETIME 40 ); 41 `); 42 43 // ── Mutable stub for execSync only ──────────────────────────────────────────── 44 45 let execSyncFn = _cmd => 'active\n'; 46 47 // ─── Mock db.js BEFORE importing module under test ──────────────────────────── 48 49 mock.module('../../src/utils/db.js', { 50 namedExports: createPgMock(db), 51 }); 52 53 // Only mock child_process (not fs — better-sqlite3 needs real fs) 54 mock.module('child_process', { 55 namedExports: { 56 execSync: (...args) => execSyncFn(...args), 57 }, 58 }); 59 60 mock.module('../../src/utils/load-env.js', { 61 namedExports: {}, 62 }); 63 64 // ─── Import AFTER mock.module ───────────────────────────────────────────────── 65 66 const { collectServiceStatus, collectRecentErrors, runSonnetOverseer } = 67 await import('../../src/cron/sonnet-overseer.js'); 68 69 // ── Temp log dir setup ──────────────────────────────────────────────────────── 70 71 const today = new Date().toISOString().slice(0, 10); 72 const testLogFile = join(PROJECT_LOGS, `pipeline-${today}.log`); 73 74 before(() => { 75 // Ensure project logs directory exists 76 mkdirSync(PROJECT_LOGS, { recursive: true }); 77 }); 78 79 after(() => { 80 // Clean up test log file if we created it 81 if (existsSync(testLogFile)) { 82 try { 83 unlinkSync(testLogFile); 84 } catch { 85 /* ignore */ 86 } 87 } 88 }); 89 90 // ── Tests ───────────────────────────────────────────────────────────────────── 91 92 describe('collectServiceStatus', () => { 93 test('returns active status for all services when all active', () => { 94 execSyncFn = () => 'active\n'; 95 const result = collectServiceStatus(); 96 assert.ok(typeof result === 'object', 'should return an object'); 97 assert.ok('333method-pipeline' in result, 'should have pipeline key'); 98 assert.ok('mmo-cron.timer' in result, 'should have cron.timer key'); 99 assert.ok('333method-dashboard' in result, 'should have dashboard key'); 100 assert.equal(result['333method-pipeline'], 'active'); 101 }); 102 103 test('returns inactive when execSync throws with stdout', () => { 104 execSyncFn = cmd => { 105 if (cmd.includes('pipeline')) { 106 const err = new Error('inactive'); 107 err.stdout = 'inactive\n'; 108 throw err; 109 } 110 return 'active\n'; 111 }; 112 const result = collectServiceStatus(); 113 assert.equal(result['333method-pipeline'], 'inactive'); 114 assert.equal(result['mmo-cron.timer'], 'active'); 115 }); 116 117 test('returns inactive when execSync throws with no stdout', () => { 118 execSyncFn = () => { 119 throw new Error('connection refused'); 120 }; 121 const result = collectServiceStatus(); 122 for (const val of Object.values(result)) { 123 assert.ok(val === 'inactive' || val === '', `unexpected status: ${val}`); 124 } 125 }); 126 }); 127 128 describe('collectRecentErrors — log file does not exist', () => { 129 test('returns empty array when log file missing', () => { 130 // Remove test log file if it exists from prior test 131 if (existsSync(testLogFile)) unlinkSync(testLogFile); 132 const result = collectRecentErrors(); 133 assert.deepEqual(result, []); 134 }); 135 }); 136 137 describe('collectRecentErrors — log file with errors', () => { 138 test('returns recent ERROR/WARN lines and filters out INFO and old errors', () => { 139 const recentTs = new Date().toISOString(); 140 const oldTs = new Date(Date.now() - 60 * 60 * 1000).toISOString(); 141 142 writeFileSync( 143 testLogFile, 144 [ 145 `[${recentTs}] [Pipeline] INFO: Normal message`, 146 `[${recentTs}] [Pipeline] [ERROR] Something went wrong`, 147 `[${recentTs}] [Scoring] [WARN] Rate limit hit`, 148 `[${oldTs}] [Pipeline] [ERROR] Old error (should be filtered out)`, 149 `[${recentTs}] [Pipeline] [ERROR] Another error`, 150 ].join('\n') 151 ); 152 153 const result = collectRecentErrors(); 154 assert.ok(Array.isArray(result), 'should return array'); 155 assert.ok(result.length >= 2, `should have ≥2 errors, got: ${result.length}`); 156 assert.ok( 157 result.some(l => l.includes('Something went wrong')), 158 'should include recent ERROR' 159 ); 160 assert.ok( 161 result.some(l => l.includes('Rate limit hit')), 162 'should include recent WARN' 163 ); 164 assert.ok(!result.some(l => l.includes('Normal message')), 'should exclude INFO lines'); 165 assert.ok( 166 !result.some(l => l.includes('Old error')), 167 'should exclude old errors outside 30min window' 168 ); 169 }); 170 171 test('deduplicates repeated error lines', () => { 172 const recentTs = new Date().toISOString(); 173 const repeated = `[${recentTs}] [Scoring] [ERROR] Timeout connecting to API`; 174 writeFileSync(testLogFile, [repeated, repeated, repeated, repeated].join('\n')); 175 176 const result = collectRecentErrors(); 177 assert.ok(result.length <= 3, `should deduplicate: ${result.length} entries`); 178 }); 179 180 test('handles lines without timestamp bracket', () => { 181 const recentTs = new Date().toISOString(); 182 183 writeFileSync( 184 testLogFile, 185 [ 186 'No timestamp [ERROR] This line has no timestamp bracket', 187 `[${recentTs}] [Pipeline] [ERROR] With timestamp`, 188 ].join('\n') 189 ); 190 191 assert.doesNotThrow(() => collectRecentErrors()); 192 }); 193 }); 194 195 describe('runSonnetOverseer — deprecated stub', () => { 196 test('returns summary: Delegated to orchestrator', async () => { 197 execSyncFn = () => 'active\n'; 198 const result = await runSonnetOverseer(); 199 assert.ok(typeof result === 'object', 'should return object'); 200 assert.equal(result.summary, 'Delegated to orchestrator'); 201 assert.equal(result.severity, 'ok'); 202 assert.equal(result.actions_taken, 0); 203 }); 204 205 test('does not throw even when services are inactive', async () => { 206 execSyncFn = () => { 207 throw new Error('service not found'); 208 }; 209 await assert.doesNotReject(() => runSonnetOverseer()); 210 }); 211 });