Files
msd-core/sdk/src/context-engine.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

212 lines
7.4 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { ContextEngine, PHASE_FILE_MANIFEST } from './context-engine.js';
import { PhaseType } from './types.js';
import type { GSDLogger } from './logger.js';
// ─── Helpers ─────────────────────────────────────────────────────────────────
async function createTempProject(): Promise<string> {
return mkdtemp(join(tmpdir(), 'gsd-ctx-'));
}
async function createPlanningDir(projectDir: string, files: Record<string, string>): Promise<void> {
const planningDir = join(projectDir, '.planning');
await mkdir(planningDir, { recursive: true });
for (const [filename, content] of Object.entries(files)) {
await writeFile(join(planningDir, filename), content, 'utf-8');
}
}
function makeMockLogger(): GSDLogger {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
setPhase: vi.fn(),
setPlan: vi.fn(),
setSessionId: vi.fn(),
} as unknown as GSDLogger;
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('ContextEngine', () => {
let projectDir: string;
beforeEach(async () => {
projectDir = await createTempProject();
});
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
});
describe('resolveContextFiles', () => {
it('returns all files for plan phase when all exist', async () => {
await createPlanningDir(projectDir, {
'STATE.md': '# State\nproject: test',
'ROADMAP.md': '# Roadmap\nphase 01',
'CONTEXT.md': '# Context\nstack: node',
'RESEARCH.md': '# Research\nfindings here',
'REQUIREMENTS.md': '# Requirements\nR1: auth',
});
const engine = new ContextEngine(projectDir);
const files = await engine.resolveContextFiles(PhaseType.Plan);
expect(files.state).toBe('# State\nproject: test');
expect(files.roadmap).toBe('# Roadmap\nphase 01');
expect(files.context).toBe('# Context\nstack: node');
expect(files.research).toBe('# Research\nfindings here');
expect(files.requirements).toBe('# Requirements\nR1: auth');
});
it('returns minimal files for execute phase', async () => {
await createPlanningDir(projectDir, {
'STATE.md': '# State',
'config.json': '{"model":"claude"}',
'ROADMAP.md': '# Roadmap — should not be read',
'CONTEXT.md': '# Context — should not be read',
});
const engine = new ContextEngine(projectDir);
const files = await engine.resolveContextFiles(PhaseType.Execute);
expect(files.state).toBe('# State');
expect(files.config).toBe('{"model":"claude"}');
expect(files.roadmap).toBeUndefined();
expect(files.context).toBeUndefined();
});
it('returns state + roadmap + context for research phase', async () => {
await createPlanningDir(projectDir, {
'STATE.md': '# State',
'ROADMAP.md': '# Roadmap',
'CONTEXT.md': '# Context',
});
const engine = new ContextEngine(projectDir);
const files = await engine.resolveContextFiles(PhaseType.Research);
expect(files.state).toBe('# State');
expect(files.roadmap).toBe('# Roadmap');
expect(files.context).toBe('# Context');
expect(files.requirements).toBeUndefined();
});
it('returns state + roadmap + requirements for verify phase', async () => {
await createPlanningDir(projectDir, {
'STATE.md': '# State',
'ROADMAP.md': '# Roadmap',
'REQUIREMENTS.md': '# Requirements',
'PLAN.md': '# Plan',
'SUMMARY.md': '# Summary',
});
const engine = new ContextEngine(projectDir);
const files = await engine.resolveContextFiles(PhaseType.Verify);
expect(files.state).toBe('# State');
expect(files.roadmap).toBe('# Roadmap');
expect(files.requirements).toBe('# Requirements');
expect(files.plan).toBe('# Plan');
expect(files.summary).toBe('# Summary');
});
it('returns state + optional files for discuss phase', async () => {
await createPlanningDir(projectDir, {
'STATE.md': '# State',
'ROADMAP.md': '# Roadmap',
});
const engine = new ContextEngine(projectDir);
const files = await engine.resolveContextFiles(PhaseType.Discuss);
expect(files.state).toBe('# State');
expect(files.roadmap).toBe('# Roadmap');
expect(files.context).toBeUndefined();
});
it('returns undefined for missing optional files without warning', async () => {
await createPlanningDir(projectDir, {
'STATE.md': '# State',
'ROADMAP.md': '# Roadmap',
'CONTEXT.md': '# Context',
});
const logger = makeMockLogger();
const engine = new ContextEngine(projectDir, logger);
const files = await engine.resolveContextFiles(PhaseType.Plan);
// research and requirements are optional for plan — no warning
expect(files.research).toBeUndefined();
expect(files.requirements).toBeUndefined();
expect(logger.warn).not.toHaveBeenCalled();
});
it('warns for missing required files', async () => {
// Empty .planning dir — STATE.md is required for all phases
await createPlanningDir(projectDir, {});
const logger = makeMockLogger();
const engine = new ContextEngine(projectDir, logger);
await engine.resolveContextFiles(PhaseType.Execute);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('STATE.md'),
expect.objectContaining({ phase: PhaseType.Execute }),
);
});
it('handles missing .planning directory gracefully', async () => {
// No .planning dir at all
const engine = new ContextEngine(projectDir);
const files = await engine.resolveContextFiles(PhaseType.Execute);
expect(files.state).toBeUndefined();
expect(files.config).toBeUndefined();
});
it('handles empty file content', async () => {
await createPlanningDir(projectDir, {
'STATE.md': '',
});
const engine = new ContextEngine(projectDir);
const files = await engine.resolveContextFiles(PhaseType.Execute);
// Empty string is still defined — the file exists
expect(files.state).toBe('');
});
});
describe('PHASE_FILE_MANIFEST', () => {
it('covers all phase types', () => {
for (const phase of Object.values(PhaseType)) {
expect(PHASE_FILE_MANIFEST[phase]).toBeDefined();
expect(PHASE_FILE_MANIFEST[phase].length).toBeGreaterThan(0);
}
});
it('execute phase has fewest files', () => {
const executeCount = PHASE_FILE_MANIFEST[PhaseType.Execute].length;
const planCount = PHASE_FILE_MANIFEST[PhaseType.Plan].length;
expect(executeCount).toBeLessThan(planCount);
});
it('every spec has required key, filename, and required flag', () => {
for (const specs of Object.values(PHASE_FILE_MANIFEST)) {
for (const spec of specs) {
expect(spec.key).toBeDefined();
expect(spec.filename).toBeDefined();
expect(typeof spec.required).toBe('boolean');
}
}
});
});
});