spawn/cli/src/__tests__/cli-version-and-dispatch.test.ts
A 94b09ab29e
security: fix path traversal risk in SPAWN_HOME validation (#1402)
* security: fix path traversal risk in SPAWN_HOME validation

Agent: security-auditor
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: add missing join import and update tests for SPAWN_HOME security validation

Addresses security review feedback on PR #1402:
- Add missing 'join' import to cli-version-and-dispatch.test.ts
- Update all test files to use homedir() instead of tmpdir() for SPAWN_HOME

The security fix in history.ts now enforces that SPAWN_HOME must be within
the user's home directory. All tests have been updated to use home-based
test directories instead of /tmp paths.

Changes:
- cli/src/__tests__/cli-version-and-dispatch.test.ts: Add join to path imports
- All test files: Replace tmpdir() with homedir() and /tmp/spawn- with /.spawn-test-

Tests:
- bun test history.test.ts:  69 pass
- bun test clear-history.test.ts:  27 pass
- bun test cli-version-and-dispatch.test.ts:  62 pass
- bun test list-table-rendering.test.ts:  8 pass

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-17 12:57:01 -05:00

528 lines
19 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { resolve, join } from "path";
/**
* Tests for CLI version output and dispatch routing via subprocess execution.
*
* These tests exercise the ACTUAL index.ts entry point by running it as a
* subprocess, verifying the real behavior users see when they run spawn commands.
* This catches integration issues that unit tests with mocked modules miss:
*
* - showVersion: output format, runtime info (bun/node, platform, arch)
* - Version flags: --version, -v, -V, and "version" subcommand
* - Help flags: --help, -h, and "help" subcommand
* - handleNoCommand: --dry-run and --prompt without agent/cloud
* - Subcommand aliases: "m" for "matrix", "ls"/"history" for "list"
* - Verb alias routing: "run", "launch", "start", "deploy", "exec"
* - Unknown flag error messaging
* - Extra args warning
* - showInfoOrError: unknown command with fuzzy suggestions
*
* Agent: test-engineer
*/
const CLI_PATH = resolve(import.meta.dir, "../../src/index.ts");
const REPO_ROOT = resolve(import.meta.dir, "../../..");
/**
* Run the CLI with given args as a subprocess.
* Sets SPAWN_NO_UPDATE_CHECK to skip auto-update and BUN_ENV=test to skip
* local manifest loading. Returns { stdout, stderr, exitCode }.
*/
function runCLI(
args: string[],
env?: Record<string, string>,
): { stdout: string; stderr: string; exitCode: number } {
const { spawnSync } = require("child_process");
const result = spawnSync("bun", ["run", CLI_PATH, ...args], {
cwd: REPO_ROOT,
encoding: "utf-8",
timeout: 15000,
env: {
...process.env,
// Ensure bun is in PATH for child processes
PATH: `${process.env.HOME}/.bun/bin:${process.env.PATH}`,
SPAWN_NO_UPDATE_CHECK: "1",
BUN_ENV: "test",
// Avoid terminal-dependent output
TERM: "dumb",
SPAWN_NO_UNICODE: "1",
// Ensure no color codes in output for easier assertion
NO_COLOR: "1",
...env,
},
stdio: ["pipe", "pipe", "pipe"],
});
return {
stdout: (result.stdout || "").toString(),
stderr: (result.stderr || "").toString(),
exitCode: result.status ?? 1,
};
}
// ── showVersion output ──────────────────────────────────────────────────────
describe("showVersion via CLI subprocess", () => {
it("should show version string with 'spawn v' prefix", () => {
const { stdout, exitCode } = runCLI(["version"]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/spawn v\d+\.\d+\.\d+/);
});
it("should show bun runtime info", () => {
const { stdout, exitCode } = runCLI(["version"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("bun");
});
it("should show platform info", () => {
const { stdout, exitCode } = runCLI(["version"]);
expect(exitCode).toBe(0);
expect(stdout).toContain(process.platform);
});
it("should show arch info", () => {
const { stdout, exitCode } = runCLI(["version"]);
expect(exitCode).toBe(0);
expect(stdout).toContain(process.arch);
});
it("should suggest 'spawn update' command", () => {
const { stdout, exitCode } = runCLI(["version"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("spawn update");
});
it("should show binary path", () => {
const { stdout, exitCode } = runCLI(["version"]);
expect(exitCode).toBe(0);
// The binary path should contain the path to index.ts
expect(stdout).toContain("index.ts");
});
});
// ── Version flag aliases ────────────────────────────────────────────────────
describe("version flag aliases", () => {
it("--version should produce same version line as 'version'", () => {
const versionResult = runCLI(["version"]);
const flagResult = runCLI(["--version"]);
expect(flagResult.exitCode).toBe(0);
// Both should contain the version string
const versionMatch = versionResult.stdout.match(/spawn v[\d.]+/);
const flagMatch = flagResult.stdout.match(/spawn v[\d.]+/);
expect(versionMatch).not.toBeNull();
expect(flagMatch).not.toBeNull();
expect(versionMatch![0]).toBe(flagMatch![0]);
});
it("-v should produce same version line as 'version'", () => {
const { stdout, exitCode } = runCLI(["-v"]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/spawn v\d+\.\d+\.\d+/);
});
it("-V should produce same version line as 'version'", () => {
const { stdout, exitCode } = runCLI(["-V"]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/spawn v\d+\.\d+\.\d+/);
});
});
// ── Help flags ──────────────────────────────────────────────────────────────
describe("help command and flags", () => {
it("'help' should show USAGE section", () => {
const { stdout, exitCode } = runCLI(["help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("USAGE");
});
it("--help should show USAGE section", () => {
const { stdout, exitCode } = runCLI(["--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("USAGE");
});
it("-h should show USAGE section", () => {
const { stdout, exitCode } = runCLI(["-h"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("USAGE");
});
it("help should include EXAMPLES section", () => {
const { stdout } = runCLI(["help"]);
expect(stdout).toContain("EXAMPLES");
});
it("help should include AUTHENTICATION section", () => {
const { stdout } = runCLI(["help"]);
expect(stdout).toContain("AUTHENTICATION");
});
it("help should include ENVIRONMENT VARIABLES section", () => {
const { stdout } = runCLI(["help"]);
expect(stdout).toContain("ENVIRONMENT VARIABLES");
});
it("help should include TROUBLESHOOTING section", () => {
const { stdout } = runCLI(["help"]);
expect(stdout).toContain("TROUBLESHOOTING");
});
it("help should mention --dry-run flag", () => {
const { stdout } = runCLI(["help"]);
expect(stdout).toContain("--dry-run");
});
it("help should mention --prompt-file flag", () => {
const { stdout } = runCLI(["help"]);
expect(stdout).toContain("--prompt-file");
});
it("help should mention list aliases (ls, history)", () => {
const { stdout } = runCLI(["help"]);
expect(stdout).toContain("ls");
expect(stdout).toContain("history");
});
it("help should mention matrix alias (m)", () => {
const { stdout } = runCLI(["help"]);
expect(stdout).toContain("matrix");
});
});
// ── Trailing help flag on subcommands ───────────────────────────────────────
describe("trailing help flag on subcommands", () => {
it("'agents --help' should show help, not agents list", () => {
const { stdout, exitCode } = runCLI(["agents", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("USAGE");
});
it("'clouds -h' should show help", () => {
const { stdout, exitCode } = runCLI(["clouds", "-h"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("USAGE");
});
it("'matrix --help' should show help", () => {
const { stdout, exitCode } = runCLI(["matrix", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("USAGE");
});
it("'list --help' should show help", () => {
const { stdout, exitCode } = runCLI(["list", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("USAGE");
});
it("'update --help' should show help", () => {
const { stdout, exitCode } = runCLI(["update", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("USAGE");
});
});
// ── handleNoCommand: --dry-run and --prompt without agent/cloud ─────────────
describe("handleNoCommand error paths", () => {
it("--dry-run without agent/cloud should error", () => {
const { stderr, exitCode } = runCLI(["--dry-run"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("--dry-run requires both");
});
it("-n without agent/cloud should error", () => {
const { stderr, exitCode } = runCLI(["-n"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("--dry-run requires both");
});
it("--prompt without agent/cloud should error", () => {
const { stderr, exitCode } = runCLI(["--prompt", "hello"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("--prompt requires both");
});
it("--prompt-file with nonexistent file should error with file-not-found", () => {
const { stderr, exitCode } = runCLI(["--prompt-file", "/tmp/nonexistent-spawn-test"]);
expect(exitCode).toBe(1);
// The file read error occurs before the no-agent/cloud check
expect(stderr).toContain("not found");
});
});
// ── --dry-run with only agent (no cloud) ────────────────────────────────────
describe("--dry-run with only agent", () => {
it("should error when --dry-run is used with agent only", () => {
const { stderr, exitCode } = runCLI(["claude", "--dry-run"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("--dry-run requires both");
});
});
// ── --prompt with only agent (no cloud) ─────────────────────────────────────
describe("--prompt with only agent (no cloud)", () => {
it("should error when --prompt is used with agent only", () => {
const { stderr, exitCode } = runCLI(["claude", "--prompt", "hello"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("--prompt requires both");
});
it("should suggest available clouds for the agent", () => {
const { stderr, exitCode } = runCLI(["claude", "--prompt", "hello"]);
expect(exitCode).toBe(1);
// Should suggest cloud options
expect(stderr).toContain("spawn claude");
});
});
// ── Unknown flag detection ──────────────────────────────────────────────────
describe("unknown flag detection", () => {
it("should error on --unknown flag", () => {
const { stderr, exitCode } = runCLI(["--unknown"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("Unknown flag");
expect(stderr).toContain("--unknown");
});
it("should show supported flags in error message", () => {
const { stderr, exitCode } = runCLI(["--xyz"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("Supported flags");
expect(stderr).toContain("--prompt");
expect(stderr).toContain("--dry-run");
expect(stderr).toContain("--help");
expect(stderr).toContain("--version");
});
it("should suggest 'spawn help' when unknown flag is used", () => {
const { stderr, exitCode } = runCLI(["--foo"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("spawn help");
});
it("should not treat -1 as a flag (numeric prefix)", () => {
// -1 starts with - but matches /^-\d/, so it should not be caught as unknown flag
// It will fail for other reasons (not a valid agent) but not as "unknown flag"
const { stderr, exitCode } = runCLI(["-1"]);
expect(stderr).not.toContain("Unknown flag");
});
it("should treat --prompt-files (typo) as unknown flag", () => {
const { stderr, exitCode } = runCLI(["--prompt-files", "test.txt"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("Unknown flag");
expect(stderr).toContain("--prompt-files");
});
});
// ── Flag value requirements ─────────────────────────────────────────────────
describe("flag value requirements", () => {
it("--prompt without value should error", () => {
const { stderr, exitCode } = runCLI(["claude", "sprite", "--prompt"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("--prompt");
expect(stderr).toContain("requires a value");
});
it("-p without value should error", () => {
const { stderr, exitCode } = runCLI(["claude", "sprite", "-p"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("-p");
expect(stderr).toContain("requires a value");
});
it("--prompt-file without value should error", () => {
const { stderr, exitCode } = runCLI(["claude", "sprite", "--prompt-file"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("--prompt-file");
expect(stderr).toContain("requires a value");
});
it("-f without value should error", () => {
const { stderr, exitCode } = runCLI(["claude", "sprite", "-f"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("-f");
expect(stderr).toContain("requires a value");
});
it("--prompt and --prompt-file together should error", () => {
const { stderr, exitCode } = runCLI([
"claude", "sprite",
"--prompt", "hello",
"--prompt-file", "/tmp/test.txt",
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("cannot be used together");
});
});
// ── Verb alias routing ──────────────────────────────────────────────────────
describe("verb alias routing", () => {
it("'run' without args should error with usage hint", () => {
const { stderr, exitCode } = runCLI(["run"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("requires an agent and cloud");
});
it("'launch' without args should error with usage hint", () => {
const { stderr, exitCode } = runCLI(["launch"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("requires an agent and cloud");
});
it("'start' without args should error with usage hint", () => {
const { stderr, exitCode } = runCLI(["start"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("requires an agent and cloud");
});
it("'deploy' without args should error with usage hint", () => {
const { stderr, exitCode } = runCLI(["deploy"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("requires an agent and cloud");
});
it("'exec' without args should error with usage hint", () => {
const { stderr, exitCode } = runCLI(["exec"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("requires an agent and cloud");
});
it("verb alias error should mention it's optional", () => {
const { stderr } = runCLI(["run"]);
expect(stderr).toContain("optional");
expect(stderr).toContain("spawn <agent> <cloud>");
});
});
// ── Extra args warning ──────────────────────────────────────────────────────
describe("extra arguments warning", () => {
it("should warn about extra args after version command", () => {
const { stderr, stdout, exitCode } = runCLI(["version", "extra"]);
expect(exitCode).toBe(0);
expect(stderr.toLowerCase()).toContain("extra argument");
expect(stderr).toContain("ignored");
// Should still show version
expect(stdout).toMatch(/spawn v\d+\.\d+/);
});
it("should warn about multiple extra args", () => {
const { stderr, exitCode } = runCLI(["version", "a", "b", "c"]);
expect(exitCode).toBe(0);
expect(stderr.toLowerCase()).toContain("extra arguments");
expect(stderr).toContain("ignored");
});
it("should not warn when no extra args", () => {
const { stderr } = runCLI(["version"]);
expect(stderr.toLowerCase()).not.toContain("extra argument");
});
});
// ── Prompt file errors ──────────────────────────────────────────────────────
describe("prompt file error handling", () => {
it("should show file-not-found error for nonexistent prompt file", () => {
const { stderr, exitCode } = runCLI([
"claude", "sprite",
"--prompt-file", "/tmp/spawn-test-nonexistent-file-xyz123",
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("not found");
});
it("should show directory error when prompt-file is a directory", () => {
const { stderr, exitCode } = runCLI([
"claude", "sprite",
"--prompt-file", "/tmp",
]);
expect(exitCode).toBe(1);
expect(stderr).toContain("directory");
});
});
// ── Non-interactive terminal without command ────────────────────────────────
describe("non-interactive terminal handling", () => {
it("should show usage hint when no args and no TTY", () => {
// Running as subprocess inherently lacks a TTY for stdin
const { stderr, exitCode } = runCLI([]);
expect(exitCode).toBe(1);
expect(stderr).toContain("Cannot run interactive picker: not a terminal");
expect(stderr).toContain("spawn <agent> <cloud>");
expect(stderr).toContain("spawn agents");
expect(stderr).toContain("spawn clouds");
expect(stderr).toContain("spawn help");
});
});
// ── Subcommand alias routing ────────────────────────────────────────────────
describe("subcommand alias routing", () => {
it("'m' should work as alias for 'matrix'", () => {
const { stdout, exitCode } = runCLI(["m"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("Availability Matrix");
});
it("'agents' should list agents", () => {
const { stdout, exitCode } = runCLI(["agents"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("Agents");
});
it("'clouds' should list clouds", () => {
const { stdout, exitCode } = runCLI(["clouds"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("Cloud Providers");
});
});
// ── List command aliases ────────────────────────────────────────────────────
describe("list command aliases", () => {
it("'list' should not crash with empty history", () => {
const { homedir } = require("os");
const { exitCode } = runCLI(["list"], { SPAWN_HOME: join(homedir(), ".spawn-test-empty-home-" + Date.now()) });
// May exit 0 (shows "no spawns") or run interactive picker in non-TTY
// The important thing is it doesn't crash
expect(exitCode).toBeDefined();
});
it("'ls' should work as alias for 'list'", () => {
const { homedir } = require("os");
const { exitCode } = runCLI(["ls"], { SPAWN_HOME: join(homedir(), ".spawn-test-empty-home-" + Date.now()) });
expect(exitCode).toBeDefined();
});
it("'history' should work as alias for 'list'", () => {
const { homedir } = require("os");
const { exitCode } = runCLI(["history"], { SPAWN_HOME: join(homedir(), ".spawn-test-empty-home-" + Date.now()) });
expect(exitCode).toBeDefined();
});
it("'list -a' without value should error", () => {
const { stderr, exitCode } = runCLI(["list", "-a"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("-a");
expect(stderr).toContain("requires");
});
it("'list -c' without value should error", () => {
const { stderr, exitCode } = runCLI(["list", "-c"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("-c");
expect(stderr).toContain("requires");
});
});