From 433708709cf8789bca1baf022df4cee1bec58e4d Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:54:15 -0800 Subject: [PATCH] test: Remove 5 duplicate and theatrical test files (#1943) * test: remove 5 duplicate and theatrical test files Remove test files that are fully duplicated by more comprehensive counterparts, plus one theatrical test that only grep-checks shell script text without testing behavior. Duplicates removed: - manifest-validation.test.ts (subset of manifest-cache-lifecycle.test.ts) - matrix-compact-footer.test.ts (subset of commands-exported-utils.test.ts) - commands-output.test.ts (subset of commands-display.test.ts) - cloud-info.test.ts (subset of commands-cloud-info.test.ts) Theatrical test removed: - install-script-validation.test.ts (reads install.sh as string, checks substring presence -- tests that functions "exist" not that they work) All 1657 remaining tests pass. Zero regressions. Co-Authored-By: Claude Sonnet 4.6 * test: Fix always-pass pattern and stale comments - integration.test.ts: remove conditional `if (cacheExists)` block that silently skipped the cache-file assertion when the file wasn't written; the second loadManifest() call already exercises in-memory caching without needing the conditional; remove now-unused readFileSync/existsSync imports - commands.test.ts: remove stale references to cloud-info.test.ts and commands-output.test.ts (deleted in prior commit) from inline comment; remove unused createMockManifest import Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: spawn-qa-bot Co-authored-by: Claude Sonnet 4.6 --- packages/cli/src/__tests__/cloud-info.test.ts | 348 -------------- .../cli/src/__tests__/commands-output.test.ts | 319 ------------- packages/cli/src/__tests__/commands.test.ts | 8 +- .../install-script-validation.test.ts | 435 ------------------ .../cli/src/__tests__/integration.test.ts | 14 +- .../src/__tests__/manifest-validation.test.ts | 270 ----------- .../__tests__/matrix-compact-footer.test.ts | 339 -------------- 7 files changed, 5 insertions(+), 1728 deletions(-) delete mode 100644 packages/cli/src/__tests__/cloud-info.test.ts delete mode 100644 packages/cli/src/__tests__/commands-output.test.ts delete mode 100644 packages/cli/src/__tests__/install-script-validation.test.ts delete mode 100644 packages/cli/src/__tests__/manifest-validation.test.ts delete mode 100644 packages/cli/src/__tests__/matrix-compact-footer.test.ts diff --git a/packages/cli/src/__tests__/cloud-info.test.ts b/packages/cli/src/__tests__/cloud-info.test.ts deleted file mode 100644 index 2de89dfb..00000000 --- a/packages/cli/src/__tests__/cloud-info.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -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 " 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", - }, - local: { - name: "Local Machine", - description: "Run agents locally", - url: "https://github.com/OpenRouterTeam/spawn", - type: "local", - auth: "none", - provision_method: "none", - exec_method: "bash -c", - interactive_method: "bash -c", - }, - authcloud: { - name: "Auth Cloud", - description: "Cloud with env var auth", - url: "https://auth.example.com", - type: "cloud", - auth: "AUTH_TOKEN", - provision_method: "api", - exec_method: "ssh", - interactive_method: "ssh", - }, - }, - matrix: { - ...mockManifest.matrix, - "railway/claude": "implemented", - "railway/codex": "missing", - "local/claude": "implemented", - "local/codex": "implemented", - "authcloud/claude": "implemented", - // 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(() => {}), - autocomplete: mock(async () => "claude"), - text: mock(async () => undefined), - isCancel: () => false, -})); - -// Import commands after mock setup -const { cmdCloudInfo } = await import("../commands.js"); - -describe("cmdCloudInfo", () => { - let consoleMocks: ReturnType; - let originalFetch: typeof global.fetch; - let processExitSpy: ReturnType; - let savedORKey: string | undefined; - - beforeEach(async () => { - consoleMocks = createConsoleMocks(); - mockLogError.mockClear(); - mockLogInfo.mockClear(); - mockLogStep.mockClear(); - mockSpinnerStart.mockClear(); - mockSpinnerStop.mockClear(); - - processExitSpy = spyOn(process, "exit").mockImplementation((_code?: number): never => { - throw new Error("process.exit"); - }); - - savedORKey = process.env.OPENROUTER_API_KEY; - delete process.env.OPENROUTER_API_KEY; - - originalFetch = global.fetch; - global.fetch = mock(async () => new Response(JSON.stringify(extendedManifest))); - - await loadManifest(true); - }); - - afterEach(() => { - global.fetch = originalFetch; - processExitSpy.mockRestore(); - restoreMocks(consoleMocks.log, consoleMocks.error); - if (savedORKey !== undefined) { - process.env.OPENROUTER_API_KEY = savedORKey; - } else { - delete process.env.OPENROUTER_API_KEY; - } - }); - - // ── 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("codex"); - }); - - 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 codex 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/codex is "missing" in mock manifest - expect(output).not.toContain("spawn codex 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 codex 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 codex emptycloud"); - }); - }); - - // ── Quick-start auth display ──────────────────────────────────────────── - - describe("quick-start auth display", () => { - it("should show OPENROUTER_API_KEY for cloud with 'none' auth", async () => { - await cmdCloudInfo("local"); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("OPENROUTER_API_KEY"); - }); - - it("should not show 'none' as a command for cloud with 'none' auth", async () => { - await cmdCloudInfo("local"); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - // "none" should not appear as an auth instruction in quick-start - const quickStartLines = output.split("\n"); - const noneAsCommand = quickStartLines.some( - (line: string) => line.includes("Quick start") === false && line.trim() === "none", - ); - expect(noneAsCommand).toBe(false); - }); - - it("should show cloud-specific auth env var for cloud with env var auth", async () => { - await cmdCloudInfo("authcloud"); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("AUTH_TOKEN"); - expect(output).toContain("OPENROUTER_API_KEY"); - }); - }); - - // ── 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); - }); - }); -}); diff --git a/packages/cli/src/__tests__/commands-output.test.ts b/packages/cli/src/__tests__/commands-output.test.ts deleted file mode 100644 index d9275510..00000000 --- a/packages/cli/src/__tests__/commands-output.test.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; -import { createMockManifest, createConsoleMocks, restoreMocks } from "./test-helpers"; -import { loadManifest } from "../manifest"; - -/** - * Tests for command output functions (cmdList, cmdAgents, cmdClouds, cmdAgentInfo, cmdHelp). - * - * Strategy: mock @clack/prompts to prevent TTY output, and mock global.fetch - * so that loadManifest returns controlled test data. Before each test, we call - * loadManifest(true) to force a cache refresh through our mocked fetch. - * - * Agent: test-engineer - */ - -const mockManifest = createMockManifest(); - -// Mock @clack/prompts to prevent TTY output -const mockSpinnerStart = mock(() => {}); -const mockSpinnerStop = mock(() => {}); - -mock.module("@clack/prompts", () => ({ - spinner: () => ({ - start: mockSpinnerStart, - stop: mockSpinnerStop, - message: mock(() => {}), - }), - log: { - step: mock(() => {}), - info: mock(() => {}), - warn: mock(() => {}), - error: mock(() => {}), - }, - intro: mock(() => {}), - outro: mock(() => {}), - cancel: mock(() => {}), - select: mock(() => {}), - autocomplete: mock(async () => "claude"), - text: mock(async () => undefined), - isCancel: () => false, -})); - -// Import commands after @clack/prompts mock is set up -const { cmdMatrix, cmdAgents, cmdClouds, cmdAgentInfo, cmdHelp } = await import("../commands.js"); - -describe("Command Output Functions", () => { - let consoleMocks: ReturnType; - let originalFetch: typeof global.fetch; - - beforeEach(async () => { - consoleMocks = createConsoleMocks(); - mockSpinnerStart.mockClear(); - mockSpinnerStop.mockClear(); - - // Mock fetch to return our controlled manifest data - originalFetch = global.fetch; - global.fetch = mock(async () => new Response(JSON.stringify(mockManifest))); - - // Force-refresh the manifest cache so it picks up our mocked fetch data. - // This ensures the in-memory _cached in manifest.ts is set to our test data, - // regardless of what other test files may have loaded before us. - await loadManifest(true); - }); - - afterEach(() => { - global.fetch = originalFetch; - restoreMocks(consoleMocks.log, consoleMocks.error); - }); - - // ── cmdList ───────────────────────────────────────────────────────────── - - describe("cmdMatrix", () => { - it("should load manifest and display matrix table", async () => { - await cmdMatrix(); - expect(consoleMocks.log).toHaveBeenCalled(); - }); - - it("should show agent names in the matrix", async () => { - await cmdMatrix(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("Claude Code"); - expect(output).toContain("Codex"); - }); - - it("should show cloud names in the header", async () => { - await cmdMatrix(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("Sprite"); - expect(output).toContain("Hetzner Cloud"); - }); - - it("should show implementation count", async () => { - await cmdMatrix(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - // 3 implemented out of 4 total (2 agents x 2 clouds) - expect(output).toContain("3/4"); - }); - - it("should show legend with implemented and not-yet-available labels", async () => { - await cmdMatrix(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("implemented"); - expect(output).toContain("not yet available"); - }); - - it("should use spinner while loading", async () => { - await cmdMatrix(); - expect(mockSpinnerStart).toHaveBeenCalled(); - expect(mockSpinnerStop).toHaveBeenCalled(); - }); - }); - - // ── cmdAgents ─────────────────────────────────────────────────────────── - - describe("cmdAgents", () => { - it("should load manifest and display agents", async () => { - await cmdAgents(); - expect(consoleMocks.log).toHaveBeenCalled(); - }); - - it("should show all agent keys", async () => { - await cmdAgents(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("claude"); - expect(output).toContain("codex"); - }); - - it("should show agent display names", async () => { - await cmdAgents(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("Claude Code"); - expect(output).toContain("Codex"); - }); - - it("should show cloud count per agent", async () => { - await cmdAgents(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - // claude has 2 clouds (sprite, hetzner), codex has 1 (sprite) - expect(output).toContain("2 clouds"); - expect(output).toContain("1 cloud"); - expect(output).not.toContain("1 clouds"); - }); - - it("should show agent descriptions", async () => { - await cmdAgents(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("AI coding assistant"); - expect(output).toContain("AI pair programmer"); - }); - - it("should show usage hint at bottom", async () => { - await cmdAgents(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("spawn "); - }); - - it("should show Agents header", async () => { - await cmdAgents(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("Agents"); - }); - }); - - // ── cmdClouds ─────────────────────────────────────────────────────────── - - describe("cmdClouds", () => { - it("should load manifest and display clouds", async () => { - await cmdClouds(); - expect(consoleMocks.log).toHaveBeenCalled(); - }); - - it("should show all cloud keys", async () => { - await cmdClouds(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("sprite"); - expect(output).toContain("hetzner"); - }); - - it("should show cloud display names", async () => { - await cmdClouds(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("Sprite"); - expect(output).toContain("Hetzner Cloud"); - }); - - it("should show agent count per cloud", async () => { - await cmdClouds(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - // sprite has 2 agents (claude, codex), hetzner has 1 (claude) - shown as X/Y ratio - expect(output).toContain("2/2"); - expect(output).toContain("1/2"); - }); - - it("should show cloud descriptions", async () => { - await cmdClouds(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("Lightweight VMs"); - expect(output).toContain("European cloud provider"); - }); - - it("should show Cloud Providers header", async () => { - await cmdClouds(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("Cloud Providers"); - }); - - it("should show usage hint at bottom", async () => { - await cmdClouds(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("spawn "); - }); - }); - - // ── cmdAgentInfo ──────────────────────────────────────────────────────── - - describe("cmdAgentInfo", () => { - it("should show agent name and description", async () => { - await cmdAgentInfo("claude"); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("Claude Code"); - expect(output).toContain("AI coding assistant"); - }); - - it("should show Available clouds header", async () => { - await cmdAgentInfo("claude"); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("Available clouds"); - }); - - it("should list implemented clouds for claude", async () => { - await cmdAgentInfo("claude"); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("sprite"); - expect(output).toContain("hetzner"); - }); - - it("should show launch command hint for each cloud", async () => { - await cmdAgentInfo("claude"); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("spawn claude sprite"); - expect(output).toContain("spawn claude hetzner"); - }); - - it("should only show implemented clouds for codex", async () => { - await cmdAgentInfo("codex"); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("sprite"); - // hetzner/codex is "missing" in mock manifest - expect(output).not.toContain("spawn codex hetzner"); - }); - - it("should show codex description", async () => { - await cmdAgentInfo("codex"); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("Codex"); - expect(output).toContain("AI pair programmer"); - }); - }); - - // ── cmdHelp ───────────────────────────────────────────────────────────── - - describe("cmdHelp output content", () => { - it("should include all subcommand names", () => { - cmdHelp(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("list"); - expect(output).toContain("agents"); - expect(output).toContain("clouds"); - expect(output).toContain("update"); - expect(output).toContain("version"); - expect(output).toContain("help"); - }); - - it("should include USAGE section", () => { - cmdHelp(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("USAGE"); - }); - - it("should include EXAMPLES section", () => { - cmdHelp(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("EXAMPLES"); - }); - - it("should include AUTHENTICATION section", () => { - cmdHelp(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("AUTHENTICATION"); - expect(output).toContain("OPENROUTER_API_KEY"); - }); - - it("should include TROUBLESHOOTING section", () => { - cmdHelp(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("TROUBLESHOOTING"); - }); - - it("should include --prompt and --prompt-file usage", () => { - cmdHelp(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("--prompt"); - expect(output).toContain("--prompt-file"); - }); - - it("should include repo and OpenRouter links", () => { - cmdHelp(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("openrouter.ai"); - expect(output).toContain("github.com"); - }); - - it("should include install command", () => { - cmdHelp(); - const output = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); - expect(output).toContain("curl -fsSL"); - expect(output).toContain("install.sh"); - }); - }); -}); diff --git a/packages/cli/src/__tests__/commands.test.ts b/packages/cli/src/__tests__/commands.test.ts index 95d2fda4..46fb0083 100644 --- a/packages/cli/src/__tests__/commands.test.ts +++ b/packages/cli/src/__tests__/commands.test.ts @@ -1,8 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { cmdHelp } from "../commands"; -import { createConsoleMocks, createProcessExitMock, restoreMocks, createMockManifest } from "./test-helpers"; - -const mockManifest = createMockManifest(); +import { createConsoleMocks, createProcessExitMock, restoreMocks } from "./test-helpers"; // Note: Bun test doesn't support module mocking the same way as vitest // These tests require refactoring commands.ts to use dependency injection @@ -33,8 +31,8 @@ describe("commands", () => { // These functions are tested in dedicated files using mock.module(): // - cmdList: commands-list-grid.test.ts, commands-compact-list.test.ts - // - cmdAgents/cmdClouds: commands-display.test.ts, commands-output.test.ts, commands-list-grid.test.ts - // - cmdAgentInfo/cmdCloudInfo: commands-info-details.test.ts, commands-display.test.ts, cloud-info.test.ts + // - cmdAgents/cmdClouds: commands-display.test.ts, commands-list-grid.test.ts + // - cmdAgentInfo/cmdCloudInfo: commands-info-details.test.ts, commands-display.test.ts, commands-cloud-info.test.ts // - cmdRun: commands-resolve-run.test.ts, commands-swap-resolve.test.ts, cmdrun-resolution.test.ts // - Download/failure: download-and-failure.test.ts, commands-update-download.test.ts }); diff --git a/packages/cli/src/__tests__/install-script-validation.test.ts b/packages/cli/src/__tests__/install-script-validation.test.ts deleted file mode 100644 index 23ed68b4..00000000 --- a/packages/cli/src/__tests__/install-script-validation.test.ts +++ /dev/null @@ -1,435 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { readFileSync, existsSync } from "node:fs"; -import { resolve, join } from "node:path"; -import { isString } from "@openrouter/spawn-shared"; - -/** - * Validation tests for sh/cli/install.sh. - * - * install.sh is the critical entry point for all new users - * (curl -fsSL ... | bash). It has been modified in multiple recent PRs - * but had zero test coverage. These tests validate structure, conventions, - * security, curl|bash compatibility, and the source-mode fallback wrapper. - * - * Agent: test-engineer - */ - -const REPO_ROOT = resolve(import.meta.dir, "../../../.."); -const INSTALL_SH = join(REPO_ROOT, "sh", "cli", "install.sh"); -const content = readFileSync(INSTALL_SH, "utf-8"); -const lines = content.split("\n"); - -/** Get non-comment, non-empty lines */ -function codeLines(): string[] { - return lines.filter((line) => line.trim() !== "" && !line.trimStart().startsWith("#")); -} - -describe("install.sh validation", () => { - // ── File existence and structure ───────────────────────────────────── - - describe("file structure", () => { - it("should exist on disk", () => { - expect(existsSync(INSTALL_SH)).toBe(true); - }); - - it("should start with #!/bin/bash shebang", () => { - expect(lines[0]).toBe("#!/bin/bash"); - }); - - it("should use set -eo pipefail", () => { - expect(content).toContain("set -eo pipefail"); - }); - - it("should not be empty", () => { - expect(content.trim().length).toBeGreaterThan(100); - }); - - it("should not use set -u or set -o nounset", () => { - const code = codeLines(); - const hasSetU = code.some( - (l) => (/\bset\s+.*-.*u\b/.test(l) && !l.includes("pipefail")) || /set\s+-o\s+nounset/.test(l), - ); - expect(hasSetU).toBe(false); - }); - }); - - // ── Repository constants ───────────────────────────────────────────── - - describe("repository constants", () => { - it("should define SPAWN_REPO pointing to OpenRouterTeam/spawn", () => { - expect(content).toContain('SPAWN_REPO="OpenRouterTeam/spawn"'); - }); - - it("should define SPAWN_RAW_BASE using SPAWN_REPO", () => { - expect(content).toContain("SPAWN_RAW_BASE="); - expect(content).toContain("raw.githubusercontent.com"); - expect(content).toContain("${SPAWN_REPO}"); - }); - - it("should define MIN_BUN_VERSION", () => { - expect(content).toMatch(/MIN_BUN_VERSION="[0-9]+\.[0-9]+\.[0-9]+"/); - }); - }); - - // ── Required functions ─────────────────────────────────────────────── - - describe("required functions", () => { - it("should define log_info function", () => { - expect(content).toMatch(/log_info\(\)/); - }); - - it("should define log_warn function", () => { - expect(content).toMatch(/log_warn\(\)/); - }); - - it("should define log_error function", () => { - expect(content).toMatch(/log_error\(\)/); - }); - - it("should define version_gte function", () => { - expect(content).toContain("version_gte()"); - }); - - it("should define ensure_min_bun_version function", () => { - expect(content).toContain("ensure_min_bun_version()"); - }); - - it("should define ensure_in_path function", () => { - expect(content).toContain("ensure_in_path()"); - }); - - it("should define build_and_install function", () => { - expect(content).toContain("build_and_install()"); - }); - }); - - // ── curl|bash compatibility ────────────────────────────────────────── - - describe("curl|bash compatibility", () => { - it("should not use source <(...) process substitution", () => { - const code = codeLines(); - const hasProcessSub = code.some((l) => /source\s+<\(/.test(l)); - expect(hasProcessSub).toBe(false); - }); - - it("should not rely on BASH_SOURCE for path resolution", () => { - // install.sh runs via curl|bash so BASH_SOURCE is meaningless - expect(content).not.toContain("BASH_SOURCE"); - }); - - it("should not rely on dirname $0 for path resolution", () => { - expect(content).not.toContain('dirname "$0"'); - expect(content).not.toContain("dirname $0"); - }); - - it("should use SPAWN_INSTALL_DIR env var for override", () => { - expect(content).toContain("SPAWN_INSTALL_DIR"); - }); - - it("should check for SPAWN_INSTALL_DIR before defaulting", () => { - // build_and_install should check SPAWN_INSTALL_DIR first - const fnStart = content.indexOf("build_and_install()"); - const fnBody = content.slice(fnStart); - expect(fnBody).toContain("SPAWN_INSTALL_DIR"); - }); - }); - - // ── Bun installation ──────────────────────────────────────────────── - - describe("bun installation", () => { - it("should check if bun is available via command -v", () => { - expect(content).toContain("command -v bun"); - }); - - it("should install bun from bun.sh if not found", () => { - expect(content).toContain("https://bun.sh/install"); - }); - - it("should set BUN_INSTALL and PATH after installing bun", () => { - expect(content).toContain("BUN_INSTALL="); - expect(content).toContain("${BUN_INSTALL}/bin"); - }); - - it("should show error and exit if bun installation fails", () => { - // After installing bun, should check again and show error if still not found - const afterInstall = content.slice(content.indexOf("https://bun.sh/install")); - expect(afterInstall).toContain("command -v bun"); - expect(afterInstall).toContain("log_error"); - expect(afterInstall).toContain("exit 1"); - }); - - it("should call ensure_min_bun_version after bun is available", () => { - // ensure_min_bun_version should be called in the main flow - const mainFlow = content.slice(content.lastIndexOf("ensure_min_bun_version")); - expect(mainFlow).toContain("build_and_install"); - }); - }); - - // ── Version comparison ────────────────────────────────────────────── - - describe("version_gte logic", () => { - // Extract the full function body by finding the next function or end of file - const fnStart = content.indexOf("version_gte()"); - const fnEnd = content.indexOf("\n\n# ---", fnStart); - const fnBody = content.slice(fnStart, fnEnd > fnStart ? fnEnd : undefined); - - it("should use IFS='.' to split semver", () => { - expect(fnBody).toContain("IFS='.'"); - }); - - it("should handle missing segments with default 0", () => { - // ${a[$i]:-0} or similar default-to-zero pattern - expect(fnBody).toContain(":-0"); - }); - - it("should return 1 (false) when version is less", () => { - expect(fnBody).toContain("return 1"); - }); - - it("should return 0 (true) when version is greater or equal", () => { - expect(fnBody).toContain("return 0"); - }); - }); - - // ── Build and install logic ───────────────────────────────────────── - - describe("build_and_install", () => { - it("should create a temp directory", () => { - expect(content).toContain("mktemp -d"); - }); - - it("should clean up temp directory on exit via trap", () => { - expect(content).toContain("trap"); - expect(content).toContain("rm -rf"); - }); - - it("should download pre-built binary from GitHub releases", () => { - const fnStart = content.indexOf("build_and_install()"); - const fnBody = content.slice(fnStart); - expect(fnBody).toContain("cli-latest"); - expect(fnBody).toContain("cli.js"); - }); - - it("should default install dir to ~/.local/bin", () => { - const fnStart = content.indexOf("build_and_install()"); - const fnBody = content.slice(fnStart); - expect(fnBody).toContain("${HOME}/.local/bin"); - }); - - it("should create the install directory if it does not exist", () => { - const fnStart = content.indexOf("build_and_install()"); - const fnBody = content.slice(fnStart); - expect(fnBody).toContain('mkdir -p "${INSTALL_DIR}"'); - }); - - it("should set chmod +x on the spawn binary", () => { - expect(content).toContain('chmod +x "${INSTALL_DIR}/spawn"'); - }); - - it("should call ensure_in_path at the end", () => { - const fnStart = content.indexOf("build_and_install()"); - const fnBody = content.slice(fnStart); - expect(fnBody).toContain("ensure_in_path"); - }); - }); - - // ── Binary download ───────────────────────────────────────────────── - - describe("binary download", () => { - it("should download from GitHub releases with cli-latest tag", () => { - expect(content).toContain("github.com"); - expect(content).toContain("releases/download"); - expect(content).toContain("cli-latest"); - expect(content).toContain("cli.js"); - }); - - it("should validate that downloaded binary is not empty", () => { - const fnStart = content.indexOf("build_and_install()"); - const fnBody = content.slice(fnStart); - expect(fnBody).toContain("! -s"); - }); - - it("should copy downloaded cli.js to install directory", () => { - const fnStart = content.indexOf("build_and_install()"); - const fnBody = content.slice(fnStart); - expect(fnBody).toContain("cli.js"); - expect(fnBody).toContain("${INSTALL_DIR}/spawn"); - }); - - it("should show helpful message after installation", () => { - const fnStart = content.indexOf("ensure_in_path()"); - const fnBody = content.slice(fnStart); - expect(fnBody).toContain('spawn" version'); - expect(fnBody).toContain("to get started"); - }); - - it("should not use clone_cli or source builds (removed)", () => { - expect(content).not.toContain("clone_cli"); - expect(content).not.toContain("api.github.com"); - expect(content).not.toContain("bun run build"); - }); - }); - - // ── symlink into /usr/local/bin ──────────────────────────────────── - - describe("symlink to /usr/local/bin", () => { - it("should symlink spawn into /usr/local/bin for immediate availability", () => { - const fnStart = content.indexOf("ensure_in_path()"); - const fnBody = content.slice(fnStart); - expect(fnBody).toContain("/usr/local/bin/spawn"); - expect(fnBody).toContain("ln -sf"); - }); - - it("should try sudo if /usr/local/bin is not writable", () => { - const fnStart = content.indexOf("ensure_in_path()"); - const fnBody = content.slice(fnStart); - expect(fnBody).toContain("sudo ln -sf"); - }); - - it("should gracefully handle symlink failure", () => { - // Should not hard-fail if symlink fails — falls back to exec $SHELL - const fnStart = content.indexOf("ensure_in_path()"); - const fnBody = content.slice(fnStart); - expect(fnBody).toContain("|| true"); - expect(fnBody).toContain("exec"); - }); - - it("should default install to ~/.local/bin", () => { - expect(content).toContain("${HOME}/.local/bin"); - }); - }); - - // ── ensure_in_path function ──────────────────────────────────────── - - describe("ensure_in_path", () => { - it("should run spawn version after install", () => { - expect(content).toContain('/spawn" version'); - }); - - it("should patch zsh rc file for zsh users", () => { - expect(content).toContain(".zshrc"); - }); - - it("should patch fish PATH for fish users", () => { - expect(content).toContain("fish_add_path"); - }); - - it("should patch bashrc as default", () => { - expect(content).toContain(".bashrc"); - }); - - it("should detect shell from SHELL env var", () => { - expect(content).toContain("${SHELL:-"); - }); - - it("should show exec $SHELL fallback when symlink fails", () => { - const fnStart = content.indexOf("ensure_in_path()"); - const fnBody = content.slice(fnStart); - expect(fnBody).toContain("exec"); - }); - }); - - // ── Security ───────────────────────────────────────────────────────── - - describe("security", () => { - it("should not contain hardcoded API keys or tokens", () => { - const code = codeLines(); - for (const line of code) { - expect(line).not.toMatch(/sk-[a-zA-Z0-9]{20,}/); - expect(line).not.toMatch(/token[=:]\s*[a-f0-9]{32,}/i); - } - }); - - it("should not use eval to execute downloaded content", () => { - const code = codeLines(); - // eval should not be used to run arbitrary downloaded scripts - // (bun.sh/install is piped to bash, which is standard practice) - const evalLines = code.filter((l) => l.includes("eval") && !l.includes("2>/dev/null") && !l.includes("#")); - expect(evalLines).toEqual([]); - }); - - it("should quote variables in file operations", () => { - // Critical paths should quote variables - expect(content).toContain('"${INSTALL_DIR}/spawn"'); - expect(content).toContain('"${tmpdir}"'); - }); - - it("should use -fsSL flags for curl downloads", () => { - const curlLines = codeLines().filter((l) => l.includes("curl")); - for (const line of curlLines) { - expect(line).toMatch(/-fsSL/); - } - }); - }); - - // ── Consistency with package.json ────────────────────────────────── - - describe("consistency with package.json", () => { - it("should reference same repo as package.json", () => { - const pkgContent = readFileSync(join(REPO_ROOT, "packages", "cli", "package.json"), "utf-8"); - const pkg = JSON.parse(pkgContent); - // install.sh uses OpenRouterTeam/spawn - expect(content).toContain("OpenRouterTeam/spawn"); - // package.json should reference same repo - if (pkg.repository) { - const repo = isString(pkg.repository) ? pkg.repository : pkg.repository.url || ""; - expect(repo.toLowerCase()).toContain("openrouterteam/spawn"); - } - }); - - it("should download the correct files that exist in cli/src/", () => { - // The curl-based download path downloads .ts files from cli/src/ - // Verify that the files listed in install.sh actually exist - const srcDir = join(REPO_ROOT, "packages", "cli", "src"); - expect(existsSync(join(srcDir, "index.ts"))).toBe(true); - expect(existsSync(join(srcDir, "commands.ts"))).toBe(true); - expect(existsSync(join(srcDir, "manifest.ts"))).toBe(true); - }); - }); - - // ── Main flow order ──────────────────────────────────────────────── - - describe("main execution flow", () => { - it("should check for bun before building", () => { - const bunCheck = content.indexOf("command -v bun"); - const buildCall = content.lastIndexOf("build_and_install"); - expect(bunCheck).toBeLessThan(buildCall); - }); - - it("should ensure min bun version before building", () => { - const versionCheck = content.lastIndexOf("ensure_min_bun_version"); - const buildCall = content.lastIndexOf("build_and_install"); - expect(versionCheck).toBeLessThan(buildCall); - }); - - it("should call build_and_install as the last major step", () => { - const lastFewLines = lines.slice(-5).join("\n"); - expect(lastFewLines).toContain("build_and_install"); - }); - }); - - // ── Error handling ──────────────────────────────────────────────── - - describe("error handling", () => { - it("should show re-run instructions on bun install failure", () => { - expect(content).toContain("curl -fsSL ${SPAWN_RAW_BASE}/sh/cli/install.sh | bash"); - }); - - it("should show manual bun install instructions on failure", () => { - expect(content).toContain("curl -fsSL https://bun.sh/install | bash"); - }); - - it("should exit with code 1 on failures", () => { - const exitLines = codeLines().filter((l) => l.trim() === "exit 1"); - expect(exitLines.length).toBeGreaterThanOrEqual(2); - }); - - it("should show upgrade instructions if bun version is too low", () => { - const fnStart = content.indexOf("ensure_min_bun_version()"); - const fnEnd = content.indexOf("\n# --- Helper: ensure", fnStart); - const fnBody = content.slice(fnStart, fnEnd > fnStart ? fnEnd : undefined); - expect(fnBody).toContain("bun upgrade"); - expect(fnBody).toContain("exit 1"); - }); - }); -}); diff --git a/packages/cli/src/__tests__/integration.test.ts b/packages/cli/src/__tests__/integration.test.ts index cc87f89d..bb608b12 100644 --- a/packages/cli/src/__tests__/integration.test.ts +++ b/packages/cli/src/__tests__/integration.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { writeFileSync, mkdirSync } from "node:fs"; import type { Manifest } from "../manifest"; import type { TestEnvironment } from "./test-helpers"; import { mockSuccessfulFetch, mockFailedFetch, setupTestEnvironment, teardownTestEnvironment } from "./test-helpers"; @@ -76,18 +76,8 @@ describe("CLI Integration Tests", () => { const manifest1 = await loadManifest(true); expect(manifest1).toEqual(mockManifest); - // Cache location depends on whether the test runs in the project directory - // In the spawn project root, it uses a local manifest.json, so cache may not be written - const cacheExists = existsSync(env.cacheFile); - if (cacheExists) { - const cachedData = JSON.parse(readFileSync(env.cacheFile, "utf-8")); - expect(cachedData).toEqual(mockManifest); - } - - // Second load - should use cache + // Second load - in-memory cache should return same data const manifest2 = await loadManifest(); - - // Note: Bun's in-memory caching may behave differently expect(manifest2).toEqual(mockManifest); }); diff --git a/packages/cli/src/__tests__/manifest-validation.test.ts b/packages/cli/src/__tests__/manifest-validation.test.ts deleted file mode 100644 index e6bcef03..00000000 --- a/packages/cli/src/__tests__/manifest-validation.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; -import type { Manifest } from "../manifest"; -import { loadManifest, agentKeys, cloudKeys, matrixStatus, countImplemented } from "../manifest"; -import type { TestEnvironment } from "./test-helpers"; -import { createMockManifest, setupTestEnvironment, teardownTestEnvironment } from "./test-helpers"; - -/** - * Tests for manifest.ts validation and edge cases that are not covered - * by the existing manifest.test.ts, focusing on: - * - isValidManifest with various invalid shapes - * - logError behavior - * - loadManifest caching edge cases - * - countImplemented with mixed statuses - */ - -describe("Manifest Validation Edge Cases", () => { - describe("isValidManifest (via loadManifest)", () => { - let env: TestEnvironment; - - beforeEach(() => { - env = setupTestEnvironment(); - }); - - afterEach(() => { - teardownTestEnvironment(env); - }); - - it("should reject manifest missing agents field", async () => { - global.fetch = mock(() => - Promise.resolve( - new Response( - JSON.stringify({ - clouds: {}, - matrix: {}, - }), - ), - ), - ); - - try { - await loadManifest(true); - // If local manifest fallback kicks in, that's ok - } catch (err: any) { - expect(err.message).toContain("Cannot load manifest"); - } - }); - - it("should reject manifest missing clouds field", async () => { - global.fetch = mock(() => - Promise.resolve( - new Response( - JSON.stringify({ - agents: {}, - matrix: {}, - }), - ), - ), - ); - - try { - await loadManifest(true); - } catch (err: any) { - expect(err.message).toContain("Cannot load manifest"); - } - }); - - it("should reject manifest missing matrix field", async () => { - global.fetch = mock(() => - Promise.resolve( - new Response( - JSON.stringify({ - agents: {}, - clouds: {}, - }), - ), - ), - ); - - try { - await loadManifest(true); - } catch (err: any) { - expect(err.message).toContain("Cannot load manifest"); - } - }); - - it("should reject null manifest data", async () => { - global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(null)))); - - try { - await loadManifest(true); - } catch (err: any) { - expect(err.message).toContain("Cannot load manifest"); - } - }); - - it("should reject empty object manifest data", async () => { - global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({})))); - - try { - await loadManifest(true); - } catch (err: any) { - expect(err.message).toContain("Cannot load manifest"); - } - }); - - it("should accept valid manifest with empty collections", async () => { - const validEmpty = { - agents: {}, - clouds: {}, - matrix: {}, - }; - global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify(validEmpty)))); - - const manifest = await loadManifest(true); - expect(manifest).toHaveProperty("agents"); - expect(manifest).toHaveProperty("clouds"); - expect(manifest).toHaveProperty("matrix"); - }); - - it("should handle non-ok HTTP response from GitHub", async () => { - // When GitHub returns a non-ok response, fetchManifestFromGitHub returns null - // and loadManifest falls back to cache or throws - global.fetch = mock(() => - Promise.resolve( - new Response("Internal Server Error", { - status: 500, - statusText: "Internal Server Error", - }), - ), - ); - - try { - const manifest = await loadManifest(true); - // If it succeeds, it used a fallback (stale cache or local manifest) - expect(manifest).toHaveProperty("agents"); - expect(manifest).toHaveProperty("clouds"); - expect(manifest).toHaveProperty("matrix"); - } catch (err: any) { - // Or it failed because no cache was available - expect(err.message).toContain("Cannot load manifest"); - } - }); - }); - - describe("matrixStatus edge cases", () => { - it("should return missing for undefined cloud/agent pair", () => { - const manifest = createMockManifest(); - expect(matrixStatus(manifest, "", "")).toBe("missing"); - }); - - it("should handle keys with special but valid characters", () => { - const manifest: Manifest = { - agents: {}, - clouds: {}, - matrix: { - "my-cloud/my-agent": "implemented", - "cloud_2/agent_2": "implemented", - }, - }; - expect(matrixStatus(manifest, "my-cloud", "my-agent")).toBe("implemented"); - expect(matrixStatus(manifest, "cloud_2", "agent_2")).toBe("implemented"); - expect(matrixStatus(manifest, "my-cloud", "agent_2")).toBe("missing"); - }); - }); - - describe("countImplemented edge cases", () => { - it("should only count exactly 'implemented' status", () => { - const manifest: Manifest = { - agents: {}, - clouds: {}, - matrix: { - "a/b": "implemented", - "c/d": "missing", - "e/f": "partial", - "g/h": "implemented", - "i/j": "IMPLEMENTED", - }, - }; - expect(countImplemented(manifest)).toBe(2); - }); - - it("should count correctly with large matrix", () => { - const matrix: Record = {}; - for (let i = 0; i < 100; i++) { - matrix[`cloud${i}/agent${i}`] = i % 3 === 0 ? "implemented" : "missing"; - } - const manifest: Manifest = { - agents: {}, - clouds: {}, - matrix, - }; - // i=0,3,6,...,99 => 0,3,6,...,99 => count of multiples of 3 from 0-99 - // 0,3,6,...,99 = 34 values - expect(countImplemented(manifest)).toBe(34); - }); - }); - - describe("agentKeys and cloudKeys ordering", () => { - it("should preserve insertion order of agents", () => { - const manifest: Manifest = { - agents: { - zeta: { - name: "Zeta", - description: "", - url: "", - install: "", - launch: "", - env: {}, - }, - alpha: { - name: "Alpha", - description: "", - url: "", - install: "", - launch: "", - env: {}, - }, - mid: { - name: "Mid", - description: "", - url: "", - install: "", - launch: "", - env: {}, - }, - }, - clouds: {}, - matrix: {}, - }; - expect(agentKeys(manifest)).toEqual([ - "zeta", - "alpha", - "mid", - ]); - }); - - it("should preserve insertion order of clouds", () => { - const manifest: Manifest = { - agents: {}, - clouds: { - zebra: { - name: "Zebra", - description: "", - url: "", - type: "", - auth: "", - provision_method: "", - exec_method: "", - interactive_method: "", - }, - apple: { - name: "Apple", - description: "", - url: "", - type: "", - auth: "", - provision_method: "", - exec_method: "", - interactive_method: "", - }, - }, - matrix: {}, - }; - expect(cloudKeys(manifest)).toEqual([ - "zebra", - "apple", - ]); - }); - }); -}); diff --git a/packages/cli/src/__tests__/matrix-compact-footer.test.ts b/packages/cli/src/__tests__/matrix-compact-footer.test.ts deleted file mode 100644 index de19b5bb..00000000 --- a/packages/cli/src/__tests__/matrix-compact-footer.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import type { Manifest } from "../manifest"; -import { getImplementedClouds, getMissingClouds, calculateColumnWidth, getTerminalWidth } from "../commands"; -import { cloudKeys, agentKeys } from "../manifest"; - -/** - * Tests for exported matrix helpers in commands.ts: - * - getTerminalWidth: terminal width fallback to 80 - * - calculateColumnWidth: dynamic column sizing with minimum width - * - getMissingClouds: clouds where an agent is NOT implemented - * - getImplementedClouds: clouds where an agent IS implemented - */ - -// ── Test Manifests ──────────────────────────────────────────────────────────── - -function createTestManifest(): Manifest { - return { - 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", - }, - }, - codex: { - name: "Codex", - description: "AI pair programmer", - url: "https://codex.dev", - install: "npm install -g codex", - launch: "codex", - env: { - OPENAI_API_KEY: "test", - }, - }, - cline: { - name: "Cline", - description: "AI developer agent", - url: "https://cline.dev", - install: "npm install -g cline", - launch: "cline", - env: {}, - }, - }, - 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", - }, - 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", - }, - 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", - }, - }, - matrix: { - "sprite/claude": "implemented", - "sprite/codex": "implemented", - "sprite/cline": "missing", - "hetzner/claude": "implemented", - "hetzner/codex": "missing", - "hetzner/cline": "missing", - "vultr/claude": "implemented", - "vultr/codex": "missing", - "vultr/cline": "missing", - }, - }; -} - -const MIN_AGENT_COL_WIDTH = 16; -const MIN_CLOUD_COL_WIDTH = 10; -const COL_PADDING = 2; - -// ── Tests for exported helpers ────────────────────────────────────────────── - -describe("getTerminalWidth", () => { - it("should return a positive number", () => { - const width = getTerminalWidth(); - expect(width).toBeGreaterThan(0); - }); - - it("should return at least 80 (fallback minimum)", () => { - // In test environments without a TTY, should fall back to 80 - const width = getTerminalWidth(); - expect(width).toBeGreaterThanOrEqual(80); - }); -}); - -describe("calculateColumnWidth", () => { - it("should return minimum width when items are shorter", () => { - const width = calculateColumnWidth( - [ - "ab", - "cd", - ], - 16, - ); - expect(width).toBe(16); - }); - - it("should expand beyond minimum for long items", () => { - const width = calculateColumnWidth( - [ - "a-very-long-cloud-name", - ], - 10, - ); - expect(width).toBe("a-very-long-cloud-name".length + COL_PADDING); - }); - - it("should use the longest item to determine width", () => { - const items = [ - "short", - "medium-length", - "the-longest-item-here", - ]; - const width = calculateColumnWidth(items, 10); - expect(width).toBe("the-longest-item-here".length + COL_PADDING); - }); - - it("should return minimum width for empty items list", () => { - const width = calculateColumnWidth([], 16); - expect(width).toBe(16); - }); - - it("should handle single item", () => { - const width = calculateColumnWidth( - [ - "x", - ], - 16, - ); - expect(width).toBe(16); // "x" + 2 padding = 3, less than min 16 - }); - - it("should add COL_PADDING (2) to item length", () => { - // Item with length 15 + 2 padding = 17 > min 16 - const item = "a".repeat(15); - const width = calculateColumnWidth( - [ - item, - ], - 16, - ); - expect(width).toBe(17); - }); - - it("should handle item at exactly minimum width - padding", () => { - // Item with length 14 + 2 padding = 16 = min - const item = "a".repeat(14); - const width = calculateColumnWidth( - [ - item, - ], - 16, - ); - expect(width).toBe(16); - }); - - it("should handle item at minimum width - padding + 1", () => { - // Item with length 15 + 2 padding = 17 > min 16 - const item = "a".repeat(15); - const width = calculateColumnWidth( - [ - item, - ], - 16, - ); - expect(width).toBe(17); - }); -}); - -describe("getMissingClouds", () => { - it("should return clouds where agent is not implemented", () => { - const manifest = createTestManifest(); - const clouds = cloudKeys(manifest); - const missing = getMissingClouds(manifest, "codex", clouds); - expect(missing).toContain("hetzner"); - expect(missing).toContain("vultr"); - expect(missing).not.toContain("sprite"); - }); - - it("should return empty array for fully implemented agent", () => { - const manifest = createTestManifest(); - const clouds = cloudKeys(manifest); - const missing = getMissingClouds(manifest, "claude", clouds); - expect(missing).toEqual([]); - }); - - it("should return all clouds for unimplemented agent", () => { - const manifest = createTestManifest(); - const clouds = cloudKeys(manifest); - const missing = getMissingClouds(manifest, "cline", clouds); - expect(missing).toHaveLength(3); - expect(missing).toContain("sprite"); - expect(missing).toContain("hetzner"); - expect(missing).toContain("vultr"); - }); - - it("should return empty for empty clouds list", () => { - const manifest = createTestManifest(); - const missing = getMissingClouds(manifest, "codex", []); - expect(missing).toEqual([]); - }); - - it("should return all for unknown agent (not in matrix)", () => { - const manifest = createTestManifest(); - const clouds = cloudKeys(manifest); - const missing = getMissingClouds(manifest, "unknown", clouds); - expect(missing).toHaveLength(3); // All clouds are "missing" for unknown agent - }); - - it("should preserve cloud order from input", () => { - const manifest = createTestManifest(); - const clouds = [ - "vultr", - "sprite", - "hetzner", - ]; - const missing = getMissingClouds(manifest, "cline", clouds); - expect(missing).toEqual([ - "vultr", - "sprite", - "hetzner", - ]); - }); -}); - -describe("getImplementedClouds", () => { - it("should return clouds where agent is implemented", () => { - const manifest = createTestManifest(); - const impl = getImplementedClouds(manifest, "codex"); - expect(impl).toEqual([ - "sprite", - ]); - }); - - it("should return all clouds for fully implemented agent", () => { - const manifest = createTestManifest(); - const impl = getImplementedClouds(manifest, "claude"); - expect(impl).toHaveLength(3); - expect(impl).toContain("sprite"); - expect(impl).toContain("hetzner"); - expect(impl).toContain("vultr"); - }); - - it("should return empty array for unimplemented agent", () => { - const manifest = createTestManifest(); - const impl = getImplementedClouds(manifest, "cline"); - expect(impl).toEqual([]); - }); - - it("should return empty array for unknown agent", () => { - const manifest = createTestManifest(); - const impl = getImplementedClouds(manifest, "nonexistent"); - expect(impl).toEqual([]); - }); - - it("should preserve manifest cloud key order", () => { - const manifest = createTestManifest(); - const impl = getImplementedClouds(manifest, "claude"); - // Keys should be in the order they appear in manifest.clouds - const allClouds = cloudKeys(manifest); - const expected = allClouds.filter((c) => impl.includes(c)); - expect(impl).toEqual(expected); - }); -}); - -// ── Integration: compact view vs grid view decision ────────────────────────── - -describe("compact vs grid view decision", () => { - it("should calculate grid width as agentColWidth + clouds * cloudColWidth", () => { - const manifest = createTestManifest(); - const agents = agentKeys(manifest); - const clouds = cloudKeys(manifest); - - const agentColWidth = calculateColumnWidth( - agents.map((a) => manifest.agents[a].name), - MIN_AGENT_COL_WIDTH, - ); - const cloudColWidth = calculateColumnWidth( - clouds.map((c) => manifest.clouds[c].name), - MIN_CLOUD_COL_WIDTH, - ); - - const gridWidth = agentColWidth + clouds.length * cloudColWidth; - expect(gridWidth).toBeGreaterThan(0); - // With 3 clouds and reasonable names, grid should be moderate width - expect(gridWidth).toBeLessThan(200); - }); - - it("should use compact view when grid is wider than terminal", () => { - const manifest = createTestManifest(); - const agents = agentKeys(manifest); - const clouds = cloudKeys(manifest); - - const agentColWidth = calculateColumnWidth( - agents.map((a) => manifest.agents[a].name), - MIN_AGENT_COL_WIDTH, - ); - const cloudColWidth = calculateColumnWidth( - clouds.map((c) => manifest.clouds[c].name), - MIN_CLOUD_COL_WIDTH, - ); - - const gridWidth = agentColWidth + clouds.length * cloudColWidth; - const termWidth = getTerminalWidth(); - - // This tests the decision logic: isCompact = gridWidth > termWidth - const isCompact = gridWidth > termWidth; - // With only 3 clouds and 80+ terminal, grid should fit - // But the key point is the logic is correct - expect(typeof isCompact).toBe("boolean"); - }); -});