import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { Command } from 'commander'; import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { registerForgeCommand } from './cli.js'; import { loadManifest } from './pipeline-runner.js'; describe('registerForgeCommand', () => { it('registers a "forge" command on the parent program', () => { const program = new Command(); registerForgeCommand(program); const forgeCmd = program.commands.find((c) => c.name() === 'forge'); expect(forgeCmd).toBeDefined(); }); it('registers the four required subcommands under forge', () => { const program = new Command(); registerForgeCommand(program); const forgeCmd = program.commands.find((c) => c.name() === 'forge'); expect(forgeCmd).toBeDefined(); const subNames = forgeCmd!.commands.map((c) => c.name()); expect(subNames).toContain('run'); expect(subNames).toContain('status'); expect(subNames).toContain('resume'); expect(subNames).toContain('personas'); }); it('registers "personas list" as a subcommand of "forge personas"', () => { const program = new Command(); registerForgeCommand(program); const forgeCmd = program.commands.find((c) => c.name() === 'forge'); const personasCmd = forgeCmd!.commands.find((c) => c.name() === 'personas'); expect(personasCmd).toBeDefined(); const personasSubNames = personasCmd!.commands.map((c) => c.name()); expect(personasSubNames).toContain('list'); }); it('does not modify the parent program name or description', () => { const program = new Command('mosaic'); program.description('Mosaic Stack CLI'); registerForgeCommand(program); expect(program.name()).toBe('mosaic'); expect(program.description()).toBe('Mosaic Stack CLI'); }); it('can be called multiple times without throwing', () => { const program = new Command(); expect(() => { registerForgeCommand(program); }).not.toThrow(); }); }); describe('forge run fail-closed behavior (SDLC-D-035)', () => { let tmpDir: string; let briefPath: string; let errSpy: ReturnType; let logSpy: ReturnType; let prevExitCode: string | number | null | undefined; const parse = (args: string[]) => { const program = new Command(); registerForgeCommand(program); return program.parseAsync(['forge', ...args], { from: 'user' }); }; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-cli-failclosed-')); briefPath = path.join(tmpDir, 'brief.md'); fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.'); errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); prevExitCode = process.exitCode; }); afterEach(() => { errSpy.mockRestore(); logSpy.mockRestore(); process.exitCode = prevExitCode; fs.rmSync(tmpDir, { recursive: true, force: true }); }); it('exits nonzero with a typed FORGE_NO_EXECUTOR error when no executor is wired and --simulate is absent', async () => { await parse(['run', '--brief', briefPath, '--codebase', tmpDir]); expect(process.exitCode).toBe(1); const errText = errSpy.mock.calls.map((c) => c.join(' ')).join('\n'); expect(errText).toContain('FORGE_NO_EXECUTOR'); // It must never run the pipeline with a stub and report success. expect(fs.existsSync(path.join(tmpDir, '.forge', 'runs'))).toBe(false); }); it('completes with typed simulated results and exit 0 under explicit --simulate', async () => { await parse(['run', '--brief', briefPath, '--codebase', tmpDir, '--simulate']); expect(process.exitCode).toBeUndefined(); // Loud simulated-mode summary. const logText = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); expect(logText).toContain('SIMULATED'); // Manifest records the mode and simulated per-result statuses. const runsDir = path.join(tmpDir, '.forge', 'runs'); const runIds = fs.readdirSync(runsDir); expect(runIds).toHaveLength(1); const manifest = loadManifest(path.join(runsDir, runIds[0]!)); expect(manifest.mode).toBe('simulated'); expect(manifest.status).toBe('simulated'); for (const stageStatus of Object.values(manifest.stages)) { expect(stageStatus?.status).toBe('simulated'); for (const gateResult of stageStatus?.gateResults ?? []) { expect(gateResult.outcome).toBe('simulated'); } } }); it('resume exits nonzero with a typed FORGE_NO_EXECUTOR error without --simulate', async () => { const runDir = path.join(tmpDir, '.forge', 'runs', '20260101-000000'); fs.mkdirSync(runDir, { recursive: true }); fs.writeFileSync( path.join(runDir, 'manifest.json'), JSON.stringify({ runId: '20260101-000000', brief: briefPath, codebase: tmpDir, briefClass: 'hotfix', classSource: 'frontmatter', 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' } }, }), ); await parse(['resume', '20260101-000000', '--project', tmpDir]); expect(process.exitCode).toBe(1); const errText = errSpy.mock.calls.map((c) => c.join(' ')).join('\n'); expect(errText).toContain('FORGE_NO_EXECUTOR'); }); });