153 lines
5.4 KiB
TypeScript
153 lines
5.4 KiB
TypeScript
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<typeof vi.spyOn>;
|
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
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');
|
|
});
|
|
});
|