From 285bc4c5c219e97af2de4a273c2fcc81ac00c95e Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:13:14 -0800 Subject: [PATCH] test: add 41 tests for dry-run preview feature (#482) The showDryRunPreview function (added in PR #479) had only 1 basic subprocess test. These tests cover all branches: agent info display (name, description, install, launch), cloud info (name, description, defaults), script URL format, env var display with OPENROUTER_API_KEY redaction, prompt truncation at 100 chars, section ordering, and verification that no script download occurs in dry-run mode. Agent: test-engineer Co-authored-by: A <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- cli/src/__tests__/dry-run-preview.test.ts | 620 ++++++++++++++++++++++ 1 file changed, 620 insertions(+) create mode 100644 cli/src/__tests__/dry-run-preview.test.ts diff --git a/cli/src/__tests__/dry-run-preview.test.ts b/cli/src/__tests__/dry-run-preview.test.ts new file mode 100644 index 00000000..8e65f51d --- /dev/null +++ b/cli/src/__tests__/dry-run-preview.test.ts @@ -0,0 +1,620 @@ +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { loadManifest, type Manifest } from "../manifest"; + +/** + * Tests for the dry-run preview feature (showDryRunPreview via cmdRun). + * + * showDryRunPreview (commands.ts:328-372) renders agent info, cloud info, + * script URL, environment variables, and an optional prompt without + * provisioning any resources. It was added in PR #479. + * + * These tests cover: + * - Basic output structure (section headers, completion message) + * - Agent info display (name, description, install, launch) + * - Cloud info display (name, description, defaults) + * - Script URL format + * - Environment variable display with OPENROUTER_API_KEY redaction + * - Prompt display with truncation at 100 characters + * - No script download occurs in dry-run mode + * - Different agent/cloud combinations + * - Minimal agent (no install/launch/env) + * + * Agent: test-engineer + */ + +// ── Manifests ────────────────────────────────────────────────────────────────── + +const standardManifest: Manifest = { + agents: { + claude: { + name: "Claude Code", + description: "AI coding assistant by Anthropic", + url: "https://claude.ai", + install: "npm install -g @anthropic-ai/claude-code", + launch: "claude", + env: { + ANTHROPIC_API_KEY: "$OPENROUTER_API_KEY", + ANTHROPIC_BASE_URL: "https://openrouter.ai/api", + }, + }, + aider: { + name: "Aider", + description: "AI pair programmer", + url: "https://aider.chat", + install: "pip install aider-chat", + launch: "aider", + env: { + OPENAI_API_KEY: "test-key", + }, + }, + }, + clouds: { + sprite: { + name: "Sprite", + description: "Lightweight VMs", + url: "https://sprite.sh", + type: "vm", + auth: "SPRITE_TOKEN", + provision_method: "api", + exec_method: "ssh", + interactive_method: "ssh", + defaults: { + region: "us-east-1", + size: "small", + }, + }, + hetzner: { + name: "Hetzner Cloud", + description: "European cloud provider", + url: "https://hetzner.com", + type: "cloud", + auth: "HCLOUD_TOKEN", + provision_method: "api", + exec_method: "ssh", + interactive_method: "ssh", + }, + }, + matrix: { + "sprite/claude": "implemented", + "sprite/aider": "implemented", + "hetzner/claude": "implemented", + "hetzner/aider": "missing", + }, +}; + +const minimalAgentManifest: Manifest = { + agents: { + bare: { + name: "Bare Agent", + description: "Agent with minimal fields", + url: "https://example.com", + install: "", + launch: "", + env: {}, + }, + }, + clouds: { + testcloud: { + name: "Test Cloud", + description: "A test cloud", + url: "https://testcloud.example.com", + type: "container", + auth: "TEST_TOKEN", + provision_method: "cli", + exec_method: "exec", + interactive_method: "exec", + }, + }, + matrix: { + "testcloud/bare": "implemented", + }, +}; + +// ── Mock setup ───────────────────────────────────────────────────────────────── + +const mockLogError = mock(() => {}); +const mockLogInfo = mock(() => {}); +const mockLogStep = 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, + warn: mock(() => {}), + error: mockLogError, + success: mockLogSuccess, + }, + intro: mock(() => {}), + outro: mock(() => {}), + cancel: mock(() => {}), + select: mock(() => {}), + isCancel: () => false, +})); + +const { cmdRun } = await import("../commands.js"); + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe("Dry-run preview (showDryRunPreview via cmdRun)", () => { + let originalFetch: typeof global.fetch; + let consoleLogSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + function setupManifest(manifest: Manifest) { + global.fetch = mock(async () => ({ + ok: true, + json: async () => manifest, + text: async () => JSON.stringify(manifest), + })) as any; + } + + function clearMocks() { + mockLogError.mockClear(); + mockLogInfo.mockClear(); + mockLogStep.mockClear(); + mockLogSuccess.mockClear(); + mockSpinnerStart.mockClear(); + mockSpinnerStop.mockClear(); + mockSpinnerMessage.mockClear(); + } + + function getLogText(): string { + return consoleLogSpy.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); + } + + function getStepCalls(): string[] { + return mockLogStep.mock.calls.map((c: any[]) => c.join(" ")); + } + + function getInfoCalls(): string[] { + return mockLogInfo.mock.calls.map((c: any[]) => c.join(" ")); + } + + function getSuccessCalls(): string[] { + return mockLogSuccess.mock.calls.map((c: any[]) => c.join(" ")); + } + + beforeEach(async () => { + clearMocks(); + originalFetch = global.fetch; + consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); + consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + global.fetch = originalFetch; + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + // ── Basic output structure ─────────────────────────────────────────── + + describe("basic output structure", () => { + it("should show 'Dry run' info message at the start", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getInfoCalls().some(c => c.includes("Dry run"))).toBe(true); + }); + + it("should show 'no resources will be provisioned' at the start", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getInfoCalls().some(c => c.includes("no resources"))).toBe(true); + }); + + it("should show 'Dry run complete' success message", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getSuccessCalls().some(c => c.includes("Dry run complete"))).toBe(true); + }); + + it("should show 'no resources were provisioned' in completion", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getSuccessCalls().some(c => c.includes("no resources were provisioned"))).toBe(true); + }); + + it("should not fetch .sh script files during dry-run", async () => { + const fetchedUrls: string[] = []; + global.fetch = mock(async (url: string) => { + if (typeof url === "string") fetchedUrls.push(url); + return { + ok: true, + json: async () => standardManifest, + text: async () => JSON.stringify(standardManifest), + }; + }) as any; + + await loadManifest(true); + fetchedUrls.length = 0; + + await cmdRun("claude", "sprite", undefined, true); + + const scriptFetches = fetchedUrls.filter(u => u.includes(".sh")); + expect(scriptFetches).toHaveLength(0); + }); + + it("should return without error (no process.exit)", async () => { + setupManifest(standardManifest); + await loadManifest(true); + + // Should complete without throwing + await cmdRun("claude", "sprite", undefined, true); + expect(getSuccessCalls().length).toBeGreaterThan(0); + }); + }); + + // ── Agent section ──────────────────────────────────────────────────── + + describe("agent information", () => { + it("should show Agent section header", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getStepCalls().some(c => c.includes("Agent"))).toBe(true); + }); + + it("should display agent name", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getLogText()).toContain("Claude Code"); + }); + + it("should display agent description", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getLogText()).toContain("AI coding assistant by Anthropic"); + }); + + it("should display install command", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getLogText()).toContain("npm install -g @anthropic-ai/claude-code"); + }); + + it("should display launch command", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + const text = getLogText(); + expect(text).toContain("Launch:"); + expect(text).toContain("claude"); + }); + + it("should not show Install line when install is empty", async () => { + setupManifest(minimalAgentManifest); + await loadManifest(true); + await cmdRun("bare", "testcloud", undefined, true); + + expect(getLogText()).not.toContain("Install:"); + }); + + it("should not show Launch line when launch is empty", async () => { + setupManifest(minimalAgentManifest); + await loadManifest(true); + await cmdRun("bare", "testcloud", undefined, true); + + expect(getLogText()).not.toContain("Launch:"); + }); + }); + + // ── Cloud section ──────────────────────────────────────────────────── + + describe("cloud information", () => { + it("should show Cloud section header", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getStepCalls().some(c => c.includes("Cloud"))).toBe(true); + }); + + it("should display cloud name", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getLogText()).toContain("Sprite"); + }); + + it("should display cloud description", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getLogText()).toContain("Lightweight VMs"); + }); + + it("should display cloud defaults when present", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + const text = getLogText(); + expect(text).toContain("Defaults:"); + expect(text).toContain("region"); + expect(text).toContain("us-east-1"); + expect(text).toContain("size"); + expect(text).toContain("small"); + }); + + it("should not show Defaults section when cloud has no defaults", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "hetzner", undefined, true); + + expect(getLogText()).not.toContain("Defaults:"); + }); + }); + + // ── Script URL ────────────────────────────────────────────────────── + + describe("script URL", () => { + it("should show Script section header", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getStepCalls().some(c => c.includes("Script"))).toBe(true); + }); + + it("should display script URL with cloud/agent path", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getLogText()).toContain("sprite/claude.sh"); + }); + + it("should use openrouter.ai lab URL", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getLogText()).toContain("openrouter.ai/lab/spawn"); + }); + }); + + // ── Environment variables ────────────────────────────────────────── + + describe("environment variables", () => { + it("should show Environment variables section header", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getStepCalls().some(c => c.includes("Environment"))).toBe(true); + }); + + it("should display ANTHROPIC_API_KEY", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getLogText()).toContain("ANTHROPIC_API_KEY"); + }); + + it("should display ANTHROPIC_BASE_URL with its value", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + const text = getLogText(); + expect(text).toContain("ANTHROPIC_BASE_URL"); + expect(text).toContain("https://openrouter.ai/api"); + }); + + it("should redact OPENROUTER_API_KEY references as '(from OpenRouter)'", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getLogText()).toContain("(from OpenRouter)"); + }); + + it("should show non-OPENROUTER values as-is", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("aider", "sprite", undefined, true); + + expect(getLogText()).toContain("OPENAI_API_KEY=test-key"); + }); + + it("should not display env var lines when agent env is empty object", async () => { + setupManifest(minimalAgentManifest); + await loadManifest(true); + await cmdRun("bare", "testcloud", undefined, true); + + const text = getLogText(); + // No KEY=value lines should appear + expect(text).not.toMatch(/[A-Z_]+=\S/); + }); + }); + + // ── Prompt display ────────────────────────────────────────────────── + + describe("prompt display", () => { + it("should show Prompt section header when prompt provided", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", "Fix linter errors", true); + + expect(getStepCalls().some(c => c.includes("Prompt"))).toBe(true); + }); + + it("should display short prompt in full", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", "Fix all linter errors", true); + + expect(getLogText()).toContain("Fix all linter errors"); + }); + + it("should truncate prompts longer than 100 characters with ...", async () => { + setupManifest(standardManifest); + await loadManifest(true); + const longPrompt = "A".repeat(150); + await cmdRun("claude", "sprite", longPrompt, true); + + const text = getLogText(); + expect(text).toContain("A".repeat(100) + "..."); + expect(text).not.toContain("A".repeat(101)); + }); + + it("should display exactly 100-char prompt without truncation", async () => { + setupManifest(standardManifest); + await loadManifest(true); + const exactPrompt = "B".repeat(100); + await cmdRun("claude", "sprite", exactPrompt, true); + + const text = getLogText(); + expect(text).toContain("B".repeat(100)); + // The 100-char string should appear without trailing "..." + const lines = consoleLogSpy.mock.calls.map((c: any[]) => c.join(" ")); + const promptLine = lines.find(l => l.includes("B".repeat(50))); + expect(promptLine).toBeDefined(); + expect(promptLine!.endsWith("...")).toBe(false); + }); + + it("should truncate 101-char prompt", async () => { + setupManifest(standardManifest); + await loadManifest(true); + const prompt101 = "C".repeat(101); + await cmdRun("claude", "sprite", prompt101, true); + + const text = getLogText(); + expect(text).toContain("C".repeat(100) + "..."); + }); + + it("should not show Prompt section when no prompt", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + expect(getStepCalls().some(c => c.includes("Prompt"))).toBe(false); + }); + }); + + // ── Different combinations ───────────────────────────────────────── + + describe("different agent/cloud combinations", () => { + it("should work with aider on sprite", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("aider", "sprite", undefined, true); + + const text = getLogText(); + expect(text).toContain("Aider"); + expect(text).toContain("AI pair programmer"); + expect(text).toContain("sprite/aider.sh"); + expect(getSuccessCalls().some(c => c.includes("Dry run complete"))).toBe(true); + }); + + it("should work with claude on hetzner (cloud without defaults)", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "hetzner", undefined, true); + + const text = getLogText(); + expect(text).toContain("Claude Code"); + expect(text).toContain("Hetzner Cloud"); + expect(text).toContain("European cloud provider"); + expect(text).toContain("hetzner/claude.sh"); + expect(text).not.toContain("Defaults:"); + }); + + it("should work with minimal agent (empty install, launch, env)", async () => { + setupManifest(minimalAgentManifest); + await loadManifest(true); + await cmdRun("bare", "testcloud", undefined, true); + + const text = getLogText(); + expect(text).toContain("Bare Agent"); + expect(text).toContain("Test Cloud"); + expect(getSuccessCalls().some(c => c.includes("Dry run complete"))).toBe(true); + }); + + it("should include prompt with different agent/cloud", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("aider", "sprite", "Add unit tests", true); + + const text = getLogText(); + expect(text).toContain("Add unit tests"); + expect(getStepCalls().some(c => c.includes("Prompt"))).toBe(true); + }); + }); + + // ── Section ordering ────────────────────────────────────────────── + + describe("section ordering", () => { + it("should show Agent before Cloud section", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + const steps = getStepCalls(); + const agentIdx = steps.findIndex(c => c.includes("Agent")); + const cloudIdx = steps.findIndex(c => c.includes("Cloud")); + expect(agentIdx).toBeLessThan(cloudIdx); + }); + + it("should show Cloud before Script section", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + const steps = getStepCalls(); + const cloudIdx = steps.findIndex(c => c.includes("Cloud")); + const scriptIdx = steps.findIndex(c => c.includes("Script")); + expect(cloudIdx).toBeLessThan(scriptIdx); + }); + + it("should show Script before Environment section", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", undefined, true); + + const steps = getStepCalls(); + const scriptIdx = steps.findIndex(c => c.includes("Script")); + const envIdx = steps.findIndex(c => c.includes("Environment")); + expect(scriptIdx).toBeLessThan(envIdx); + }); + + it("should show Environment before Prompt section", async () => { + setupManifest(standardManifest); + await loadManifest(true); + await cmdRun("claude", "sprite", "test prompt", true); + + const steps = getStepCalls(); + const envIdx = steps.findIndex(c => c.includes("Environment")); + const promptIdx = steps.findIndex(c => c.includes("Prompt")); + expect(envIdx).toBeLessThan(promptIdx); + }); + }); +});