spawn/packages/cli/src/__tests__/script-failure-guidance.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

578 lines
24 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import {
getScriptFailureGuidance as _getScriptFailureGuidance,
getSignalGuidance as _getSignalGuidance,
buildRetryCommand,
} from "../commands";
/** Strip ANSI escape codes from a string so assertions work regardless of color support. */
function stripAnsi(s: string): string {
return s.replace(/\x1b\[[0-9;]*m/g, "");
}
/** Wrapper that strips ANSI codes from all returned lines. */
function getScriptFailureGuidance(...args: Parameters<typeof _getScriptFailureGuidance>): string[] {
return _getScriptFailureGuidance(...args).map(stripAnsi);
}
/** Wrapper that strips ANSI codes from all returned lines. */
function getSignalGuidance(...args: Parameters<typeof _getSignalGuidance>): string[] {
return _getSignalGuidance(...args).map(stripAnsi);
}
/**
* Tests for getScriptFailureGuidance() in commands/run.ts.
*
* This function maps exit codes from failed spawn scripts to user-facing
* guidance strings. It was recently modified (PRs #450, #449) but has
* zero direct test coverage.
*/
describe("getScriptFailureGuidance", () => {
// ── Exit code 127: command not found ──────────────────────────────────────
describe("exit code 127 (command not found)", () => {
it("should return guidance about missing commands with required tools and cloud name", () => {
const lines = getScriptFailureGuidance(127, "hetzner");
const joined = lines.join("\n");
expect(lines[0]).toContain("command was not found");
expect(joined).toContain("bash");
expect(joined).toContain("curl");
expect(joined).toContain("ssh");
expect(joined).toContain("jq");
expect(joined).toContain("spawn hetzner");
});
it("should embed a different cloud name when provided", () => {
const lines = getScriptFailureGuidance(127, "vultr");
const joined = lines.join("\n");
expect(joined).toContain("spawn vultr");
expect(joined).not.toContain("spawn hetzner");
});
it("should return exactly 3 guidance lines", () => {
const lines = getScriptFailureGuidance(127, "sprite");
expect(lines).toHaveLength(3);
});
});
// ── Exit code 126: permission denied ──────────────────────────────────────
describe("exit code 126 (permission denied)", () => {
it("should mention permission denied, causes, issue link, and return 4 lines", () => {
const lines = getScriptFailureGuidance(126, "sprite");
const joined = lines.join("\n");
expect(joined).toContain("permission denied");
expect(joined).toContain("could not be executed");
expect(joined).toContain("execute permissions");
expect(joined).toContain("root/sudo");
expect(joined).toContain("github.com");
expect(joined).toContain("issues");
expect(lines).toHaveLength(4);
});
});
// ── Exit code 1: generic failure ──────────────────────────────────────────
describe("exit code 1 (generic failure)", () => {
it("should start with Common causes, mention credentials, and reference cloud name", () => {
const lines = getScriptFailureGuidance(1, "digital-ocean");
const joined = lines.join("\n");
expect(lines[0]).toBe("Common causes:");
expect(joined).toContain("credentials");
expect(joined).toContain("spawn digital-ocean");
});
it("should mention API error causes, provisioning failure, and return at least 4 lines", () => {
const lines = getScriptFailureGuidance(1, "sprite");
const joined = lines.join("\n");
expect(joined).toContain("API error");
expect(joined).toContain("quota");
expect(joined).toContain("provisioning failed");
expect(lines.length).toBeGreaterThanOrEqual(4);
});
});
// ── Default case: unknown/other exit codes ────────────────────────────────
describe("default case (unknown exit codes)", () => {
it("should return common causes with credentials, rate limits, dependencies, and cloud name", () => {
const lines = getScriptFailureGuidance(42, "linode");
const joined = lines.join("\n");
expect(lines[0]).toBe("Common causes:");
expect(joined).toContain("credentials");
expect(joined).toContain("rate limit");
expect(joined).toContain("quota");
expect(joined).toContain("SSH");
expect(joined).toContain("curl");
expect(joined).toContain("jq");
expect(joined).toContain("spawn linode");
expect(lines.length).toBeGreaterThanOrEqual(4);
});
});
// ── null exit code (no exit code extracted) ───────────────────────────────
describe("null exit code", () => {
it("should fall through to default case with credentials and cloud name", () => {
const lines = getScriptFailureGuidance(null, "sprite");
const joined = lines.join("\n");
expect(lines[0]).toBe("Common causes:");
expect(joined).toContain("credentials");
expect(joined).toContain("spawn sprite");
expect(lines.length).toBeGreaterThanOrEqual(4);
});
});
// ── Exit code 130: user interrupt (Ctrl+C) ────────────────────────────────
describe("exit code 130 (user interrupt)", () => {
it("should mention Ctrl+C, interruption, orphaned server warning, and return 3 lines", () => {
const lines = getScriptFailureGuidance(130, "sprite");
const joined = lines.join("\n");
expect(joined).toContain("Ctrl+C");
expect(joined).toContain("interrupted");
expect(joined).toContain("may still be running");
expect(joined).toContain("cloud provider dashboard");
expect(lines).toHaveLength(3);
});
});
// ── Exit code 137: killed (OOM / timeout) ─────────────────────────────────
describe("exit code 137 (killed)", () => {
it("should mention killed, timeout/OOM, larger instance suggestion, and return 4 lines", () => {
const lines = getScriptFailureGuidance(137, "sprite");
const joined = lines.join("\n");
expect(joined).toContain("killed");
expect(joined).toContain("timeout");
expect(joined).toContain("out of memory");
expect(joined).toContain("larger instance size");
expect(joined).toContain("cloud provider dashboard");
expect(lines).toHaveLength(4);
});
});
// ── Exit code 255: SSH connection failed ───────────────────────────────────
describe("exit code 255 (SSH failure)", () => {
it("should mention SSH failure, booting, firewall, termination, and return 4 lines", () => {
const lines = getScriptFailureGuidance(255, "sprite");
const joined = lines.join("\n");
expect(joined).toContain("SSH connection failed");
expect(joined).toContain("still booting");
expect(joined).toContain("Firewall");
expect(joined).toContain("SSH");
expect(joined).toContain("terminated");
expect(lines).toHaveLength(4);
});
});
// ── Exit code 2: shell syntax error ────────────────────────────────────────
describe("exit code 2 (shell syntax error)", () => {
it("should mention syntax error, bug report link, and return 2 lines", () => {
const lines = getScriptFailureGuidance(2, "sprite");
const joined = lines.join("\n");
expect(joined).toContain("Shell syntax or argument error");
expect(joined).toContain("bug in the script");
expect(joined).toContain("github.com");
expect(joined).toContain("issues");
expect(lines).toHaveLength(2);
});
});
// ── Auth hint parameter ──────────────────────────────────────────────────
describe("auth hint parameter", () => {
it("should show specific env var name and setup hint for exit code 1 when authHint is provided", () => {
const savedOR = process.env.OPENROUTER_API_KEY;
delete process.env.OPENROUTER_API_KEY;
try {
const lines = getScriptFailureGuidance(1, "hetzner", "HCLOUD_TOKEN");
const joined = lines.join("\n");
expect(joined).toContain("HCLOUD_TOKEN");
expect(joined).toContain("OPENROUTER_API_KEY");
expect(joined).toContain("spawn hetzner");
expect(joined).toContain("setup");
} finally {
if (savedOR !== undefined) {
process.env.OPENROUTER_API_KEY = savedOR;
}
}
});
it("should show generic setup hint for exit code 1 when no authHint", () => {
const lines = getScriptFailureGuidance(1, "hetzner");
const joined = lines.join("\n");
expect(joined).toContain("spawn hetzner");
expect(joined).not.toContain("HCLOUD_TOKEN");
});
it("should show specific env var name and setup hint for default case when authHint is provided", () => {
const savedOR = process.env.OPENROUTER_API_KEY;
delete process.env.OPENROUTER_API_KEY;
try {
const lines = getScriptFailureGuidance(42, "digitalocean", "DO_API_TOKEN");
const joined = lines.join("\n");
expect(joined).toContain("DO_API_TOKEN");
expect(joined).toContain("OPENROUTER_API_KEY");
expect(joined).toContain("spawn digitalocean");
expect(joined).toContain("setup");
} finally {
if (savedOR !== undefined) {
process.env.OPENROUTER_API_KEY = savedOR;
}
}
});
it("should show generic setup hint for default case when no authHint", () => {
const lines = getScriptFailureGuidance(42, "digitalocean");
const joined = lines.join("\n");
expect(joined).toContain("spawn digitalocean");
expect(joined).not.toContain("DO_API_TOKEN");
});
it("should handle multi-credential auth hint", () => {
const lines = getScriptFailureGuidance(1, "contabo", "CONTABO_CLIENT_ID + CONTABO_CLIENT_SECRET");
const joined = lines.join("\n");
// Each credential var should be listed individually
expect(joined).toContain("CONTABO_CLIENT_ID");
expect(joined).toContain("CONTABO_CLIENT_SECRET");
});
it("should not affect non-credential exit codes (130, 137, etc.)", () => {
const lines130 = getScriptFailureGuidance(130, "hetzner", "HCLOUD_TOKEN");
const joined130 = lines130.join("\n");
expect(joined130).not.toContain("HCLOUD_TOKEN");
expect(joined130).toContain("Ctrl+C");
const lines255 = getScriptFailureGuidance(255, "hetzner", "HCLOUD_TOKEN");
const joined255 = lines255.join("\n");
expect(joined255).not.toContain("HCLOUD_TOKEN");
expect(joined255).toContain("SSH");
});
it("should include setup instruction line for exit code 1 with authHint", () => {
const lines = getScriptFailureGuidance(1, "hetzner", "HCLOUD_TOKEN");
expect(lines.length).toBeGreaterThanOrEqual(5);
const joined = lines.join("\n");
expect(joined).toContain("spawn hetzner");
expect(joined).toContain("setup");
});
it("should include setup instruction line for default case with authHint", () => {
const lines = getScriptFailureGuidance(42, "hetzner", "HCLOUD_TOKEN");
expect(lines.length).toBeGreaterThanOrEqual(5);
const joined = lines.join("\n");
expect(joined).toContain("spawn hetzner");
expect(joined).toContain("setup");
});
});
// ── Edge cases ────────────────────────────────────────────────────────────
describe("edge cases", () => {
it("should handle exit code 0 as default case", () => {
const lines = getScriptFailureGuidance(0, "sprite");
expect(lines[0]).toBe("Common causes:");
});
it("should handle negative exit code as default case", () => {
const lines = getScriptFailureGuidance(-1, "hetzner");
expect(lines[0]).toBe("Common causes:");
});
it("should handle empty cloud name", () => {
const lines = getScriptFailureGuidance(127, "");
const joined = lines.join("\n");
expect(joined).toContain("spawn ");
});
it("should handle cloud name with special characters", () => {
const lines = getScriptFailureGuidance(1, "digital-ocean");
const joined = lines.join("\n");
expect(joined).toContain("spawn digital-ocean");
});
});
// ── Return type and structure ─────────────────────────────────────────────
describe("return type and structure", () => {
it("should produce different output for each handled exit code", () => {
const result130 = getScriptFailureGuidance(130, "sprite");
const result137 = getScriptFailureGuidance(137, "sprite");
const result255 = getScriptFailureGuidance(255, "sprite");
const result127 = getScriptFailureGuidance(127, "sprite");
const result126 = getScriptFailureGuidance(126, "sprite");
const result2 = getScriptFailureGuidance(2, "sprite");
const result1 = getScriptFailureGuidance(1, "sprite");
const resultDefault = getScriptFailureGuidance(42, "sprite");
const all = [
result130,
result137,
result255,
result127,
result126,
result2,
result1,
resultDefault,
];
// Every handled exit code should produce unique output
for (let i = 0; i < all.length; i++) {
for (let j = i + 1; j < all.length; j++) {
expect(all[i].join("\n")).not.toBe(all[j].join("\n"));
}
}
});
});
});
describe("getSignalGuidance", () => {
describe("SIGKILL", () => {
it("should mention OOM killer, larger instance size, and cloud provider dashboard", () => {
const lines = getSignalGuidance("SIGKILL");
const joined = lines.join("\n");
expect(joined).toContain("SIGKILL");
expect(joined).toContain("Out of memory");
expect(joined).toContain("larger instance size");
expect(joined).toContain("cloud provider dashboard");
});
});
describe("SIGTERM", () => {
it("should mention process was terminated and server shutdown", () => {
const lines = getSignalGuidance("SIGTERM");
const joined = lines.join("\n");
expect(joined).toContain("terminated");
expect(joined).toContain("SIGTERM");
expect(joined).toContain("shutdown");
});
});
describe("SIGINT", () => {
it("should mention Ctrl+C and warn about orphaned servers", () => {
const lines = getSignalGuidance("SIGINT");
const joined = lines.join("\n");
expect(joined).toContain("Ctrl+C");
expect(joined).toContain("cloud provider dashboard");
});
});
describe("SIGHUP", () => {
it("should mention terminal disconnection and suggest tmux/screen", () => {
const lines = getSignalGuidance("SIGHUP");
const joined = lines.join("\n");
expect(joined).toContain("terminal connection");
expect(joined).toContain("SIGHUP");
expect(joined).toContain("tmux");
});
});
describe("unknown signal", () => {
it("should show the signal name for unknown signals", () => {
const lines = getSignalGuidance("SIGUSR1");
const joined = lines.join("\n");
expect(joined).toContain("SIGUSR1");
});
it("should always return a non-empty array", () => {
const lines = getSignalGuidance("SIGFOO");
expect(lines.length).toBeGreaterThan(0);
});
});
describe("signal output uniqueness", () => {
it("should produce different output for each handled signal", () => {
const sigkill = getSignalGuidance("SIGKILL").join("\n");
const sigterm = getSignalGuidance("SIGTERM").join("\n");
const sigint = getSignalGuidance("SIGINT").join("\n");
const sighup = getSignalGuidance("SIGHUP").join("\n");
expect(sigkill).not.toBe(sigterm);
expect(sigterm).not.toBe(sigint);
expect(sigint).not.toBe(sighup);
});
});
});
describe("buildRetryCommand", () => {
it("should return simple command without prompt", () => {
expect(buildRetryCommand("claude", "sprite")).toBe("spawn claude sprite");
});
it("should include --prompt when prompt is provided", () => {
expect(buildRetryCommand("claude", "sprite", "Fix all bugs")).toBe('spawn claude sprite --prompt "Fix all bugs"');
});
it("should suggest --prompt-file for long prompts instead of truncating", () => {
const longPrompt = "A".repeat(100);
const result = buildRetryCommand("claude", "sprite", longPrompt);
expect(result).toBe("spawn claude sprite --prompt-file <your-prompt-file>");
expect(result).not.toContain("A"); // no truncated prompt content
});
it("should include full prompt at exactly 80 characters", () => {
const exactPrompt = "B".repeat(80);
const result = buildRetryCommand("codex", "hetzner", exactPrompt);
expect(result).toBe(`spawn codex hetzner --prompt "${exactPrompt}"`);
expect(result).not.toContain("prompt-file");
});
it("should suggest --prompt-file for prompts over 80 characters", () => {
const longPrompt = "C".repeat(81);
const result = buildRetryCommand("codex", "hetzner", longPrompt);
expect(result).toBe("spawn codex hetzner --prompt-file <your-prompt-file>");
});
it("should escape double quotes in prompt", () => {
const result = buildRetryCommand("claude", "sprite", 'Fix "all" bugs');
expect(result).toBe('spawn claude sprite --prompt "Fix \\"all\\" bugs"');
});
it("should return simple command when prompt is undefined", () => {
expect(buildRetryCommand("codex", "vultr", undefined)).toBe("spawn codex vultr");
});
it("should return simple command when prompt is empty string", () => {
expect(buildRetryCommand("codex", "vultr", "")).toBe("spawn codex vultr");
});
// ── spawnName parameter (issue #1709) ────────────────────────────────────
it("should include --name flag when spawnName is provided without prompt", () => {
expect(buildRetryCommand("claude", "hetzner", undefined, "my-box")).toBe('spawn claude hetzner --name "my-box"');
});
it("should include --name flag when spawnName is provided with short prompt", () => {
expect(buildRetryCommand("claude", "hetzner", "Fix all bugs", "my-box")).toBe(
'spawn claude hetzner --name "my-box" --prompt "Fix all bugs"',
);
});
it("should include --name flag when spawnName is provided with long prompt", () => {
const longPrompt = "A".repeat(100);
const result = buildRetryCommand("claude", "hetzner", longPrompt, "my-box");
expect(result).toBe('spawn claude hetzner --name "my-box" --prompt-file <your-prompt-file>');
});
it("should not include --name flag when spawnName is undefined", () => {
expect(buildRetryCommand("claude", "hetzner", undefined, undefined)).toBe("spawn claude hetzner");
expect(buildRetryCommand("claude", "hetzner")).toBe("spawn claude hetzner");
});
it("should not include --name flag when spawnName is empty string", () => {
expect(buildRetryCommand("claude", "hetzner", undefined, "")).toBe("spawn claude hetzner");
});
it("should place --name before --prompt in the command", () => {
const result = buildRetryCommand("codex", "sprite", "short prompt", "dev-server");
expect(result).toBe('spawn codex sprite --name "dev-server" --prompt "short prompt"');
// Verify ordering: --name comes before --prompt
const nameIdx = result.indexOf("--name");
const promptIdx = result.indexOf("--prompt");
expect(nameIdx).toBeLessThan(promptIdx);
});
it("should quote --name value when it contains spaces", () => {
expect(buildRetryCommand("claude", "hetzner", undefined, "my dev box")).toBe(
'spawn claude hetzner --name "my dev box"',
);
});
it("should escape double quotes in --name value", () => {
expect(buildRetryCommand("claude", "hetzner", undefined, 'my "box"')).toBe(
'spawn claude hetzner --name "my \\"box\\""',
);
});
it("should always quote --name value to prevent shell injection", () => {
// Names with shell metacharacters should be safely quoted
const result = buildRetryCommand("claude", "hetzner", undefined, "foo; rm -rf");
expect(result).toBe('spawn claude hetzner --name "foo; rm -rf"');
});
});
describe("dashboard URL in guidance", () => {
describe("getScriptFailureGuidance with dashboardUrl", () => {
it("should include dashboard URL for exit code 1 when provided", () => {
const lines = getScriptFailureGuidance(1, "hetzner", undefined, "https://console.hetzner.cloud/");
const joined = lines.join("\n");
expect(joined).toContain("https://console.hetzner.cloud/");
expect(joined).toContain("dashboard");
});
it("should include dashboard URL for exit code 130 when provided", () => {
const lines = getScriptFailureGuidance(130, "sprite", undefined, "https://sprite.sh");
const joined = lines.join("\n");
expect(joined).toContain("https://sprite.sh");
expect(joined).toContain("dashboard");
});
it("should include dashboard URL for exit code 137 when provided", () => {
const lines = getScriptFailureGuidance(137, "vultr", undefined, "https://my.vultr.com/");
const joined = lines.join("\n");
expect(joined).toContain("https://my.vultr.com/");
});
it("should include dashboard URL for default exit code when provided", () => {
const lines = getScriptFailureGuidance(42, "digitalocean", undefined, "https://cloud.digitalocean.com/");
const joined = lines.join("\n");
expect(joined).toContain("https://cloud.digitalocean.com/");
});
it("should fall back to generic message when no dashboardUrl", () => {
const lines = getScriptFailureGuidance(130, "sprite");
const joined = lines.join("\n");
expect(joined).toContain("cloud provider dashboard");
expect(joined).not.toContain("https://");
});
it("should not add dashboard URL for exit codes 127, 126, 255, 2", () => {
for (const code of [
127,
126,
255,
2,
]) {
const lines = getScriptFailureGuidance(code, "hetzner", undefined, "https://console.hetzner.cloud/");
const joined = lines.join("\n");
expect(joined).not.toContain("https://console.hetzner.cloud/");
}
});
});
describe("getSignalGuidance with dashboardUrl", () => {
it("should include dashboard URL for SIGKILL when provided", () => {
const lines = getSignalGuidance("SIGKILL", "https://console.hetzner.cloud/");
const joined = lines.join("\n");
expect(joined).toContain("https://console.hetzner.cloud/");
expect(joined).toContain("dashboard");
});
it("should include dashboard URL for SIGTERM when provided", () => {
const lines = getSignalGuidance("SIGTERM", "https://my.vultr.com/");
const joined = lines.join("\n");
expect(joined).toContain("https://my.vultr.com/");
});
it("should include dashboard URL for SIGINT when provided", () => {
const lines = getSignalGuidance("SIGINT", "https://cloud.digitalocean.com/");
const joined = lines.join("\n");
expect(joined).toContain("https://cloud.digitalocean.com/");
});
it("should fall back to generic message when no dashboardUrl", () => {
const lines = getSignalGuidance("SIGKILL");
const joined = lines.join("\n");
expect(joined).toContain("cloud provider dashboard");
expect(joined).not.toContain("https://");
});
it("should not add dashboard URL for SIGHUP", () => {
const lines = getSignalGuidance("SIGHUP", "https://example.com");
const joined = lines.join("\n");
expect(joined).not.toContain("https://example.com");
});
});
});