From bc89a2821bcf11dc1b1e44b50e5fcde4a39ba2b2 Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Wed, 11 Feb 2026 10:29:50 -0800 Subject: [PATCH] test: add 43 tests for list display logic (formatTimestamp, parseListFilters, cmdList) (#489) Cover the history list feature display layer added in PRs #486-#488: - formatTimestamp: valid ISO, invalid dates, edge cases (epoch, far future) - parseListFilters: -a/-c flag extraction, missing values, duplicates, ordering - cmdList output: table headers, rerun hint, count summary, filter messages, newest-first ordering, singular/plural, corrupted history resilience 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__/list-display.test.ts | 456 +++++++++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 cli/src/__tests__/list-display.test.ts diff --git a/cli/src/__tests__/list-display.test.ts b/cli/src/__tests__/list-display.test.ts new file mode 100644 index 00000000..79e602eb --- /dev/null +++ b/cli/src/__tests__/list-display.test.ts @@ -0,0 +1,456 @@ +import { describe, it, expect, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdirSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +/** + * Tests for the `spawn list` display logic in commands.ts and index.ts. + * + * Covers: + * - formatTimestamp: date parsing and formatting for history display + * - parseListFilters: `-a` and `-c` flag extraction from args + * - cmdList output: column layout, rerun hint, empty states, filter messages + * + * These functions were added in PRs #486-#488 (history feature) and lack + * direct unit test coverage. formatTimestamp is not exported, so we test + * an exact replica; parseListFilters is replicated from index.ts. + * + * Agent: test-engineer + */ + +// ── Exact replica of formatTimestamp from commands.ts lines 731-741 ────────── + +function formatTimestamp(iso: string): string { + try { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); + return `${date} ${time}`; + } catch { + return iso; + } +} + +// ── Exact replica of parseListFilters from index.ts lines 303-316 ─────────── + +function parseListFilters(args: string[]): { agentFilter?: string; cloudFilter?: string } { + let agentFilter: string | undefined; + let cloudFilter: string | undefined; + for (let i = 0; i < args.length; i++) { + if (args[i] === "-a" && args[i + 1] && !args[i + 1].startsWith("-")) { + agentFilter = args[i + 1]; + i++; + } else if (args[i] === "-c" && args[i + 1] && !args[i + 1].startsWith("-")) { + cloudFilter = args[i + 1]; + i++; + } + } + return { agentFilter, cloudFilter }; +} + +// ── formatTimestamp tests ──────────────────────────────────────────────────── + +describe("formatTimestamp", () => { + describe("valid ISO timestamps", () => { + it("should format a standard ISO timestamp", () => { + const result = formatTimestamp("2026-02-11T14:30:00.000Z"); + // Should contain month, day, year + expect(result).toContain("2026"); + expect(result).toContain("Feb"); + expect(result).toContain("11"); + }); + + it("should include time component", () => { + const result = formatTimestamp("2026-01-15T09:05:00.000Z"); + // Time should be in HH:MM format + expect(result).toMatch(/\d{2}:\d{2}/); + }); + + it("should format midnight correctly", () => { + const result = formatTimestamp("2026-06-01T00:00:00.000Z"); + expect(result).toContain("2026"); + // Should still have a time component + expect(result).toMatch(/\d{2}:\d{2}/); + }); + + it("should format end-of-day correctly", () => { + const result = formatTimestamp("2026-12-31T23:59:59.000Z"); + expect(result).toContain("Dec"); + expect(result).toContain("31"); + expect(result).toContain("2026"); + }); + + it("should handle ISO timestamp without milliseconds", () => { + const result = formatTimestamp("2026-03-20T16:45:00Z"); + expect(result).toContain("2026"); + expect(result).toContain("Mar"); + expect(result).toContain("20"); + }); + + it("should handle ISO timestamp with timezone offset", () => { + const result = formatTimestamp("2026-07-04T12:00:00+05:00"); + expect(result).toContain("2026"); + expect(result).toMatch(/\d{2}:\d{2}/); + }); + }); + + describe("invalid timestamps", () => { + it("should return the original string for non-date text", () => { + expect(formatTimestamp("not-a-date")).toBe("not-a-date"); + }); + + it("should return the original string for empty string", () => { + expect(formatTimestamp("")).toBe(""); + }); + + it("should return the original string for random text", () => { + expect(formatTimestamp("hello world")).toBe("hello world"); + }); + + it("should return the original string for 'Invalid Date' text", () => { + expect(formatTimestamp("Invalid Date")).toBe("Invalid Date"); + }); + + it("should return the original string for incomplete ISO format", () => { + // "2026-13-45" has month 13 which is invalid + // new Date("2026-13-45") returns Invalid Date + const result = formatTimestamp("2026-13-45"); + // Should either return formatted (if JS interprets it) or original + const d = new Date("2026-13-45"); + if (isNaN(d.getTime())) { + expect(result).toBe("2026-13-45"); + } + }); + }); + + describe("edge cases", () => { + it("should handle epoch zero", () => { + const result = formatTimestamp("1970-01-01T00:00:00.000Z"); + expect(result).toContain("1970"); + expect(result).toContain("Jan"); + }); + + it("should handle date-only string", () => { + const result = formatTimestamp("2026-06-15"); + // JS Date can parse date-only strings + expect(result).toContain("2026"); + }); + + it("should handle numeric timestamp string that JS Date accepts", () => { + // new Date("0") is valid in some JS engines + const d = new Date("0"); + const result = formatTimestamp("0"); + if (isNaN(d.getTime())) { + expect(result).toBe("0"); + } else { + expect(result).toContain("2000"); + } + }); + + it("should produce consistent format (date + space + time)", () => { + const result = formatTimestamp("2026-02-11T14:30:00.000Z"); + // Format is "MMM DD, YYYY HH:MM" or similar locale-specific output + // Should have at least one space separating date and time + const parts = result.split(" "); + expect(parts.length).toBeGreaterThanOrEqual(2); + }); + + it("should handle far future dates", () => { + const result = formatTimestamp("2099-12-31T23:59:59.000Z"); + expect(result).toContain("2099"); + expect(result).toContain("Dec"); + }); + + it("should handle dates before 2000", () => { + const result = formatTimestamp("1999-01-01T00:00:00.000Z"); + expect(result).toContain("1999"); + }); + }); +}); + +// ── parseListFilters tests ────────────────────────────────────────────────── + +describe("parseListFilters", () => { + describe("basic flag extraction", () => { + it("should extract -a flag with value", () => { + const result = parseListFilters(["-a", "claude"]); + expect(result.agentFilter).toBe("claude"); + expect(result.cloudFilter).toBeUndefined(); + }); + + it("should extract -c flag with value", () => { + const result = parseListFilters(["-c", "sprite"]); + expect(result.agentFilter).toBeUndefined(); + expect(result.cloudFilter).toBe("sprite"); + }); + + it("should extract both -a and -c flags", () => { + const result = parseListFilters(["-a", "claude", "-c", "hetzner"]); + expect(result.agentFilter).toBe("claude"); + expect(result.cloudFilter).toBe("hetzner"); + }); + + it("should handle -c before -a", () => { + const result = parseListFilters(["-c", "sprite", "-a", "aider"]); + expect(result.agentFilter).toBe("aider"); + expect(result.cloudFilter).toBe("sprite"); + }); + }); + + describe("edge cases", () => { + it("should return no filters for empty args", () => { + const result = parseListFilters([]); + expect(result.agentFilter).toBeUndefined(); + expect(result.cloudFilter).toBeUndefined(); + }); + + it("should return no filters for unrelated args", () => { + const result = parseListFilters(["list", "extra"]); + expect(result.agentFilter).toBeUndefined(); + expect(result.cloudFilter).toBeUndefined(); + }); + + it("should not extract -a without a following value", () => { + const result = parseListFilters(["-a"]); + expect(result.agentFilter).toBeUndefined(); + }); + + it("should not extract -c without a following value", () => { + const result = parseListFilters(["-c"]); + expect(result.cloudFilter).toBeUndefined(); + }); + + it("should not use a flag as a value (value starts with -)", () => { + const result = parseListFilters(["-a", "-c", "sprite"]); + // -a sees -c as starting with -, so agentFilter is undefined + expect(result.agentFilter).toBeUndefined(); + // -c gets "sprite" + expect(result.cloudFilter).toBe("sprite"); + }); + + it("should use last occurrence when -a is specified twice", () => { + const result = parseListFilters(["-a", "claude", "-a", "aider"]); + // Second -a overwrites the first + expect(result.agentFilter).toBe("aider"); + }); + + it("should use last occurrence when -c is specified twice", () => { + const result = parseListFilters(["-c", "sprite", "-c", "hetzner"]); + expect(result.cloudFilter).toBe("hetzner"); + }); + + it("should ignore unrelated flags between -a and -c", () => { + const result = parseListFilters(["-a", "claude", "extra", "-c", "sprite"]); + expect(result.agentFilter).toBe("claude"); + expect(result.cloudFilter).toBe("sprite"); + }); + + it("should not confuse -a and --agent or -c and --cloud", () => { + // Only short flags -a and -c are supported + const result = parseListFilters(["--agent", "claude", "--cloud", "sprite"]); + expect(result.agentFilter).toBeUndefined(); + expect(result.cloudFilter).toBeUndefined(); + }); + + it("should handle value with hyphens (e.g., agent name with hyphen)", () => { + const result = parseListFilters(["-a", "claude-code"]); + expect(result.agentFilter).toBe("claude-code"); + }); + + it("should handle single-char values", () => { + const result = parseListFilters(["-a", "x", "-c", "y"]); + expect(result.agentFilter).toBe("x"); + expect(result.cloudFilter).toBe("y"); + }); + }); +}); + +// ── cmdList output integration tests ──────────────────────────────────────── + +describe("cmdList output", () => { + let testDir: string; + let consoleMocks: { log: ReturnType; error: ReturnType }; + let processExitMock: ReturnType; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + testDir = join(tmpdir(), `spawn-list-test-${Date.now()}-${Math.random()}`); + mkdirSync(testDir, { recursive: true }); + originalEnv = { ...process.env }; + process.env.SPAWN_HOME = testDir; + consoleMocks = { + log: spyOn(console, "log").mockImplementation(() => {}), + error: spyOn(console, "error").mockImplementation(() => {}), + }; + processExitMock = spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as any); + }); + + afterEach(() => { + consoleMocks.log.mockRestore(); + consoleMocks.error.mockRestore(); + processExitMock.mockRestore(); + process.env = originalEnv; + try { rmSync(testDir, { recursive: true, force: true }); } catch {} + }); + + it("should show empty state when no history exists", async () => { + const { cmdList } = await import("../commands.js"); + await cmdList(); + // Should have logged "No spawns recorded yet." + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + // The message goes through p.log.info which uses console.log + // Check that it didn't output a table header + expect(allOutput).not.toContain("AGENT"); + }); + + it("should show filter message when no matching records found", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00Z" }]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList("nonexistent"); + // Should indicate no matching records, not show the table + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + expect(allOutput).not.toContain("AGENT"); + }); + + it("should show table header when records exist", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00Z" }]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList(); + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + expect(allOutput).toContain("AGENT"); + expect(allOutput).toContain("CLOUD"); + expect(allOutput).toContain("WHEN"); + }); + + it("should show rerun hint for the most recent spawn", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([ + { agent: "aider", cloud: "hetzner", timestamp: "2026-02-10T08:00:00Z" }, + { agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00Z" }, + ]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList(); + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + // Newest-first means claude/sprite is the first record shown + // Rerun hint should reference the most recent (first) record + expect(allOutput).toContain("Rerun last"); + expect(allOutput).toContain("spawn claude sprite"); + }); + + it("should show spawn count in summary", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([ + { agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00Z" }, + { agent: "aider", cloud: "hetzner", timestamp: "2026-02-11T11:00:00Z" }, + { agent: "claude", cloud: "hetzner", timestamp: "2026-02-11T12:00:00Z" }, + ]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList(); + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + expect(allOutput).toContain("3 spawns recorded"); + }); + + it("should use singular 'spawn' for single record", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([ + { agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00Z" }, + ]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList(); + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + expect(allOutput).toContain("1 spawn recorded"); + expect(allOutput).not.toContain("1 spawns"); + }); + + it("should show filter usage hint", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([ + { agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00Z" }, + ]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList(); + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + expect(allOutput).toContain("spawn list -a"); + expect(allOutput).toContain("spawn list -c"); + }); + + it("should filter by agent when agentFilter is provided", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([ + { agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00Z" }, + { agent: "aider", cloud: "hetzner", timestamp: "2026-02-11T11:00:00Z" }, + ]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList("claude"); + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + expect(allOutput).toContain("claude"); + expect(allOutput).toContain("1 spawn recorded"); + }); + + it("should filter by cloud when cloudFilter is provided", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([ + { agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00Z" }, + { agent: "aider", cloud: "hetzner", timestamp: "2026-02-11T11:00:00Z" }, + ]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList(undefined, "hetzner"); + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + expect(allOutput).toContain("hetzner"); + expect(allOutput).toContain("1 spawn recorded"); + }); + + it("should show records in newest-first order", async () => { + writeFileSync( + join(testDir, "history.json"), + JSON.stringify([ + { agent: "aider", cloud: "hetzner", timestamp: "2026-02-09T08:00:00Z" }, + { agent: "claude", cloud: "sprite", timestamp: "2026-02-10T10:00:00Z" }, + { agent: "codex", cloud: "vultr", timestamp: "2026-02-11T12:00:00Z" }, + ]) + ); + const { cmdList } = await import("../commands.js"); + await cmdList(); + const logCalls = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")); + // Find lines that contain agent names to verify order + const agentLines = logCalls.filter(l => l.includes("codex") || l.includes("claude") || l.includes("aider")); + // codex (newest) should come before claude, which should come before aider + if (agentLines.length >= 3) { + const codexIdx = logCalls.findIndex(l => l.includes("codex")); + const claudeIdx = logCalls.findIndex(l => l.includes("claude")); + const aiderIdx = logCalls.findIndex(l => l.includes("aider")); + expect(codexIdx).toBeLessThan(claudeIdx); + expect(claudeIdx).toBeLessThan(aiderIdx); + } + }); + + it("should handle corrupted history file gracefully", async () => { + writeFileSync(join(testDir, "history.json"), "not json at all"); + const { cmdList } = await import("../commands.js"); + await cmdList(); + // Should not crash, should show empty state + const allOutput = consoleMocks.log.mock.calls.map(c => String(c[0] ?? "")).join("\n"); + expect(allOutput).not.toContain("AGENT"); + }); +});