diff --git a/scripts/__tests__/morning-report.test.ts b/scripts/__tests__/morning-report.test.ts index 74358fd..f2a6d34 100644 --- a/scripts/__tests__/morning-report.test.ts +++ b/scripts/__tests__/morning-report.test.ts @@ -1,69 +1,84 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { readMock, pendingMock } = vi.hoisted(() => ({ + readMock: vi.fn(), + pendingMock: vi.fn(), +})); + +vi.mock('../../src/backend/services/WorkflowStatusManager', () => ({ + WorkflowStatusManager: vi.fn().mockImplementation(() => ({ + read: readMock, + })), +})); + +vi.mock('../../src/backend/services/PhaseUpdateQueue', () => ({ + getPendingPhaseUpdates: pendingMock, +})); + import main from '../morning-report'; -describe('morning-report: stalled-state and report composition', () => { - const OLD_ENV = process.env; - let logs: string[] = []; +describe('morning-report', () => { + let consoleSpy: any; + beforeEach(() => { - logs = []; - // @ts-ignore - global.console = { log: (msg: string) => logs.push(msg) }; - jest.resetModules(); - jest.clearAllMocks(); + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); }); - afterEach(() => { process.env = OLD_ENV }); it('detects stalled workflow and formats report', async () => { const now = new Date('2026-03-26T18:00:00.000Z'); - const fakeStatus = { + readMock.mockResolvedValue({ currentPhase: 'parse', overallStatus: 'running', lastUpdated: '2026-03-26T16:00:00.000Z', lastFailureReason: null, nextAction: '', - completedPhases: ['a'] - }; - const fakeCommits = [ - { hash: 'abc123', msg: 'test commit', date: '2026-03-26T10:00:00.000Z' } - ]; - const fakePending = [ - { id: 'foo', eventType: 'phase_started', phase: 'parse', timestamp: '2026-03-26T16:00:00.000Z', summary: 'Phase parse started', relayStatus: 'pending' } - ]; - // Mock deps - jest.mock('../../src/backend/services/WorkflowStatusManager', () => ({ - WorkflowStatusManager: jest.fn().mockImplementation(() => ({ - read: async () => fakeStatus - })) - })); - jest.mock('../../src/backend/services/PhaseUpdateQueue', () => ({ - getPendingPhaseUpdates: async () => fakePending, - getAllPhaseUpdates: async () => fakePending - })); - jest.spyOn(require('../morning-report'), 'getRecentCommits').mockResolvedValue(fakeCommits); + completedPhases: ['a'], + }); + pendingMock.mockResolvedValue([ + { + id: 'foo', + eventType: 'phase_started', + phase: 'parse', + timestamp: '2026-03-26T16:00:00.000Z', + summary: 'Phase parse started', + relayStatus: 'pending', + }, + ]); + await main({ + commitWindowHours: 24, + stalledThresholdMinutes: 60, + now, + getRecentCommitsFn: async () => [ + { hash: 'abc123', msg: 'test commit', date: '2026-03-26T10:00:00.000Z' }, + ], + }); - // Main - await main({ commitWindowHours: 24, stalledThresholdMinutes: 60, now }); - expect(logs.join('\n')).toContain('⚠️ Workflow Stalled'); - expect(logs.join('\n')).toContain('abc123'); - expect(logs.join('\n')).toContain('phase_started'); - expect(logs.join('\n')).toContain('pending'); + const output = consoleSpy.mock.calls[0][0] as string; + expect(output).toContain('⚠️ Workflow Stalled'); + expect(output).toContain('abc123'); + expect(output).toContain('[phase_started]'); + expect(output).toContain('[id: foo]'); }); - it('shows no blockers, no stalled, no pending, no commits', async () => { + it('shows empty/no-status states', async () => { const now = new Date('2026-03-26T11:00:00.000Z'); - // No status, no events, no commits - jest.mock('../../src/backend/services/WorkflowStatusManager', () => ({ - WorkflowStatusManager: jest.fn().mockImplementation(() => ({ - read: async () => null - })) - })); - jest.mock('../../src/backend/services/PhaseUpdateQueue', () => ({ - getPendingPhaseUpdates: async () => [], - getAllPhaseUpdates: async () => [] - })); - jest.spyOn(require('../morning-report'), 'getRecentCommits').mockResolvedValue([]); - await main({ commitWindowHours: 24, stalledThresholdMinutes: 60, now }); - expect(logs.join('\n')).toContain('(No commits in window)'); - expect(logs.join('\n')).toContain('No workflow status available'); - expect(logs.join('\n')).toContain('All phase updates relayed'); + readMock.mockResolvedValue(null); + pendingMock.mockResolvedValue([]); + await main({ + commitWindowHours: 24, + stalledThresholdMinutes: 60, + now, + getRecentCommitsFn: async () => [], + }); + + const output = consoleSpy.mock.calls[0][0] as string; + expect(output).toContain('(No commits in window)'); + expect(output).toContain('No workflow status available'); + expect(output).toContain('All phase updates relayed'); }); }); diff --git a/scripts/morning-report.ts b/scripts/morning-report.ts index 5ca1045..096de98 100644 --- a/scripts/morning-report.ts +++ b/scripts/morning-report.ts @@ -1,7 +1,6 @@ #!/usr/bin/env ts-node -import { WorkflowStatusManager } from './src/backend/services/WorkflowStatusManager'; -import { getPendingPhaseUpdates, getAllPhaseUpdates } from './src/backend/services/PhaseUpdateQueue'; -import * as fs from 'fs/promises'; +import { WorkflowStatusManager } from '../src/backend/services/WorkflowStatusManager'; +import { getPendingPhaseUpdates } from '../src/backend/services/PhaseUpdateQueue'; import * as path from 'path'; // Configurable recency window in hours for recent commits @@ -11,13 +10,14 @@ type ReportConfig = { phaseUpdatesPath?: string; stalledThresholdMinutes?: number; now?: Date; + getRecentCommitsFn?: (windowHours: number) => Promise<{hash: string; msg: string; date: string}[]>; }; const DEFAULT_STATUS_PATH = path.join(process.cwd(), 'status/workflow-status.json'); const DEFAULT_PHASE_UPDATES_PATH = path.join(process.cwd(), 'status/phase-updates.jsonl'); const DEFAULT_STALLED_THRESHOLD_MINUTES = 60; -async function getRecentCommits(windowHours: number): Promise<{hash: string, msg: string, date: string}[]> { +export async function getRecentCommits(windowHours: number): Promise<{hash: string, msg: string, date: string}[]> { const sinceArg = `--since='${windowHours} hours ago'`; const cmd = `git log --oneline --date=iso --pretty=format:'%h|%s|%cd' ${sinceArg}`; const { exec } = require('child_process'); @@ -78,7 +78,7 @@ async function main(config: ReportConfig = {}) { const now = config.now || new Date(); // 1. Recent commits - const commits = await getRecentCommits(commitWindow); + const commits = await (config.getRecentCommitsFn || getRecentCommits)(commitWindow); // 2. Workflow status const wsm = new WorkflowStatusManager(statusPath); @@ -97,10 +97,10 @@ async function main(config: ReportConfig = {}) { let out = `# 🌅 Morning Workflow Report\n`; out += `\n## Recent Commits (last ${commitWindow}h)\n`; - if (commits.length) { - commits.forEach(c => { - out += `- \\`${c.hash}\\` ${c.msg} _(at ${c.date})_\n`; - }); + if (commits.length > 0) { + for (const c of commits) { + out += `- \`${c.hash}\` ${c.msg} _(at ${c.date})_\n`; + } } else { out += '(No commits in window)\n'; } @@ -120,18 +120,18 @@ async function main(config: ReportConfig = {}) { out += `\n## ⚠️ Workflow Stalled\n- Reason: ${stalled.reason}\n- Recommendation: ${stalled.recommend}\n`; } - if (blockers.length) { + if (blockers.length > 0) { out += `\n## Blockers\n`; - blockers.forEach(b => { + for (const b of blockers) { out += `- ${b}\n`; - }); + } } out += `\n## Pending Phase Updates\n`; - if (pendingUpdates.length) { - pendingUpdates.forEach(e => { + if (pendingUpdates.length > 0) { + for (const e of pendingUpdates) { out += `- [${e.eventType}] ${e.summary} (phase: ${e.phase}) at ${e.timestamp} [id: ${e.id}]\n`; - }); + } } else { out += '(All phase updates relayed)\n'; }