From 93ee6646f1ac33c8dbfb942bc79dcf8f18510e24 Mon Sep 17 00:00:00 2001 From: frizynn Date: Mon, 16 Mar 2026 16:18:30 -0300 Subject: [PATCH] test: add integration test for gsd headless command End-to-end test that validates the headless CLI subcommand by: - Creating a temp dir with a complete .gsd/ project fixture - Spawning `node dist/loader.js headless --step --json` - Validating exit code, JSONL stdout, stderr progress, and artifact Supports --dry-run for fixture validation without running the agent. --- .../gsd/tests/integration/headless-command.ts | 534 ++++++++++++++++++ 1 file changed, 534 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/integration/headless-command.ts diff --git a/src/resources/extensions/gsd/tests/integration/headless-command.ts b/src/resources/extensions/gsd/tests/integration/headless-command.ts new file mode 100644 index 000000000..fc5f3582d --- /dev/null +++ b/src/resources/extensions/gsd/tests/integration/headless-command.ts @@ -0,0 +1,534 @@ +/** + * Integration test for `gsd headless` CLI subcommand + * + * Validates that the headless CLI entry point works end-to-end: + * 1. Creates a temp dir with a complete .gsd/ project fixture + * 2. Initializes a git repo in the temp dir + * 3. Spawns `node dist/loader.js headless --step --json` as a child process + * 4. Waits for the process to exit (with a 5-minute timeout) + * 5. Validates exit code, JSONL stdout, stderr progress, and task artifact + * + * Auth: Uses OAuth credentials from ~/.gsd/agent/auth.json (Claude Code Max). + * Falls back to ANTHROPIC_API_KEY env var if OAuth is not configured (D013). + * + * Usage: + * npx tsx src/resources/extensions/gsd/tests/integration/headless-command.ts + * Add --dry-run to validate fixture without running the agent. + */ + +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir, homedir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import { spawn, execSync } from "node:child_process"; + +// ── Configuration ──────────────────────────────────────────────────────────── + +const TIMEOUT_MS = parseInt(process.env.HEADLESS_TIMEOUT_MS ?? "300000", 10); // 5 minutes +const DRY_RUN = process.argv.includes("--dry-run"); + +// ── Fixture Data ───────────────────────────────────────────────────────────── +// A complete .gsd/ project state that deriveState() can parse. +// The trivial task asks the agent to create a single file — zero questions needed. + +const FIXTURE_PROJECT_MD = `# Project + +## What This Is + +Headless proof test project. A minimal fixture used to validate GSD auto-mode via RPC. + +## Core Value + +Proves headless auto-mode works end-to-end. + +## Current State + +Empty project with GSD milestone planned. + +## Architecture / Key Patterns + +- Single milestone, single slice, single task + +## Capability Contract + +None. + +## Milestone Sequence + +- [ ] M001: Headless Proof — Create a test file to prove the agent loop works +`; + +const FIXTURE_STATE_MD = `# GSD State + +**Active Milestone:** M001 — Headless Proof +**Active Slice:** S01 — Create Test File +**Phase:** executing +**Requirements Status:** 0 active · 0 validated · 0 deferred · 0 out of scope + +## Milestone Registry +- 🔄 **M001:** Headless Proof + +## Recent Decisions +- None recorded + +## Blockers +- None + +## Next Action +Execute T01: Create hello.txt in slice S01. +`; + +const FIXTURE_CONTEXT_MD = `# M001: Headless Proof — Context + +**Gathered:** 2025-01-01 +**Status:** Ready for planning + +## Project Description + +A minimal test project for validating GSD auto-mode in headless/RPC mode. + +## Why This Milestone + +Proves that the agent loop can complete a task without a TUI attached. + +## User-Visible Outcome + +### When this milestone is complete, the user can: + +- Run GSD in headless mode and have it complete a trivial task + +### Entry point / environment + +- Entry point: RPC mode via headless-proof.ts +- Environment: local dev +- Live dependencies involved: none + +## Completion Class + +- Contract complete means: agent creates the requested file +- Integration complete means: not applicable +- Operational complete means: not applicable + +## Final Integrated Acceptance + +To call this milestone complete, we must prove: + +- Agent creates hello.txt with the correct content + +## Risks and Unknowns + +- None — this is a trivial proof task + +## Existing Codebase / Prior Art + +- None + +## Relevant Requirements + +- None + +## Scope + +### In Scope + +- Creating a single file + +### Out of Scope / Non-Goals + +- Everything else + +## Technical Constraints + +- None + +## Integration Points + +- None + +## Open Questions + +- None +`; + +const FIXTURE_ROADMAP_MD = `# M001: Headless Proof + +**Vision:** Prove GSD auto-mode works headlessly. + +## Success Criteria + +- Agent creates hello.txt with content "Hello from headless GSD" + +## Key Risks / Unknowns + +- None + +## Slices + +- [ ] **S01: Create Test File** \`risk:low\` \`depends:[]\` + > After this: hello.txt exists in the project root + +## Boundary Map + +### S01 + +Produces: +- hello.txt file in project root + +Consumes: +- nothing (first slice) +`; + +const FIXTURE_PLAN_MD = `# S01: Create Test File + +**Goal:** Create a single file to prove the agent loop works headlessly. +**Demo:** hello.txt exists with the correct content after the agent runs. + +## Must-Haves + +- hello.txt created with content "Hello from headless GSD" + +## Verification + +- File hello.txt exists in project root with content "Hello from headless GSD" + +## Tasks + +- [ ] **T01: Create hello.txt** \`est:5m\` + - Why: Proves the agent can execute a tool call and produce an artifact + - Files: \`hello.txt\` + - Do: Create a file called hello.txt in the project root with the content "Hello from headless GSD" + - Verify: File exists with correct content + - Done when: hello.txt exists with content "Hello from headless GSD" + +## Files Likely Touched + +- \`hello.txt\` +`; + +const FIXTURE_TASK_PLAN_MD = `--- +estimated_steps: 1 +estimated_files: 1 +--- + +# T01: Create hello.txt + +**Slice:** S01 — Create Test File +**Milestone:** M001 + +## Description + +Create a file called hello.txt in the project root with the content "Hello from headless GSD". + +## Steps + +1. Create the file hello.txt with the content "Hello from headless GSD" + +## Must-Haves + +- [ ] hello.txt created with content "Hello from headless GSD" + +## Verification + +- File hello.txt exists in project root with content "Hello from headless GSD" + +## Expected Output + +- \`hello.txt\` — file containing "Hello from headless GSD" +`; + +// ── Fixture Creation ───────────────────────────────────────────────────────── + +function createFixture(): string { + const tmpDir = mkdtempSync(join(tmpdir(), "gsd-headless-cmd-")); + + // Initialize git repo (GSD requires it for branch-per-slice) + execSync("git init -b main", { cwd: tmpDir, stdio: "pipe" }); + execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: "pipe" }); + execSync('git config user.name "Test"', { cwd: tmpDir, stdio: "pipe" }); + + // Create .gsd/ structure + const gsdDir = join(tmpDir, ".gsd"); + const milestonesDir = join(gsdDir, "milestones"); + const m001Dir = join(milestonesDir, "M001"); + const slicesDir = join(m001Dir, "slices"); + const s01Dir = join(slicesDir, "S01"); + const tasksDir = join(s01Dir, "tasks"); + + mkdirSync(tasksDir, { recursive: true }); + + // Write fixture files + writeFileSync(join(gsdDir, "PROJECT.md"), FIXTURE_PROJECT_MD); + writeFileSync(join(gsdDir, "STATE.md"), FIXTURE_STATE_MD); + writeFileSync(join(m001Dir, "M001-CONTEXT.md"), FIXTURE_CONTEXT_MD); + writeFileSync(join(m001Dir, "M001-ROADMAP.md"), FIXTURE_ROADMAP_MD); + writeFileSync(join(s01Dir, "S01-PLAN.md"), FIXTURE_PLAN_MD); + writeFileSync(join(tasksDir, "T01-PLAN.md"), FIXTURE_TASK_PLAN_MD); + + // Add .gitignore for runtime files + writeFileSync(join(tmpDir, ".gitignore"), [ + ".gsd/auto.lock", + ".gsd/completed-units.json", + ".gsd/metrics.json", + ".gsd/activity/", + ".gsd/runtime/", + ].join("\n") + "\n"); + + // Initial commit so GSD has a clean git state + execSync("git add -A && git commit -m 'init: headless command test fixture'", { + cwd: tmpDir, + stdio: "pipe", + }); + + return tmpDir; +} + +function cleanup(dir: string): void { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // Best effort + console.warn(` [warn] Failed to clean up temp dir: ${dir}`); + } +} + +// ── JSONL Parsing ──────────────────────────────────────────────────────────── + +interface JsonlEvent { + type?: string; + [key: string]: unknown; +} + +function parseJsonlLines(output: string): JsonlEvent[] { + const events: JsonlEvent[] = []; + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + events.push(JSON.parse(trimmed) as JsonlEvent); + } catch { + // Not valid JSON — skip (could be non-JSONL output) + } + } + return events; +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + // Resolve gsd-2 repo root (6 levels up from tests/integration/) + const repoRoot = join(__dirname, "..", "..", "..", "..", "..", ".."); + + console.log("=== GSD Headless Command Integration Test ===\n"); + + // ── Step 1: Create fixture ────────────────────────────────────────────── + console.log("[1/6] Creating fixture..."); + const fixtureDir = createFixture(); + console.log(` Fixture created at: ${fixtureDir}`); + + // Validate fixture structure + const requiredFiles = [ + ".gsd/PROJECT.md", + ".gsd/STATE.md", + ".gsd/milestones/M001/M001-CONTEXT.md", + ".gsd/milestones/M001/M001-ROADMAP.md", + ".gsd/milestones/M001/slices/S01/S01-PLAN.md", + ".gsd/milestones/M001/slices/S01/tasks/T01-PLAN.md", + ]; + + for (const file of requiredFiles) { + const fullPath = join(fixtureDir, file); + if (!existsSync(fullPath)) { + console.error(` FAIL: Missing fixture file: ${file}`); + cleanup(fixtureDir); + process.exit(1); + } + console.log(` OK ${file}`); + } + + // ── Step 2: Validate environment ──────────────────────────────────────── + console.log("\n[2/6] Validating environment..."); + + // Auth: prefer OAuth credentials from ~/.gsd/agent/auth.json (D013). + // Fall back to ANTHROPIC_API_KEY env var if present. + const authJsonPath = join(homedir(), ".gsd", "agent", "auth.json"); + let hasOAuth = false; + if (existsSync(authJsonPath)) { + try { + const authData = JSON.parse(readFileSync(authJsonPath, "utf-8")); + hasOAuth = authData?.anthropic?.type === "oauth"; + } catch { + // Non-fatal + } + } + + if (hasOAuth) { + console.log(" OK OAuth credentials found in ~/.gsd/agent/auth.json (Claude Code Max)"); + } else if (process.env.ANTHROPIC_API_KEY) { + console.log(" OK ANTHROPIC_API_KEY present (env var fallback)"); + } else { + console.error(" FAIL: No auth available. Need either:"); + console.error(" - OAuth credentials in ~/.gsd/agent/auth.json (Claude Code Max)"); + console.error(" - ANTHROPIC_API_KEY environment variable"); + cleanup(fixtureDir); + process.exit(1); + } + + const loaderPath = join(repoRoot, "dist", "loader.js"); + if (!existsSync(loaderPath)) { + console.error(` FAIL: CLI not found at ${loaderPath}. Run 'npm run build' first.`); + cleanup(fixtureDir); + process.exit(1); + } + console.log(` OK CLI found at ${loaderPath}`); + + // ── Step 3: Dry-run exit ──────────────────────────────────────────────── + if (DRY_RUN) { + console.log("\n[dry-run] Fixture validated. Skipping headless execution."); + console.log("[dry-run] All checks passed.\n"); + cleanup(fixtureDir); + process.exit(0); + } + + // ── Step 4: Spawn headless command ────────────────────────────────────── + console.log("\n[3/6] Spawning headless command..."); + console.log(` Command: node ${loaderPath} headless --step --json`); + console.log(` CWD: ${fixtureDir}`); + console.log(` Timeout: ${TIMEOUT_MS / 1000}s`); + + const { exitCode, stdout, stderr } = await new Promise<{ + exitCode: number | null; + stdout: string; + stderr: string; + }>((resolve) => { + let stdoutBuf = ""; + let stderrBuf = ""; + let settled = false; + + const child = spawn("node", [loaderPath, "headless", "--step", "--json"], { + cwd: fixtureDir, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout.on("data", (chunk: Buffer) => { + stdoutBuf += chunk.toString(); + }); + + child.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString(); + stderrBuf += text; + // Stream stderr for live progress visibility + process.stderr.write(` [headless] ${text}`); + }); + + const timer = setTimeout(() => { + if (!settled) { + settled = true; + console.error(`\n TIMEOUT: Process did not exit within ${TIMEOUT_MS / 1000}s. Killing...`); + child.kill("SIGTERM"); + // Give it a moment to exit gracefully, then force kill + setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + }, 5000); + resolve({ exitCode: null, stdout: stdoutBuf, stderr: stderrBuf }); + } + }, TIMEOUT_MS); + + child.on("close", (code) => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve({ exitCode: code, stdout: stdoutBuf, stderr: stderrBuf }); + } + }); + + child.on("error", (err) => { + if (!settled) { + settled = true; + clearTimeout(timer); + stderrBuf += `\nSpawn error: ${err.message}`; + resolve({ exitCode: 1, stdout: stdoutBuf, stderr: stderrBuf }); + } + }); + }); + + // ── Step 5: Validate results ──────────────────────────────────────────── + console.log("\n[4/6] Validating process output..."); + + let allPassed = true; + + // Check 1: Exit code + const exitOk = exitCode === 0; + console.log(` ${exitOk ? "PASS" : "FAIL"} Exit code: ${exitCode ?? "null (timeout)"}`); + if (!exitOk) allPassed = false; + + // Check 2: stdout contains JSONL events + const events = parseJsonlLines(stdout); + const hasJsonlEvents = events.length > 0; + console.log(` ${hasJsonlEvents ? "PASS" : "FAIL"} JSONL events in stdout: ${events.length}`); + if (!hasJsonlEvents) allPassed = false; + + if (hasJsonlEvents) { + // Summarize event types + const typeCounts: Record = {}; + for (const event of events) { + const type = String(event.type ?? "unknown"); + typeCounts[type] = (typeCounts[type] ?? 0) + 1; + } + console.log(` Event types: ${JSON.stringify(typeCounts)}`); + } + + // Check 3: stderr contains progress output + const hasStderrOutput = stderr.trim().length > 0; + console.log(` ${hasStderrOutput ? "PASS" : "FAIL"} stderr contains progress output: ${hasStderrOutput} (${stderr.length} bytes)`); + if (!hasStderrOutput) allPassed = false; + + // ── Step 6: Verify artifact ───────────────────────────────────────────── + console.log("\n[5/6] Verifying task artifact..."); + + const helloPath = join(fixtureDir, "hello.txt"); + const artifactExists = existsSync(helloPath); + console.log(` ${artifactExists ? "PASS" : "FAIL"} hello.txt exists: ${artifactExists}`); + if (!artifactExists) allPassed = false; + + if (artifactExists) { + const content = readFileSync(helloPath, "utf-8").trim(); + const contentMatch = content === "Hello from headless GSD"; + console.log(` ${contentMatch ? "PASS" : "WARN"} hello.txt content: "${content.slice(0, 80)}"`); + } + + // ── Summary ───────────────────────────────────────────────────────────── + console.log("\n[6/6] Summary"); + console.log(` Exit code: ${exitCode ?? "null (timeout)"}`); + console.log(` JSONL events: ${events.length}`); + console.log(` stderr length: ${stderr.length} bytes`); + console.log(` hello.txt exists: ${artifactExists}`); + + // Cleanup + cleanup(fixtureDir); + + if (allPassed) { + console.log("\n=== PASSED ===\n"); + process.exit(0); + } else { + // Print diagnostic info on failure + if (stdout.length > 0) { + console.log(`\n--- stdout (last 2000 chars) ---`); + console.log(stdout.slice(-2000)); + } + if (stderr.length > 0) { + console.log(`\n--- stderr (last 2000 chars) ---`); + console.log(stderr.slice(-2000)); + } + console.log("\n=== FAILED ===\n"); + process.exit(1); + } +} + +main().catch((err) => { + console.error("Unhandled error:", err); + process.exit(1); +});