mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-06 16:31:08 +00:00
test: add 34 tests for cmdList table rendering and resolveDisplayName (#515)
Cover the integration of cmdList with manifest-resolved display names, table header/separator rendering, prompt preview truncation in rows vs footer, filtered result counts, timestamp formatting, and the resolveDisplayName exported utility. Agent: test-engineer Co-authored-by: A <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
66b890493a
commit
9a5502906d
1 changed files with 526 additions and 0 deletions
526
cli/src/__tests__/list-table-rendering.test.ts
Normal file
526
cli/src/__tests__/list-table-rendering.test.ts
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
|
||||
import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { createMockManifest, createConsoleMocks, restoreMocks } from "./test-helpers";
|
||||
import { loadManifest } from "../manifest";
|
||||
import type { Manifest } from "../manifest";
|
||||
|
||||
/**
|
||||
* Tests for cmdList table rendering with manifest-resolved display names.
|
||||
*
|
||||
* cmdList (commands.ts:845-882) renders spawn history as a formatted table:
|
||||
* - Resolves agent/cloud keys to display names via the manifest
|
||||
* - Falls back to raw keys when manifest is unavailable
|
||||
* - Shows table header: AGENT, CLOUD, WHEN
|
||||
* - Renders rows with agent name (green), cloud name, timestamp, optional prompt
|
||||
* - Truncates prompt previews > 40 chars in rows
|
||||
* - Delegates footer to showListFooter (tested in list-empty-footer.test.ts)
|
||||
*
|
||||
* resolveDisplayName (commands.ts:839-843) is exported but has no direct tests:
|
||||
* - Returns entry.name when key exists in manifest
|
||||
* - Returns raw key when key is not in manifest
|
||||
* - Returns raw key when manifest is null
|
||||
*
|
||||
* Agent: test-engineer
|
||||
*/
|
||||
|
||||
const mockManifest = createMockManifest();
|
||||
|
||||
// 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,
|
||||
error: mockLogError,
|
||||
warn: mock(() => {}),
|
||||
success: mock(() => {}),
|
||||
},
|
||||
intro: mock(() => {}),
|
||||
outro: mock(() => {}),
|
||||
cancel: mock(() => {}),
|
||||
select: mock(() => {}),
|
||||
isCancel: () => false,
|
||||
}));
|
||||
|
||||
// Import after mock setup
|
||||
const { cmdList, resolveDisplayName } = await import("../commands.js");
|
||||
|
||||
// ── resolveDisplayName direct tests ──────────────────────────────────────────
|
||||
|
||||
describe("resolveDisplayName", () => {
|
||||
it("should return agent display name when key exists", () => {
|
||||
expect(resolveDisplayName(mockManifest, "claude", "agent")).toBe("Claude Code");
|
||||
});
|
||||
|
||||
it("should return cloud display name when key exists", () => {
|
||||
expect(resolveDisplayName(mockManifest, "sprite", "cloud")).toBe("Sprite");
|
||||
});
|
||||
|
||||
it("should return raw key when agent key is not in manifest", () => {
|
||||
expect(resolveDisplayName(mockManifest, "unknown-agent", "agent")).toBe("unknown-agent");
|
||||
});
|
||||
|
||||
it("should return raw key when cloud key is not in manifest", () => {
|
||||
expect(resolveDisplayName(mockManifest, "unknown-cloud", "cloud")).toBe("unknown-cloud");
|
||||
});
|
||||
|
||||
it("should return raw key when manifest is null", () => {
|
||||
expect(resolveDisplayName(null, "claude", "agent")).toBe("claude");
|
||||
});
|
||||
|
||||
it("should return raw key when manifest is null for cloud", () => {
|
||||
expect(resolveDisplayName(null, "sprite", "cloud")).toBe("sprite");
|
||||
});
|
||||
|
||||
it("should handle second agent key correctly", () => {
|
||||
expect(resolveDisplayName(mockManifest, "aider", "agent")).toBe("Aider");
|
||||
});
|
||||
|
||||
it("should handle second cloud key correctly", () => {
|
||||
expect(resolveDisplayName(mockManifest, "hetzner", "cloud")).toBe("Hetzner Cloud");
|
||||
});
|
||||
});
|
||||
|
||||
// ── cmdList table rendering integration ──────────────────────────────────────
|
||||
|
||||
describe("cmdList table rendering", () => {
|
||||
let consoleMocks: ReturnType<typeof createConsoleMocks>;
|
||||
let originalFetch: typeof global.fetch;
|
||||
let testDir: string;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(async () => {
|
||||
consoleMocks = createConsoleMocks();
|
||||
mockLogError.mockClear();
|
||||
mockLogInfo.mockClear();
|
||||
mockLogStep.mockClear();
|
||||
mockSpinnerStart.mockClear();
|
||||
mockSpinnerStop.mockClear();
|
||||
|
||||
originalFetch = global.fetch;
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Set up temp history dir
|
||||
testDir = join(tmpdir(), `spawn-list-table-test-${Date.now()}-${Math.random()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
process.env.SPAWN_HOME = testDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
process.env = originalEnv;
|
||||
restoreMocks(consoleMocks.log, consoleMocks.error);
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function setManifest(manifest: any) {
|
||||
global.fetch = mock(async () => ({
|
||||
ok: true,
|
||||
json: async () => manifest,
|
||||
text: async () => JSON.stringify(manifest),
|
||||
})) as any;
|
||||
return loadManifest(true);
|
||||
}
|
||||
|
||||
function writeHistory(records: Array<{ agent: string; cloud: string; timestamp: string; prompt?: string }>) {
|
||||
writeFileSync(join(testDir, "history.json"), JSON.stringify(records));
|
||||
}
|
||||
|
||||
function getOutput(): string {
|
||||
return consoleMocks.log.mock.calls
|
||||
.map((c: any[]) => c.join(" "))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// ── Table header ───────────────────────────────────────────────────────
|
||||
|
||||
describe("table header", () => {
|
||||
it("should render AGENT, CLOUD, WHEN header columns", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).toContain("AGENT");
|
||||
expect(output).toContain("CLOUD");
|
||||
expect(output).toContain("WHEN");
|
||||
});
|
||||
|
||||
it("should render a separator line with dashes", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).toContain("----");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Display name resolution in rows ────────────────────────────────────
|
||||
|
||||
describe("display name resolution", () => {
|
||||
it("should show agent display name from manifest", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).toContain("Claude Code");
|
||||
});
|
||||
|
||||
it("should show cloud display name from manifest", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "hetzner", timestamp: "2026-02-11T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).toContain("Hetzner Cloud");
|
||||
});
|
||||
|
||||
it("should show raw key when agent is not in manifest", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "unknown-agent", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).toContain("unknown-agent");
|
||||
});
|
||||
|
||||
it("should show raw key when cloud is not in manifest", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "unknown-cloud", timestamp: "2026-02-11T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).toContain("unknown-cloud");
|
||||
});
|
||||
|
||||
it("should fall back to raw keys when manifest fetch fails", async () => {
|
||||
global.fetch = mock(async () => {
|
||||
throw new Error("Network error");
|
||||
}) as any;
|
||||
// Force manifest cache to be cleared
|
||||
try { await loadManifest(true); } catch { /* expected */ }
|
||||
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
// When manifest is unavailable, the raw keys should be shown
|
||||
// Either "claude" or "Claude Code" is acceptable depending on cache
|
||||
expect(output).toMatch(/claude|Claude Code/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Multiple rows ──────────────────────────────────────────────────────
|
||||
|
||||
describe("multiple rows", () => {
|
||||
it("should render all records in reverse chronological order", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-01-01T10:00:00.000Z" },
|
||||
{ agent: "aider", cloud: "hetzner", timestamp: "2026-01-02T10:00:00.000Z" },
|
||||
{ agent: "claude", cloud: "hetzner", timestamp: "2026-01-03T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
// All agents should appear
|
||||
expect(output).toContain("Claude Code");
|
||||
expect(output).toContain("Aider");
|
||||
// All clouds should appear
|
||||
expect(output).toContain("Sprite");
|
||||
expect(output).toContain("Hetzner Cloud");
|
||||
});
|
||||
|
||||
it("should show rerun hint with most recent record", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-01-01T10:00:00.000Z" },
|
||||
{ agent: "aider", cloud: "hetzner", timestamp: "2026-01-02T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
// Most recent is aider/hetzner (reversed = first), so rerun hint should use it
|
||||
expect(output).toContain("spawn aider hetzner");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Prompt preview in rows ─────────────────────────────────────────────
|
||||
|
||||
describe("prompt preview in rows", () => {
|
||||
it("should show short prompt inline with row", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z", prompt: "Fix bugs" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).toContain("Fix bugs");
|
||||
});
|
||||
|
||||
it("should truncate prompt > 40 chars in row with ellipsis", async () => {
|
||||
await setManifest(mockManifest);
|
||||
const longPrompt = "A".repeat(50);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z", prompt: longPrompt },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
// Should show first 40 chars + "..."
|
||||
expect(output).toContain("A".repeat(40) + "...");
|
||||
// Should NOT show the full 50-char prompt
|
||||
expect(output).not.toContain("A".repeat(50));
|
||||
});
|
||||
|
||||
it("should show exactly 40-char prompt without truncation", async () => {
|
||||
await setManifest(mockManifest);
|
||||
const exactPrompt = "B".repeat(40);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z", prompt: exactPrompt },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).toContain("B".repeat(40));
|
||||
// Should not have ellipsis since it's exactly 40
|
||||
expect(output).not.toContain("B".repeat(40) + "...");
|
||||
});
|
||||
|
||||
it("should not show --prompt in row when no prompt given", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).not.toContain("--prompt");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Prompt in footer rerun hint ────────────────────────────────────────
|
||||
|
||||
describe("prompt in footer rerun hint", () => {
|
||||
it("should include --prompt in rerun hint when latest has prompt", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z", prompt: "Fix bugs" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).toContain('--prompt "Fix bugs"');
|
||||
});
|
||||
|
||||
it("should truncate long prompt in rerun hint (> 30 chars)", async () => {
|
||||
await setManifest(mockManifest);
|
||||
const longPrompt = "C".repeat(35);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z", prompt: longPrompt },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
// Footer truncates at 30 chars
|
||||
expect(output).toContain("C".repeat(30) + "...");
|
||||
});
|
||||
|
||||
it("should not include --prompt in rerun hint when latest has no prompt", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
// Rerun line should not have --prompt
|
||||
const lines = consoleMocks.log.mock.calls.map((c: any[]) => c.join(" "));
|
||||
const rerunLine = lines.find((l: string) => l.includes("Rerun last"));
|
||||
expect(rerunLine).toBeDefined();
|
||||
expect(rerunLine!).not.toContain("--prompt");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Filtered results display ───────────────────────────────────────────
|
||||
|
||||
describe("filtered results", () => {
|
||||
it("should show only matching records with agent filter", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-01-01T10:00:00.000Z" },
|
||||
{ agent: "aider", cloud: "hetzner", timestamp: "2026-01-02T10:00:00.000Z" },
|
||||
{ agent: "claude", cloud: "hetzner", timestamp: "2026-01-03T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList("claude");
|
||||
const output = getOutput();
|
||||
expect(output).toContain("Claude Code");
|
||||
// Aider should not appear in filtered results
|
||||
expect(output).not.toContain("Aider");
|
||||
});
|
||||
|
||||
it("should show only matching records with cloud filter", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-01-01T10:00:00.000Z" },
|
||||
{ agent: "aider", cloud: "hetzner", timestamp: "2026-01-02T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList(undefined, "sprite");
|
||||
const output = getOutput();
|
||||
expect(output).toContain("Sprite");
|
||||
// Hetzner should not appear
|
||||
expect(output).not.toContain("Hetzner Cloud");
|
||||
});
|
||||
|
||||
it("should show 'Showing N of M' when filters are active", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-01-01T10:00:00.000Z" },
|
||||
{ agent: "aider", cloud: "hetzner", timestamp: "2026-01-02T10:00:00.000Z" },
|
||||
{ agent: "claude", cloud: "hetzner", timestamp: "2026-01-03T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList("claude");
|
||||
const output = getOutput();
|
||||
expect(output).toContain("Showing 2 of 3");
|
||||
});
|
||||
|
||||
it("should show 'Clear filter' hint when filters are active", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-01-01T10:00:00.000Z" },
|
||||
{ agent: "aider", cloud: "hetzner", timestamp: "2026-01-02T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList("claude");
|
||||
const output = getOutput();
|
||||
expect(output).toContain("Clear filter");
|
||||
expect(output).toContain("spawn list");
|
||||
});
|
||||
|
||||
it("should show filter hint when no filters are active", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).toContain("Filter");
|
||||
expect(output).toContain("spawn list -a");
|
||||
});
|
||||
|
||||
it("should show total count without 'Showing' when unfiltered", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-01-01T10:00:00.000Z" },
|
||||
{ agent: "aider", cloud: "hetzner", timestamp: "2026-01-02T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).toContain("2 spawns recorded");
|
||||
expect(output).not.toContain("Showing");
|
||||
});
|
||||
|
||||
it("should use singular 'spawn' for single record", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
expect(output).toContain("1 spawn recorded");
|
||||
// Should not say "1 spawns"
|
||||
expect(output).not.toContain("1 spawns");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Timestamp formatting in rows ───────────────────────────────────────
|
||||
|
||||
describe("timestamp display", () => {
|
||||
it("should show formatted date for valid ISO timestamp", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T14:30:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
// Should contain a formatted date, not the raw ISO string
|
||||
expect(output).toContain("2026");
|
||||
// The exact format depends on locale, but should contain month/day
|
||||
expect(output).toContain("Feb");
|
||||
});
|
||||
|
||||
it("should handle invalid timestamp gracefully", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "not-a-date" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
// formatTimestamp returns the raw string for invalid dates
|
||||
expect(output).toContain("not-a-date");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Single record edge case ────────────────────────────────────────────
|
||||
|
||||
describe("single record", () => {
|
||||
it("should render a complete table for a single record", async () => {
|
||||
await setManifest(mockManifest);
|
||||
writeHistory([
|
||||
{ agent: "claude", cloud: "sprite", timestamp: "2026-02-11T10:00:00.000Z" },
|
||||
]);
|
||||
|
||||
await cmdList();
|
||||
const output = getOutput();
|
||||
// Header
|
||||
expect(output).toContain("AGENT");
|
||||
expect(output).toContain("CLOUD");
|
||||
expect(output).toContain("WHEN");
|
||||
// Data
|
||||
expect(output).toContain("Claude Code");
|
||||
expect(output).toContain("Sprite");
|
||||
// Footer
|
||||
expect(output).toContain("Rerun last");
|
||||
expect(output).toContain("spawn claude sprite");
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue