pipeline-status-monitor-supplement.test.js
1 /** 2 * Pipeline Status Monitor — Supplement 3 * 4 * Covers branches not hit by the main test: 5 * - Lines 108-119: checkServices — cron service stuck in "activating" state 6 * - Lines 214-241: checkBrowserLoopHung — hung browser loop (restart) + healthy path 7 * - Lines 257-258: recentErrors — statSync failure returns false 8 * - Lines 280-282: recentErrors — outer catch when readdirSync fails 9 * - Lines 432-451: checkSchemaIntegrity — site_status table CHECK constraint failures 10 * - Lines 761-762: runPipelineStatusMonitor — schema warning pushed to allActions 11 * - Lines 777-778: runPipelineStatusMonitor — snapshot writeFileSync failure (catch) 12 * - Lines 813-826: runPipelineStatusMonitor — LLM cost variance alerts 13 * - Lines 859-868: CLI entry point (import.meta.url guard) 14 * 15 * MUST be run with --experimental-test-module-mocks. 16 */ 17 18 import { test, describe, mock, beforeEach } from 'node:test'; 19 import assert from 'node:assert/strict'; 20 21 // ═══════════════════════════════════════════════════════════════ 22 // Mock setup — all mocks BEFORE importing the module under test 23 // ═══════════════════════════════════════════════════════════════ 24 25 mock.module('dotenv', { 26 defaultExport: { config: () => {} }, 27 namedExports: { config: () => {} }, 28 }); 29 30 const execSyncMock = mock.fn(() => 'active\n'); 31 mock.module('child_process', { 32 namedExports: { execSync: execSyncMock }, 33 }); 34 35 let capturedAppendCalls = []; 36 let capturedWriteCalls = []; 37 let mockFileContents = {}; 38 let mockExistsResults = {}; 39 let mockReaddirResults = {}; 40 let mockStatResults = {}; 41 let appendShouldFail = false; 42 let writeFileSyncShouldFail = false; 43 44 const appendFileSyncMock = mock.fn((path, content) => { 45 if (appendShouldFail) { 46 appendShouldFail = false; 47 throw new Error('ENOENT: no such file or directory'); 48 } 49 capturedAppendCalls.push({ path, content }); 50 }); 51 52 const readFileSyncMock = mock.fn((path, _encoding) => { 53 if (mockFileContents[path] !== undefined) return mockFileContents[path]; 54 for (const [key, val] of Object.entries(mockFileContents)) { 55 if (path.includes(key)) return val; 56 } 57 throw new Error(`ENOENT: no such file: ${path}`); 58 }); 59 60 const writeFileSyncMock = mock.fn((path, content) => { 61 if (writeFileSyncShouldFail) { 62 throw new Error('EROFS: read-only file system'); 63 } 64 capturedWriteCalls.push({ path, content }); 65 }); 66 67 const existsSyncMock = mock.fn(path => { 68 for (const [key, val] of Object.entries(mockExistsResults)) { 69 if (path.includes(key)) return val; 70 } 71 return false; 72 }); 73 74 const readdirSyncMock = mock.fn(path => { 75 if (readdirSyncShouldFail) { 76 throw new Error('EACCES: permission denied'); 77 } 78 if (readdirSyncFailAfterCalls >= 0 && readdirSyncCallCount >= readdirSyncFailAfterCalls) { 79 readdirSyncCallCount++; 80 throw new Error('EACCES: permission denied'); 81 } 82 readdirSyncCallCount++; 83 for (const [key, val] of Object.entries(mockReaddirResults)) { 84 if (path.includes(key)) return val; 85 } 86 return []; 87 }); 88 89 const statSyncMock = mock.fn(path => { 90 for (const [key, val] of Object.entries(mockStatResults)) { 91 if (path.includes(key)) return val; 92 } 93 return { mtimeMs: Date.now() }; 94 }); 95 96 const mkdirSyncMock = mock.fn(() => {}); 97 98 let readdirSyncShouldFail = false; 99 let readdirSyncFailAfterCalls = -1; // -1 = disabled; N = fail on Nth call (0-indexed) 100 let readdirSyncCallCount = 0; 101 102 mock.module('fs', { 103 namedExports: { 104 appendFileSync: appendFileSyncMock, 105 readFileSync: readFileSyncMock, 106 writeFileSync: writeFileSyncMock, 107 existsSync: existsSyncMock, 108 readdirSync: readdirSyncMock, 109 statSync: statSyncMock, 110 mkdirSync: mkdirSyncMock, 111 }, 112 }); 113 114 // ── Mock better-sqlite3 with configurable browser loop data ────────────────── 115 116 let mockBrowserLoopRow = null; // null = no row returned 117 let schemaRunShouldThrowFor = null; // set to 'sites' or 'site_status' to throw CHECK constraint 118 119 class MockStatement { 120 constructor(sql) { 121 this.sql = sql; 122 } 123 124 all() { 125 if ( 126 this.sql.includes('FROM sites') && 127 this.sql.includes('GROUP BY status') && 128 !this.sql.includes('updated_at') 129 ) { 130 return []; 131 } 132 if (this.sql.includes('FROM sites') && this.sql.includes('updated_at')) { 133 return []; 134 } 135 if (this.sql.includes('FROM agent_tasks') && this.sql.includes('assigned_to')) { 136 return []; 137 } 138 if (this.sql.includes('FROM agent_tasks') && this.sql.includes('GROUP BY status')) { 139 return []; 140 } 141 return []; 142 } 143 144 get() { 145 if (this.sql.includes('last_browser_loop_at')) { 146 return mockBrowserLoopRow; 147 } 148 if (this.sql.includes('COUNT(*)') && this.sql.includes('4 hours')) { 149 return { cnt: 0 }; 150 } 151 return { cnt: 0 }; 152 } 153 154 run() { 155 // Simulate CHECK constraint failure for schema integrity tests 156 if (schemaRunShouldThrowFor) { 157 if ( 158 (schemaRunShouldThrowFor === 'sites' && this.sql.includes('UPDATE sites')) || 159 (schemaRunShouldThrowFor === 'site_status' && this.sql.includes('UPDATE site_status')) 160 ) { 161 throw new Error('CHECK constraint failed: status IN (...)'); 162 } 163 } 164 return { changes: 0, lastInsertRowid: 0 }; 165 } 166 } 167 168 class MockDatabase { 169 constructor() {} 170 pragma() {} 171 prepare(sql) { 172 return new MockStatement(sql); 173 } 174 close() {} 175 } 176 177 mock.module('better-sqlite3', { 178 defaultExport: MockDatabase, 179 }); 180 181 // ── Mock db.js to route queries through the same mock logic ───────────────── 182 // pipeline-status-monitor.js uses db.js (getOne/getAll/run), not better-sqlite3 directly. 183 184 mock.module('../../src/utils/db.js', { 185 namedExports: { 186 getOne: async (sql, _params = []) => { 187 if (sql.includes('last_browser_loop_at')) return mockBrowserLoopRow; 188 if (sql.includes('COUNT(*)') && sql.includes('4 hours')) return { cnt: 0 }; 189 return null; 190 }, 191 getAll: async (sql, _params = []) => { 192 if (sql.includes('FROM sites') && sql.includes('GROUP BY status')) return []; 193 if (sql.includes('FROM sites') && sql.includes('updated_at')) return []; 194 if (sql.includes('FROM agent_tasks') && sql.includes('assigned_to')) return []; 195 if (sql.includes('FROM agent_tasks') && sql.includes('GROUP BY status')) return []; 196 if (sql.includes('llm_usage')) return []; 197 if (sql.includes('outreaches')) return []; 198 return []; 199 }, 200 run: async (sql, _params = []) => { 201 if (schemaRunShouldThrowFor) { 202 if (schemaRunShouldThrowFor === 'sites' && sql.includes('UPDATE sites')) { 203 throw new Error('CHECK constraint failed: status IN (...)'); 204 } 205 if (schemaRunShouldThrowFor === 'site_status' && sql.includes('UPDATE site_status')) { 206 throw new Error('CHECK constraint failed: status IN (...)'); 207 } 208 } 209 return { changes: 0, rowCount: 0 }; 210 }, 211 query: async (_sql, _params = []) => ({ rows: [], rowCount: 0 }), 212 withTransaction: async fn => fn({ query: async () => ({ rows: [] }) }), 213 closePool: async () => {}, 214 getPool: () => ({}), 215 createDatabaseConnection: () => ({}), 216 closeDatabaseConnection: async () => {}, 217 }, 218 }); 219 220 // ── Mock llm-usage-tracker for LLM cost variance tests ───────────────────── 221 222 const checkBudgetVarianceMock = mock.fn(() => []); 223 224 mock.module('../../src/utils/llm-usage-tracker.js', { 225 namedExports: { 226 checkBudgetVariance: checkBudgetVarianceMock, 227 logLLMUsage: mock.fn(() => {}), 228 getLLMBudget: mock.fn(() => null), 229 }, 230 }); 231 232 // Import after all mocks 233 process.env.LOGS_DIR = '/tmp/test-logs'; 234 process.env.DATABASE_PATH = '/tmp/test-psm-supp.db'; 235 236 const { runPipelineStatusMonitor } = await import('../../src/cron/pipeline-status-monitor.js'); 237 238 // ═══════════════════════════════════════════════════════════════ 239 // Test suites 240 // ═══════════════════════════════════════════════════════════════ 241 242 beforeEach(() => { 243 execSyncMock.mock.resetCalls(); 244 appendFileSyncMock.mock.resetCalls(); 245 readFileSyncMock.mock.resetCalls(); 246 writeFileSyncMock.mock.resetCalls(); 247 existsSyncMock.mock.resetCalls(); 248 readdirSyncMock.mock.resetCalls(); 249 statSyncMock.mock.resetCalls(); 250 mkdirSyncMock.mock.resetCalls(); 251 252 capturedAppendCalls = []; 253 capturedWriteCalls = []; 254 mockFileContents = {}; 255 mockExistsResults = {}; 256 mockReaddirResults = {}; 257 mockStatResults = {}; 258 appendShouldFail = false; 259 writeFileSyncShouldFail = false; 260 readdirSyncShouldFail = false; 261 readdirSyncFailAfterCalls = -1; 262 readdirSyncCallCount = 0; 263 mockBrowserLoopRow = null; 264 schemaRunShouldThrowFor = null; 265 266 checkBudgetVarianceMock.mock.resetCalls(); 267 checkBudgetVarianceMock.mock.mockImplementation(() => []); 268 269 execSyncMock.mock.mockImplementation(cmd => { 270 if (cmd.includes('is-active')) return 'active\n'; 271 if (cmd.includes('journalctl')) return ''; 272 if (cmd.includes('ps --no-header')) return '0'; 273 if (cmd.includes('show mmo-cron.service')) 274 return 'ActiveState=active\nStateChangeTimestamp=\n'; 275 return ''; 276 }); 277 278 mockReaddirResults['logs'] = []; 279 mockExistsResults['pipeline-status-snapshot.json'] = false; 280 }); 281 282 // ── Lines 108-119: cron service stuck in "activating" ──────────────────────── 283 284 describe('checkServices — cron stuck in activating (lines 108-119)', () => { 285 test('cron stuck in activating for >12min emits warning', async () => { 286 const fifteenMinAgo = new Date(Date.now() - 15 * 60 * 1000).toUTCString(); 287 288 execSyncMock.mock.mockImplementation(cmd => { 289 if (cmd.includes('show mmo-cron.service')) { 290 return `ActiveState=activating\nStateChangeTimestamp=${fifteenMinAgo}\n`; 291 } 292 if (cmd.includes('is-active')) return 'active\n'; 293 if (cmd.includes('journalctl')) return ''; 294 if (cmd.includes('ps --no-header')) return '0'; 295 return ''; 296 }); 297 298 await runPipelineStatusMonitor(); 299 300 const content = capturedAppendCalls[0]?.content || ''; 301 assert.ok( 302 content.includes('Cron SERVICE stuck'), 303 `expected "Cron SERVICE stuck" in output, got: ${content.slice(0, 300)}` 304 ); 305 assert.ok(content.includes('activating'), 'should mention activating state'); 306 }); 307 308 test('cron activating but within 12min does NOT emit warning', async () => { 309 const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toUTCString(); 310 311 execSyncMock.mock.mockImplementation(cmd => { 312 if (cmd.includes('show mmo-cron.service')) { 313 return `ActiveState=activating\nStateChangeTimestamp=${fiveMinAgo}\n`; 314 } 315 if (cmd.includes('is-active')) return 'active\n'; 316 if (cmd.includes('journalctl')) return ''; 317 if (cmd.includes('ps --no-header')) return '0'; 318 return ''; 319 }); 320 321 await runPipelineStatusMonitor(); 322 323 const content = capturedAppendCalls[0]?.content || ''; 324 assert.ok( 325 !content.includes('Cron SERVICE stuck'), 326 'should not warn when activating for only 5min' 327 ); 328 }); 329 330 test('systemctl show failure is swallowed silently', async () => { 331 execSyncMock.mock.mockImplementation(cmd => { 332 if (cmd.includes('show mmo-cron.service')) { 333 throw new Error('D-Bus connection failure'); 334 } 335 if (cmd.includes('is-active')) return 'active\n'; 336 if (cmd.includes('journalctl')) return ''; 337 if (cmd.includes('ps --no-header')) return '0'; 338 return ''; 339 }); 340 341 // Should not throw — the catch block swallows this 342 const result = await runPipelineStatusMonitor(); 343 assert.ok(result, 'should still return a result'); 344 }); 345 }); 346 347 // ── Lines 214-241: checkBrowserLoopHung ────────────────────────────────────── 348 349 describe('checkBrowserLoopHung (lines 214-241)', () => { 350 test('browser loop healthy: both cycles recent → reports active', async () => { 351 // Both browser and API cycle happened recently 352 const tenMinAgo = new Date(Date.now() - 10 * 60 * 1000) 353 .toISOString() 354 .replace('T', ' ') 355 .slice(0, 19); 356 mockBrowserLoopRow = { 357 last_browser_loop_at: tenMinAgo, 358 last_api_loop_at: tenMinAgo, 359 }; 360 361 await runPipelineStatusMonitor(); 362 363 const content = capturedAppendCalls[0]?.content || ''; 364 assert.ok( 365 content.includes('browser_loop active'), 366 `expected "browser_loop active" in output, got: ${content.slice(0, 500)}` 367 ); 368 }); 369 370 test('browser loop hung: stale browser + recent API → triggers restart', async () => { 371 const thirtyFiveMinAgo = new Date(Date.now() - 95 * 60 * 1000) 372 .toISOString() 373 .replace('T', ' ') 374 .slice(0, 19); 375 const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000) 376 .toISOString() 377 .replace('T', ' ') 378 .slice(0, 19); 379 380 mockBrowserLoopRow = { 381 last_browser_loop_at: thirtyFiveMinAgo, 382 last_api_loop_at: fiveMinAgo, 383 }; 384 385 execSyncMock.mock.mockImplementation(cmd => { 386 if (cmd.includes('restart') && cmd.includes('333method-pipeline')) return 'ok\n'; 387 if (cmd.includes('is-active')) return 'active\n'; 388 if (cmd.includes('journalctl')) return ''; 389 if (cmd.includes('ps --no-header')) return '0'; 390 if (cmd.includes('show mmo-cron.service')) return 'ActiveState=active\n'; 391 return ''; 392 }); 393 394 const result = await runPipelineStatusMonitor(); 395 396 const content = capturedAppendCalls[0]?.content || ''; 397 assert.ok( 398 content.includes('browser_loop stale'), 399 `expected "browser_loop stale" in output, got: ${content.slice(0, 500)}` 400 ); 401 assert.ok( 402 result.actions.some(a => a.startsWith('restarted_hung_browser_loop')), 403 'should record browser loop restart action' 404 ); 405 }); 406 407 test('browser loop hung: restart failure is logged', async () => { 408 const thirtyFiveMinAgo = new Date(Date.now() - 95 * 60 * 1000) 409 .toISOString() 410 .replace('T', ' ') 411 .slice(0, 19); 412 const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000) 413 .toISOString() 414 .replace('T', ' ') 415 .slice(0, 19); 416 417 mockBrowserLoopRow = { 418 last_browser_loop_at: thirtyFiveMinAgo, 419 last_api_loop_at: fiveMinAgo, 420 }; 421 422 execSyncMock.mock.mockImplementation(cmd => { 423 if (cmd.includes('restart') && cmd.includes('333method-pipeline')) { 424 throw new Error('unit not found'); 425 } 426 if (cmd.includes('is-active')) return 'active\n'; 427 if (cmd.includes('journalctl')) return ''; 428 if (cmd.includes('ps --no-header')) return '0'; 429 if (cmd.includes('show mmo-cron.service')) return 'ActiveState=active\n'; 430 return ''; 431 }); 432 433 await runPipelineStatusMonitor(); 434 435 const content = capturedAppendCalls[0]?.content || ''; 436 assert.ok(content.includes('Restart failed'), 'should report restart failure'); 437 assert.ok(content.includes('unit not found'), 'should include error detail'); 438 }); 439 440 test('browser loop: no timestamps in DB → reports no timestamps yet', async () => { 441 mockBrowserLoopRow = { last_browser_loop_at: null, last_api_loop_at: null }; 442 443 await runPipelineStatusMonitor(); 444 445 const content = capturedAppendCalls[0]?.content || ''; 446 assert.ok( 447 content.includes('no timestamps yet'), 448 `expected "no timestamps yet" in output, got: ${content.slice(0, 300)}` 449 ); 450 }); 451 452 test('browser loop: row is null → reports no timestamps yet', async () => { 453 mockBrowserLoopRow = null; // DB returns null (no row) 454 455 await runPipelineStatusMonitor(); 456 457 const content = capturedAppendCalls[0]?.content || ''; 458 assert.ok( 459 content.includes('no timestamps yet'), 460 `expected "no timestamps yet" in output, got: ${content.slice(0, 300)}` 461 ); 462 }); 463 }); 464 465 // ── Lines 257-258: recentErrors — statSync failure ──────────────────────────── 466 467 describe('recentErrors — statSync failure (lines 257-258)', () => { 468 test('statSync failure on log file causes it to be excluded', async () => { 469 // A log file exists but statSync throws for it 470 const realLog = 'pipeline-2026-03-07.log'; 471 const badLog = 'unreadable-2026-03-07.log'; 472 473 mockReaddirResults['logs'] = [realLog, badLog]; 474 475 // statSync throws for the bad log, returns recent mtime for the real log 476 statSyncMock.mock.mockImplementation(path => { 477 if (path.includes('unreadable')) throw new Error('EACCES: permission denied'); 478 return { mtimeMs: Date.now() - 2 * 60 * 1000 }; // 2 min ago 479 }); 480 481 mockFileContents[realLog] = '[2026-03-07] [ERROR] Real error in pipeline\n'; 482 483 await runPipelineStatusMonitor(); 484 485 const content = capturedAppendCalls[0]?.content || ''; 486 // The bad log was filtered out (statSync threw → return false) 487 // The real log was included 488 assert.ok( 489 content.includes('Real error in pipeline'), 490 'should include errors from readable log' 491 ); 492 // Should NOT throw despite one log being unreadable 493 }); 494 }); 495 496 // ── Lines 280-282: recentErrors — outer catch ───────────────────────────────── 497 498 describe('recentErrors — outer catch when readdirSync fails (lines 280-282)', () => { 499 test('readdirSync failure in recentErrors is caught and reported', async () => { 500 // checkPipelineHung calls readdirSync first (call 0, succeeds → returns []) 501 // recentErrors calls readdirSync second (call 1, throws) 502 readdirSyncFailAfterCalls = 1; 503 readdirSyncCallCount = 0; 504 505 await runPipelineStatusMonitor(); 506 507 const content = capturedAppendCalls[0]?.content || ''; 508 assert.ok( 509 content.includes('error reading logs'), 510 `expected "error reading logs" in output, got: ${content.slice(0, 400)}` 511 ); 512 assert.ok(content.includes('permission denied'), 'should include error message'); 513 }); 514 }); 515 516 // ── Lines 432-451: checkSchemaIntegrity — site_status table ────────────────── 517 518 describe('checkSchemaIntegrity — site_status table (lines 432-451)', () => { 519 test('site_status CHECK constraint failure triggers schema warning', async () => { 520 // sites table passes, but site_status table throws CHECK constraint error 521 schemaRunShouldThrowFor = 'site_status'; 522 523 await runPipelineStatusMonitor(); 524 525 const content = capturedAppendCalls[0]?.content || ''; 526 assert.ok( 527 content.includes('SCHEMA MIGRATION MISSING'), 528 `expected "SCHEMA MIGRATION MISSING" in output, got: ${content.slice(0, 500)}` 529 ); 530 assert.ok(content.includes('site_status.status'), 'should mention site_status table'); 531 }); 532 533 test('sites CHECK constraint failure triggers schema warning for sites table', async () => { 534 schemaRunShouldThrowFor = 'sites'; 535 536 await runPipelineStatusMonitor(); 537 538 const content = capturedAppendCalls[0]?.content || ''; 539 assert.ok( 540 content.includes('SCHEMA MIGRATION MISSING'), 541 `expected "SCHEMA MIGRATION MISSING" in output, got: ${content.slice(0, 500)}` 542 ); 543 assert.ok(content.includes('sites.status'), 'should mention sites table'); 544 }); 545 546 test('schema warning is pushed to allActions (lines 761-762)', async () => { 547 schemaRunShouldThrowFor = 'site_status'; 548 549 const result = await runPipelineStatusMonitor(); 550 551 assert.ok( 552 result.actions.some(a => a.includes('SCHEMA MIGRATION MISSING')), 553 'schema warning should be in allActions' 554 ); 555 assert.ok(result.summary.includes('actions'), 'summary should mention actions'); 556 }); 557 }); 558 559 // ── Lines 777-778: snapshot writeFileSync failure ───────────────────────────── 560 561 describe('snapshot writeFileSync failure (lines 777-778)', () => { 562 test('snapshot write failure is swallowed (non-fatal)', async () => { 563 writeFileSyncShouldFail = true; 564 565 // Should not throw despite snapshot write failing 566 const result = await runPipelineStatusMonitor(); 567 assert.ok(result, 'should still return a result when snapshot write fails'); 568 // The status file append should still succeed (appendFileSync is separate) 569 assert.ok(capturedAppendCalls.length >= 1, 'should still write status file'); 570 }); 571 }); 572 573 // ── Lines 813-826: LLM cost variance alerts ─────────────────────────────────── 574 575 describe('LLM cost variance alerts (lines 813-826)', () => { 576 test('cost_variance type produces per-stage cost alert line', async () => { 577 checkBudgetVarianceMock.mock.mockImplementation(() => [ 578 { 579 type: 'cost_variance', 580 stage: 'scoring', 581 avgCost: 0.0312, 582 expectedCost: 0.025, 583 maxCost: 0.04, 584 callCount: 47, 585 }, 586 ]); 587 588 await runPipelineStatusMonitor(); 589 590 const content = capturedAppendCalls[0]?.content || ''; 591 assert.ok(content.includes('LLM COST ALERTS'), 'should show LLM cost alerts header'); 592 assert.ok(content.includes('scoring'), 'should include stage name'); 593 assert.ok(content.includes('0.0312'), 'should include avg cost'); 594 assert.ok(content.includes('47 calls'), 'should include call count'); 595 }); 596 597 test('model_mismatch type produces model mismatch alert line', async () => { 598 checkBudgetVarianceMock.mock.mockImplementation(() => [ 599 { 600 type: 'model_mismatch', 601 stage: 'proposals', 602 actualModel: 'gpt-4o', 603 expectedModel: 'gpt-4o-mini', 604 callCount: 12, 605 }, 606 ]); 607 608 await runPipelineStatusMonitor(); 609 610 const content = capturedAppendCalls[0]?.content || ''; 611 assert.ok(content.includes('LLM COST ALERTS'), 'should show LLM cost alerts header'); 612 assert.ok( 613 content.includes('model_mismatch') || content.includes('gpt-4o'), 614 'should include model info' 615 ); 616 assert.ok(content.includes('proposals'), 'should include stage name'); 617 }); 618 619 test('no variances: LLM cost section is omitted', async () => { 620 checkBudgetVarianceMock.mock.mockImplementation(() => []); 621 622 await runPipelineStatusMonitor(); 623 624 const content = capturedAppendCalls[0]?.content || ''; 625 assert.ok(!content.includes('LLM COST ALERTS'), 'should omit section when no variances'); 626 }); 627 });