fix(task5): make morning report vitest-compatible and deterministic

This commit is contained in:
Paul Huliganga 2026-03-26 13:48:32 -04:00
parent 012cfb1ddc
commit 1516ef87d2
2 changed files with 81 additions and 66 deletions

View File

@ -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'; import main from '../morning-report';
describe('morning-report: stalled-state and report composition', () => { describe('morning-report', () => {
const OLD_ENV = process.env; let consoleSpy: any;
let logs: string[] = [];
beforeEach(() => { beforeEach(() => {
logs = []; vi.clearAllMocks();
// @ts-ignore consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
global.console = { log: (msg: string) => logs.push(msg) }; });
jest.resetModules();
jest.clearAllMocks(); afterEach(() => {
consoleSpy.mockRestore();
}); });
afterEach(() => { process.env = OLD_ENV });
it('detects stalled workflow and formats report', async () => { it('detects stalled workflow and formats report', async () => {
const now = new Date('2026-03-26T18:00:00.000Z'); const now = new Date('2026-03-26T18:00:00.000Z');
const fakeStatus = { readMock.mockResolvedValue({
currentPhase: 'parse', currentPhase: 'parse',
overallStatus: 'running', overallStatus: 'running',
lastUpdated: '2026-03-26T16:00:00.000Z', lastUpdated: '2026-03-26T16:00:00.000Z',
lastFailureReason: null, lastFailureReason: null,
nextAction: '', nextAction: '',
completedPhases: ['a'] completedPhases: ['a'],
}; });
const fakeCommits = [ pendingMock.mockResolvedValue([
{ hash: 'abc123', msg: 'test commit', date: '2026-03-26T10:00:00.000Z' } {
]; id: 'foo',
const fakePending = [ eventType: 'phase_started',
{ id: 'foo', eventType: 'phase_started', phase: 'parse', timestamp: '2026-03-26T16:00:00.000Z', summary: 'Phase parse started', relayStatus: 'pending' } phase: 'parse',
]; timestamp: '2026-03-26T16:00:00.000Z',
// Mock deps summary: 'Phase parse started',
jest.mock('../../src/backend/services/WorkflowStatusManager', () => ({ relayStatus: 'pending',
WorkflowStatusManager: jest.fn().mockImplementation(() => ({ },
read: async () => fakeStatus ]);
})) await main({
})); commitWindowHours: 24,
jest.mock('../../src/backend/services/PhaseUpdateQueue', () => ({ stalledThresholdMinutes: 60,
getPendingPhaseUpdates: async () => fakePending, now,
getAllPhaseUpdates: async () => fakePending getRecentCommitsFn: async () => [
})); { hash: 'abc123', msg: 'test commit', date: '2026-03-26T10:00:00.000Z' },
jest.spyOn(require('../morning-report'), 'getRecentCommits').mockResolvedValue(fakeCommits); ],
});
// Main const output = consoleSpy.mock.calls[0][0] as string;
await main({ commitWindowHours: 24, stalledThresholdMinutes: 60, now }); expect(output).toContain('⚠️ Workflow Stalled');
expect(logs.join('\n')).toContain('⚠️ Workflow Stalled'); expect(output).toContain('abc123');
expect(logs.join('\n')).toContain('abc123'); expect(output).toContain('[phase_started]');
expect(logs.join('\n')).toContain('phase_started'); expect(output).toContain('[id: foo]');
expect(logs.join('\n')).toContain('pending');
}); });
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'); const now = new Date('2026-03-26T11:00:00.000Z');
// No status, no events, no commits readMock.mockResolvedValue(null);
jest.mock('../../src/backend/services/WorkflowStatusManager', () => ({ pendingMock.mockResolvedValue([]);
WorkflowStatusManager: jest.fn().mockImplementation(() => ({ await main({
read: async () => null commitWindowHours: 24,
})) stalledThresholdMinutes: 60,
})); now,
jest.mock('../../src/backend/services/PhaseUpdateQueue', () => ({ getRecentCommitsFn: async () => [],
getPendingPhaseUpdates: async () => [], });
getAllPhaseUpdates: async () => []
})); const output = consoleSpy.mock.calls[0][0] as string;
jest.spyOn(require('../morning-report'), 'getRecentCommits').mockResolvedValue([]); expect(output).toContain('(No commits in window)');
await main({ commitWindowHours: 24, stalledThresholdMinutes: 60, now }); expect(output).toContain('No workflow status available');
expect(logs.join('\n')).toContain('(No commits in window)'); expect(output).toContain('All phase updates relayed');
expect(logs.join('\n')).toContain('No workflow status available');
expect(logs.join('\n')).toContain('All phase updates relayed');
}); });
}); });

View File

@ -1,7 +1,6 @@
#!/usr/bin/env ts-node #!/usr/bin/env ts-node
import { WorkflowStatusManager } from './src/backend/services/WorkflowStatusManager'; import { WorkflowStatusManager } from '../src/backend/services/WorkflowStatusManager';
import { getPendingPhaseUpdates, getAllPhaseUpdates } from './src/backend/services/PhaseUpdateQueue'; import { getPendingPhaseUpdates } from '../src/backend/services/PhaseUpdateQueue';
import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
// Configurable recency window in hours for recent commits // Configurable recency window in hours for recent commits
@ -11,13 +10,14 @@ type ReportConfig = {
phaseUpdatesPath?: string; phaseUpdatesPath?: string;
stalledThresholdMinutes?: number; stalledThresholdMinutes?: number;
now?: Date; 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_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_PHASE_UPDATES_PATH = path.join(process.cwd(), 'status/phase-updates.jsonl');
const DEFAULT_STALLED_THRESHOLD_MINUTES = 60; 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 sinceArg = `--since='${windowHours} hours ago'`;
const cmd = `git log --oneline --date=iso --pretty=format:'%h|%s|%cd' ${sinceArg}`; const cmd = `git log --oneline --date=iso --pretty=format:'%h|%s|%cd' ${sinceArg}`;
const { exec } = require('child_process'); const { exec } = require('child_process');
@ -78,7 +78,7 @@ async function main(config: ReportConfig = {}) {
const now = config.now || new Date(); const now = config.now || new Date();
// 1. Recent commits // 1. Recent commits
const commits = await getRecentCommits(commitWindow); const commits = await (config.getRecentCommitsFn || getRecentCommits)(commitWindow);
// 2. Workflow status // 2. Workflow status
const wsm = new WorkflowStatusManager(statusPath); const wsm = new WorkflowStatusManager(statusPath);
@ -97,10 +97,10 @@ async function main(config: ReportConfig = {}) {
let out = `# 🌅 Morning Workflow Report\n`; let out = `# 🌅 Morning Workflow Report\n`;
out += `\n## Recent Commits (last ${commitWindow}h)\n`; out += `\n## Recent Commits (last ${commitWindow}h)\n`;
if (commits.length) { if (commits.length > 0) {
commits.forEach(c => { for (const c of commits) {
out += `- \\`${c.hash}\\` ${c.msg} _(at ${c.date})_\n`; out += `- \`${c.hash}\` ${c.msg} _(at ${c.date})_\n`;
}); }
} else { } else {
out += '(No commits in window)\n'; 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`; out += `\n## ⚠️ Workflow Stalled\n- Reason: ${stalled.reason}\n- Recommendation: ${stalled.recommend}\n`;
} }
if (blockers.length) { if (blockers.length > 0) {
out += `\n## Blockers\n`; out += `\n## Blockers\n`;
blockers.forEach(b => { for (const b of blockers) {
out += `- ${b}\n`; out += `- ${b}\n`;
}); }
} }
out += `\n## Pending Phase Updates\n`; out += `\n## Pending Phase Updates\n`;
if (pendingUpdates.length) { if (pendingUpdates.length > 0) {
pendingUpdates.forEach(e => { for (const e of pendingUpdates) {
out += `- [${e.eventType}] ${e.summary} (phase: ${e.phase}) at ${e.timestamp} [id: ${e.id}]\n`; out += `- [${e.eventType}] ${e.summary} (phase: ${e.phase}) at ${e.timestamp} [id: ${e.id}]\n`;
}); }
} else { } else {
out += '(All phase updates relayed)\n'; out += '(All phase updates relayed)\n';
} }