feat: GSD SDK — headless CLI with init + auto commands (#1407)
* test: Bootstrapped sdk/ as TypeScript ESM package with full GSD-1 PLAN.… - "sdk/package.json" - "sdk/tsconfig.json" - "sdk/vitest.config.ts" - "sdk/src/types.ts" - "sdk/src/plan-parser.ts" - "sdk/src/plan-parser.test.ts" GSD-Task: S01/T01 * test: Implemented config reader and gsd-tools bridge with 25 unit tests… - "sdk/src/config.ts" - "sdk/src/config.test.ts" - "sdk/src/gsd-tools.ts" - "sdk/src/gsd-tools.test.ts" GSD-Task: S01/T02 * test: Built prompt-builder, session-runner, and GSD class — 85 total un… - "sdk/src/prompt-builder.ts" - "sdk/src/prompt-builder.test.ts" - "sdk/src/session-runner.ts" - "sdk/src/index.ts" - "sdk/src/types.ts" GSD-Task: S01/T03 * test: Created E2E integration test with fixtures proving full SDK pipel… - "sdk/src/e2e.integration.test.ts" - "sdk/test-fixtures/sample-plan.md" - "sdk/test-fixtures/.planning/config.json" - "sdk/test-fixtures/.planning/STATE.md" - "vitest.config.ts" - "tsconfig.json" GSD-Task: S01/T04 * test: Added PhaseType/GSDEventType enums, 16-variant GSDEvent union, GS… - "sdk/src/types.ts" - "sdk/src/event-stream.ts" - "sdk/src/logger.ts" - "sdk/src/event-stream.test.ts" - "sdk/src/logger.test.ts" GSD-Task: S02/T01 * test: Built ContextEngine for phase-aware context file resolution, getT… - "sdk/src/context-engine.ts" - "sdk/src/tool-scoping.ts" - "sdk/src/phase-prompt.ts" - "sdk/src/context-engine.test.ts" - "sdk/src/tool-scoping.test.ts" - "sdk/src/phase-prompt.test.ts" GSD-Task: S02/T02 * test: Wired event stream into session runner, added onEvent()/addTransp… - "sdk/src/session-runner.ts" - "sdk/src/index.ts" - "sdk/src/e2e.integration.test.ts" GSD-Task: S02/T03 * feat: Added PhaseStepType enum, PhaseOpInfo interface, phase lifecycle… - "sdk/src/types.ts" - "sdk/src/gsd-tools.ts" - "sdk/src/session-runner.ts" - "sdk/src/index.ts" - "sdk/src/phase-runner-types.test.ts" GSD-Task: S03/T01 * test: Implemented PhaseRunner state machine with 39 unit tests covering… - "sdk/src/phase-runner.ts" - "sdk/src/phase-runner.test.ts" GSD-Task: S03/T02 * test: Wired PhaseRunner into GSD.runPhase() public API with full re-exp… - "sdk/src/index.ts" - "sdk/src/phase-runner.integration.test.ts" - "sdk/src/phase-runner.ts" GSD-Task: S03/T03 * test: Expanded runVerifyStep with full gap closure cycle (plan → execut… - "sdk/src/types.ts" - "sdk/src/phase-runner.ts" - "sdk/src/phase-runner.test.ts" GSD-Task: S04/T02 * fix: Added 3 integration tests proving phasePlanIndex returns correct t… - "sdk/src/phase-runner.integration.test.ts" - "sdk/src/index.ts" GSD-Task: S04/T03 * test: Add milestone-level types, typed roadmapAnalyze(), GSD.run() orch… - "sdk/src/types.ts" - "sdk/src/gsd-tools.ts" - "sdk/src/index.ts" - "sdk/src/milestone-runner.test.ts" GSD-Task: S05/T01 * test: Added CLITransport (structured stdout log lines) and WSTransport… - "sdk/src/cli-transport.ts" - "sdk/src/cli-transport.test.ts" - "sdk/src/ws-transport.ts" - "sdk/src/ws-transport.test.ts" - "sdk/src/index.ts" - "sdk/package.json" GSD-Task: S05/T02 * test: Added gsd-sdk CLI entry point with argument parsing, bin field, p… - "sdk/src/cli.ts" - "sdk/src/cli.test.ts" - "sdk/package.json" GSD-Task: S05/T03 * feat: Add InitNewProjectInfo type, initNewProject()/configSet() GSDTool… - "sdk/src/types.ts" - "sdk/src/gsd-tools.ts" - "sdk/src/cli.ts" - "sdk/src/cli.test.ts" - "sdk/src/gsd-tools.test.ts" GSD-Task: S01/T01 * chore: Created InitRunner orchestrator with setup → config → PROJECT.md… - "sdk/src/init-runner.ts" - "sdk/src/types.ts" - "sdk/src/index.ts" GSD-Task: S01/T02 * test: Wired InitRunner into CLI main() for full gsd-sdk init dispatch a… - "sdk/src/cli.ts" - "sdk/src/init-runner.test.ts" - "sdk/src/cli.test.ts" GSD-Task: S01/T03 * test: Add PlanCheck step, AI self-discuss, and retryOnce wrapper to Pha… - "sdk/src/types.ts" - "sdk/src/phase-runner.ts" - "sdk/src/session-runner.ts" - "sdk/src/phase-runner.test.ts" - "sdk/src/phase-runner-types.test.ts" GSD-Task: S02/T01 * feat: Rewrite CLITransport with ANSI colors, phase banners, spawn indic… - "sdk/src/cli-transport.ts" - "sdk/src/cli-transport.test.ts" GSD-Task: S02/T02 * test: Add `gsd-sdk auto` command with autoMode config override, USAGE t… - "sdk/src/cli.ts" - "sdk/src/cli.test.ts" - "sdk/src/index.ts" - "sdk/src/types.ts" GSD-Task: S02/T03 * fix: CLI shebang + gsd-tools non-JSON output handling Three bugs found during first real gsd-sdk run: 1. cli.ts shebang was commented out — shell executed JS as bash, triggering ImageMagick's import command instead of Node 2. configSet() called exec() which JSON.parse()d the output, but gsd-tools config-set returns 'key=value' text, not JSON. Added execRaw() method for commands that return plain text. 3. Same JSON parse bug affected commit() (returns git SHA), stateLoad(), verifySummary(), initExecutePhase(), stateBeginPhase(), and phaseComplete(). All switched to execRaw(). Tests updated to match real gsd-tools output format (plain text instead of mocked JSON). 376/376 tests pass.
This commit is contained in:
415
sdk/src/milestone-runner.test.ts
Normal file
415
sdk/src/milestone-runner.test.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type {
|
||||
PhaseRunnerResult,
|
||||
RoadmapPhaseInfo,
|
||||
RoadmapAnalysis,
|
||||
GSDEvent,
|
||||
MilestoneRunnerOptions,
|
||||
} from './types.js';
|
||||
import { GSDEventType } from './types.js';
|
||||
|
||||
// ─── Mock modules ────────────────────────────────────────────────────────────
|
||||
|
||||
// Mock the heavy dependencies that GSD constructor + runPhase pull in
|
||||
vi.mock('./plan-parser.js', () => ({
|
||||
parsePlan: vi.fn(),
|
||||
parsePlanFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
loadConfig: vi.fn().mockResolvedValue({
|
||||
model_profile: 'test-model',
|
||||
tools: [],
|
||||
phases: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./session-runner.js', () => ({
|
||||
runPlanSession: vi.fn(),
|
||||
runPhaseStepSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./prompt-builder.js', () => ({
|
||||
buildExecutorPrompt: vi.fn(),
|
||||
parseAgentTools: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('./event-stream.js', () => {
|
||||
return {
|
||||
GSDEventStream: vi.fn().mockImplementation(() => ({
|
||||
emitEvent: vi.fn(),
|
||||
on: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
addTransport: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./phase-runner.js', () => ({
|
||||
PhaseRunner: vi.fn(),
|
||||
PhaseRunnerError: class extends Error {
|
||||
name = 'PhaseRunnerError';
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./context-engine.js', () => ({
|
||||
ContextEngine: vi.fn(),
|
||||
PHASE_FILE_MANIFEST: [],
|
||||
}));
|
||||
|
||||
vi.mock('./phase-prompt.js', () => ({
|
||||
PromptFactory: vi.fn(),
|
||||
extractBlock: vi.fn(),
|
||||
extractSteps: vi.fn(),
|
||||
PHASE_WORKFLOW_MAP: {},
|
||||
}));
|
||||
|
||||
vi.mock('./gsd-tools.js', () => ({
|
||||
GSDTools: vi.fn().mockImplementation(() => ({
|
||||
roadmapAnalyze: vi.fn(),
|
||||
})),
|
||||
GSDToolsError: class extends Error {
|
||||
name = 'GSDToolsError';
|
||||
},
|
||||
}));
|
||||
|
||||
import { GSD } from './index.js';
|
||||
import { GSDTools } from './gsd-tools.js';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makePhaseInfo(overrides: Partial<RoadmapPhaseInfo> = {}): RoadmapPhaseInfo {
|
||||
return {
|
||||
number: '1',
|
||||
disk_status: 'not_started',
|
||||
roadmap_complete: false,
|
||||
phase_name: 'Auth',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePhaseResult(overrides: Partial<PhaseRunnerResult> = {}): PhaseRunnerResult {
|
||||
return {
|
||||
phaseNumber: '1',
|
||||
phaseName: 'Auth',
|
||||
steps: [],
|
||||
success: true,
|
||||
totalCostUsd: 0.50,
|
||||
totalDurationMs: 5000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAnalysis(phases: RoadmapPhaseInfo[]): RoadmapAnalysis {
|
||||
return { phases };
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GSD.run()', () => {
|
||||
let gsd: GSD;
|
||||
let mockRoadmapAnalyze: ReturnType<typeof vi.fn>;
|
||||
let events: GSDEvent[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
gsd = new GSD({ projectDir: '/tmp/test-project' });
|
||||
events = [];
|
||||
|
||||
// Capture emitted events
|
||||
(gsd.eventStream.emitEvent as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(event: GSDEvent) => events.push(event),
|
||||
);
|
||||
|
||||
// Wire mock roadmapAnalyze on the GSDTools instance
|
||||
mockRoadmapAnalyze = vi.fn();
|
||||
vi.mocked(GSDTools).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
roadmapAnalyze: mockRoadmapAnalyze,
|
||||
}) as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('discovers phases and calls runPhase for each incomplete one', async () => {
|
||||
const phases = [
|
||||
makePhaseInfo({ number: '1', phase_name: 'Auth', roadmap_complete: false }),
|
||||
makePhaseInfo({ number: '2', phase_name: 'Dashboard', roadmap_complete: false }),
|
||||
];
|
||||
|
||||
mockRoadmapAnalyze
|
||||
.mockResolvedValueOnce(makeAnalysis(phases)) // initial discovery
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: false }),
|
||||
])) // after phase 1
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: true }),
|
||||
])); // after phase 2
|
||||
|
||||
const runPhaseSpy = vi.spyOn(gsd, 'runPhase')
|
||||
.mockResolvedValueOnce(makePhaseResult({ phaseNumber: '1' }))
|
||||
.mockResolvedValueOnce(makePhaseResult({ phaseNumber: '2' }));
|
||||
|
||||
const result = await gsd.run('build the app');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.phases).toHaveLength(2);
|
||||
expect(runPhaseSpy).toHaveBeenCalledTimes(2);
|
||||
expect(runPhaseSpy).toHaveBeenCalledWith('1', undefined);
|
||||
expect(runPhaseSpy).toHaveBeenCalledWith('2', undefined);
|
||||
});
|
||||
|
||||
it('skips phases where roadmap_complete === true', async () => {
|
||||
const phases = [
|
||||
makePhaseInfo({ number: '1', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: false }),
|
||||
makePhaseInfo({ number: '3', roadmap_complete: true }),
|
||||
];
|
||||
|
||||
mockRoadmapAnalyze
|
||||
.mockResolvedValueOnce(makeAnalysis(phases))
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '3', roadmap_complete: true }),
|
||||
]));
|
||||
|
||||
const runPhaseSpy = vi.spyOn(gsd, 'runPhase')
|
||||
.mockResolvedValueOnce(makePhaseResult({ phaseNumber: '2' }));
|
||||
|
||||
const result = await gsd.run('build it');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.phases).toHaveLength(1);
|
||||
expect(runPhaseSpy).toHaveBeenCalledTimes(1);
|
||||
expect(runPhaseSpy).toHaveBeenCalledWith('2', undefined);
|
||||
});
|
||||
|
||||
it('re-discovers phases after each completion to catch dynamically inserted phases', async () => {
|
||||
// Initially phase 1 and 2 are incomplete
|
||||
mockRoadmapAnalyze
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: false }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: false }),
|
||||
]))
|
||||
// After phase 1, a new phase 1.5 was inserted
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '1.5', phase_name: 'Hotfix', roadmap_complete: false }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: false }),
|
||||
]))
|
||||
// After phase 1.5 completes
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '1.5', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: false }),
|
||||
]))
|
||||
// After phase 2 completes
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '1.5', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: true }),
|
||||
]));
|
||||
|
||||
const runPhaseSpy = vi.spyOn(gsd, 'runPhase')
|
||||
.mockResolvedValueOnce(makePhaseResult({ phaseNumber: '1' }))
|
||||
.mockResolvedValueOnce(makePhaseResult({ phaseNumber: '1.5', phaseName: 'Hotfix' }))
|
||||
.mockResolvedValueOnce(makePhaseResult({ phaseNumber: '2' }));
|
||||
|
||||
const result = await gsd.run('build it');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.phases).toHaveLength(3);
|
||||
expect(runPhaseSpy).toHaveBeenCalledTimes(3);
|
||||
// The dynamically inserted phase 1.5 was executed
|
||||
expect(runPhaseSpy).toHaveBeenNthCalledWith(2, '1.5', undefined);
|
||||
});
|
||||
|
||||
it('aggregates costs from all phases', async () => {
|
||||
mockRoadmapAnalyze
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: false }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: false }),
|
||||
]))
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: false }),
|
||||
]))
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: true }),
|
||||
]));
|
||||
|
||||
vi.spyOn(gsd, 'runPhase')
|
||||
.mockResolvedValueOnce(makePhaseResult({ totalCostUsd: 1.25 }))
|
||||
.mockResolvedValueOnce(makePhaseResult({ totalCostUsd: 0.75 }));
|
||||
|
||||
const result = await gsd.run('build it');
|
||||
|
||||
expect(result.totalCostUsd).toBeCloseTo(2.0, 2);
|
||||
});
|
||||
|
||||
it('emits MilestoneStart and MilestoneComplete events', async () => {
|
||||
mockRoadmapAnalyze
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: false }),
|
||||
]))
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: true }),
|
||||
]));
|
||||
|
||||
vi.spyOn(gsd, 'runPhase')
|
||||
.mockResolvedValueOnce(makePhaseResult({ totalCostUsd: 0.50 }));
|
||||
|
||||
await gsd.run('build it');
|
||||
|
||||
const startEvents = events.filter(e => e.type === GSDEventType.MilestoneStart);
|
||||
const completeEvents = events.filter(e => e.type === GSDEventType.MilestoneComplete);
|
||||
|
||||
expect(startEvents).toHaveLength(1);
|
||||
expect(completeEvents).toHaveLength(1);
|
||||
|
||||
const start = startEvents[0] as any;
|
||||
expect(start.phaseCount).toBe(1);
|
||||
expect(start.prompt).toBe('build it');
|
||||
|
||||
const complete = completeEvents[0] as any;
|
||||
expect(complete.success).toBe(true);
|
||||
expect(complete.phasesCompleted).toBe(1);
|
||||
expect(complete.totalCostUsd).toBeCloseTo(0.50, 2);
|
||||
});
|
||||
|
||||
it('stops on phase failure', async () => {
|
||||
mockRoadmapAnalyze
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: false }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: false }),
|
||||
]));
|
||||
|
||||
vi.spyOn(gsd, 'runPhase')
|
||||
.mockResolvedValueOnce(makePhaseResult({ phaseNumber: '1', success: false }));
|
||||
|
||||
const result = await gsd.run('build it');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.phases).toHaveLength(1);
|
||||
// Phase 2 was never started
|
||||
});
|
||||
|
||||
it('handles empty phase list', async () => {
|
||||
mockRoadmapAnalyze
|
||||
.mockResolvedValueOnce(makeAnalysis([]));
|
||||
|
||||
const runPhaseSpy = vi.spyOn(gsd, 'runPhase');
|
||||
|
||||
const result = await gsd.run('build it');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.phases).toHaveLength(0);
|
||||
expect(runPhaseSpy).not.toHaveBeenCalled();
|
||||
expect(result.totalCostUsd).toBe(0);
|
||||
});
|
||||
|
||||
it('sorts phases numerically, not lexicographically', async () => {
|
||||
const phases = [
|
||||
makePhaseInfo({ number: '10', phase_name: 'Ten', roadmap_complete: false }),
|
||||
makePhaseInfo({ number: '2', phase_name: 'Two', roadmap_complete: false }),
|
||||
makePhaseInfo({ number: '1.5', phase_name: 'OnePointFive', roadmap_complete: false }),
|
||||
];
|
||||
|
||||
mockRoadmapAnalyze
|
||||
.mockResolvedValueOnce(makeAnalysis(phases))
|
||||
// After phase 1.5
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1.5', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: false }),
|
||||
makePhaseInfo({ number: '10', roadmap_complete: false }),
|
||||
]))
|
||||
// After phase 2
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1.5', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '10', roadmap_complete: false }),
|
||||
]))
|
||||
// After phase 10
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1.5', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: true }),
|
||||
makePhaseInfo({ number: '10', roadmap_complete: true }),
|
||||
]));
|
||||
|
||||
const executionOrder: string[] = [];
|
||||
vi.spyOn(gsd, 'runPhase').mockImplementation(async (phaseNumber: string) => {
|
||||
executionOrder.push(phaseNumber);
|
||||
return makePhaseResult({ phaseNumber });
|
||||
});
|
||||
|
||||
await gsd.run('build it');
|
||||
|
||||
// Numeric order: 1.5 → 2 → 10 (not lexicographic: "10" < "2")
|
||||
expect(executionOrder).toEqual(['1.5', '2', '10']);
|
||||
});
|
||||
|
||||
it('handles phase throwing an unexpected error', async () => {
|
||||
mockRoadmapAnalyze
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', phase_name: 'Broken', roadmap_complete: false }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: false }),
|
||||
]));
|
||||
|
||||
vi.spyOn(gsd, 'runPhase')
|
||||
.mockRejectedValueOnce(new Error('Unexpected explosion'));
|
||||
|
||||
const result = await gsd.run('build it');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.phases).toHaveLength(1);
|
||||
expect(result.phases[0].success).toBe(false);
|
||||
expect(result.phases[0].phaseNumber).toBe('1');
|
||||
});
|
||||
|
||||
it('passes MilestoneRunnerOptions through to runPhase', async () => {
|
||||
mockRoadmapAnalyze
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: false }),
|
||||
]))
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: true }),
|
||||
]));
|
||||
|
||||
const runPhaseSpy = vi.spyOn(gsd, 'runPhase')
|
||||
.mockResolvedValueOnce(makePhaseResult());
|
||||
|
||||
const opts: MilestoneRunnerOptions = {
|
||||
model: 'claude-sonnet-4-6',
|
||||
maxBudgetPerStep: 2.0,
|
||||
onPhaseComplete: vi.fn(),
|
||||
};
|
||||
|
||||
await gsd.run('build it', opts);
|
||||
|
||||
expect(runPhaseSpy).toHaveBeenCalledWith('1', opts);
|
||||
});
|
||||
|
||||
it('respects onPhaseComplete returning stop', async () => {
|
||||
mockRoadmapAnalyze
|
||||
.mockResolvedValueOnce(makeAnalysis([
|
||||
makePhaseInfo({ number: '1', roadmap_complete: false }),
|
||||
makePhaseInfo({ number: '2', roadmap_complete: false }),
|
||||
]));
|
||||
|
||||
vi.spyOn(gsd, 'runPhase')
|
||||
.mockResolvedValueOnce(makePhaseResult({ phaseNumber: '1' }));
|
||||
|
||||
const result = await gsd.run('build it', {
|
||||
onPhaseComplete: async () => 'stop',
|
||||
});
|
||||
|
||||
// Only 1 phase was executed because callback said stop
|
||||
expect(result.phases).toHaveLength(1);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user