* refactor: deepen gsdtools query execution seams * docs: add changeset for query seam deepening * docs: fix changeset summary text * fix: address coderabbit query seam findings * test: address remaining coderabbit findings and notes * refactor: use internal gsdtools error type import
182 lines
7.1 KiB
TypeScript
182 lines
7.1 KiB
TypeScript
/**
|
|
* E2E integration test — proves full SDK pipeline:
|
|
* parse → prompt → query() → SUMMARY.md
|
|
*
|
|
* Requires Claude Code CLI (`claude`) installed and authenticated, plus
|
|
* opt-in env `GSD_ENABLE_E2E=1`. Skips if env unset or CLI unavailable.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { execSync } from 'node:child_process';
|
|
import { mkdtemp, cp, rm, readFile, readdir } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { GSD, parsePlanFile, GSDEventType } from './index.js';
|
|
import type { GSDEvent } from './index.js';
|
|
|
|
// ─── CLI availability check ─────────────────────────────────────────────────
|
|
|
|
let cliAvailable = false;
|
|
try {
|
|
execSync('which claude', { stdio: 'ignore' });
|
|
cliAvailable = true;
|
|
} catch {
|
|
cliAvailable = false;
|
|
}
|
|
|
|
const e2eEnabled = process.env.GSD_ENABLE_E2E === '1';
|
|
const canRunE2E = cliAvailable && e2eEnabled;
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
const fixturesDir = join(__dirname, '..', 'test-fixtures');
|
|
|
|
// ─── Test suite ──────────────────────────────────────────────────────────────
|
|
|
|
describe.skipIf(!canRunE2E)('E2E: Single plan execution', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-e2e-'));
|
|
// Copy fixture files to temp directory
|
|
await cp(fixturesDir, tmpDir, { recursive: true });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (tmpDir) {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('executes a single plan and returns a valid PlanResult', async () => {
|
|
const gsd = new GSD({ projectDir: tmpDir, maxBudgetUsd: 1.0, maxTurns: 20 });
|
|
const result = await gsd.executePlan('sample-plan.md');
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(typeof result.sessionId).toBe('string');
|
|
expect(result.sessionId.length).toBeGreaterThan(0);
|
|
expect(result.totalCostUsd).toBeGreaterThanOrEqual(0);
|
|
expect(result.durationMs).toBeGreaterThan(0);
|
|
expect(result.numTurns).toBeGreaterThan(0);
|
|
|
|
// Verify the plan's task was executed — output.txt should exist
|
|
const outputPath = join(tmpDir, 'output.txt');
|
|
const outputContent = await readFile(outputPath, 'utf-8');
|
|
expect(outputContent).toContain('hello from gsd-sdk');
|
|
}, 120_000); // 2 minute timeout for real CLI execution
|
|
|
|
it('proves session isolation (R014) — different session IDs for sequential runs', async () => {
|
|
// Create a second temp dir for isolation proof
|
|
const tmpDir2 = await mkdtemp(join(tmpdir(), 'gsd-sdk-e2e-'));
|
|
await cp(fixturesDir, tmpDir2, { recursive: true });
|
|
|
|
try {
|
|
const gsd1 = new GSD({ projectDir: tmpDir, maxBudgetUsd: 1.0, maxTurns: 20 });
|
|
const gsd2 = new GSD({ projectDir: tmpDir2, maxBudgetUsd: 1.0, maxTurns: 20 });
|
|
|
|
const result1 = await gsd1.executePlan('sample-plan.md');
|
|
const result2 = await gsd2.executePlan('sample-plan.md');
|
|
|
|
// Different sessions must have different session IDs
|
|
expect(result1.sessionId).not.toBe(result2.sessionId);
|
|
|
|
// Both should track cost independently
|
|
expect(result1.totalCostUsd).toBeGreaterThanOrEqual(0);
|
|
expect(result2.totalCostUsd).toBeGreaterThanOrEqual(0);
|
|
} finally {
|
|
await rm(tmpDir2, { recursive: true, force: true });
|
|
}
|
|
}, 240_000); // 4 minute timeout — two sequential runs
|
|
});
|
|
|
|
describe('E2E: Fixture validation (no CLI required)', () => {
|
|
it('fixture PLAN.md is valid and parseable', async () => {
|
|
const plan = await parsePlanFile(join(fixturesDir, 'sample-plan.md'));
|
|
|
|
expect(plan.frontmatter.phase).toBe('01-test');
|
|
expect(plan.frontmatter.plan).toBe('01');
|
|
expect(plan.frontmatter.type).toBe('execute');
|
|
expect(plan.frontmatter.wave).toBe(1);
|
|
expect(plan.frontmatter.depends_on).toEqual([]);
|
|
expect(plan.frontmatter.files_modified).toEqual(['output.txt']);
|
|
expect(plan.frontmatter.autonomous).toBe(true);
|
|
expect(plan.frontmatter.requirements).toEqual(['TEST-01']);
|
|
expect(plan.frontmatter.must_haves.truths).toEqual(['output.txt exists with expected content']);
|
|
|
|
expect(plan.objective).toContain('simple output file');
|
|
expect(plan.tasks).toHaveLength(1);
|
|
expect(plan.tasks[0].name).toBe('Create output file');
|
|
expect(plan.tasks[0].type).toBe('auto');
|
|
expect(plan.tasks[0].verify).toBe('test -f output.txt');
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!canRunE2E)('E2E: Event stream during plan execution (R007)', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-e2e-stream-'));
|
|
await cp(fixturesDir, tmpDir, { recursive: true });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (tmpDir) {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('event stream emits events during plan execution (R007)', async () => {
|
|
const events: GSDEvent[] = [];
|
|
const gsd = new GSD({ projectDir: tmpDir, maxBudgetUsd: 1.0, maxTurns: 20 });
|
|
|
|
// Subscribe to all events
|
|
gsd.onEvent((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
const result = await gsd.executePlan('sample-plan.md');
|
|
expect(result.success).toBe(true);
|
|
|
|
// (a) At least one session_init event received
|
|
const initEvents = events.filter(e => e.type === GSDEventType.SessionInit);
|
|
expect(initEvents.length).toBeGreaterThanOrEqual(1);
|
|
|
|
// (b) At least one tool_call event received
|
|
const toolCallEvents = events.filter(e => e.type === GSDEventType.ToolCall);
|
|
expect(toolCallEvents.length).toBeGreaterThanOrEqual(1);
|
|
|
|
// (c) Exactly one session_complete event with cost >= 0
|
|
const completeEvents = events.filter(e => e.type === GSDEventType.SessionComplete);
|
|
expect(completeEvents).toHaveLength(1);
|
|
const completeEvent = completeEvents[0]!;
|
|
if (completeEvent.type === GSDEventType.SessionComplete) {
|
|
expect(completeEvent.totalCostUsd).toBeGreaterThanOrEqual(0);
|
|
}
|
|
|
|
// (d) Events arrived in order: session_init before tool_call before session_complete
|
|
const initIdx = events.findIndex(e => e.type === GSDEventType.SessionInit);
|
|
const toolCallIdx = events.findIndex(e => e.type === GSDEventType.ToolCall);
|
|
const completeIdx = events.findIndex(e => e.type === GSDEventType.SessionComplete);
|
|
expect(initIdx).toBeLessThan(toolCallIdx);
|
|
expect(toolCallIdx).toBeLessThan(completeIdx);
|
|
|
|
// Bonus: at least one cost_update event was emitted
|
|
const costEvents = events.filter(e => e.type === GSDEventType.CostUpdate);
|
|
expect(costEvents.length).toBeGreaterThanOrEqual(1);
|
|
}, 120_000);
|
|
});
|
|
|
|
describe('E2E: Error handling', () => {
|
|
it('returns failure for nonexistent plan path', async () => {
|
|
const tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sdk-e2e-err-'));
|
|
|
|
try {
|
|
const gsd = new GSD({ projectDir: tmpDir });
|
|
await expect(gsd.executePlan('nonexistent-plan.md')).rejects.toThrow();
|
|
} finally {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|