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:
commit
28bb77b999
2 changed files with 357 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue