spawn/packages/cli/src/__tests__/commands-error-paths.test.ts
L 65a81edc57
fix: add unique spawn IDs to prevent history record corruption (#2235)
* fix: add unique spawn IDs to prevent history record corruption

History records were matched by heuristic ("most recent record for this
cloud without a connection"), which caused saveVmConnection and
saveLaunchCmd to overwrite the wrong record during concurrent or failed
spawns.

Fix: every SpawnRecord now has a unique `id` (UUID). All history
operations (saveVmConnection, saveLaunchCmd, removeRecord,
markRecordDeleted, mergeLastConnection) match by id when available,
falling back to the old heuristic for pre-migration records.

The orchestrator (TS path) now creates the history record AFTER server
creation succeeds, not before — so failed provisions don't leave orphan
entries.

Also adds "Remove from history" option to the spawn ls action picker,
restoring the ability to soft-delete entries without destroying the VM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add 18 unit tests for spawn ID history behavior

Tests cover:
- generateSpawnId returns unique UUIDs
- saveSpawnRecord auto-generates id when not provided
- saveVmConnection matches by spawnId (not heuristic)
- saveVmConnection does not cross-contaminate concurrent spawns
- saveVmConnection falls back to heuristic without spawnId
- saveLaunchCmd matches by spawnId (not heuristic)
- saveLaunchCmd falls back without spawnId
- removeRecord matches by id, not by timestamp+agent+cloud
- removeRecord handles duplicate timestamps correctly
- removeRecord falls back for legacy records without id
- markRecordDeleted targets correct record by id
- mergeLastConnection uses spawn_id from last-connection.json
- mergeLastConnection falls back to heuristic without spawn_id

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: enable biome import sorting with grouped imports

Adds organizeImports to biome assist config with groups:
1. Type imports
2. Node built-ins
3. Third-party packages
4. @openrouter/* packages
5. Aliases

Auto-fixed import order and lint issues across all TypeScript files,
including .claude/skills/ and packages/cli/src/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-05 23:27:03 -08:00

374 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { loadManifest } from "../manifest";
import { isString } from "../shared/type-guards";
import { createConsoleMocks, createMockManifest, mockClackPrompts, restoreMocks } from "./test-helpers";
/**
* Tests for commands/ error/validation paths that call process.exit(1).
*
* - cmdRun with invalid identifiers (injection characters, path traversal)
* - cmdRun with unknown agent or cloud names
* - cmdRun with unimplemented agent/cloud combinations
* - cmdRun with invalid prompts (command injection patterns)
* - cmdAgentInfo with unknown agent
* - cmdAgentInfo with invalid identifier
* - validateNonEmptyString triggering process.exit for empty inputs
* - validateImplementation showing available clouds when combination is missing
*/
const mockManifest = createMockManifest();
const {
logError: mockLogError,
logInfo: mockLogInfo,
logStep: mockLogStep,
logWarn: mockLogWarn,
spinnerStart: mockSpinnerStart,
spinnerStop: mockSpinnerStop,
} = mockClackPrompts();
// Import commands after @clack/prompts mock is set up
const { cmdRun, cmdAgentInfo } = await import("../commands.js");
describe("Commands Error Paths", () => {
let consoleMocks: ReturnType<typeof createConsoleMocks>;
let originalFetch: typeof global.fetch;
let processExitSpy: ReturnType<typeof spyOn>;
beforeEach(async () => {
consoleMocks = createConsoleMocks();
mockLogError.mockClear();
mockLogInfo.mockClear();
mockLogStep.mockClear();
mockLogWarn.mockClear();
mockSpinnerStart.mockClear();
mockSpinnerStop.mockClear();
// Mock process.exit to throw instead of exiting
processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => {
throw new Error("process.exit");
});
// Mock fetch to return our controlled manifest data
originalFetch = global.fetch;
global.fetch = mock(async () => new Response(JSON.stringify(mockManifest)));
// Force-refresh the manifest cache
await loadManifest(true);
});
afterEach(() => {
global.fetch = originalFetch;
processExitSpy.mockRestore();
restoreMocks(consoleMocks.log, consoleMocks.error);
});
// ── cmdRun: identifier validation ─────────────────────────────────────
describe("cmdRun - identifier validation", () => {
it("should reject agent name with path traversal characters", async () => {
await expect(cmdRun("../etc/passwd", "sprite")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject agent name with uppercase letters", async () => {
await expect(cmdRun("Claude", "sprite")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject agent name with spaces", async () => {
await expect(cmdRun("claude code", "sprite")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject agent name with shell metacharacters", async () => {
await expect(cmdRun("claude;rm", "sprite")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject cloud name with path traversal", async () => {
await expect(cmdRun("claude", "../../root")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject cloud name with special characters", async () => {
await expect(cmdRun("claude", "spr$ite")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject agent name exceeding 64 characters", async () => {
const longName = "a".repeat(65);
await expect(cmdRun(longName, "sprite")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should accept agent name at exactly 64 characters", async () => {
const name64 = "a".repeat(64);
// This will pass identifier validation but fail at validateEntity (unknown agent)
await expect(cmdRun(name64, "sprite")).rejects.toThrow("process.exit");
// It should get past identifier validation -- the error should be from validateEntity
expect(mockLogError).toHaveBeenCalled();
});
});
// ── cmdRun: unknown agent/cloud ───────────────────────────────────────
describe("cmdRun - unknown agent or cloud", () => {
it("should exit with error for unknown agent", async () => {
await expect(cmdRun("nonexistent", "sprite")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
// Should show "Unknown agent" error via @clack/prompts log.error
const errorCalls = mockLogError.mock.calls.map((c: unknown[]) => c.join(" "));
expect(errorCalls.some((msg: string) => msg.includes("Unknown agent"))).toBe(true);
});
it("should suggest spawn agents command for unknown agent", async () => {
await expect(cmdRun("nonexistent", "sprite")).rejects.toThrow("process.exit");
const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" "));
expect(infoCalls.some((msg: string) => msg.includes("spawn agents"))).toBe(true);
});
it("should exit with error for unknown cloud", async () => {
await expect(cmdRun("claude", "nonexistent")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
const errorCalls = mockLogError.mock.calls.map((c: unknown[]) => c.join(" "));
expect(errorCalls.some((msg: string) => msg.includes("Unknown cloud"))).toBe(true);
});
it("should suggest spawn clouds command for unknown cloud", async () => {
await expect(cmdRun("claude", "nonexistent")).rejects.toThrow("process.exit");
const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" "));
expect(infoCalls.some((msg: string) => msg.includes("spawn clouds"))).toBe(true);
});
});
// ── cmdRun: unimplemented combination ─────────────────────────────────
describe("cmdRun - unimplemented combination", () => {
it("should exit with error for unimplemented agent/cloud combination", async () => {
// hetzner/codex is "missing" in mock manifest
await expect(cmdRun("codex", "hetzner")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should suggest available clouds when combination is not implemented", async () => {
// hetzner/codex is "missing", but sprite/codex is "implemented"
await expect(cmdRun("codex", "hetzner")).rejects.toThrow("process.exit");
const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" "));
// Should suggest sprite as an alternative
expect(infoCalls.some((msg: string) => msg.includes("spawn codex sprite"))).toBe(true);
});
it("should show how many clouds are available", async () => {
await expect(cmdRun("codex", "hetzner")).rejects.toThrow("process.exit");
const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" "));
// codex has 1 implemented cloud (sprite)
expect(infoCalls.some((msg: string) => msg.includes("1 cloud"))).toBe(true);
});
});
// ── cmdRun: prompt validation ─────────────────────────────────────────
describe("cmdRun - prompt validation", () => {
it("should reject prompt with command substitution $()", async () => {
await expect(cmdRun("claude", "sprite", "$(rm -rf /)")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject prompt with backtick command substitution", async () => {
await expect(cmdRun("claude", "sprite", "`whoami`")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject prompt piping to bash", async () => {
await expect(cmdRun("claude", "sprite", "echo test | bash")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject prompt with rm -rf chain", async () => {
await expect(cmdRun("claude", "sprite", "fix bugs; rm -rf /")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject empty prompt", async () => {
await expect(cmdRun("claude", "sprite", "")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject prompt exceeding 10KB", async () => {
const largePrompt = "a".repeat(10 * 1024 + 1);
await expect(cmdRun("claude", "sprite", largePrompt)).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});
// ── cmdAgentInfo: error paths ─────────────────────────────────────────
describe("cmdAgentInfo - error paths", () => {
it("should exit with error for unknown agent", async () => {
await expect(cmdAgentInfo("nonexistent")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
const errorCalls = mockLogError.mock.calls.map((c: unknown[]) => c.join(" "));
expect(errorCalls.some((msg: string) => msg.includes("Unknown agent"))).toBe(true);
});
it("should reject agent with invalid identifier characters", async () => {
await expect(cmdAgentInfo("../hack")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject agent with uppercase letters", async () => {
await expect(cmdAgentInfo("Claude")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject empty agent name", async () => {
await expect(cmdAgentInfo("")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject whitespace-only agent name", async () => {
await expect(cmdAgentInfo(" ")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});
// ── cmdRun: empty input validation ────────────────────────────────────
describe("cmdRun - empty input handling", () => {
it("should reject empty cloud name", async () => {
await expect(cmdRun("claude", "")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject whitespace-only cloud name", async () => {
await expect(cmdRun("claude", " ")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject empty agent name", async () => {
await expect(cmdRun("", "sprite")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});
// ── cmdRun: valid input reaches script download ───────────────────────
describe("cmdRun - valid inputs proceed past validation", () => {
it("should pass validation for valid agent and cloud and attempt download", async () => {
// Mock fetch to simulate script download failure (not a valid script)
global.fetch = mock(async (url: string) => {
if (isString(url) && url.includes("manifest.json")) {
return new Response(JSON.stringify(mockManifest));
}
// Script download returns non-script content
return new Response("not a valid script");
});
// Force refresh manifest with updated fetch
await loadManifest(true);
// cmdRun should pass validation and attempt to download + run the script.
// It will fail at validateScriptContent because "not a valid script" lacks shebang.
try {
await cmdRun("claude", "sprite");
} catch {
// Expected - either process.exit from validateScriptContent or Error thrown
}
// The log.step should have been called with the launch message
// (meaning validation passed and it attempted to download)
const stepCalls = mockLogStep.mock.calls.map((c: unknown[]) => c.join(" "));
expect(stepCalls.some((msg: string) => msg.includes("Claude Code") && msg.includes("Sprite"))).toBe(true);
});
it("should show prompt indicator when prompt is provided", async () => {
global.fetch = mock(async (url: string) => {
if (isString(url) && url.includes("manifest.json")) {
return new Response(JSON.stringify(mockManifest));
}
return new Response("not a valid script");
});
await loadManifest(true);
try {
await cmdRun("claude", "sprite", "Fix all bugs");
} catch {
// Expected
}
const stepCalls = mockLogStep.mock.calls.map((c: unknown[]) => c.join(" "));
expect(stepCalls.some((msg: string) => msg.includes("with prompt"))).toBe(true);
});
});
// ── cmdRun: batch validation (both errors at once) ──────────────────
describe("cmdRun - batch validation shows all errors at once", () => {
it("should show both unknown agent AND unknown cloud errors together", async () => {
await expect(cmdRun("badagent", "badcloud")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
const errorCalls = mockLogError.mock.calls.map((c: unknown[]) => c.join(" "));
const hasAgentError = errorCalls.some((msg: string) => msg.includes("Unknown agent"));
const hasCloudError = errorCalls.some((msg: string) => msg.includes("Unknown cloud"));
// Both errors should be reported, not just the first one
expect(hasAgentError).toBe(true);
expect(hasCloudError).toBe(true);
});
it("should show agent error and cloud-is-actually-agent error together", async () => {
// "spawn badagent codex" - badagent is unknown, codex is an agent not a cloud
await expect(cmdRun("badagent", "codex")).rejects.toThrow("process.exit");
const errorCalls = mockLogError.mock.calls.map((c: unknown[]) => c.join(" "));
const hasAgentError = errorCalls.some((msg: string) => msg.includes("Unknown agent"));
const hasCloudError = errorCalls.some((msg: string) => msg.includes("Unknown cloud"));
expect(hasAgentError).toBe(true);
expect(hasCloudError).toBe(true);
});
it("should only call process.exit once even with multiple errors", async () => {
try {
await cmdRun("badagent", "badcloud");
} catch {
// Expected
}
// process.exit should be called exactly once (not twice, once per error)
expect(processExitSpy).toHaveBeenCalledTimes(1);
});
});
// ── cmdRun: two agents or two clouds ────────────────────────────────
describe("cmdRun - mismatched argument types", () => {
it("should tell user when cloud arg is actually an agent", async () => {
// "spawn claude codex" - both are agents, not cloud
await expect(cmdRun("claude", "codex")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" "));
expect(infoCalls.some((msg: string) => msg.includes('"codex" is an agent'))).toBe(true);
expect(infoCalls.some((msg: string) => msg.includes("spawn <agent> <cloud>"))).toBe(true);
});
it("should tell user when agent arg is actually a cloud (not swappable)", async () => {
// "spawn hetzner sprite" - both are clouds, swap detection won't fire
// because sprite is not an agent
await expect(cmdRun("hetzner", "sprite")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
const infoCalls = mockLogInfo.mock.calls.map((c: unknown[]) => c.join(" "));
expect(infoCalls.some((msg: string) => msg.includes('"hetzner" is a cloud provider'))).toBe(true);
expect(infoCalls.some((msg: string) => msg.includes("spawn <agent> <cloud>"))).toBe(true);
});
});
});