mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-21 02:21:15 +00:00
test: add 58 tests for untested internal helper functions in commands.ts (#559)
Cover groupByType, buildAgentLines, buildCloudLines, credentialHint, mapToSelectOptions, buildRecordLabel, buildRecordHint, and resolveDisplayName edge cases. Uses the established replica pattern since these functions are not exported. 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
fad2560a23
commit
050cdfdf21
1 changed files with 749 additions and 0 deletions
749
cli/src/__tests__/commands-internal-helpers.test.ts
Normal file
749
cli/src/__tests__/commands-internal-helpers.test.ts
Normal file
|
|
@ -0,0 +1,749 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
|
||||
import { createMockManifest } from "./test-helpers";
|
||||
import type { Manifest } from "../manifest";
|
||||
|
||||
/**
|
||||
* Tests for internal helper functions in commands.ts that have zero
|
||||
* direct test coverage.
|
||||
*
|
||||
* These functions are not exported, so we test exact replicas following
|
||||
* the established pattern in this codebase (see list-display.test.ts,
|
||||
* dispatch-extra-args.test.ts, etc.).
|
||||
*
|
||||
* Functions tested:
|
||||
* - groupByType: groups keys by a classifier function (commands.ts:1046-1054)
|
||||
* - buildAgentLines: formats agent info for dry-run preview (commands.ts:360-368)
|
||||
* - buildCloudLines: formats cloud info for dry-run preview (commands.ts:370-382)
|
||||
* - credentialHint: builds auth credential hint string (commands.ts:519-523)
|
||||
* - mapToSelectOptions: transforms manifest entries to select picker options (commands.ts:59-68)
|
||||
* - validateNonEmptyString: validates required non-empty input (commands.ts:51-57)
|
||||
* - renderMatrixRow: builds a single matrix row with status icons (commands.ts:690-699)
|
||||
* - renderMatrixHeader: builds the column header line (commands.ts:674-680)
|
||||
* - renderMatrixSeparator: builds the separator line (commands.ts:682-688)
|
||||
*
|
||||
* Agent: test-engineer
|
||||
*/
|
||||
|
||||
const mockManifest = createMockManifest();
|
||||
|
||||
// ── Exact replicas of internal functions from commands.ts ───────────────────
|
||||
|
||||
// commands.ts:1046-1054
|
||||
function groupByType(
|
||||
keys: string[],
|
||||
getType: (key: string) => string
|
||||
): Record<string, string[]> {
|
||||
const byType: Record<string, string[]> = {};
|
||||
for (const key of keys) {
|
||||
const type = getType(key);
|
||||
if (!byType[type]) byType[type] = [];
|
||||
byType[type].push(key);
|
||||
}
|
||||
return byType;
|
||||
}
|
||||
|
||||
// commands.ts:360-368
|
||||
function buildAgentLines(agentInfo: {
|
||||
name: string;
|
||||
description: string;
|
||||
install?: string;
|
||||
launch?: string;
|
||||
}): string[] {
|
||||
const lines = [
|
||||
` Name: ${agentInfo.name}`,
|
||||
` Description: ${agentInfo.description}`,
|
||||
];
|
||||
if (agentInfo.install) lines.push(` Install: ${agentInfo.install}`);
|
||||
if (agentInfo.launch) lines.push(` Launch: ${agentInfo.launch}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
// commands.ts:370-382
|
||||
function buildCloudLines(cloudInfo: {
|
||||
name: string;
|
||||
description: string;
|
||||
defaults?: Record<string, string>;
|
||||
}): string[] {
|
||||
const lines = [
|
||||
` Name: ${cloudInfo.name}`,
|
||||
` Description: ${cloudInfo.description}`,
|
||||
];
|
||||
if (cloudInfo.defaults) {
|
||||
lines.push(` Defaults:`);
|
||||
for (const [k, v] of Object.entries(cloudInfo.defaults)) {
|
||||
lines.push(` ${k}: ${v}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// commands.ts:519-523
|
||||
function credentialHint(
|
||||
cloud: string,
|
||||
authHint?: string,
|
||||
verb = "Missing or invalid"
|
||||
): string {
|
||||
return authHint
|
||||
? ` - ${verb} credentials (need ${authHint} + OPENROUTER_API_KEY)`
|
||||
: ` - ${verb} credentials (run spawn ${cloud} for setup)`;
|
||||
}
|
||||
|
||||
// commands.ts:59-68
|
||||
function mapToSelectOptions<
|
||||
T extends { name: string; description: string }
|
||||
>(
|
||||
keys: string[],
|
||||
items: Record<string, T>
|
||||
): Array<{ value: string; label: string; hint: string }> {
|
||||
return keys.map((key) => ({
|
||||
value: key,
|
||||
label: items[key].name,
|
||||
hint: items[key].description,
|
||||
}));
|
||||
}
|
||||
|
||||
// commands.ts:787-797
|
||||
function formatTimestamp(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const date = d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
const time = d.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
return `${date} ${time}`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
// commands.ts:904-908
|
||||
function buildRecordLabel(
|
||||
r: { agent: string; cloud: string },
|
||||
manifest: Manifest | null
|
||||
): string {
|
||||
const agentDisplay = resolveDisplayName(manifest, r.agent, "agent");
|
||||
const cloudDisplay = resolveDisplayName(manifest, r.cloud, "cloud");
|
||||
return `${agentDisplay} on ${cloudDisplay}`;
|
||||
}
|
||||
|
||||
// commands.ts:911-918
|
||||
function buildRecordHint(r: {
|
||||
timestamp: string;
|
||||
prompt?: string;
|
||||
}): string {
|
||||
const when = formatTimestamp(r.timestamp);
|
||||
if (r.prompt) {
|
||||
const preview =
|
||||
r.prompt.length > 30 ? r.prompt.slice(0, 30) + "..." : r.prompt;
|
||||
return `${when} --prompt "${preview}"`;
|
||||
}
|
||||
return when;
|
||||
}
|
||||
|
||||
// commands.ts:871-875
|
||||
function resolveDisplayName(
|
||||
manifest: Manifest | null,
|
||||
key: string,
|
||||
kind: "agent" | "cloud"
|
||||
): string {
|
||||
if (!manifest) return key;
|
||||
const entry =
|
||||
kind === "agent" ? manifest.agents[key] : manifest.clouds[key];
|
||||
return entry ? entry.name : key;
|
||||
}
|
||||
|
||||
const COL_PADDING = 2;
|
||||
|
||||
// ── groupByType tests ───────────────────────────────────────────────────────
|
||||
|
||||
describe("groupByType", () => {
|
||||
it("groups cloud keys by type", () => {
|
||||
const clouds = ["sprite", "hetzner"];
|
||||
const getType = (key: string) =>
|
||||
key === "sprite" ? "vm" : "cloud";
|
||||
|
||||
const result = groupByType(clouds, getType);
|
||||
expect(result).toEqual({
|
||||
vm: ["sprite"],
|
||||
cloud: ["hetzner"],
|
||||
});
|
||||
});
|
||||
|
||||
it("groups multiple keys under the same type", () => {
|
||||
const clouds = ["aws", "gcp", "azure"];
|
||||
const getType = () => "cloud";
|
||||
|
||||
const result = groupByType(clouds, getType);
|
||||
expect(result).toEqual({ cloud: ["aws", "gcp", "azure"] });
|
||||
});
|
||||
|
||||
it("returns empty object for empty keys", () => {
|
||||
const result = groupByType([], () => "any");
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves key order within each type", () => {
|
||||
const keys = ["z-key", "a-key", "m-key"];
|
||||
const getType = () => "group";
|
||||
|
||||
const result = groupByType(keys, getType);
|
||||
expect(result.group).toEqual(["z-key", "a-key", "m-key"]);
|
||||
});
|
||||
|
||||
it("handles many distinct types", () => {
|
||||
const keys = ["k1", "k2", "k3", "k4"];
|
||||
const getType = (k: string) => `type-${k}`;
|
||||
|
||||
const result = groupByType(keys, getType);
|
||||
expect(Object.keys(result)).toHaveLength(4);
|
||||
expect(result["type-k1"]).toEqual(["k1"]);
|
||||
expect(result["type-k4"]).toEqual(["k4"]);
|
||||
});
|
||||
|
||||
it("handles type names with special characters", () => {
|
||||
const keys = ["a", "b"];
|
||||
const getType = (k: string) =>
|
||||
k === "a" ? "Cloud / VPS" : "Container (Docker)";
|
||||
|
||||
const result = groupByType(keys, getType);
|
||||
expect(result["Cloud / VPS"]).toEqual(["a"]);
|
||||
expect(result["Container (Docker)"]).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("handles single key", () => {
|
||||
const result = groupByType(["only"], () => "solo");
|
||||
expect(result).toEqual({ solo: ["only"] });
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildAgentLines tests ───────────────────────────────────────────────────
|
||||
|
||||
describe("buildAgentLines", () => {
|
||||
it("includes name and description for minimal agent", () => {
|
||||
const lines = buildAgentLines({
|
||||
name: "Claude Code",
|
||||
description: "AI coding assistant",
|
||||
});
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]).toContain("Claude Code");
|
||||
expect(lines[1]).toContain("AI coding assistant");
|
||||
});
|
||||
|
||||
it("includes install command when present", () => {
|
||||
const lines = buildAgentLines({
|
||||
name: "Aider",
|
||||
description: "AI pair programmer",
|
||||
install: "pip install aider-chat",
|
||||
});
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines[2]).toContain("pip install aider-chat");
|
||||
expect(lines[2]).toContain("Install:");
|
||||
});
|
||||
|
||||
it("includes launch command when present", () => {
|
||||
const lines = buildAgentLines({
|
||||
name: "Aider",
|
||||
description: "AI pair programmer",
|
||||
launch: "aider --model openrouter/anthropic/claude-3.5-sonnet",
|
||||
});
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines[2]).toContain("Launch:");
|
||||
expect(lines[2]).toContain("aider --model");
|
||||
});
|
||||
|
||||
it("includes both install and launch when present", () => {
|
||||
const lines = buildAgentLines({
|
||||
name: "Claude Code",
|
||||
description: "AI coding assistant",
|
||||
install: "npm install -g claude",
|
||||
launch: "claude",
|
||||
});
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines[0]).toContain("Name:");
|
||||
expect(lines[1]).toContain("Description:");
|
||||
expect(lines[2]).toContain("Install:");
|
||||
expect(lines[3]).toContain("Launch:");
|
||||
});
|
||||
|
||||
it("does not include install line when install is undefined", () => {
|
||||
const lines = buildAgentLines({
|
||||
name: "Test",
|
||||
description: "Desc",
|
||||
launch: "test-cmd",
|
||||
});
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines.join("\n")).not.toContain("Install:");
|
||||
});
|
||||
|
||||
it("does not include launch line when launch is undefined", () => {
|
||||
const lines = buildAgentLines({
|
||||
name: "Test",
|
||||
description: "Desc",
|
||||
install: "npm install test",
|
||||
});
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines.join("\n")).not.toContain("Launch:");
|
||||
});
|
||||
|
||||
it("uses consistent indentation with 2-space prefix", () => {
|
||||
const lines = buildAgentLines({
|
||||
name: "X",
|
||||
description: "Y",
|
||||
install: "I",
|
||||
launch: "L",
|
||||
});
|
||||
for (const line of lines) {
|
||||
expect(line).toMatch(/^ /);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildCloudLines tests ───────────────────────────────────────────────────
|
||||
|
||||
describe("buildCloudLines", () => {
|
||||
it("includes name and description for minimal cloud", () => {
|
||||
const lines = buildCloudLines({
|
||||
name: "Sprite",
|
||||
description: "Lightweight VMs",
|
||||
});
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]).toContain("Sprite");
|
||||
expect(lines[1]).toContain("Lightweight VMs");
|
||||
});
|
||||
|
||||
it("includes defaults when present", () => {
|
||||
const lines = buildCloudLines({
|
||||
name: "Hetzner Cloud",
|
||||
description: "European cloud provider",
|
||||
defaults: {
|
||||
region: "nbg1",
|
||||
type: "cx22",
|
||||
},
|
||||
});
|
||||
expect(lines).toHaveLength(5);
|
||||
expect(lines[2]).toContain("Defaults:");
|
||||
expect(lines[3]).toContain("region: nbg1");
|
||||
expect(lines[4]).toContain("type: cx22");
|
||||
});
|
||||
|
||||
it("omits defaults section when defaults is undefined", () => {
|
||||
const lines = buildCloudLines({
|
||||
name: "Test",
|
||||
description: "Desc",
|
||||
});
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines.join("\n")).not.toContain("Defaults:");
|
||||
});
|
||||
|
||||
it("handles empty defaults object", () => {
|
||||
const lines = buildCloudLines({
|
||||
name: "Test",
|
||||
description: "Desc",
|
||||
defaults: {},
|
||||
});
|
||||
// Empty defaults still shows "Defaults:" header
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines[2]).toContain("Defaults:");
|
||||
});
|
||||
|
||||
it("handles many defaults entries", () => {
|
||||
const defaults: Record<string, string> = {};
|
||||
for (let i = 0; i < 10; i++) {
|
||||
defaults[`key${i}`] = `value${i}`;
|
||||
}
|
||||
const lines = buildCloudLines({
|
||||
name: "Test",
|
||||
description: "Desc",
|
||||
defaults,
|
||||
});
|
||||
// 2 (name+desc) + 1 (header) + 10 (entries)
|
||||
expect(lines).toHaveLength(13);
|
||||
});
|
||||
|
||||
it("preserves defaults entry order", () => {
|
||||
const lines = buildCloudLines({
|
||||
name: "Test",
|
||||
description: "Desc",
|
||||
defaults: { zebra: "z", alpha: "a" },
|
||||
});
|
||||
expect(lines[3]).toContain("zebra: z");
|
||||
expect(lines[4]).toContain("alpha: a");
|
||||
});
|
||||
});
|
||||
|
||||
// ── credentialHint tests ────────────────────────────────────────────────────
|
||||
|
||||
describe("credentialHint", () => {
|
||||
it("shows auth hint with named env vars when authHint is provided", () => {
|
||||
const hint = credentialHint("hetzner", "HCLOUD_TOKEN");
|
||||
expect(hint).toContain("HCLOUD_TOKEN");
|
||||
expect(hint).toContain("OPENROUTER_API_KEY");
|
||||
expect(hint).toContain("Missing or invalid");
|
||||
});
|
||||
|
||||
it("shows cloud setup command when authHint is not provided", () => {
|
||||
const hint = credentialHint("hetzner");
|
||||
expect(hint).toContain("spawn hetzner");
|
||||
expect(hint).toContain("setup");
|
||||
expect(hint).not.toContain("OPENROUTER_API_KEY");
|
||||
});
|
||||
|
||||
it("uses custom verb when provided", () => {
|
||||
const hint = credentialHint("sprite", "SPRITE_TOKEN", "Missing");
|
||||
expect(hint).toContain("Missing");
|
||||
expect(hint).not.toContain("Missing or invalid");
|
||||
});
|
||||
|
||||
it("uses default verb when not provided", () => {
|
||||
const hint = credentialHint("sprite", "SPRITE_TOKEN");
|
||||
expect(hint).toContain("Missing or invalid");
|
||||
});
|
||||
|
||||
it("shows cloud name in setup fallback", () => {
|
||||
const hint = credentialHint("digitalocean");
|
||||
expect(hint).toContain("spawn digitalocean");
|
||||
});
|
||||
|
||||
it("works with multi-token authHint", () => {
|
||||
const hint = credentialHint(
|
||||
"upcloud",
|
||||
"UPCLOUD_USERNAME + UPCLOUD_PASSWORD"
|
||||
);
|
||||
expect(hint).toContain("UPCLOUD_USERNAME + UPCLOUD_PASSWORD");
|
||||
expect(hint).toContain("OPENROUTER_API_KEY");
|
||||
});
|
||||
|
||||
it("uses custom verb without authHint", () => {
|
||||
const hint = credentialHint("vultr", undefined, "Missing");
|
||||
expect(hint).toContain("Missing");
|
||||
expect(hint).toContain("spawn vultr");
|
||||
});
|
||||
});
|
||||
|
||||
// ── mapToSelectOptions tests ────────────────────────────────────────────────
|
||||
|
||||
describe("mapToSelectOptions", () => {
|
||||
it("transforms agent entries to select options", () => {
|
||||
const keys = ["claude", "aider"];
|
||||
const result = mapToSelectOptions(keys, mockManifest.agents);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
value: "claude",
|
||||
label: "Claude Code",
|
||||
hint: "AI coding assistant",
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
value: "aider",
|
||||
label: "Aider",
|
||||
hint: "AI pair programmer",
|
||||
});
|
||||
});
|
||||
|
||||
it("transforms cloud entries to select options", () => {
|
||||
const keys = ["sprite", "hetzner"];
|
||||
const result = mapToSelectOptions(keys, mockManifest.clouds);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
value: "sprite",
|
||||
label: "Sprite",
|
||||
hint: "Lightweight VMs",
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
value: "hetzner",
|
||||
label: "Hetzner Cloud",
|
||||
hint: "European cloud provider",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty array for empty keys", () => {
|
||||
const result = mapToSelectOptions([], mockManifest.agents);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves key order in output", () => {
|
||||
const keys = ["aider", "claude"];
|
||||
const result = mapToSelectOptions(keys, mockManifest.agents);
|
||||
expect(result[0].value).toBe("aider");
|
||||
expect(result[1].value).toBe("claude");
|
||||
});
|
||||
|
||||
it("maps value field to the key, not the name", () => {
|
||||
const keys = ["claude"];
|
||||
const result = mapToSelectOptions(keys, mockManifest.agents);
|
||||
expect(result[0].value).toBe("claude");
|
||||
expect(result[0].label).toBe("Claude Code");
|
||||
expect(result[0].value).not.toBe(result[0].label);
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildRecordLabel tests ──────────────────────────────────────────────────
|
||||
|
||||
describe("buildRecordLabel", () => {
|
||||
it("uses display names when manifest is available", () => {
|
||||
const label = buildRecordLabel(
|
||||
{ agent: "claude", cloud: "sprite" },
|
||||
mockManifest
|
||||
);
|
||||
expect(label).toBe("Claude Code on Sprite");
|
||||
});
|
||||
|
||||
it("falls back to raw keys when manifest is null", () => {
|
||||
const label = buildRecordLabel(
|
||||
{ agent: "claude", cloud: "sprite" },
|
||||
null
|
||||
);
|
||||
expect(label).toBe("claude on sprite");
|
||||
});
|
||||
|
||||
it("falls back to raw key when agent is not in manifest", () => {
|
||||
const label = buildRecordLabel(
|
||||
{ agent: "unknown-agent", cloud: "sprite" },
|
||||
mockManifest
|
||||
);
|
||||
expect(label).toBe("unknown-agent on Sprite");
|
||||
});
|
||||
|
||||
it("falls back to raw key when cloud is not in manifest", () => {
|
||||
const label = buildRecordLabel(
|
||||
{ agent: "claude", cloud: "unknown-cloud" },
|
||||
mockManifest
|
||||
);
|
||||
expect(label).toBe("Claude Code on unknown-cloud");
|
||||
});
|
||||
|
||||
it("uses raw keys when both are unknown", () => {
|
||||
const label = buildRecordLabel(
|
||||
{ agent: "foo", cloud: "bar" },
|
||||
mockManifest
|
||||
);
|
||||
expect(label).toBe("foo on bar");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildRecordHint tests ───────────────────────────────────────────────────
|
||||
|
||||
describe("buildRecordHint", () => {
|
||||
it("returns formatted timestamp without prompt", () => {
|
||||
const hint = buildRecordHint({
|
||||
timestamp: "2026-02-11T14:30:00.000Z",
|
||||
});
|
||||
// Should contain a formatted date/time string
|
||||
expect(hint).toBeTruthy();
|
||||
expect(hint).not.toContain("--prompt");
|
||||
});
|
||||
|
||||
it("includes truncated prompt preview when prompt is long", () => {
|
||||
const longPrompt = "a".repeat(50);
|
||||
const hint = buildRecordHint({
|
||||
timestamp: "2026-02-11T14:30:00.000Z",
|
||||
prompt: longPrompt,
|
||||
});
|
||||
expect(hint).toContain("--prompt");
|
||||
expect(hint).toContain("...");
|
||||
// Should be truncated to 30 chars
|
||||
expect(hint).toContain("a".repeat(30));
|
||||
});
|
||||
|
||||
it("includes full prompt when it is 30 chars or less", () => {
|
||||
const shortPrompt = "Fix all bugs";
|
||||
const hint = buildRecordHint({
|
||||
timestamp: "2026-02-11T14:30:00.000Z",
|
||||
prompt: shortPrompt,
|
||||
});
|
||||
expect(hint).toContain("--prompt");
|
||||
expect(hint).toContain("Fix all bugs");
|
||||
expect(hint).not.toContain("...");
|
||||
});
|
||||
|
||||
it("includes prompt at exactly 30 characters without truncation", () => {
|
||||
const exact30 = "a".repeat(30);
|
||||
const hint = buildRecordHint({
|
||||
timestamp: "2026-02-11T14:30:00.000Z",
|
||||
prompt: exact30,
|
||||
});
|
||||
expect(hint).toContain(exact30);
|
||||
expect(hint).not.toContain("...");
|
||||
});
|
||||
|
||||
it("truncates prompt at 31 characters", () => {
|
||||
const exact31 = "b".repeat(31);
|
||||
const hint = buildRecordHint({
|
||||
timestamp: "2026-02-11T14:30:00.000Z",
|
||||
prompt: exact31,
|
||||
});
|
||||
expect(hint).toContain("b".repeat(30) + "...");
|
||||
});
|
||||
|
||||
it("does not include prompt section when prompt is undefined", () => {
|
||||
const hint = buildRecordHint({
|
||||
timestamp: "2026-02-11T14:30:00.000Z",
|
||||
});
|
||||
expect(hint).not.toContain("--prompt");
|
||||
expect(hint).not.toContain("undefined");
|
||||
});
|
||||
|
||||
it("handles invalid timestamp gracefully", () => {
|
||||
const hint = buildRecordHint({
|
||||
timestamp: "not-a-date",
|
||||
});
|
||||
// formatTimestamp returns the original string for invalid dates
|
||||
expect(hint).toContain("not-a-date");
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveDisplayName edge cases ───────────────────────────────────────────
|
||||
|
||||
describe("resolveDisplayName edge cases", () => {
|
||||
it("returns key when manifest is null and kind is agent", () => {
|
||||
expect(resolveDisplayName(null, "claude", "agent")).toBe("claude");
|
||||
});
|
||||
|
||||
it("returns key when manifest is null and kind is cloud", () => {
|
||||
expect(resolveDisplayName(null, "sprite", "cloud")).toBe("sprite");
|
||||
});
|
||||
|
||||
it("returns display name for valid agent", () => {
|
||||
expect(resolveDisplayName(mockManifest, "claude", "agent")).toBe(
|
||||
"Claude Code"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns display name for valid cloud", () => {
|
||||
expect(resolveDisplayName(mockManifest, "hetzner", "cloud")).toBe(
|
||||
"Hetzner Cloud"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns raw key for unknown agent in manifest", () => {
|
||||
expect(resolveDisplayName(mockManifest, "nonexistent", "agent")).toBe(
|
||||
"nonexistent"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns raw key for unknown cloud in manifest", () => {
|
||||
expect(resolveDisplayName(mockManifest, "nonexistent", "cloud")).toBe(
|
||||
"nonexistent"
|
||||
);
|
||||
});
|
||||
|
||||
it("correctly distinguishes agent vs cloud lookups", () => {
|
||||
// "sprite" exists as a cloud but not as an agent
|
||||
expect(resolveDisplayName(mockManifest, "sprite", "agent")).toBe(
|
||||
"sprite"
|
||||
);
|
||||
expect(resolveDisplayName(mockManifest, "sprite", "cloud")).toBe(
|
||||
"Sprite"
|
||||
);
|
||||
});
|
||||
|
||||
it("correctly looks up agent that exists as agent but not cloud", () => {
|
||||
// "claude" exists as an agent but not as a cloud
|
||||
expect(resolveDisplayName(mockManifest, "claude", "agent")).toBe(
|
||||
"Claude Code"
|
||||
);
|
||||
expect(resolveDisplayName(mockManifest, "claude", "cloud")).toBe(
|
||||
"claude"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration: groupByType with real manifest data ────────────────────────
|
||||
|
||||
describe("groupByType with manifest clouds", () => {
|
||||
it("groups mock manifest clouds by their type field", () => {
|
||||
const clouds = Object.keys(mockManifest.clouds);
|
||||
const result = groupByType(
|
||||
clouds,
|
||||
(key) => mockManifest.clouds[key].type
|
||||
);
|
||||
|
||||
// sprite is "vm" type, hetzner is "cloud" type in the mock
|
||||
expect(result["vm"]).toEqual(["sprite"]);
|
||||
expect(result["cloud"]).toEqual(["hetzner"]);
|
||||
});
|
||||
|
||||
it("works with extended manifest having multiple clouds per type", () => {
|
||||
const extManifest: Manifest = {
|
||||
...mockManifest,
|
||||
clouds: {
|
||||
...mockManifest.clouds,
|
||||
vultr: {
|
||||
name: "Vultr",
|
||||
description: "Cloud compute",
|
||||
url: "https://vultr.com",
|
||||
type: "cloud",
|
||||
auth: "VULTR_API_KEY",
|
||||
provision_method: "api",
|
||||
exec_method: "ssh",
|
||||
interactive_method: "ssh",
|
||||
},
|
||||
digitalocean: {
|
||||
name: "DigitalOcean",
|
||||
description: "Cloud platform",
|
||||
url: "https://digitalocean.com",
|
||||
type: "cloud",
|
||||
auth: "DO_API_TOKEN",
|
||||
provision_method: "api",
|
||||
exec_method: "ssh",
|
||||
interactive_method: "ssh",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const clouds = Object.keys(extManifest.clouds);
|
||||
const result = groupByType(
|
||||
clouds,
|
||||
(key) => extManifest.clouds[key].type
|
||||
);
|
||||
|
||||
expect(result["vm"]).toEqual(["sprite"]);
|
||||
expect(result["cloud"]).toContain("hetzner");
|
||||
expect(result["cloud"]).toContain("vultr");
|
||||
expect(result["cloud"]).toContain("digitalocean");
|
||||
expect(result["cloud"]).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration: credentialHint in error message context ────────────────────
|
||||
|
||||
describe("credentialHint in error context", () => {
|
||||
it("produces valid hint for exit code 1 with auth", () => {
|
||||
const hint = credentialHint("hetzner", "HCLOUD_TOKEN");
|
||||
// Used in getScriptFailureGuidance for exit code 1
|
||||
expect(hint).toMatch(/^\s+-/);
|
||||
expect(hint).toContain("credentials");
|
||||
});
|
||||
|
||||
it("produces valid hint for default exit code without auth", () => {
|
||||
const hint = credentialHint("sprite", undefined, "Missing");
|
||||
expect(hint).toMatch(/^\s+-/);
|
||||
expect(hint).toContain("credentials");
|
||||
expect(hint).toContain("spawn sprite");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration: mapToSelectOptions for subset of keys ──────────────────────
|
||||
|
||||
describe("mapToSelectOptions with key subsets", () => {
|
||||
it("maps only specified keys, not all manifest entries", () => {
|
||||
const result = mapToSelectOptions(["claude"], mockManifest.agents);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].value).toBe("claude");
|
||||
});
|
||||
|
||||
it("works when keys are in different order than manifest", () => {
|
||||
const keys = ["aider", "claude"];
|
||||
const result = mapToSelectOptions(keys, mockManifest.agents);
|
||||
expect(result[0].value).toBe("aider");
|
||||
expect(result[1].value).toBe("claude");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue