Files
stack/packages/forge/__tests__/pipeline-runner.test.ts
Mos (Agent) 10689a30d2
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
feat: monorepo consolidation — forge pipeline, MACP protocol, framework plugin, profiles/guides/skills
Work packages completed:
- WP1: packages/forge — pipeline runner, stage adapter, board tasks, brief classifier,
  persona loader with project-level overrides. 89 tests, 95.62% coverage.
- WP2: packages/macp — credential resolver, gate runner, event emitter, protocol types.
  65 tests, 96.24% coverage. Full Python-to-TS port preserving all behavior.
- WP3: plugins/mosaic-framework — OC rails injection plugin (before_agent_start +
  subagent_spawning hooks for Mosaic contract enforcement).
- WP4: profiles/ (domains, tech-stacks, workflows), guides/ (17 docs),
  skills/ (5 universal skills), forge pipeline assets (48 markdown files).

Board deliberation: docs/reviews/consolidation-board-memo.md
Brief: briefs/monorepo-consolidation.md

Consolidates mosaic/stack (forge, MACP, bootstrap framework) into mosaic/mosaic-stack.
154 new tests total. Zero Python — all TypeScript/ESM.
2026-03-30 19:43:24 +00:00

332 lines
9.6 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
generateRunId,
selectStages,
saveManifest,
loadManifest,
runPipeline,
resumePipeline,
getPipelineStatus,
} from '../src/pipeline-runner.js';
import type { ForgeTask, RunManifest, TaskExecutor } from '../src/types.js';
import type { TaskResult } from '@mosaic/macp';
/** Mock TaskExecutor that records submitted tasks and returns success. */
function createMockExecutor(options?: {
failStage?: string;
}): TaskExecutor & { submittedTasks: ForgeTask[] } {
const submittedTasks: ForgeTask[] = [];
return {
submittedTasks,
async submitTask(task: ForgeTask) {
submittedTasks.push(task);
},
async waitForCompletion(taskId: string): Promise<TaskResult> {
const failStage = options?.failStage;
const task = submittedTasks.find((t) => t.id === taskId);
const stageName = task?.metadata?.['stageName'] as string | undefined;
if (failStage && stageName === failStage) {
return {
task_id: taskId,
status: 'failed',
completed_at: new Date().toISOString(),
exit_code: 1,
gate_results: [],
};
}
return {
task_id: taskId,
status: 'completed',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [],
};
},
async getTaskStatus() {
return 'completed' as const;
},
};
}
describe('generateRunId', () => {
it('returns a timestamp string', () => {
const id = generateRunId();
expect(id).toMatch(/^\d{8}-\d{6}$/);
});
it('returns unique IDs', () => {
const ids = new Set(Array.from({ length: 10 }, generateRunId));
// Given they run in the same second, they should at least be consistent format
expect(ids.size).toBeGreaterThanOrEqual(1);
});
});
describe('selectStages', () => {
it('returns full sequence when no args', () => {
const stages = selectStages();
expect(stages.length).toBeGreaterThan(0);
expect(stages[0]).toBe('00-intake');
});
it('returns provided stages', () => {
const stages = selectStages(['00-intake', '05-coding']);
expect(stages).toEqual(['00-intake', '05-coding']);
});
it('throws for unknown stages', () => {
expect(() => selectStages(['unknown'])).toThrow('Unknown Forge stages');
});
it('skips to specified stage', () => {
const stages = selectStages(undefined, '05-coding');
expect(stages[0]).toBe('05-coding');
expect(stages).not.toContain('00-intake');
});
it('throws if skipTo not in selected stages', () => {
expect(() => selectStages(['00-intake'], '05-coding')).toThrow(
"skip_to stage '05-coding' is not present",
);
});
});
describe('manifest operations', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-manifest-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('saveManifest and loadManifest roundtrip', () => {
const manifest: RunManifest = {
runId: 'test-123',
brief: '/path/to/brief.md',
codebase: '/project',
briefClass: 'strategic',
classSource: 'auto',
forceBoard: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: {
'00-intake': { status: 'passed', startedAt: '2026-01-01T00:00:00Z' },
},
};
saveManifest(tmpDir, manifest);
const loaded = loadManifest(tmpDir);
expect(loaded.runId).toBe('test-123');
expect(loaded.briefClass).toBe('strategic');
expect(loaded.stages['00-intake']?.status).toBe('passed');
});
it('loadManifest throws for missing file', () => {
expect(() => loadManifest('/nonexistent')).toThrow('manifest.json not found');
});
});
describe('runPipeline', () => {
let tmpDir: string;
let briefPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-pipeline-'));
briefPath = path.join(tmpDir, 'test-brief.md');
fs.writeFileSync(
briefPath,
'---\nclass: hotfix\n---\n\n# Fix CSS bug\n\nFix the bugfix for lint cleanup.',
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('runs pipeline to completion with mock executor', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '00b-discovery'],
});
expect(result.runId).toMatch(/^\d{8}-\d{6}$/);
expect(result.stages).toEqual(['00-intake', '00b-discovery']);
expect(result.manifest.status).toBe('completed');
expect(executor.submittedTasks).toHaveLength(2);
});
it('creates run directory under .forge/runs/', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake'],
});
expect(result.runDir).toContain(path.join('.forge', 'runs'));
expect(fs.existsSync(result.runDir)).toBe(true);
});
it('writes manifest with stage statuses', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '00b-discovery'],
});
const manifest = loadManifest(result.runDir);
expect(manifest.stages['00-intake']?.status).toBe('passed');
expect(manifest.stages['00b-discovery']?.status).toBe('passed');
});
it('respects CLI class override', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
briefClass: 'strategic',
stages: ['00-intake'],
});
expect(result.manifest.briefClass).toBe('strategic');
expect(result.manifest.classSource).toBe('cli');
});
it('uses frontmatter class', async () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake'],
});
expect(result.manifest.briefClass).toBe('hotfix');
expect(result.manifest.classSource).toBe('frontmatter');
});
it('builds dependency chain between tasks', async () => {
const executor = createMockExecutor();
await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '00b-discovery', '02-planning-1'],
});
expect(executor.submittedTasks[0]!.dependsOn).toBeUndefined();
expect(executor.submittedTasks[1]!.dependsOn).toEqual([executor.submittedTasks[0]!.id]);
expect(executor.submittedTasks[2]!.dependsOn).toEqual([executor.submittedTasks[1]!.id]);
});
it('handles stage failure', async () => {
const executor = createMockExecutor({ failStage: '00b-discovery' });
await expect(
runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '00b-discovery'],
}),
).rejects.toThrow('Stage 00b-discovery failed');
});
it('marks manifest as failed on stage failure', async () => {
const executor = createMockExecutor({ failStage: '00-intake' });
try {
await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake'],
});
} catch {
// expected
}
// Find the run dir (we don't have it from the failed result)
const runsDir = path.join(tmpDir, '.forge', 'runs');
const runDirs = fs.readdirSync(runsDir);
expect(runDirs).toHaveLength(1);
const manifest = loadManifest(path.join(runsDir, runDirs[0]!));
expect(manifest.status).toBe('failed');
expect(manifest.stages['00-intake']?.status).toBe('failed');
});
});
describe('resumePipeline', () => {
let tmpDir: string;
let briefPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-resume-'));
briefPath = path.join(tmpDir, 'brief.md');
fs.writeFileSync(briefPath, '---\nclass: hotfix\n---\n\n# Fix bug');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('resumes from first incomplete stage', async () => {
// First run fails on discovery
const executor1 = createMockExecutor({ failStage: '00b-discovery' });
let runDir: string;
try {
await runPipeline(briefPath, tmpDir, {
executor: executor1,
stages: ['00-intake', '00b-discovery', '02-planning-1'],
});
} catch {
// expected
}
const runsDir = path.join(tmpDir, '.forge', 'runs');
runDir = path.join(runsDir, fs.readdirSync(runsDir)[0]!);
// Resume should pick up from 00b-discovery
const executor2 = createMockExecutor();
const result = await resumePipeline(runDir, executor2);
expect(result.manifest.status).toBe('completed');
// Should have re-run from 00b-discovery onward
expect(result.stages[0]).toBe('00b-discovery');
});
});
describe('getPipelineStatus', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-status-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('returns manifest', () => {
const manifest: RunManifest = {
runId: 'test',
brief: '/brief.md',
codebase: '',
briefClass: 'strategic',
classSource: 'auto',
forceBoard: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: {},
};
saveManifest(tmpDir, manifest);
const status = getPipelineStatus(tmpDir);
expect(status.runId).toBe('test');
expect(status.status).toBe('in_progress');
});
});