From 881067bf8bb50701416f408ecf5e0986c54eb576 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:43:23 -0800 Subject: [PATCH] test: add 50 tests for unified printQuickStart and buildDashboardHint helpers (#1042) (#1044) Cover the unified printQuickStart function and extracted buildDashboardHint helper from PR #1042. Tests verify: - buildDashboardHint edge cases: empty string URL fallback, very long URLs, consistency across signal types, per-exit-code dashboard URL inclusion - printQuickStart unified behavior via cmdCloudInfo: single-auth, no-agent, ready-to-go shortcut, non-parseable auth, none-auth - printQuickStart unified behavior via cmdAgentInfo: credential-prioritized cloud ordering, no-implementations, single-cloud ready-to-go - cmdCloudInfo agent list count display and metadata - cmdAgentInfo cloud list count display and metadata Agent: test-engineer Co-authored-by: A <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- .../quickstart-dashboard-helpers.test.ts | 930 ++++++++++++++++++ 1 file changed, 930 insertions(+) create mode 100644 cli/src/__tests__/quickstart-dashboard-helpers.test.ts diff --git a/cli/src/__tests__/quickstart-dashboard-helpers.test.ts b/cli/src/__tests__/quickstart-dashboard-helpers.test.ts new file mode 100644 index 00000000..0985cb1f --- /dev/null +++ b/cli/src/__tests__/quickstart-dashboard-helpers.test.ts @@ -0,0 +1,930 @@ +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import type { Manifest } from "../manifest"; + +/** + * Tests for the unified printQuickStart function and the extracted + * buildDashboardHint helper introduced in PR #1042. + * + * PR #1042 merged printAgentQuickStart and printCloudQuickStart into + * a single printQuickStart(opts) function, and extracted the repeated + * dashboardUrl ternary into buildDashboardHint(dashboardUrl). + * + * Existing coverage: + * - cloud-agent-quickstart.test.ts: integration tests for cmdCloudInfo/cmdAgentInfo + * Quick start sections (multi-auth, none-auth, credential indicators) + * - script-failure-guidance.test.ts: dashboardUrl in getSignalGuidance and + * getScriptFailureGuidance (tests the extracted buildDashboardHint indirectly) + * + * This file covers the UNTESTED paths: + * - buildDashboardHint: tested directly via getSignalGuidance/getScriptFailureGuidance + * with edge cases (empty string URL, undefined, very long URL) + * - printQuickStart unified behavior: credential-ready shortcut with no spawnCmd, + * auth string that parses to zero env vars with spawnCmd, partial credential state + * - cmdAgentInfo: credential-prioritized cloud ordering in Quick start + * - cmdCloudInfo: dashboard hint in post-exec failure messages when cloud has url + * + * Agent: test-engineer + */ + +// ── Test manifests ──────────────────────────────────────────────────────────── + +function makeManifest(overrides?: Partial<{ + clouds: Manifest["clouds"]; + agents: Manifest["agents"]; + matrix: Manifest["matrix"]; +}>): Manifest { + return { + agents: overrides?.agents ?? { + claude: { + name: "Claude Code", + description: "AI coding assistant", + url: "https://claude.ai", + install: "npm install -g claude", + launch: "claude", + env: { ANTHROPIC_API_KEY: "$OPENROUTER_API_KEY" }, + }, + }, + clouds: overrides?.clouds ?? { + sprite: { + name: "Sprite", + description: "Dev VMs", + url: "https://sprite.sh", + type: "vm", + auth: "SPRITE_TOKEN", + provision_method: "api", + exec_method: "ssh", + interactive_method: "ssh", + }, + }, + matrix: overrides?.matrix ?? { + "sprite/claude": "implemented", + }, + }; +} + +// ── Mock setup ──────────────────────────────────────────────────────────────── + +const mockLogError = mock(() => {}); +const mockLogInfo = mock(() => {}); +const mockLogStep = mock(() => {}); +const mockLogWarn = mock(() => {}); +const mockLogSuccess = mock(() => {}); +const mockSpinnerStart = mock(() => {}); +const mockSpinnerStop = mock(() => {}); +const mockSpinnerMessage = mock(() => {}); + +mock.module("@clack/prompts", () => ({ + spinner: () => ({ + start: mockSpinnerStart, + stop: mockSpinnerStop, + message: mockSpinnerMessage, + }), + log: { + step: mockLogStep, + info: mockLogInfo, + error: mockLogError, + warn: mockLogWarn, + success: mockLogSuccess, + }, + intro: mock(() => {}), + outro: mock(() => {}), + cancel: mock(() => {}), + select: mock(() => {}), + confirm: mock(() => Promise.resolve(true)), + isCancel: () => false, +})); + +const { + getSignalGuidance, + getScriptFailureGuidance, + cmdCloudInfo, + cmdAgentInfo, +} = await import("../commands.js"); +const { loadManifest } = await import("../manifest.js"); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function clearMocks() { + mockLogError.mockClear(); + mockLogInfo.mockClear(); + mockLogStep.mockClear(); + mockLogWarn.mockClear(); + mockLogSuccess.mockClear(); + mockSpinnerStart.mockClear(); + mockSpinnerStop.mockClear(); + mockSpinnerMessage.mockClear(); +} + +// ── buildDashboardHint via getSignalGuidance ───────────────────────────────── + +describe("buildDashboardHint edge cases via getSignalGuidance", () => { + it("should use provided URL in dashboard hint for SIGKILL", () => { + const lines = getSignalGuidance("SIGKILL", "https://console.hetzner.cloud/"); + const joined = lines.join("\n"); + expect(joined).toContain("https://console.hetzner.cloud/"); + expect(joined).toContain("Check your dashboard"); + }); + + it("should use generic fallback when dashboardUrl is undefined", () => { + const lines = getSignalGuidance("SIGKILL"); + const joined = lines.join("\n"); + expect(joined).toContain("Check your cloud provider dashboard"); + expect(joined).not.toContain("https://"); + }); + + it("should use generic fallback when dashboardUrl is empty string", () => { + const lines = getSignalGuidance("SIGKILL", ""); + const joined = lines.join("\n"); + // Empty string is falsy, so buildDashboardHint should use fallback + expect(joined).toContain("cloud provider dashboard"); + }); + + it("should handle very long dashboard URL without truncation", () => { + const longUrl = "https://very-long-subdomain.cloud-provider.example.com/dashboard/projects/12345/servers"; + const lines = getSignalGuidance("SIGTERM", longUrl); + const joined = lines.join("\n"); + expect(joined).toContain(longUrl); + }); + + it("should include dashboard hint in SIGKILL, SIGTERM, SIGINT but NOT SIGHUP", () => { + const url = "https://test.cloud/dashboard"; + for (const sig of ["SIGKILL", "SIGTERM", "SIGINT"]) { + const lines = getSignalGuidance(sig, url); + const joined = lines.join("\n"); + expect(joined).toContain(url); + } + // SIGHUP uses hardcoded message about terminal multiplexer, no dashboard hint + const sighupLines = getSignalGuidance("SIGHUP", url); + const sighupJoined = sighupLines.join("\n"); + expect(sighupJoined).not.toContain(url); + }); + + it("should include dashboard hint in unknown signal case", () => { + const url = "https://my.vultr.com/"; + const lines = getSignalGuidance("SIGUSR2", url); + const joined = lines.join("\n"); + expect(joined).toContain(url); + }); + + it("should use generic fallback for unknown signal without URL", () => { + const lines = getSignalGuidance("SIGXCPU"); + const joined = lines.join("\n"); + expect(joined).toContain("cloud provider dashboard"); + }); +}); + +// ── buildDashboardHint via getScriptFailureGuidance ───────────────────────── + +describe("buildDashboardHint edge cases via getScriptFailureGuidance", () => { + it("should include dashboard URL for exit code 130 (Ctrl+C)", () => { + const lines = getScriptFailureGuidance(130, "hetzner", undefined, "https://console.hetzner.cloud/"); + const joined = lines.join("\n"); + expect(joined).toContain("https://console.hetzner.cloud/"); + }); + + it("should include dashboard URL for exit code 137 (OOM)", () => { + const lines = getScriptFailureGuidance(137, "vultr", undefined, "https://my.vultr.com/"); + const joined = lines.join("\n"); + expect(joined).toContain("https://my.vultr.com/"); + }); + + it("should use generic fallback for exit 130 without URL", () => { + const lines = getScriptFailureGuidance(130, "sprite"); + const joined = lines.join("\n"); + expect(joined).toContain("cloud provider dashboard"); + expect(joined).not.toContain("https://"); + }); + + it("should use generic fallback for exit 137 without URL", () => { + const lines = getScriptFailureGuidance(137, "sprite"); + const joined = lines.join("\n"); + expect(joined).toContain("cloud provider dashboard"); + }); + + it("should NOT include dashboard hint for exit 255 (SSH failure)", () => { + const lines = getScriptFailureGuidance(255, "sprite", undefined, "https://sprite.sh"); + const joined = lines.join("\n"); + // SSH failure doesn't need dashboard hint - it's a connectivity issue + expect(joined).not.toContain("https://sprite.sh"); + }); + + it("should NOT include dashboard hint for exit 127 (command not found)", () => { + const lines = getScriptFailureGuidance(127, "hetzner", undefined, "https://console.hetzner.cloud/"); + const joined = lines.join("\n"); + expect(joined).not.toContain("https://console.hetzner.cloud/"); + }); + + it("should NOT include dashboard hint for exit 126 (permission denied)", () => { + const lines = getScriptFailureGuidance(126, "hetzner", undefined, "https://console.hetzner.cloud/"); + const joined = lines.join("\n"); + expect(joined).not.toContain("https://console.hetzner.cloud/"); + }); + + it("should NOT include dashboard hint for exit 2 (shell syntax error)", () => { + const lines = getScriptFailureGuidance(2, "hetzner", undefined, "https://console.hetzner.cloud/"); + const joined = lines.join("\n"); + expect(joined).not.toContain("https://console.hetzner.cloud/"); + }); + + it("should include dashboard URL for exit code 1 (generic failure)", () => { + const lines = getScriptFailureGuidance(1, "sprite", undefined, "https://sprite.sh"); + const joined = lines.join("\n"); + expect(joined).toContain("https://sprite.sh"); + }); + + it("should include dashboard URL for unknown exit codes", () => { + const lines = getScriptFailureGuidance(42, "sprite", undefined, "https://sprite.sh"); + const joined = lines.join("\n"); + expect(joined).toContain("https://sprite.sh"); + }); + + it("should include dashboard URL for null exit code", () => { + const lines = getScriptFailureGuidance(null, "sprite", undefined, "https://sprite.sh"); + const joined = lines.join("\n"); + expect(joined).toContain("https://sprite.sh"); + }); + + it("should omit dashboard line for exit code 1 when URL is empty string", () => { + const lines = getScriptFailureGuidance(1, "sprite", undefined, ""); + const joined = lines.join("\n"); + // Empty string is falsy -- no dashboard line is added at all for exit code 1 + // (exit code 1 uses inline ternary, not buildDashboardHint) + expect(joined).not.toContain("dashboard"); + }); + + it("should consistently use 'Check your dashboard' wording with URL", () => { + for (const code of [130, 137, 1, 42, null]) { + const lines = getScriptFailureGuidance(code as any, "test", undefined, "https://example.com"); + const joined = lines.join("\n"); + if (joined.includes("https://example.com")) { + expect(joined).toContain("dashboard"); + } + } + }); +}); + +// ── printQuickStart via cmdCloudInfo ───────────────────────────────────────── + +describe("printQuickStart unified behavior via cmdCloudInfo", () => { + let consoleSpy: ReturnType; + let consoleErrSpy: ReturnType; + let originalFetch: typeof global.fetch; + let processExitSpy: ReturnType; + let savedEnv: Record; + + function setupManifest(manifest: Manifest) { + global.fetch = mock(async () => ({ + ok: true, + json: async () => manifest, + text: async () => JSON.stringify(manifest), + })) as any; + return loadManifest(true); + } + + function getOutput(): string { + return consoleSpy.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); + } + + function getLines(): string[] { + return consoleSpy.mock.calls.map((c: any[]) => c.join(" ")); + } + + beforeEach(async () => { + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + consoleErrSpy = spyOn(console, "error").mockImplementation(() => {}); + clearMocks(); + processExitSpy = spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as any); + originalFetch = global.fetch; + + savedEnv = { + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, + SPRITE_TOKEN: process.env.SPRITE_TOKEN, + HCLOUD_TOKEN: process.env.HCLOUD_TOKEN, + UPCLOUD_USERNAME: process.env.UPCLOUD_USERNAME, + UPCLOUD_PASSWORD: process.env.UPCLOUD_PASSWORD, + }; + }); + + afterEach(() => { + global.fetch = originalFetch; + processExitSpy.mockRestore(); + consoleSpy.mockRestore(); + consoleErrSpy.mockRestore(); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + describe("Quick start with single-auth cloud", () => { + it("should show export instruction for missing SPRITE_TOKEN", async () => { + delete process.env.SPRITE_TOKEN; + delete process.env.OPENROUTER_API_KEY; + await setupManifest(makeManifest()); + await cmdCloudInfo("sprite"); + const output = getOutput(); + expect(output).toContain("SPRITE_TOKEN"); + expect(output).toContain("OPENROUTER_API_KEY"); + }); + + it("should show spawn command with first agent", async () => { + delete process.env.SPRITE_TOKEN; + await setupManifest(makeManifest()); + await cmdCloudInfo("sprite"); + const output = getOutput(); + expect(output).toContain("spawn claude sprite"); + }); + + it("should show cloud URL hint next to first auth var", async () => { + delete process.env.SPRITE_TOKEN; + await setupManifest(makeManifest()); + await cmdCloudInfo("sprite"); + const output = getOutput(); + expect(output).toContain("sprite.sh"); + }); + }); + + describe("Quick start with no implemented agents", () => { + it("should not show spawn command when no agents are implemented", async () => { + const manifest = makeManifest({ + matrix: { "sprite/claude": "missing" }, + }); + delete process.env.SPRITE_TOKEN; + await setupManifest(manifest); + await cmdCloudInfo("sprite"); + const output = getOutput(); + expect(output).toContain("Quick start"); + expect(output).not.toContain("spawn claude sprite"); + }); + + it("should still show auth env vars even with no agents", async () => { + const manifest = makeManifest({ + matrix: { "sprite/claude": "missing" }, + }); + delete process.env.SPRITE_TOKEN; + await setupManifest(manifest); + await cmdCloudInfo("sprite"); + const output = getOutput(); + expect(output).toContain("SPRITE_TOKEN"); + expect(output).toContain("OPENROUTER_API_KEY"); + }); + }); + + describe("Quick start ready-to-go shortcut", () => { + it("should show ready-to-go when all credentials are set", async () => { + process.env.OPENROUTER_API_KEY = "sk-or-test"; + process.env.SPRITE_TOKEN = "test-token"; + await setupManifest(makeManifest()); + await cmdCloudInfo("sprite"); + const output = getOutput(); + expect(output).toContain("ready to go"); + expect(output).toContain("spawn claude sprite"); + }); + + it("should NOT show ready-to-go when only cloud cred is set", async () => { + delete process.env.OPENROUTER_API_KEY; + process.env.SPRITE_TOKEN = "test-token"; + await setupManifest(makeManifest()); + await cmdCloudInfo("sprite"); + const output = getOutput(); + expect(output).not.toContain("ready to go"); + }); + + it("should NOT show ready-to-go when cloud has no agents (no spawnCmd)", async () => { + process.env.OPENROUTER_API_KEY = "sk-or-test"; + process.env.SPRITE_TOKEN = "test-token"; + const manifest = makeManifest({ + matrix: { "sprite/claude": "missing" }, + }); + await setupManifest(manifest); + await cmdCloudInfo("sprite"); + const output = getOutput(); + // ready-to-go requires a spawnCmd; no agents means no spawnCmd + expect(output).not.toContain("ready to go"); + }); + }); + + describe("Quick start with non-parseable auth", () => { + it("should show raw auth string when it yields no env vars", async () => { + const manifest = makeManifest({ + clouds: { + localcloud: { + name: "Local Cloud", + description: "Local provider", + url: "https://local.example.com", + type: "local", + auth: "OAuth + browser flow", + provision_method: "cli", + exec_method: "bash", + interactive_method: "bash", + }, + }, + agents: { + claude: { + name: "Claude Code", + description: "AI", + url: "", + install: "", + launch: "", + env: {}, + }, + }, + matrix: { "localcloud/claude": "implemented" }, + }); + delete process.env.OPENROUTER_API_KEY; + await setupManifest(manifest); + await cmdCloudInfo("localcloud"); + const output = getOutput(); + // Should show the auth string as-is since no env vars parsed + expect(output).toContain("Auth:"); + expect(output).toContain("OAuth"); + }); + + it("should not show 'none' auth as a hint in Quick start", async () => { + const manifest = makeManifest({ + clouds: { + noauth: { + name: "NoAuth Cloud", + description: "No auth needed", + url: "https://noauth.example.com", + type: "local", + auth: "none", + provision_method: "none", + exec_method: "bash", + interactive_method: "bash", + }, + }, + agents: { + claude: { + name: "Claude Code", + description: "AI", + url: "", + install: "", + launch: "", + env: {}, + }, + }, + matrix: { "noauth/claude": "implemented" }, + }); + delete process.env.OPENROUTER_API_KEY; + await setupManifest(manifest); + await cmdCloudInfo("noauth"); + const lines = getLines(); + const quickStartIdx = lines.findIndex((l: string) => l.includes("Quick start")); + const afterQuickStart = lines.slice(quickStartIdx + 1); + // Should not show "Auth: none" in Quick start section + const noneAuthLines = afterQuickStart.filter( + (l: string) => l.includes("Auth:") && l.includes("none") + ); + expect(noneAuthLines).toHaveLength(0); + }); + }); +}); + +// ── printQuickStart via cmdAgentInfo ───────────────────────────────────────── + +describe("printQuickStart unified behavior via cmdAgentInfo", () => { + let consoleSpy: ReturnType; + let consoleErrSpy: ReturnType; + let originalFetch: typeof global.fetch; + let processExitSpy: ReturnType; + let savedEnv: Record; + + function setupManifest(manifest: Manifest) { + global.fetch = mock(async () => ({ + ok: true, + json: async () => manifest, + text: async () => JSON.stringify(manifest), + })) as any; + return loadManifest(true); + } + + function getOutput(): string { + return consoleSpy.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); + } + + function getLines(): string[] { + return consoleSpy.mock.calls.map((c: any[]) => c.join(" ")); + } + + beforeEach(async () => { + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + consoleErrSpy = spyOn(console, "error").mockImplementation(() => {}); + clearMocks(); + processExitSpy = spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as any); + originalFetch = global.fetch; + + savedEnv = { + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY, + SPRITE_TOKEN: process.env.SPRITE_TOKEN, + HCLOUD_TOKEN: process.env.HCLOUD_TOKEN, + }; + }); + + afterEach(() => { + global.fetch = originalFetch; + processExitSpy.mockRestore(); + consoleSpy.mockRestore(); + consoleErrSpy.mockRestore(); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + describe("credential-prioritized cloud in Quick start", () => { + it("should use the first sorted cloud (with credentials) for Quick start", async () => { + // Set up: hetzner has credentials, sprite does not + process.env.HCLOUD_TOKEN = "test-token"; + delete process.env.SPRITE_TOKEN; + delete process.env.OPENROUTER_API_KEY; + + const manifest = makeManifest({ + clouds: { + sprite: { + name: "Sprite", + description: "Dev VMs", + url: "https://sprite.sh", + type: "vm", + auth: "SPRITE_TOKEN", + provision_method: "api", + exec_method: "ssh", + interactive_method: "ssh", + }, + hetzner: { + name: "Hetzner Cloud", + description: "EU cloud", + url: "https://console.hetzner.cloud", + type: "cloud", + auth: "HCLOUD_TOKEN", + provision_method: "api", + exec_method: "ssh", + interactive_method: "ssh", + }, + }, + matrix: { + "sprite/claude": "implemented", + "hetzner/claude": "implemented", + }, + }); + await setupManifest(manifest); + await cmdAgentInfo("claude"); + const output = getOutput(); + // The Quick start example should use hetzner (has credentials) not sprite + expect(output).toContain("spawn claude hetzner"); + }); + + it("should show Quick start with first cloud if no credentials detected", async () => { + delete process.env.SPRITE_TOKEN; + delete process.env.HCLOUD_TOKEN; + delete process.env.OPENROUTER_API_KEY; + + const manifest = makeManifest({ + clouds: { + sprite: { + name: "Sprite", + description: "Dev VMs", + url: "https://sprite.sh", + type: "vm", + auth: "SPRITE_TOKEN", + provision_method: "api", + exec_method: "ssh", + interactive_method: "ssh", + }, + hetzner: { + name: "Hetzner Cloud", + description: "EU cloud", + url: "https://console.hetzner.cloud", + type: "cloud", + auth: "HCLOUD_TOKEN", + provision_method: "api", + exec_method: "ssh", + interactive_method: "ssh", + }, + }, + matrix: { + "sprite/claude": "implemented", + "hetzner/claude": "implemented", + }, + }); + await setupManifest(manifest); + await cmdAgentInfo("claude"); + const output = getOutput(); + // When no credentials detected anywhere, order is preserved + expect(output).toContain("spawn claude sprite"); + }); + }); + + describe("agent with no implementations", () => { + it("should not show Quick start section", async () => { + const manifest = makeManifest({ + matrix: { "sprite/claude": "missing" }, + }); + await setupManifest(manifest); + await cmdAgentInfo("claude"); + const output = getOutput(); + expect(output).not.toContain("Quick start"); + }); + + it("should show no-implementations message", async () => { + const manifest = makeManifest({ + matrix: { "sprite/claude": "missing" }, + }); + await setupManifest(manifest); + await cmdAgentInfo("claude"); + const output = getOutput(); + expect(output).toContain("No implemented clouds"); + }); + }); + + describe("agent with single cloud ready-to-go", () => { + it("should show ready-to-go with spawn command when all creds set", async () => { + process.env.OPENROUTER_API_KEY = "sk-or-test"; + process.env.SPRITE_TOKEN = "test-token"; + await setupManifest(makeManifest()); + await cmdAgentInfo("claude"); + const output = getOutput(); + expect(output).toContain("ready to go"); + expect(output).toContain("spawn claude sprite"); + }); + + it("should not show export lines when ready-to-go", async () => { + process.env.OPENROUTER_API_KEY = "sk-or-test"; + process.env.SPRITE_TOKEN = "test-token"; + await setupManifest(makeManifest()); + await cmdAgentInfo("claude"); + const lines = getLines(); + const quickStartIdx = lines.findIndex((l: string) => l.includes("Quick start")); + expect(quickStartIdx).toBeGreaterThanOrEqual(0); + // After Quick start + "ready to go" line, the next line is the spawn command + // There should be no "export" lines + const afterQuickStart = lines.slice(quickStartIdx + 1, quickStartIdx + 3); + const exportLines = afterQuickStart.filter((l: string) => l.includes("export")); + expect(exportLines).toHaveLength(0); + }); + }); +}); + +// ── buildDashboardHint consistency across exit codes ───────────────────────── + +describe("buildDashboardHint consistency", () => { + const url = "https://cloud.example.com/dashboard"; + + it("should produce identical dashboard hint for all signal types that use it", () => { + const sigkill = getSignalGuidance("SIGKILL", url); + const sigterm = getSignalGuidance("SIGTERM", url); + const sigint = getSignalGuidance("SIGINT", url); + const unknown = getSignalGuidance("SIGFOO", url); + + // All should contain the same dashboard hint text + for (const lines of [sigkill, sigterm, sigint, unknown]) { + const dashboardLine = lines.find((l: string) => l.includes(url)); + expect(dashboardLine).toBeDefined(); + expect(dashboardLine).toContain("Check your dashboard"); + } + }); + + it("should produce identical fallback for all signal types without URL", () => { + const sigkill = getSignalGuidance("SIGKILL"); + const sigterm = getSignalGuidance("SIGTERM"); + const sigint = getSignalGuidance("SIGINT"); + const unknown = getSignalGuidance("SIGFOO"); + + for (const lines of [sigkill, sigterm, sigint, unknown]) { + const dashboardLine = lines.find((l: string) => + l.includes("cloud provider dashboard") + ); + expect(dashboardLine).toBeDefined(); + } + }); + + it("should produce identical dashboard hint for exit codes 130 and 137", () => { + const code130 = getScriptFailureGuidance(130, "test", undefined, url); + const code137 = getScriptFailureGuidance(137, "test", undefined, url); + + const hint130 = code130.find((l: string) => l.includes(url)); + const hint137 = code137.find((l: string) => l.includes(url)); + expect(hint130).toBeDefined(); + expect(hint137).toBeDefined(); + // Both should use the same "Check your dashboard" wording + expect(hint130).toEqual(hint137); + }); + + it("should produce different hint format for exit code 1 vs 130", () => { + // Exit 1 uses inline dashboard URL in the list, exit 130 uses buildDashboardHint + const code1 = getScriptFailureGuidance(1, "test", undefined, url); + const code130 = getScriptFailureGuidance(130, "test", undefined, url); + + // Both mention the URL but in different contexts + const joined1 = code1.join("\n"); + const joined130 = code130.join("\n"); + expect(joined1).toContain(url); + expect(joined130).toContain(url); + }); +}); + +// ── cmdCloudInfo agent list section ───────────────────────────────────────── + +describe("cmdCloudInfo agent list and count display", () => { + let consoleSpy: ReturnType; + let consoleErrSpy: ReturnType; + let originalFetch: typeof global.fetch; + let processExitSpy: ReturnType; + + function setupManifest(manifest: Manifest) { + global.fetch = mock(async () => ({ + ok: true, + json: async () => manifest, + text: async () => JSON.stringify(manifest), + })) as any; + return loadManifest(true); + } + + function getOutput(): string { + return consoleSpy.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); + } + + beforeEach(async () => { + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + consoleErrSpy = spyOn(console, "error").mockImplementation(() => {}); + clearMocks(); + processExitSpy = spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as any); + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + processExitSpy.mockRestore(); + consoleSpy.mockRestore(); + consoleErrSpy.mockRestore(); + }); + + it("should show correct agent count header", async () => { + const manifest = makeManifest({ + agents: { + claude: { name: "Claude Code", description: "a", url: "", install: "", launch: "", env: {} }, + aider: { name: "Aider", description: "b", url: "", install: "", launch: "", env: {} }, + codex: { name: "Codex", description: "c", url: "", install: "", launch: "", env: {} }, + }, + matrix: { + "sprite/claude": "implemented", + "sprite/aider": "implemented", + "sprite/codex": "missing", + }, + }); + await setupManifest(manifest); + await cmdCloudInfo("sprite"); + const output = getOutput(); + // 2 of 3 agents implemented + expect(output).toContain("2 of 3"); + }); + + it("should show all-implemented state", async () => { + const manifest = makeManifest({ + agents: { + claude: { name: "Claude Code", description: "a", url: "", install: "", launch: "", env: {} }, + aider: { name: "Aider", description: "b", url: "", install: "", launch: "", env: {} }, + }, + matrix: { + "sprite/claude": "implemented", + "sprite/aider": "implemented", + }, + }); + await setupManifest(manifest); + await cmdCloudInfo("sprite"); + const output = getOutput(); + expect(output).toContain("2 of 2"); + }); + + it("should show zero-implemented state", async () => { + const manifest = makeManifest({ + matrix: { "sprite/claude": "missing" }, + }); + await setupManifest(manifest); + await cmdCloudInfo("sprite"); + const output = getOutput(); + expect(output).toContain("0 of 1"); + expect(output).toContain("No implemented agents"); + }); + + it("should show cloud type and auth info in header", async () => { + await setupManifest(makeManifest()); + await cmdCloudInfo("sprite"); + const output = getOutput(); + expect(output).toContain("Type:"); + expect(output).toContain("Auth:"); + expect(output).toContain("SPRITE_TOKEN"); + }); + + it("should show cloud description", async () => { + await setupManifest(makeManifest()); + await cmdCloudInfo("sprite"); + const output = getOutput(); + expect(output).toContain("Dev VMs"); + }); + + it("should show setup guide link with cloud key", async () => { + await setupManifest(makeManifest()); + await cmdCloudInfo("sprite"); + const output = getOutput(); + expect(output).toContain("Full setup guide"); + expect(output).toContain("/sprite"); + }); +}); + +// ── cmdAgentInfo cloud list section ───────────────────────────────────────── + +describe("cmdAgentInfo cloud list and count display", () => { + let consoleSpy: ReturnType; + let consoleErrSpy: ReturnType; + let originalFetch: typeof global.fetch; + let processExitSpy: ReturnType; + + function setupManifest(manifest: Manifest) { + global.fetch = mock(async () => ({ + ok: true, + json: async () => manifest, + text: async () => JSON.stringify(manifest), + })) as any; + return loadManifest(true); + } + + function getOutput(): string { + return consoleSpy.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); + } + + beforeEach(async () => { + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + consoleErrSpy = spyOn(console, "error").mockImplementation(() => {}); + clearMocks(); + processExitSpy = spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as any); + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + processExitSpy.mockRestore(); + consoleSpy.mockRestore(); + consoleErrSpy.mockRestore(); + }); + + it("should show correct cloud count for agent", async () => { + const manifest = makeManifest({ + clouds: { + sprite: { + name: "Sprite", description: "Dev VMs", url: "https://sprite.sh", + type: "vm", auth: "SPRITE_TOKEN", + provision_method: "api", exec_method: "ssh", interactive_method: "ssh", + }, + hetzner: { + name: "Hetzner Cloud", description: "EU cloud", url: "https://console.hetzner.cloud", + type: "cloud", auth: "HCLOUD_TOKEN", + provision_method: "api", exec_method: "ssh", interactive_method: "ssh", + }, + vultr: { + name: "Vultr", description: "Global cloud", url: "https://my.vultr.com", + type: "cloud", auth: "VULTR_API_KEY", + provision_method: "api", exec_method: "ssh", interactive_method: "ssh", + }, + }, + matrix: { + "sprite/claude": "implemented", + "hetzner/claude": "implemented", + "vultr/claude": "missing", + }, + }); + await setupManifest(manifest); + await cmdAgentInfo("claude"); + const output = getOutput(); + expect(output).toContain("2 of 3"); + }); + + it("should show agent description", async () => { + await setupManifest(makeManifest()); + await cmdAgentInfo("claude"); + const output = getOutput(); + expect(output).toContain("AI coding assistant"); + }); + + it("should show agent install command", async () => { + await setupManifest(makeManifest()); + await cmdAgentInfo("claude"); + const output = getOutput(); + expect(output).toContain("npm install -g claude"); + }); + + it("should show agent URL", async () => { + await setupManifest(makeManifest()); + await cmdAgentInfo("claude"); + const output = getOutput(); + expect(output).toContain("https://claude.ai"); + }); +});