test: Add cmdCloudInfo test coverage (23 tests) (#223)

cmdCloudInfo was the only major command function with zero test coverage.
Tests cover happy paths, cloud notes display, empty agents state, error
paths (invalid identifiers, unknown clouds), and typo suggestions.

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:
A 2026-02-10 12:33:51 -08:00 committed by GitHub
parent 116305f32c
commit 35b322b320
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -0,0 +1,290 @@
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { createMockManifest, createConsoleMocks, restoreMocks } from "./test-helpers";
import { loadManifest } from "../manifest";
import type { Manifest } from "../manifest";
/**
* Tests for cmdCloudInfo in commands.ts.
*
* cmdCloudInfo is the only major command function with zero test coverage.
* It handles "spawn <cloud>" to show available agents for a cloud provider.
*
* Covers:
* - Happy path: display cloud name, description, available agents
* - Cloud with notes field
* - Cloud with no implemented agents
* - Error paths: invalid identifier, unknown cloud, empty/whitespace name
* - Typo suggestion for unknown cloud names
*
* Agent: test-engineer
*/
const mockManifest = createMockManifest();
// Extended manifest with a cloud that has notes and a cloud with no agents
const extendedManifest: Manifest = {
agents: mockManifest.agents,
clouds: {
...mockManifest.clouds,
railway: {
name: "Railway",
description: "Container platform",
url: "https://railway.app",
type: "container",
auth: "token",
provision_method: "cli",
exec_method: "exec",
interactive_method: "exec",
notes: "Requires Railway CLI installed locally",
},
emptycloud: {
name: "Empty Cloud",
description: "No agents here",
url: "https://empty.example.com",
type: "cloud",
auth: "token",
provision_method: "api",
exec_method: "ssh",
interactive_method: "ssh",
},
},
matrix: {
...mockManifest.matrix,
"railway/claude": "implemented",
"railway/aider": "missing",
// emptycloud has no matrix entries at all
},
};
// Mock @clack/prompts
const mockLogError = mock(() => {});
const mockLogInfo = mock(() => {});
const mockLogStep = mock(() => {});
const mockSpinnerStart = mock(() => {});
const mockSpinnerStop = mock(() => {});
mock.module("@clack/prompts", () => ({
spinner: () => ({
start: mockSpinnerStart,
stop: mockSpinnerStop,
message: mock(() => {}),
}),
log: {
step: mockLogStep,
info: mockLogInfo,
warn: mock(() => {}),
error: mockLogError,
},
intro: mock(() => {}),
outro: mock(() => {}),
cancel: mock(() => {}),
select: mock(() => {}),
isCancel: () => false,
}));
// Import commands after mock setup
const { cmdCloudInfo } = await import("../commands.js");
describe("cmdCloudInfo", () => {
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();
mockSpinnerStart.mockClear();
mockSpinnerStop.mockClear();
processExitSpy = spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit");
}) as any);
originalFetch = global.fetch;
global.fetch = mock(async () => ({
ok: true,
json: async () => extendedManifest,
text: async () => JSON.stringify(extendedManifest),
})) as any;
await loadManifest(true);
});
afterEach(() => {
global.fetch = originalFetch;
processExitSpy.mockRestore();
restoreMocks(consoleMocks.log, consoleMocks.error);
});
// ── Happy path ──────────────────────────────────────────────────────────
describe("display output for valid cloud", () => {
it("should show cloud name and description for sprite", async () => {
await cmdCloudInfo("sprite");
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(output).toContain("Sprite");
expect(output).toContain("Lightweight VMs");
});
it("should show Available agents header", async () => {
await cmdCloudInfo("sprite");
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(output).toContain("Available agents");
});
it("should list implemented agents for sprite", async () => {
await cmdCloudInfo("sprite");
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(output).toContain("claude");
expect(output).toContain("aider");
});
it("should show launch command hint for each agent", async () => {
await cmdCloudInfo("sprite");
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(output).toContain("spawn claude sprite");
expect(output).toContain("spawn aider sprite");
});
it("should only show implemented agents for hetzner", async () => {
await cmdCloudInfo("hetzner");
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(output).toContain("claude");
// hetzner/aider is "missing" in mock manifest
expect(output).not.toContain("spawn aider hetzner");
});
it("should show hetzner name and description", async () => {
await cmdCloudInfo("hetzner");
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(output).toContain("Hetzner Cloud");
expect(output).toContain("European cloud provider");
});
it("should use spinner while loading manifest", async () => {
await cmdCloudInfo("sprite");
expect(mockSpinnerStart).toHaveBeenCalled();
expect(mockSpinnerStop).toHaveBeenCalled();
});
});
// ── Cloud with notes field ──────────────────────────────────────────────
describe("cloud with notes", () => {
it("should display notes when the cloud has them", async () => {
await cmdCloudInfo("railway");
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(output).toContain("Requires Railway CLI installed locally");
});
it("should show railway name and description", async () => {
await cmdCloudInfo("railway");
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(output).toContain("Railway");
expect(output).toContain("Container platform");
});
it("should show only implemented agents for railway", async () => {
await cmdCloudInfo("railway");
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(output).toContain("spawn claude railway");
expect(output).not.toContain("spawn aider railway");
});
});
// ── Cloud with no implemented agents ───────────────────────────────────
describe("cloud with no implemented agents", () => {
it("should show 'No implemented agents' message", async () => {
await cmdCloudInfo("emptycloud");
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(output).toContain("No implemented agents");
});
it("should still show cloud name and description", async () => {
await cmdCloudInfo("emptycloud");
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(output).toContain("Empty Cloud");
expect(output).toContain("No agents here");
});
it("should not show any spawn commands", async () => {
await cmdCloudInfo("emptycloud");
const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n");
expect(output).not.toContain("spawn claude emptycloud");
expect(output).not.toContain("spawn aider emptycloud");
});
});
// ── Error paths ─────────────────────────────────────────────────────────
describe("error paths", () => {
it("should exit with error for unknown cloud", async () => {
await expect(cmdCloudInfo("nonexistent")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
const errorCalls = mockLogError.mock.calls.map((c: any[]) => 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(cmdCloudInfo("nonexistent")).rejects.toThrow("process.exit");
const infoCalls = mockLogInfo.mock.calls.map((c: any[]) => c.join(" "));
expect(infoCalls.some((msg: string) => msg.includes("spawn clouds"))).toBe(true);
});
it("should reject cloud with invalid identifier characters", async () => {
await expect(cmdCloudInfo("../hack")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject cloud with uppercase letters", async () => {
await expect(cmdCloudInfo("Sprite")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject empty cloud name", async () => {
await expect(cmdCloudInfo("")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject whitespace-only cloud name", async () => {
await expect(cmdCloudInfo(" ")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject cloud name with shell metacharacters", async () => {
await expect(cmdCloudInfo("sprite;rm")).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
it("should reject cloud name exceeding 64 characters", async () => {
const longName = "a".repeat(65);
await expect(cmdCloudInfo(longName)).rejects.toThrow("process.exit");
expect(processExitSpy).toHaveBeenCalledWith(1);
});
});
// ── Typo suggestions ───────────────────────────────────────────────────
describe("typo suggestions", () => {
it("should suggest closest cloud name for typo", async () => {
// "sprit" is distance 1 from "sprite"
await expect(cmdCloudInfo("sprit")).rejects.toThrow("process.exit");
const infoCalls = mockLogInfo.mock.calls.map((c: any[]) => c.join(" "));
expect(infoCalls.some((msg: string) => msg.includes("Did you mean"))).toBe(true);
});
it("should not suggest when input is very different", async () => {
// "kubernetes" is far from any cloud name
await expect(cmdCloudInfo("kubernetes")).rejects.toThrow("process.exit");
const infoCalls = mockLogInfo.mock.calls.map((c: any[]) => c.join(" "));
expect(infoCalls.every((msg: string) => !msg.includes("Did you mean"))).toBe(true);
});
});
});