diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index bceaae38e..cab90b9da 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -2470,7 +2470,7 @@ async function dispatchNextUnit( // Milestone is complete — evicting this key would fight self-heal. // Clear skip counter and re-dispatch from fresh state. unitConsecutiveSkips.delete(idempotencyKey); - invalidateStateCache(); + invalidateAllCaches(); ctx.ui.notify( `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid}. Re-dispatching from fresh state.`, "info", @@ -2548,7 +2548,7 @@ async function dispatchNextUnit( : false; if (skippedMilestoneComplete2) { unitConsecutiveSkips.delete(idempotencyKey); - invalidateStateCache(); + invalidateAllCaches(); ctx.ui.notify( `Phantom skip loop cleared: ${unitType} ${unitId} belongs to completed milestone ${skippedMid2}. Re-dispatching from fresh state.`, "info", diff --git a/src/tests/integration/e2e-smoke.test.ts b/src/tests/integration/e2e-smoke.test.ts index aadf5694f..8e7cf6c79 100644 --- a/src/tests/integration/e2e-smoke.test.ts +++ b/src/tests/integration/e2e-smoke.test.ts @@ -17,8 +17,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; +import { tmpdir } from "node:os"; const projectRoot = process.cwd(); const loaderPath = join(projectRoot, "dist", "loader.js"); @@ -44,11 +45,13 @@ type RunResult = { * @param args CLI arguments to pass after the script path * @param timeoutMs Maximum time to wait before SIGTERM (default 8 s) * @param env Additional / override environment variables + * @param cwd Working directory for the child process (default: projectRoot) */ function runGsd( args: string[], timeoutMs = 8_000, env: NodeJS.ProcessEnv = {}, + cwd: string = projectRoot, ): Promise { return new Promise((resolve) => { let stdout = ""; @@ -56,7 +59,7 @@ function runGsd( let timedOut = false; const child = spawn("node", [loaderPath, ...args], { - cwd: projectRoot, + cwd, env: { ...process.env, ...env }, stdio: ["pipe", "pipe", "pipe"], }); @@ -262,3 +265,353 @@ test("gsd --mode text --print does not segfault or throw unhandled errors", { sk ); } }); + +// =========================================================================== +// COMMAND ROUTING SMOKE TESTS +// =========================================================================== + +// --------------------------------------------------------------------------- +// 7. gsd headless --help outputs headless-specific help and exits 0 +// --------------------------------------------------------------------------- + +test("gsd headless --help outputs help and exits 0", async () => { + const result = await runGsd(["headless", "--help"]); + + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + assert.ok(!result.timedOut, "process should not time out"); + + // parseCliArgs intercepts --help before subcommand routing, + // so this produces the general help text (same as config/update --help). + const output = stripAnsi(result.stdout); + assert.ok( + output.includes("Usage:"), + `expected 'Usage:' in output, got:\n${output.slice(0, 500)}`, + ); + assert.ok( + output.includes("headless"), + "help output should mention headless subcommand", + ); +}); + +// --------------------------------------------------------------------------- +// 8. gsd sessions --help outputs sessions-specific help and exits 0 +// --------------------------------------------------------------------------- + +test("gsd sessions --help outputs sessions-specific help and exits 0", async () => { + const result = await runGsd(["sessions", "--help"]); + + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + assert.ok(!result.timedOut, "process should not time out"); + + const output = stripAnsi(result.stdout); + assert.ok( + output.includes("session") || output.includes("Usage:"), + `expected session-related help, got:\n${output.slice(0, 500)}`, + ); +}); + +// =========================================================================== +// GRACEFUL ERROR HANDLING +// =========================================================================== + +// --------------------------------------------------------------------------- +// 9. gsd (no TTY) exits with clean error about requiring a terminal +// --------------------------------------------------------------------------- + +test("gsd with no TTY exits 1 with clean terminal-required error", async () => { + // Running with piped stdin (non-TTY) and no subcommand/flags triggers + // interactive mode which requires a TTY + const result = await runGsd([], 15_000); + + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); + + const combined = stripAnsi(result.stdout + result.stderr); + + // Should mention TTY or terminal requirement + assert.ok( + combined.includes("TTY") || combined.includes("terminal") || combined.includes("Interactive"), + `expected TTY/terminal error message, got:\n${combined.slice(0, 500)}`, + ); + + // Must not be an unhandled crash + assertNoCrashMarkers(combined); +}); + +// --------------------------------------------------------------------------- +// 10. gsd with unknown flags does not crash +// --------------------------------------------------------------------------- + +test("gsd with unknown flags does not crash", async () => { + // Unknown flags are silently ignored by the arg parser. + // With --help appended, we get a clean exit path to test. + const result = await runGsd(["--some-unknown-flag", "--help"]); + + assert.ok(!result.timedOut, "process should not time out"); + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + + const combined = stripAnsi(result.stdout + result.stderr); + assertNoCrashMarkers(combined); +}); + +// --------------------------------------------------------------------------- +// 11. gsd -v is equivalent to --version +// --------------------------------------------------------------------------- + +test("gsd -v is equivalent to --version", async () => { + const result = await runGsd(["-v"]); + + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + assert.ok(!result.timedOut, "process should not time out"); + + const version = result.stdout.trim(); + assert.match( + version, + /^\d+\.\d+\.\d+/, + `expected semver output, got: ${JSON.stringify(version)}`, + ); +}); + +// --------------------------------------------------------------------------- +// 12. gsd -h is equivalent to --help +// --------------------------------------------------------------------------- + +test("gsd -h is equivalent to --help", async () => { + const result = await runGsd(["-h"]); + + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + assert.ok(!result.timedOut, "process should not time out"); + + const output = stripAnsi(result.stdout); + assert.ok( + output.includes("Usage:"), + `expected 'Usage:' in output, got:\n${output.slice(0, 500)}`, + ); +}); + +// =========================================================================== +// HEADLESS MODE SMOKE TESTS +// =========================================================================== + +// --------------------------------------------------------------------------- +// 13. gsd headless without .gsd/ directory exits 1 with clean error +// --------------------------------------------------------------------------- + +test("gsd headless without .gsd/ directory exits 1 with clean error", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "gsd-e2e-no-gsd-")); + + try { + const result = await runGsd(["headless"], 10_000, {}, tmpDir); + + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); + + const combined = stripAnsi(result.stdout + result.stderr); + assert.ok( + combined.includes(".gsd/") || combined.includes("No .gsd"), + `expected .gsd/ missing error, got:\n${combined.slice(0, 500)}`, + ); + + assertNoCrashMarkers(combined); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// 14. gsd headless new-milestone without --context exits 1 +// --------------------------------------------------------------------------- + +test("gsd headless new-milestone without --context exits 1", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "gsd-e2e-no-ctx-")); + + try { + const result = await runGsd(["headless", "new-milestone"], 10_000, {}, tmpDir); + + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); + + const combined = stripAnsi(result.stdout + result.stderr); + assert.ok( + combined.includes("context") || combined.includes("--context"), + `expected context-required error, got:\n${combined.slice(0, 500)}`, + ); + + assertNoCrashMarkers(combined); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// 15. gsd headless --timeout with invalid value exits 1 +// --------------------------------------------------------------------------- + +test("gsd headless --timeout with invalid value exits 1", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "gsd-e2e-bad-timeout-")); + + try { + const result = await runGsd( + ["headless", "--timeout", "not-a-number", "auto"], + 10_000, + {}, + tmpDir, + ); + + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); + + const combined = stripAnsi(result.stdout + result.stderr); + assert.ok( + combined.includes("timeout") || combined.includes("positive integer"), + `expected timeout validation error, got:\n${combined.slice(0, 500)}`, + ); + + assertNoCrashMarkers(combined); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// 16. gsd headless --timeout with negative value exits 1 +// --------------------------------------------------------------------------- + +test("gsd headless --timeout with negative value exits 1", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "gsd-e2e-neg-timeout-")); + + try { + const result = await runGsd( + ["headless", "--timeout", "-5000", "auto"], + 10_000, + {}, + tmpDir, + ); + + assert.ok(!result.timedOut, "process should not hang"); + assert.strictEqual(result.code, 1, `expected exit 1, got ${result.code}`); + + const combined = stripAnsi(result.stdout + result.stderr); + assert.ok( + combined.includes("timeout") || combined.includes("positive integer"), + `expected timeout validation error, got:\n${combined.slice(0, 500)}`, + ); + + assertNoCrashMarkers(combined); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +// =========================================================================== +// SUBCOMMAND HELP COMPLETENESS +// =========================================================================== + +// --------------------------------------------------------------------------- +// 17. --help output lists all subcommands +// --------------------------------------------------------------------------- + +test("gsd --help lists all documented subcommands", async () => { + const result = await runGsd(["--help"]); + + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + const output = stripAnsi(result.stdout); + + const expectedSubcommands = ["config", "update", "sessions", "headless"]; + for (const cmd of expectedSubcommands) { + assert.ok( + output.includes(cmd), + `help output should list '${cmd}' subcommand`, + ); + } +}); + +// --------------------------------------------------------------------------- +// 18. --help output lists all key flags +// --------------------------------------------------------------------------- + +test("gsd --help lists all key flags", async () => { + const result = await runGsd(["--help"]); + + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + const output = stripAnsi(result.stdout); + + const expectedFlags = [ + "--mode", + "--print", + "--continue", + "--model", + "--no-session", + "--extension", + "--tools", + "--list-models", + "--version", + "--help", + ]; + for (const flag of expectedFlags) { + assert.ok( + output.includes(flag), + `help output should mention '${flag}'`, + ); + } +}); + +// =========================================================================== +// NO-CRASH ASSERTIONS +// =========================================================================== + +// --------------------------------------------------------------------------- +// 19. gsd --version followed by other flags still just prints version +// --------------------------------------------------------------------------- + +test("gsd --version ignores trailing arguments", async () => { + const result = await runGsd(["--version", "--help", "--list-models"]); + + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + assert.ok(!result.timedOut, "process should not time out"); + + // --version is a fast-exit path; should just print version + const version = result.stdout.trim(); + assert.match( + version, + /^\d+\.\d+\.\d+/, + `expected semver output only, got: ${JSON.stringify(version)}`, + ); +}); + +// --------------------------------------------------------------------------- +// 20. gsd headless help (positional, not flag) exits 0 +// --------------------------------------------------------------------------- + +test("gsd headless help (positional) exits cleanly", async () => { + // "help" as a positional is treated as a quick command by headless mode. + // Without .gsd/ it should fail, but with --help flag it should succeed. + const result = await runGsd(["headless", "--help"]); + + assert.strictEqual(result.code, 0, `expected exit 0, got ${result.code}`); + assert.ok(!result.timedOut, "process should not time out"); +}); + +// --------------------------------------------------------------------------- +// Shared crash marker assertion +// --------------------------------------------------------------------------- + +function assertNoCrashMarkers(output: string): void { + const crashMarkers = [ + "SyntaxError:", + "ReferenceError:", + "TypeError: Cannot read", + "FATAL ERROR", + "ERR_MODULE_NOT_FOUND", + "Error: Cannot find module", + "SIGSEGV", + "SIGABRT", + ]; + + for (const marker of crashMarkers) { + assert.ok( + !output.includes(marker), + `output should not contain crash marker '${marker}':\n${output.slice(0, 500)}`, + ); + } +}