Merge pull request #805 from jeremymcs/test/expand-e2e-smoke-tests

test: expand E2E smoke tests with 14 new CLI verification tests
This commit is contained in:
TÂCHES 2026-03-16 23:18:25 -06:00 committed by GitHub
commit 28bb77b999
2 changed files with 357 additions and 4 deletions

View file

@ -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",

View file

@ -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<RunResult> {
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)}`,
);
}
}