mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
* 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>
578 lines
24 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|