fix(task5): make morning report vitest-compatible and deterministic
This commit is contained in:
parent
012cfb1ddc
commit
1516ef87d2
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue