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';
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');
});
});

View File

@ -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';
}