Files
msd-core/sdk/src/phase-runner.integration.test.ts
TÂCHES 596ce2d252 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.
2026-03-26 20:27:51 -06:00

377 lines
13 KiB
TypeScript

/**
* Integration test — proves PhaseRunner state machine works against real gsd-tools.cjs.
*
* Creates a temp `.planning/` directory structure, instantiates real GSDTools,
* and exercises the state machine. Sessions will fail (no Claude CLI in CI) but
* the state machine's control flow, event emission, and error capture are proven.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { homedir } from 'node:os';
import { GSDTools } from './gsd-tools.js';
import { PhaseRunner } from './phase-runner.js';
import type { PhaseRunnerDeps } from './phase-runner.js';
import { ContextEngine } from './context-engine.js';
import { PromptFactory } from './phase-prompt.js';
import { GSDEventStream } from './event-stream.js';
import { loadConfig } from './config.js';
import type { GSDEvent } from './types.js';
import { GSDEventType, PhaseStepType } from './types.js';
// ─── Helpers ─────────────────────────────────────────────────────────────────
const GSD_TOOLS_PATH = join(homedir(), '.claude', 'get-shit-done', 'bin', 'gsd-tools.cjs');
async function createTempPlanningDir(): Promise<string> {
const tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-phase-int-'));
// Create .planning structure
const planningDir = join(tmpDir, '.planning');
const phasesDir = join(planningDir, 'phases');
const phaseDir = join(phasesDir, '01-integration-test');
await mkdir(phaseDir, { recursive: true });
// config.json
await writeFile(
join(planningDir, 'config.json'),
JSON.stringify({
model_profile: 'balanced',
commit_docs: false,
workflow: {
research: true,
verifier: true,
auto_advance: true,
skip_discuss: false,
},
}),
);
// ROADMAP.md — required for roadmap_exists
await writeFile(join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n## Phase 01: Integration Test\n');
// CONTEXT.md in phase dir — triggers has_context=true → discuss is skipped
await writeFile(
join(phaseDir, 'CONTEXT.md'),
'# Context\n\nThis is an integration test phase with pre-existing context.\n',
);
return tmpDir;
}
// ─── Test suite ──────────────────────────────────────────────────────────────
describe('Integration: PhaseRunner against real gsd-tools.cjs', () => {
let tmpDir: string;
let tools: GSDTools;
beforeAll(async () => {
tmpDir = await createTempPlanningDir();
tools = new GSDTools({
projectDir: tmpDir,
gsdToolsPath: GSD_TOOLS_PATH,
timeoutMs: 10_000,
});
});
afterAll(async () => {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
});
// ── Test 1: initPhaseOp returns valid PhaseOpInfo ──
it('initPhaseOp returns valid PhaseOpInfo for temp phase', async () => {
const info = await tools.initPhaseOp('01');
expect(info.phase_found).toBe(true);
expect(info.phase_number).toBe('01');
expect(info.phase_name).toBe('integration-test');
expect(info.phase_dir).toBe('.planning/phases/01-integration-test');
expect(info.has_context).toBe(true);
expect(info.has_plans).toBe(false);
expect(info.plan_count).toBe(0);
expect(info.roadmap_exists).toBe(true);
expect(info.planning_exists).toBe(true);
});
it('initPhaseOp returns phase_found=false for nonexistent phase', async () => {
const info = await tools.initPhaseOp('99');
expect(info.phase_found).toBe(false);
expect(info.has_context).toBe(false);
expect(info.plan_count).toBe(0);
});
// ── Test 2: PhaseRunner state machine control flow ──
it('PhaseRunner emits lifecycle events and captures session errors gracefully', { timeout: 300_000 }, async () => {
const eventStream = new GSDEventStream();
const config = await loadConfig(tmpDir);
const contextEngine = new ContextEngine(tmpDir);
const promptFactory = new PromptFactory();
const events: GSDEvent[] = [];
eventStream.on('event', (e: GSDEvent) => events.push(e));
const deps: PhaseRunnerDeps = {
projectDir: tmpDir,
tools,
promptFactory,
contextEngine,
eventStream,
config,
};
const runner = new PhaseRunner(deps);
// Tight budget/turns so each session finishes fast
const result = await runner.run('01', {
maxTurnsPerStep: 2,
maxBudgetPerStep: 0.10,
});
// ── (a) Phase start event emitted ──
const phaseStartEvents = events.filter(e => e.type === GSDEventType.PhaseStart);
expect(phaseStartEvents).toHaveLength(1);
const phaseStart = phaseStartEvents[0]!;
if (phaseStart.type === GSDEventType.PhaseStart) {
expect(phaseStart.phaseNumber).toBe('01');
expect(phaseStart.phaseName).toBe('integration-test');
}
// ── (b) Discuss should be skipped (has_context=true) ──
// No discuss step in results since it was skipped
const discussSteps = result.steps.filter(s => s.step === PhaseStepType.Discuss);
expect(discussSteps).toHaveLength(0);
// ── (c) Step start events emitted for attempted steps ──
const stepStartEvents = events.filter(e => e.type === GSDEventType.PhaseStepStart);
expect(stepStartEvents.length).toBeGreaterThanOrEqual(1);
// ── (d) Step results are properly structured ──
// With CLI available, sessions may succeed or fail depending on budget/turns.
// Either way, each step result must have correct structure.
expect(result.steps.length).toBeGreaterThanOrEqual(1);
for (const step of result.steps) {
expect(Object.values(PhaseStepType)).toContain(step.step);
expect(typeof step.success).toBe('boolean');
expect(typeof step.durationMs).toBe('number');
// Failed steps may or may not have an error message
// (e.g. advance step can fail without explicit error string)
}
// ── (e) Phase complete event emitted ──
const phaseCompleteEvents = events.filter(e => e.type === GSDEventType.PhaseComplete);
expect(phaseCompleteEvents).toHaveLength(1);
// ── (f) Result structure is valid ──
expect(result.phaseNumber).toBe('01');
expect(result.phaseName).toBe('integration-test');
expect(typeof result.totalCostUsd).toBe('number');
expect(typeof result.totalDurationMs).toBe('number');
expect(result.totalDurationMs).toBeGreaterThan(0);
});
// ── Test 3: PhaseRunner with nonexistent phase throws ──
it('PhaseRunner throws PhaseRunnerError for nonexistent phase', async () => {
const eventStream = new GSDEventStream();
const config = await loadConfig(tmpDir);
const contextEngine = new ContextEngine(tmpDir);
const promptFactory = new PromptFactory();
const deps: PhaseRunnerDeps = {
projectDir: tmpDir,
tools,
promptFactory,
contextEngine,
eventStream,
config,
};
const runner = new PhaseRunner(deps);
await expect(runner.run('99')).rejects.toThrow('Phase 99 not found on disk');
});
// ── Test 4: GSD.runPhase() public API delegates correctly ──
it('GSD.runPhase() creates collaborators and delegates to PhaseRunner', { timeout: 300_000 }, async () => {
// Import GSD here to test the public API wiring
const { GSD } = await import('./index.js');
const gsd = new GSD({ projectDir: tmpDir });
const events: GSDEvent[] = [];
gsd.onEvent((e) => events.push(e));
const result = await gsd.runPhase('01', {
maxTurnsPerStep: 2,
maxBudgetPerStep: 0.10,
});
// Proves the full wiring works: GSD → PhaseRunner → GSDTools → gsd-tools.cjs
expect(result.phaseNumber).toBe('01');
expect(result.phaseName).toBe('integration-test');
expect(result.steps.length).toBeGreaterThanOrEqual(1);
expect(events.some(e => e.type === GSDEventType.PhaseStart)).toBe(true);
expect(events.some(e => e.type === GSDEventType.PhaseComplete)).toBe(true);
});
});
// ─── Wave / phasePlanIndex Integration Tests ─────────────────────────────────
/**
* Creates a temp `.planning/` directory with multi-wave plan files.
* - Plans 01 and 02 are wave 1 (parallel)
* - Plan 03 is wave 2 (depends on wave 1)
* - Plan 01 has a SUMMARY.md (marks it as completed)
*/
async function createMultiWavePlanningDir(): Promise<string> {
const tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-wave-int-'));
const planningDir = join(tmpDir, '.planning');
const phaseDir = join(planningDir, 'phases', '01-wave-test');
await mkdir(phaseDir, { recursive: true });
// config.json — with parallelization enabled
await writeFile(
join(planningDir, 'config.json'),
JSON.stringify({
model_profile: 'balanced',
commit_docs: false,
parallelization: true,
workflow: {
research: true,
verifier: true,
auto_advance: true,
skip_discuss: false,
},
}),
);
// ROADMAP.md
await writeFile(join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n## Phase 01: Wave Test\n');
const planTemplate = (id: string, wave: number, dependsOn: string[] = []) => `---
phase: "01"
plan: "${id}"
type: "feature"
wave: ${wave}
depends_on: [${dependsOn.map(d => `"${d}"`).join(', ')}]
files_modified: ["src/${id}.ts"]
autonomous: true
requirements: []
must_haves:
truths: ["${id} exists"]
artifacts: []
key_links: []
---
# Plan: ${id}
<task type="code" name="Create ${id}" files="src/${id}.ts">
<read_first>none</read_first>
<action>Create ${id}</action>
<verify>File exists</verify>
<acceptance_criteria>
- File exists
</acceptance_criteria>
<done>Done</done>
</task>
`;
// Wave 1 plans (parallel)
await writeFile(join(phaseDir, '01-wave-test-01-PLAN.md'), planTemplate('01-wave-test-01', 1));
await writeFile(join(phaseDir, '01-wave-test-02-PLAN.md'), planTemplate('01-wave-test-02', 1));
// Wave 2 plan (depends on wave 1)
await writeFile(
join(phaseDir, '01-wave-test-03-PLAN.md'),
planTemplate('01-wave-test-03', 2, ['01-wave-test-01']),
);
// Summary for plan 01 — marks it as completed
await writeFile(
join(phaseDir, '01-wave-test-01-SUMMARY.md'),
`---\nresult: pass\nplan: "01-wave-test-01"\ncost_usd: 0.01\nduration_ms: 1000\n---\n\n# Summary\n\nAll tasks completed.\n`,
);
return tmpDir;
}
describe('Integration: phasePlanIndex and wave execution', () => {
let tmpDir: string;
let tools: GSDTools;
beforeAll(async () => {
tmpDir = await createMultiWavePlanningDir();
tools = new GSDTools({
projectDir: tmpDir,
gsdToolsPath: GSD_TOOLS_PATH,
timeoutMs: 10_000,
});
});
afterAll(async () => {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true });
}
});
it('phasePlanIndex returns typed PhasePlanIndex with correct wave grouping', async () => {
const index = await tools.phasePlanIndex('01');
// 3 plans total
expect(index.plans).toHaveLength(3);
// Wave grouping: wave 1 has 2 plans, wave 2 has 1
expect(index.waves['1']).toHaveLength(2);
expect(index.waves['1']).toContain('01-wave-test-01');
expect(index.waves['1']).toContain('01-wave-test-02');
expect(index.waves['2']).toHaveLength(1);
expect(index.waves['2']).toContain('01-wave-test-03');
// Incomplete: plan 01 has summary so only 02 and 03 are incomplete
expect(index.incomplete).toHaveLength(2);
expect(index.incomplete).toContain('01-wave-test-02');
expect(index.incomplete).toContain('01-wave-test-03');
// All autonomous → no checkpoints
expect(index.has_checkpoints).toBe(false);
// Phase ID correct
expect(index.phase).toBe('01');
});
it('phasePlanIndex marks has_summary correctly per plan', async () => {
const index = await tools.phasePlanIndex('01');
// Plan 01 has a SUMMARY.md on disk
const plan01 = index.plans.find(p => p.id === '01-wave-test-01');
expect(plan01).toBeDefined();
expect(plan01!.has_summary).toBe(true);
// Plans 02 and 03 have no summary
const plan02 = index.plans.find(p => p.id === '01-wave-test-02');
expect(plan02).toBeDefined();
expect(plan02!.has_summary).toBe(false);
const plan03 = index.plans.find(p => p.id === '01-wave-test-03');
expect(plan03).toBeDefined();
expect(plan03!.has_summary).toBe(false);
});
it('phasePlanIndex for nonexistent phase returns empty plans', async () => {
const index = await tools.phasePlanIndex('99');
expect(index.plans).toHaveLength(0);
expect(Object.keys(index.waves)).toHaveLength(0);
expect(index.incomplete).toHaveLength(0);
expect(index.has_checkpoints).toBe(false);
});
});