From 5866f7e2ded991cfcd6170a6719e6635df5ab3a4 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:23:46 -0800 Subject: [PATCH] test: add cmdList integration tests through real exported function (#532) Add 26 tests covering the cmdList pipeline through the actual exported function with mock.module for @clack/prompts. Tests the full path from history file through rendering: empty history, table rendering with display names, agent/cloud filtering, prompt display, manifest fallback, footer formatting. 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__/cmdlist-integration.test.ts | 580 ++++++++++++++++++ 1 file changed, 580 insertions(+) create mode 100644 cli/src/__tests__/cmdlist-integration.test.ts diff --git a/cli/src/__tests__/cmdlist-integration.test.ts b/cli/src/__tests__/cmdlist-integration.test.ts new file mode 100644 index 00000000..f794fa61 --- /dev/null +++ b/cli/src/__tests__/cmdlist-integration.test.ts @@ -0,0 +1,580 @@ +import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { createMockManifest, createConsoleMocks, restoreMocks } from "./test-helpers"; +import type { SpawnRecord } from "../history"; + +/** + * Integration tests for cmdList through the real exported function. + * + * Existing tests cover: + * - history.test.ts: data layer (loadHistory, saveSpawnRecord, filterHistory) + * - list-display.test.ts: formatTimestamp and parseListFilters replicas + * - list-table-rendering.test.ts: resolveDisplayName unit tests and table rendering + * - list-empty-footer.test.ts: showEmptyListMessage and showListFooter replicas + * - list-filter-suggestions.test.ts: suggestFilterCorrection replicas + * - list-prompt-display.test.ts: prompt preview rendering + * + * This file covers the UNTESTED integration path: calling the real cmdList + * exported function with mock.module for @clack/prompts and a controlled + * SPAWN_HOME, verifying the full pipeline from history file -> rendering. + * + * Tested paths: + * - cmdList with no history records (empty list message) + * - cmdList with records (table rendering with resolved display names) + * - cmdList with agent filter that matches records + * - cmdList with cloud filter that matches records + * - cmdList with filters that match nothing (empty + suggestion flow) + * - cmdList when manifest is unavailable (falls back to raw keys) + * - cmdList footer: rerun hint with/without prompt, filter count text + * + * Agent: test-engineer + */ + +const mockManifest = createMockManifest(); + +// Mock @clack/prompts +const mockLogError = mock(() => {}); +const mockLogInfo = mock(() => {}); +const mockLogStep = mock(() => {}); +const mockLogSuccess = mock(() => {}); +const mockSpinnerStart = mock(() => {}); +const mockSpinnerStop = mock(() => {}); + +mock.module("@clack/prompts", () => ({ + spinner: () => ({ + start: mockSpinnerStart, + stop: mockSpinnerStop, + message: mock(() => {}), + }), + log: { + step: mockLogStep, + info: mockLogInfo, + error: mockLogError, + warn: mock(() => {}), + success: mockLogSuccess, + }, + intro: mock(() => {}), + outro: mock(() => {}), + cancel: mock(() => {}), + select: mock(() => {}), + isCancel: () => false, +})); + +// Import after mock setup +const { cmdList, resolveDisplayName } = await import("../commands.js"); + +// ── Test Setup ────────────────────────────────────────────────────────────────── + +describe("cmdList integration", () => { + let testDir: string; + let originalEnv: NodeJS.ProcessEnv; + let consoleMocks: ReturnType; + let originalFetch: typeof global.fetch; + let processExitSpy: ReturnType; + + function writeHistory(records: SpawnRecord[]) { + writeFileSync(join(testDir, "history.json"), JSON.stringify(records)); + } + + function consoleOutput(): string { + return consoleMocks.log.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); + } + + function logInfoOutput(): string { + return mockLogInfo.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); + } + + function consoleErrorOutput(): string { + return consoleMocks.error.mock.calls.map((c: any[]) => c.join(" ")).join("\n"); + } + + beforeEach(() => { + testDir = join(tmpdir(), `spawn-cmdlist-test-${Date.now()}-${Math.random()}`); + mkdirSync(testDir, { recursive: true }); + + originalEnv = { ...process.env }; + process.env.SPAWN_HOME = testDir; + + consoleMocks = createConsoleMocks(); + mockLogError.mockClear(); + mockLogInfo.mockClear(); + mockLogStep.mockClear(); + mockLogSuccess.mockClear(); + mockSpinnerStart.mockClear(); + mockSpinnerStop.mockClear(); + + originalFetch = global.fetch; + processExitSpy = spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as any); + }); + + afterEach(() => { + process.env = originalEnv; + global.fetch = originalFetch; + processExitSpy.mockRestore(); + restoreMocks(consoleMocks.log, consoleMocks.error); + }); + + // ── Empty history ─────────────────────────────────────────────────────────── + + describe("empty history (no records)", () => { + it("should show 'No spawns recorded yet' when no history file exists", async () => { + await cmdList(); + + const info = logInfoOutput(); + expect(info).toContain("No spawns recorded yet"); + }); + + it("should suggest 'spawn ' for first spawn", async () => { + await cmdList(); + + const info = logInfoOutput(); + expect(info).toContain("spawn "); + }); + + it("should show 'No spawns found matching' when filter matches nothing", async () => { + writeHistory([ + { agent: "claude", cloud: "sprite", timestamp: "2026-01-01T00:00:00Z" }, + ]); + + await cmdList("nonexistent"); + + const info = logInfoOutput(); + expect(info).toContain("No spawns found matching"); + expect(info).toContain("nonexistent"); + }); + + it("should suggest clearing filter when filtered results are empty", async () => { + writeHistory([ + { agent: "claude", cloud: "sprite", timestamp: "2026-01-01T00:00:00Z" }, + { agent: "aider", cloud: "hetzner", timestamp: "2026-01-02T00:00:00Z" }, + ]); + + await cmdList("nonexistent"); + + const info = logInfoOutput(); + expect(info).toContain("spawn list"); + // Should mention total record count + expect(info).toContain("2"); + }); + + it("should show empty message for empty history with agent and cloud filters", async () => { + await cmdList("claude", "sprite"); + + const info = logInfoOutput(); + expect(info).toContain("No spawns"); + }); + }); + + // ── History with records ──────────────────────────────────────────────────── + + describe("history with records (table rendering)", () => { + const sampleRecords: SpawnRecord[] = [ + { agent: "claude", cloud: "sprite", timestamp: "2026-01-01T10:00:00Z" }, + { agent: "aider", cloud: "hetzner", timestamp: "2026-01-02T14:30:00Z" }, + { agent: "claude", cloud: "hetzner", timestamp: "2026-01-03T09:15:00Z" }, + ]; + + it("should render table header with AGENT, CLOUD, WHEN columns", async () => { + writeHistory(sampleRecords); + + // Mock fetch to return manifest (for display names) + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + expect(output).toContain("AGENT"); + expect(output).toContain("CLOUD"); + expect(output).toContain("WHEN"); + }); + + it("should show separator line under header", async () => { + writeHistory(sampleRecords); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + expect(output).toContain("-".repeat(10)); + }); + + it("should render records in reverse chronological order (newest first)", async () => { + writeHistory(sampleRecords); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + const lines = output.split("\n"); + + // Find lines with agent names (after header/separator) + const dataLines = lines.filter( + (l: string) => l.includes("Claude Code") || l.includes("Aider") || l.includes("Hetzner") + ); + + // The most recent record (Jan 3) should appear before the oldest (Jan 1) + expect(dataLines.length).toBeGreaterThan(0); + }); + + it("should show display names when manifest is available", async () => { + writeHistory(sampleRecords); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + // Display names from manifest + expect(output).toContain("Claude Code"); + expect(output).toContain("Aider"); + }); + + it("should fall back to raw keys when manifest is unavailable", async () => { + writeHistory(sampleRecords); + + // Mock fetch to fail + global.fetch = mock(() => Promise.reject(new Error("Network error"))); + + await cmdList(); + + const output = consoleOutput(); + // Should still render the table (with raw keys) + expect(output).toContain("AGENT"); + expect(output).toContain("CLOUD"); + // Raw keys should appear since manifest is unavailable + expect(output).toContain("claude"); + }); + + it("should show rerun hint in footer", async () => { + writeHistory(sampleRecords); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + expect(output).toContain("Rerun last:"); + // The most recent record is claude/hetzner + expect(output).toContain("spawn claude hetzner"); + }); + + it("should show record count in footer", async () => { + writeHistory(sampleRecords); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + expect(output).toContain("3 spawns recorded"); + }); + + it("should use singular 'spawn' for single record", async () => { + writeHistory([ + { agent: "claude", cloud: "sprite", timestamp: "2026-01-01T10:00:00Z" }, + ]); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + // Should say "1 spawn recorded" not "1 spawns recorded" + expect(output).toMatch(/1 spawn[^s]/); + }); + }); + + // ── Prompt display in history ───────────────────────────────────────────── + + describe("prompt display in history records", () => { + it("should show prompt preview in table row", async () => { + writeHistory([ + { + agent: "claude", + cloud: "sprite", + timestamp: "2026-01-01T10:00:00Z", + prompt: "Fix all linter errors", + }, + ]); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + expect(output).toContain("Fix all linter errors"); + }); + + it("should truncate long prompts with ellipsis", async () => { + writeHistory([ + { + agent: "claude", + cloud: "sprite", + timestamp: "2026-01-01T10:00:00Z", + prompt: "This is a very long prompt that should be truncated because it exceeds the display limit in the table", + }, + ]); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + expect(output).toContain("..."); + }); + + it("should include prompt in rerun hint for latest record with prompt", async () => { + writeHistory([ + { + agent: "claude", + cloud: "sprite", + timestamp: "2026-01-01T10:00:00Z", + prompt: "Fix the auth bug", + }, + ]); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + expect(output).toContain('--prompt "'); + expect(output).toContain("Fix the auth bug"); + }); + }); + + // ── Filtering ───────────────────────────────────────────────────────────── + + describe("filtering by agent and cloud", () => { + const records: SpawnRecord[] = [ + { agent: "claude", cloud: "sprite", timestamp: "2026-01-01T00:00:00Z" }, + { agent: "aider", cloud: "hetzner", timestamp: "2026-01-02T00:00:00Z" }, + { agent: "claude", cloud: "hetzner", timestamp: "2026-01-03T00:00:00Z" }, + { agent: "aider", cloud: "sprite", timestamp: "2026-01-04T00:00:00Z" }, + ]; + + it("should filter by agent name", async () => { + writeHistory(records); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList("claude"); + + const output = consoleOutput(); + // Should show "Showing 2 of 4" in footer + expect(output).toContain("2 of 4"); + }); + + it("should filter by cloud name", async () => { + writeHistory(records); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(undefined, "hetzner"); + + const output = consoleOutput(); + expect(output).toContain("2 of 4"); + }); + + it("should filter by both agent and cloud", async () => { + writeHistory(records); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList("claude", "sprite"); + + const output = consoleOutput(); + expect(output).toContain("1 of 4"); + }); + + it("should show 'Clear filter' hint when filters are active", async () => { + writeHistory(records); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList("claude"); + + const output = consoleOutput(); + expect(output).toContain("Clear filter"); + expect(output).toContain("spawn list"); + }); + + it("should show filter suggestion hint when no filters active", async () => { + writeHistory(records); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + expect(output).toContain("Filter:"); + expect(output).toContain("-a "); + expect(output).toContain("-c "); + }); + + it("should show case-insensitive filter results", async () => { + writeHistory(records); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList("CLAUDE"); + + const output = consoleOutput(); + // Should still find 2 records (case insensitive) + expect(output).toContain("2 of 4"); + }); + }); + + // ── Edge cases ──────────────────────────────────────────────────────────── + + describe("edge cases", () => { + it("should handle corrupted history file gracefully", async () => { + writeFileSync(join(testDir, "history.json"), "not valid json{{{"); + + await cmdList(); + + const info = logInfoOutput(); + // loadHistory returns [] for corrupted files + expect(info).toContain("No spawns recorded yet"); + }); + + it("should handle history file with non-array JSON", async () => { + writeFileSync(join(testDir, "history.json"), JSON.stringify({ not: "array" })); + + await cmdList(); + + const info = logInfoOutput(); + expect(info).toContain("No spawns recorded yet"); + }); + + it("should handle many records without issue", async () => { + const manyRecords: SpawnRecord[] = []; + for (let i = 0; i < 100; i++) { + manyRecords.push({ + agent: i % 2 === 0 ? "claude" : "aider", + cloud: i % 3 === 0 ? "sprite" : "hetzner", + timestamp: `2026-01-${String(1 + (i % 28)).padStart(2, "0")}T00:00:00Z`, + }); + } + writeHistory(manyRecords); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + expect(output).toContain("100 spawns recorded"); + }); + + it("should handle records with missing optional prompt field", async () => { + writeHistory([ + { agent: "claude", cloud: "sprite", timestamp: "2026-01-01T00:00:00Z" }, + ]); + + global.fetch = mock(() => + Promise.resolve({ + ok: true, + json: async () => mockManifest, + }) as any + ); + + await cmdList(); + + const output = consoleOutput(); + // Should not contain --prompt in rerun hint + expect(output).not.toContain("--prompt"); + expect(output).toContain("spawn claude sprite"); + }); + }); +});