mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-10 04:09:40 +00:00
test: Add 49 tests for command utility functions (commands-utils.test.ts) (#372)
Export and test 7 previously-unexported utility functions from commands.ts: getTerminalWidth, getMissingClouds, getImplementedAgents, getImplementedClouds, getErrorMessage, calculateColumnWidth, getStatusDescription. Agent: test-engineer Co-authored-by: A <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b04ff38c70
commit
cff08eb624
2 changed files with 450 additions and 7 deletions
443
cli/src/__tests__/commands-utils.test.ts
Normal file
443
cli/src/__tests__/commands-utils.test.ts
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
|
||||
import { createMockManifest, createConsoleMocks, restoreMocks } from "./test-helpers";
|
||||
import type { Manifest } from "../manifest";
|
||||
|
||||
/**
|
||||
* Tests for utility functions exported from commands.ts.
|
||||
*
|
||||
* These are pure/near-pure functions that were previously not exported and
|
||||
* had zero direct test coverage:
|
||||
* - getTerminalWidth() - terminal width detection with fallback
|
||||
* - getMissingClouds() - filter missing implementations for an agent
|
||||
* - getImplementedAgents() - filter implemented agents for a cloud
|
||||
* - getImplementedClouds() - filter implemented clouds for an agent
|
||||
* - getErrorMessage() - duck-typed error message extraction
|
||||
* - calculateColumnWidth() - column width calculation with padding
|
||||
* - getStatusDescription() - HTTP status to human-readable string
|
||||
*
|
||||
* Agent: test-engineer
|
||||
*/
|
||||
|
||||
// Mock @clack/prompts before importing commands
|
||||
mock.module("@clack/prompts", () => ({
|
||||
spinner: () => ({
|
||||
start: mock(() => {}),
|
||||
stop: mock(() => {}),
|
||||
message: mock(() => {}),
|
||||
}),
|
||||
log: {
|
||||
step: mock(() => {}),
|
||||
info: mock(() => {}),
|
||||
error: mock(() => {}),
|
||||
warn: mock(() => {}),
|
||||
success: mock(() => {}),
|
||||
},
|
||||
intro: mock(() => {}),
|
||||
outro: mock(() => {}),
|
||||
cancel: mock(() => {}),
|
||||
select: mock(() => {}),
|
||||
isCancel: () => false,
|
||||
}));
|
||||
|
||||
const {
|
||||
getTerminalWidth,
|
||||
getMissingClouds,
|
||||
getImplementedAgents,
|
||||
getImplementedClouds,
|
||||
getErrorMessage,
|
||||
calculateColumnWidth,
|
||||
getStatusDescription,
|
||||
} = await import("../commands.js");
|
||||
|
||||
const mockManifest = createMockManifest();
|
||||
|
||||
// Extended manifest with more clouds/agents for thorough testing
|
||||
const extendedManifest: Manifest = {
|
||||
agents: {
|
||||
claude: {
|
||||
name: "Claude Code",
|
||||
description: "AI coding assistant",
|
||||
url: "https://claude.ai",
|
||||
install: "npm install -g claude",
|
||||
launch: "claude",
|
||||
env: { ANTHROPIC_API_KEY: "test" },
|
||||
},
|
||||
aider: {
|
||||
name: "Aider",
|
||||
description: "AI pair programmer",
|
||||
url: "https://aider.chat",
|
||||
install: "pip install aider-chat",
|
||||
launch: "aider",
|
||||
env: { OPENAI_API_KEY: "test" },
|
||||
},
|
||||
codex: {
|
||||
name: "Codex",
|
||||
description: "OpenAI CLI agent",
|
||||
url: "https://openai.com",
|
||||
install: "npm install -g codex",
|
||||
launch: "codex",
|
||||
env: { OPENAI_API_KEY: "test" },
|
||||
},
|
||||
},
|
||||
clouds: {
|
||||
sprite: {
|
||||
name: "Sprite",
|
||||
description: "Lightweight VMs",
|
||||
url: "https://sprite.sh",
|
||||
type: "vm",
|
||||
auth: "token",
|
||||
provision_method: "api",
|
||||
exec_method: "ssh",
|
||||
interactive_method: "ssh",
|
||||
},
|
||||
hetzner: {
|
||||
name: "Hetzner Cloud",
|
||||
description: "European cloud provider",
|
||||
url: "https://hetzner.com",
|
||||
type: "cloud",
|
||||
auth: "token",
|
||||
provision_method: "api",
|
||||
exec_method: "ssh",
|
||||
interactive_method: "ssh",
|
||||
},
|
||||
vultr: {
|
||||
name: "Vultr",
|
||||
description: "Cloud compute",
|
||||
url: "https://vultr.com",
|
||||
type: "cloud",
|
||||
auth: "token",
|
||||
provision_method: "api",
|
||||
exec_method: "ssh",
|
||||
interactive_method: "ssh",
|
||||
},
|
||||
},
|
||||
matrix: {
|
||||
"sprite/claude": "implemented",
|
||||
"sprite/aider": "implemented",
|
||||
"sprite/codex": "missing",
|
||||
"hetzner/claude": "implemented",
|
||||
"hetzner/aider": "missing",
|
||||
"hetzner/codex": "missing",
|
||||
"vultr/claude": "implemented",
|
||||
"vultr/aider": "missing",
|
||||
"vultr/codex": "implemented",
|
||||
},
|
||||
};
|
||||
|
||||
// All-missing manifest
|
||||
const allMissingManifest: Manifest = {
|
||||
...mockManifest,
|
||||
matrix: {
|
||||
"sprite/claude": "missing",
|
||||
"sprite/aider": "missing",
|
||||
"hetzner/claude": "missing",
|
||||
"hetzner/aider": "missing",
|
||||
},
|
||||
};
|
||||
|
||||
// All-implemented manifest
|
||||
const allImplementedManifest: Manifest = {
|
||||
...mockManifest,
|
||||
matrix: {
|
||||
"sprite/claude": "implemented",
|
||||
"sprite/aider": "implemented",
|
||||
"hetzner/claude": "implemented",
|
||||
"hetzner/aider": "implemented",
|
||||
},
|
||||
};
|
||||
|
||||
describe("Command Utility Functions", () => {
|
||||
// ── getTerminalWidth ──────────────────────────────────────────────
|
||||
|
||||
describe("getTerminalWidth", () => {
|
||||
let originalColumns: number | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalColumns = process.stdout.columns;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stdout.columns = originalColumns!;
|
||||
});
|
||||
|
||||
it("should return process.stdout.columns when defined", () => {
|
||||
process.stdout.columns = 120;
|
||||
expect(getTerminalWidth()).toBe(120);
|
||||
});
|
||||
|
||||
it("should return 80 when process.stdout.columns is undefined", () => {
|
||||
(process.stdout as any).columns = undefined;
|
||||
expect(getTerminalWidth()).toBe(80);
|
||||
});
|
||||
|
||||
it("should return 80 when process.stdout.columns is 0", () => {
|
||||
(process.stdout as any).columns = 0;
|
||||
expect(getTerminalWidth()).toBe(80);
|
||||
});
|
||||
|
||||
it("should return the exact column count for narrow terminals", () => {
|
||||
process.stdout.columns = 40;
|
||||
expect(getTerminalWidth()).toBe(40);
|
||||
});
|
||||
|
||||
it("should return the exact column count for very wide terminals", () => {
|
||||
process.stdout.columns = 300;
|
||||
expect(getTerminalWidth()).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getMissingClouds ──────────────────────────────────────────────
|
||||
|
||||
describe("getMissingClouds", () => {
|
||||
it("should return missing clouds for a partially implemented agent", () => {
|
||||
const clouds = Object.keys(mockManifest.clouds);
|
||||
const missing = getMissingClouds(mockManifest, "aider", clouds);
|
||||
expect(missing).toContain("hetzner");
|
||||
expect(missing).not.toContain("sprite");
|
||||
});
|
||||
|
||||
it("should return empty array for fully implemented agent", () => {
|
||||
const clouds = Object.keys(mockManifest.clouds);
|
||||
const missing = getMissingClouds(mockManifest, "claude", clouds);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return all clouds when agent has no implementations", () => {
|
||||
const clouds = Object.keys(allMissingManifest.clouds);
|
||||
const missing = getMissingClouds(allMissingManifest, "claude", clouds);
|
||||
expect(missing).toEqual(["sprite", "hetzner"]);
|
||||
});
|
||||
|
||||
it("should return empty array when clouds list is empty", () => {
|
||||
const missing = getMissingClouds(mockManifest, "claude", []);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle extended manifest with multiple missing clouds", () => {
|
||||
const clouds = Object.keys(extendedManifest.clouds);
|
||||
const missing = getMissingClouds(extendedManifest, "aider", clouds);
|
||||
expect(missing).toContain("hetzner");
|
||||
expect(missing).toContain("vultr");
|
||||
expect(missing).not.toContain("sprite");
|
||||
expect(missing).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should only filter from the provided clouds list", () => {
|
||||
// Pass only a subset of clouds
|
||||
const missing = getMissingClouds(extendedManifest, "aider", ["sprite"]);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getImplementedAgents ──────────────────────────────────────────
|
||||
|
||||
describe("getImplementedAgents", () => {
|
||||
it("should return all agents for a cloud where all are implemented", () => {
|
||||
const agents = getImplementedAgents(mockManifest, "sprite");
|
||||
expect(agents).toContain("claude");
|
||||
expect(agents).toContain("aider");
|
||||
expect(agents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should return only implemented agents for partially implemented cloud", () => {
|
||||
const agents = getImplementedAgents(mockManifest, "hetzner");
|
||||
expect(agents).toContain("claude");
|
||||
expect(agents).not.toContain("aider");
|
||||
expect(agents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should return empty array when cloud has no implementations", () => {
|
||||
const agents = getImplementedAgents(allMissingManifest, "sprite");
|
||||
expect(agents).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for nonexistent cloud", () => {
|
||||
const agents = getImplementedAgents(mockManifest, "nonexistent");
|
||||
expect(agents).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle extended manifest correctly", () => {
|
||||
const agents = getImplementedAgents(extendedManifest, "vultr");
|
||||
expect(agents).toContain("claude");
|
||||
expect(agents).toContain("codex");
|
||||
expect(agents).not.toContain("aider");
|
||||
expect(agents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should return all agents when all are implemented", () => {
|
||||
const agents = getImplementedAgents(allImplementedManifest, "sprite");
|
||||
expect(agents).toContain("claude");
|
||||
expect(agents).toContain("aider");
|
||||
expect(agents).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getImplementedClouds ──────────────────────────────────────────
|
||||
|
||||
describe("getImplementedClouds", () => {
|
||||
it("should return all clouds for a fully implemented agent", () => {
|
||||
const clouds = getImplementedClouds(mockManifest, "claude");
|
||||
expect(clouds).toContain("sprite");
|
||||
expect(clouds).toContain("hetzner");
|
||||
expect(clouds).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should return only implemented clouds for partially implemented agent", () => {
|
||||
const clouds = getImplementedClouds(mockManifest, "aider");
|
||||
expect(clouds).toContain("sprite");
|
||||
expect(clouds).not.toContain("hetzner");
|
||||
expect(clouds).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should return empty array when agent has no implementations", () => {
|
||||
const clouds = getImplementedClouds(allMissingManifest, "claude");
|
||||
expect(clouds).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for nonexistent agent", () => {
|
||||
const clouds = getImplementedClouds(mockManifest, "nonexistent");
|
||||
expect(clouds).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle extended manifest with three clouds", () => {
|
||||
const clouds = getImplementedClouds(extendedManifest, "claude");
|
||||
expect(clouds).toContain("sprite");
|
||||
expect(clouds).toContain("hetzner");
|
||||
expect(clouds).toContain("vultr");
|
||||
expect(clouds).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should handle agent with sparse implementations", () => {
|
||||
const clouds = getImplementedClouds(extendedManifest, "codex");
|
||||
expect(clouds).toContain("vultr");
|
||||
expect(clouds).not.toContain("sprite");
|
||||
expect(clouds).not.toContain("hetzner");
|
||||
expect(clouds).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getErrorMessage ───────────────────────────────────────────────
|
||||
|
||||
describe("getErrorMessage", () => {
|
||||
it("should extract message from Error objects", () => {
|
||||
expect(getErrorMessage(new Error("test error"))).toBe("test error");
|
||||
});
|
||||
|
||||
it("should extract message from Error subclasses", () => {
|
||||
expect(getErrorMessage(new TypeError("type error"))).toBe("type error");
|
||||
expect(getErrorMessage(new RangeError("range error"))).toBe("range error");
|
||||
});
|
||||
|
||||
it("should handle objects with message property (duck typing)", () => {
|
||||
expect(getErrorMessage({ message: "custom error" })).toBe("custom error");
|
||||
});
|
||||
|
||||
it("should handle objects with numeric message", () => {
|
||||
expect(getErrorMessage({ message: 42 })).toBe("42");
|
||||
});
|
||||
|
||||
it("should stringify string values", () => {
|
||||
expect(getErrorMessage("string error")).toBe("string error");
|
||||
});
|
||||
|
||||
it("should stringify numbers", () => {
|
||||
expect(getErrorMessage(42)).toBe("42");
|
||||
});
|
||||
|
||||
it("should stringify null", () => {
|
||||
expect(getErrorMessage(null)).toBe("null");
|
||||
});
|
||||
|
||||
it("should stringify undefined", () => {
|
||||
expect(getErrorMessage(undefined)).toBe("undefined");
|
||||
});
|
||||
|
||||
it("should stringify boolean", () => {
|
||||
expect(getErrorMessage(false)).toBe("false");
|
||||
});
|
||||
|
||||
it("should handle empty Error message", () => {
|
||||
expect(getErrorMessage(new Error(""))).toBe("");
|
||||
});
|
||||
|
||||
it("should handle object without message property", () => {
|
||||
const result = getErrorMessage({ code: "ERR" });
|
||||
expect(result).toBe("[object Object]");
|
||||
});
|
||||
});
|
||||
|
||||
// ── calculateColumnWidth ──────────────────────────────────────────
|
||||
|
||||
describe("calculateColumnWidth", () => {
|
||||
it("should respect minimum width when items are short", () => {
|
||||
expect(calculateColumnWidth(["a", "b"], 15)).toBe(15);
|
||||
});
|
||||
|
||||
it("should expand for items longer than minimum", () => {
|
||||
// "Hello World" (11 chars) + COL_PADDING (2) = 13
|
||||
expect(calculateColumnWidth(["Hello World"], 10)).toBe(13);
|
||||
});
|
||||
|
||||
it("should use the longest item to determine width", () => {
|
||||
// "very long name" (14 chars) + padding (2) = 16
|
||||
expect(calculateColumnWidth(["short", "very long name"], 10)).toBe(16);
|
||||
});
|
||||
|
||||
it("should return minimum width for empty array", () => {
|
||||
expect(calculateColumnWidth([], 20)).toBe(20);
|
||||
});
|
||||
|
||||
it("should handle single-character items", () => {
|
||||
// "a" (1) + padding (2) = 3, but min is 10
|
||||
expect(calculateColumnWidth(["a"], 10)).toBe(10);
|
||||
});
|
||||
|
||||
it("should handle very long items", () => {
|
||||
const longItem = "A".repeat(100);
|
||||
// 100 + 2 = 102
|
||||
expect(calculateColumnWidth([longItem], 10)).toBe(102);
|
||||
});
|
||||
|
||||
it("should handle items exactly at minimum width", () => {
|
||||
// Item of length 8 + padding 2 = 10, which equals minimum
|
||||
expect(calculateColumnWidth(["12345678"], 10)).toBe(10);
|
||||
});
|
||||
|
||||
it("should handle items one character over minimum", () => {
|
||||
// Item of length 9 + padding 2 = 11, exceeds minimum of 10
|
||||
expect(calculateColumnWidth(["123456789"], 10)).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getStatusDescription ──────────────────────────────────────────
|
||||
|
||||
describe("getStatusDescription", () => {
|
||||
it("should return 'not found' for 404", () => {
|
||||
expect(getStatusDescription(404)).toBe("not found");
|
||||
});
|
||||
|
||||
it("should return HTTP code string for 200", () => {
|
||||
expect(getStatusDescription(200)).toBe("HTTP 200");
|
||||
});
|
||||
|
||||
it("should return HTTP code string for 500", () => {
|
||||
expect(getStatusDescription(500)).toBe("HTTP 500");
|
||||
});
|
||||
|
||||
it("should return HTTP code string for 403", () => {
|
||||
expect(getStatusDescription(403)).toBe("HTTP 403");
|
||||
});
|
||||
|
||||
it("should return HTTP code string for 401", () => {
|
||||
expect(getStatusDescription(401)).toBe("HTTP 401");
|
||||
});
|
||||
|
||||
it("should return HTTP code string for 502", () => {
|
||||
expect(getStatusDescription(502)).toBe("HTTP 502");
|
||||
});
|
||||
|
||||
it("should return HTTP code string for 503", () => {
|
||||
expect(getStatusDescription(503)).toBe("HTTP 503");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -20,7 +20,7 @@ import { validateIdentifier, validateScriptContent, validatePrompt } from "./sec
|
|||
|
||||
const FETCH_TIMEOUT = 10_000; // 10 seconds
|
||||
|
||||
function getErrorMessage(err: unknown): string {
|
||||
export function getErrorMessage(err: unknown): string {
|
||||
// Use duck typing instead of instanceof to avoid prototype chain issues
|
||||
return err && typeof err === "object" && "message" in err ? String(err.message) : String(err);
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ function mapToSelectOptions<T extends { name: string; description: string }>(
|
|||
}));
|
||||
}
|
||||
|
||||
function getImplementedClouds(manifest: Manifest, agent: string): string[] {
|
||||
export function getImplementedClouds(manifest: Manifest, agent: string): string[] {
|
||||
return cloudKeys(manifest).filter(
|
||||
(c: string): boolean => matrixStatus(manifest, c, agent) === "implemented"
|
||||
);
|
||||
|
|
@ -317,7 +317,7 @@ export async function cmdRun(agent: string, cloud: string, prompt?: string): Pro
|
|||
await execScript(cloud, agent, prompt);
|
||||
}
|
||||
|
||||
function getStatusDescription(status: number): string {
|
||||
export function getStatusDescription(status: number): string {
|
||||
return status === 404 ? "not found" : `HTTP ${status}`;
|
||||
}
|
||||
|
||||
|
|
@ -439,11 +439,11 @@ const NAME_COLUMN_WIDTH = 18;
|
|||
const COMPACT_NAME_WIDTH = 20;
|
||||
const COMPACT_COUNT_WIDTH = 10;
|
||||
|
||||
function getTerminalWidth(): number {
|
||||
export function getTerminalWidth(): number {
|
||||
return process.stdout.columns || 80;
|
||||
}
|
||||
|
||||
function calculateColumnWidth(items: string[], minWidth: number): number {
|
||||
export function calculateColumnWidth(items: string[], minWidth: number): number {
|
||||
let maxWidth = minWidth;
|
||||
for (const item of items) {
|
||||
const width = item.length + COL_PADDING;
|
||||
|
|
@ -481,7 +481,7 @@ function renderMatrixRow(agent: string, clouds: string[], manifest: Manifest, ag
|
|||
return row;
|
||||
}
|
||||
|
||||
function getMissingClouds(manifest: Manifest, agent: string, clouds: string[]): string[] {
|
||||
export function getMissingClouds(manifest: Manifest, agent: string, clouds: string[]): string[] {
|
||||
return clouds.filter((c) => matrixStatus(manifest, c, agent) !== "implemented");
|
||||
}
|
||||
|
||||
|
|
@ -557,7 +557,7 @@ export async function cmdList(): Promise<void> {
|
|||
|
||||
// ── Agents ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getImplementedAgents(manifest: Manifest, cloud: string): string[] {
|
||||
export function getImplementedAgents(manifest: Manifest, cloud: string): string[] {
|
||||
return agentKeys(manifest).filter(
|
||||
(a: string): boolean => matrixStatus(manifest, cloud, a) === "implemented"
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue