Files
claude-code-action/test/ci-output-formatter.test.ts
T
Vlad MocanuandClaude Opus 4.6 ed536bb8a3 feat: add human-readable CI log output for show_full_output mode
Replace raw JSON dumps with concise, scannable log lines when
show_full_output is enabled. Adds an onMessage callback to the SDK
runner that formats each message type into 1-2 line summaries with
emoji prefixes, tool-specific parameter display, result correlation
for parallel tool calls, and subagent progress tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:50:49 +02:00

742 lines
21 KiB
TypeScript

import { expect, test, describe, beforeEach, afterEach } from "bun:test";
import {
createCiOutputFormatter,
truncate,
formatToolParams,
formatToolResult,
formatDuration,
formatCost,
formatPermissionDenials,
} from "../src/ci-output-formatter";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
// Capture console.log output
let logged: string[];
const originalLog = console.log;
beforeEach(() => {
logged = [];
console.log = (...args: unknown[]) => {
logged.push(args.map(String).join(" "));
};
});
afterEach(() => {
console.log = originalLog;
});
// --- Helper factories for SDK messages ---
function makeSystemInit(
overrides: Partial<{
claude_code_version: string;
model: string;
tools: string[];
mcp_servers: { name: string; status: string }[];
}> = {},
): SDKMessage {
return {
type: "system",
subtype: "init",
claude_code_version: overrides.claude_code_version ?? "2.1.59",
model: overrides.model ?? "claude-sonnet-4-6",
tools: overrides.tools ?? ["Read", "Write", "Edit", "Bash", "Grep"],
mcp_servers: overrides.mcp_servers ?? [],
apiKeySource: "api_key",
betas: [],
cwd: "/tmp",
permissionMode: "default",
slash_commands: [],
output_style: "text",
skills: [],
plugins: [],
uuid: "00000000-0000-0000-0000-000000000001" as `${string}-${string}-${string}-${string}-${string}`,
session_id: "test-session",
} as unknown as SDKMessage;
}
function makeAssistantMessage(content: unknown[]): SDKMessage {
return {
type: "assistant",
message: {
content,
id: "msg_test",
type: "message",
role: "assistant",
model: "claude-sonnet-4-6",
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
},
parent_tool_use_id: null,
uuid: "00000000-0000-0000-0000-000000000002" as `${string}-${string}-${string}-${string}-${string}`,
session_id: "test-session",
} as unknown as SDKMessage;
}
function makeUserToolResult(
results: Array<{
tool_use_id: string;
content: unknown;
is_error?: boolean;
}>,
): SDKMessage {
return {
type: "user",
message: {
role: "user",
content: results.map((r) => ({
type: "tool_result",
tool_use_id: r.tool_use_id,
content: r.content,
is_error: r.is_error ?? false,
})),
},
parent_tool_use_id: null,
session_id: "test-session",
} as unknown as SDKMessage;
}
function makeResultSuccess(
overrides: Partial<{
num_turns: number;
total_cost_usd: number;
duration_ms: number;
permission_denials: Array<{
tool_name: string;
tool_use_id: string;
tool_input: Record<string, unknown>;
}>;
}> = {},
): SDKMessage {
return {
type: "result",
subtype: "success",
is_error: false,
num_turns: overrides.num_turns ?? 10,
total_cost_usd: overrides.total_cost_usd ?? 0.42,
duration_ms: overrides.duration_ms ?? 65000,
duration_api_ms: 60000,
stop_reason: "end_turn",
result: "done",
usage: {
input_tokens: 1000,
output_tokens: 500,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
modelUsage: {},
permission_denials: overrides.permission_denials ?? [],
uuid: "00000000-0000-0000-0000-000000000099" as `${string}-${string}-${string}-${string}-${string}`,
session_id: "test-session",
} as unknown as SDKMessage;
}
function makeResultError(
errors: string[],
overrides: Partial<{
subtype: string;
num_turns: number;
total_cost_usd: number;
duration_ms: number;
permission_denials: Array<{
tool_name: string;
tool_use_id: string;
tool_input: Record<string, unknown>;
}>;
}> = {},
): SDKMessage {
return {
type: "result",
subtype: overrides.subtype ?? "error_during_execution",
is_error: true,
num_turns: overrides.num_turns ?? 5,
total_cost_usd: overrides.total_cost_usd ?? 0.15,
duration_ms: overrides.duration_ms ?? 30000,
duration_api_ms: 25000,
stop_reason: null,
errors,
usage: {
input_tokens: 500,
output_tokens: 100,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
modelUsage: {},
permission_denials: overrides.permission_denials ?? [],
uuid: "00000000-0000-0000-0000-000000000099" as `${string}-${string}-${string}-${string}-${string}`,
session_id: "test-session",
} as unknown as SDKMessage;
}
// --- Unit tests for helper functions ---
describe("truncate", () => {
test("returns short text unchanged", () => {
expect(truncate("hello world")).toBe("hello world");
});
test("truncates long text with ellipsis", () => {
const long = "a".repeat(250);
const result = truncate(long);
expect(result.length).toBe(200);
expect(result.endsWith("...")).toBe(true);
});
test("collapses newlines to spaces", () => {
expect(truncate("line1\nline2\nline3")).toBe("line1 line2 line3");
});
test("trims whitespace", () => {
expect(truncate(" hello ")).toBe("hello");
});
test("respects custom maxLen", () => {
const result = truncate("a".repeat(50), 20);
expect(result.length).toBe(20);
expect(result.endsWith("...")).toBe(true);
});
});
describe("formatToolParams", () => {
test("Bash: shows command and description", () => {
const result = formatToolParams("Bash", {
command: "git log --oneline -10",
description: "Show recent git history",
});
expect(result).toBe('git log --oneline -10 — "Show recent git history"');
});
test("Bash: shows command only when no description", () => {
const result = formatToolParams("Bash", { command: "ls -la" });
expect(result).toBe("ls -la");
});
test("Read/Write/Edit: shows file_path", () => {
for (const tool of ["Read", "Write", "Edit"]) {
const result = formatToolParams(tool, {
file_path: "/src/index.ts",
});
expect(result).toBe("/src/index.ts");
}
});
test("Grep: shows pattern and path", () => {
const result = formatToolParams("Grep", {
pattern: "TODO",
path: "src/",
});
expect(result).toBe('"TODO" in src/');
});
test("Grep: shows pattern only when no path", () => {
const result = formatToolParams("Grep", { pattern: "TODO" });
expect(result).toBe('"TODO"');
});
test("Glob: shows pattern", () => {
expect(formatToolParams("Glob", { pattern: "**/*.ts" })).toBe("**/*.ts");
});
test("WebFetch: shows url", () => {
expect(formatToolParams("WebFetch", { url: "https://example.com" })).toBe(
"https://example.com",
);
});
test("WebSearch: shows query", () => {
expect(formatToolParams("WebSearch", { query: "bun test" })).toBe(
"bun test",
);
});
test("Task: shows subagent_type and description", () => {
const result = formatToolParams("Task", {
subagent_type: "Explore",
description: "Find auth code",
});
expect(result).toBe("Explore — Find auth code");
});
test("unknown tool: returns empty string", () => {
expect(formatToolParams("mcp__github__get_issue", {})).toBe("");
});
});
describe("formatToolResult", () => {
test("short string: returned as-is", () => {
expect(formatToolResult("file contents here")).toBe("file contents here");
});
test("multiline string: first line + count", () => {
const result = formatToolResult("line1\nline2\nline3");
expect(result).toContain("line1");
expect(result).toContain("(3 lines)");
});
test("empty string: shows (empty)", () => {
expect(formatToolResult("")).toBe("(empty)");
});
test("whitespace-only string: shows (empty)", () => {
expect(formatToolResult(" \n \n ")).toBe("(empty)");
});
test("content blocks array with text", () => {
const result = formatToolResult([{ type: "text", text: "hello world" }]);
expect(result).toBe("hello world");
});
test("content blocks array without text", () => {
const result = formatToolResult([{ type: "image", source: {} }]);
expect(result).toBe("(1 content blocks)");
});
test("non-string, non-array: shows complex result", () => {
expect(formatToolResult(42)).toBe("(complex result)");
expect(formatToolResult(null)).toBe("(complex result)");
});
});
describe("formatDuration", () => {
test("milliseconds for < 1s", () => {
expect(formatDuration(500)).toBe("500ms");
});
test("seconds for < 1m", () => {
expect(formatDuration(45000)).toBe("45s");
});
test("minutes and seconds for >= 1m", () => {
expect(formatDuration(65000)).toBe("1m05s");
});
test("large durations", () => {
expect(formatDuration(455000)).toBe("7m35s");
});
});
describe("formatCost", () => {
test("small costs show 4 decimals", () => {
expect(formatCost(0.0012)).toBe("$0.0012");
});
test("larger costs show 2 decimals", () => {
expect(formatCost(1.65)).toBe("$1.65");
});
test("boundary at 0.01", () => {
expect(formatCost(0.01)).toBe("$0.01");
});
});
describe("formatPermissionDenials", () => {
test("empty array returns empty string", () => {
expect(formatPermissionDenials([])).toBe("");
});
test("single denial", () => {
const result = formatPermissionDenials([
{
tool_name: "Bash",
tool_use_id: "id1",
tool_input: { command: "git push origin main" },
},
]);
expect(result).toContain("1 permission denial");
expect(result).toContain("Bash(git)");
});
test("multiple denials with consistent grammar", () => {
const result = formatPermissionDenials([
{
tool_name: "Bash",
tool_use_id: "id1",
tool_input: { command: "bun test" },
},
{
tool_name: "Write",
tool_use_id: "id2",
tool_input: {},
},
]);
expect(result).toContain("2 permission denials");
expect(result).toContain("Bash(bun)");
expect(result).toContain("Write");
});
test("non-Bash tools show tool name only", () => {
const result = formatPermissionDenials([
{
tool_name: "Edit",
tool_use_id: "id1",
tool_input: { file_path: "/etc/passwd" },
},
]);
expect(result).toContain("Edit");
expect(result).not.toContain("file_path");
});
});
// --- Integration tests for createCiOutputFormatter ---
describe("createCiOutputFormatter", () => {
describe("system init", () => {
test("formats init message with version, model, tools", () => {
const formatter = createCiOutputFormatter();
formatter(makeSystemInit());
expect(logged[0]).toContain("Claude Code v2.1.59");
expect(logged[0]).toContain("claude-sonnet-4-6");
expect(logged[0]).toContain("5 tools");
});
test("includes MCP server count when connected", () => {
const formatter = createCiOutputFormatter();
formatter(
makeSystemInit({
mcp_servers: [
{ name: "github_comment", status: "connected" },
{ name: "github_ci", status: "connected" },
{ name: "failing", status: "error" },
],
}),
);
expect(logged[0]).toContain("2 MCP servers");
});
test("omits MCP info when none connected", () => {
const formatter = createCiOutputFormatter();
formatter(makeSystemInit({ mcp_servers: [] }));
expect(logged[0]).not.toContain("MCP");
});
});
describe("assistant messages", () => {
test("formats text content", () => {
const formatter = createCiOutputFormatter();
formatter(
makeAssistantMessage([
{ type: "text", text: "I'll help you fix this bug." },
]),
);
expect(logged[0]).toBe("💬 I'll help you fix this bug.");
});
test("skips thinking blocks", () => {
const formatter = createCiOutputFormatter();
formatter(
makeAssistantMessage([
{ type: "thinking", thinking: "Let me think about this..." },
{ type: "text", text: "Here's my answer." },
]),
);
expect(logged).toHaveLength(1);
expect(logged[0]).toContain("Here's my answer.");
});
test("formats tool_use with params", () => {
const formatter = createCiOutputFormatter();
formatter(
makeAssistantMessage([
{
type: "tool_use",
id: "tu_1",
name: "Read",
input: { file_path: "/src/index.ts" },
},
]),
);
expect(logged[0]).toBe("🔧 Read: /src/index.ts");
});
test("formats tool_use without params (MCP)", () => {
const formatter = createCiOutputFormatter();
formatter(
makeAssistantMessage([
{
type: "tool_use",
id: "tu_1",
name: "mcp__github_comment__update_claude_comment",
input: { body: "done" },
},
]),
);
expect(logged[0]).toBe("🔧 mcp__github_comment__update_claude_comment");
});
test("handles multiple content blocks in one message", () => {
const formatter = createCiOutputFormatter();
formatter(
makeAssistantMessage([
{ type: "text", text: "Reading files..." },
{
type: "tool_use",
id: "tu_1",
name: "Read",
input: { file_path: "a.ts" },
},
{
type: "tool_use",
id: "tu_2",
name: "Read",
input: { file_path: "b.ts" },
},
]),
);
expect(logged).toHaveLength(3);
expect(logged[0]).toContain("Reading files...");
expect(logged[1]).toContain("Read: a.ts");
expect(logged[2]).toContain("Read: b.ts");
});
});
describe("tool results (user messages)", () => {
test("formats successful result", () => {
const formatter = createCiOutputFormatter();
formatter(
makeUserToolResult([
{ tool_use_id: "tu_1", content: "file contents here" },
]),
);
expect(logged[0]).toBe(" ↳ file contents here");
});
test("formats error result", () => {
const formatter = createCiOutputFormatter();
formatter(
makeUserToolResult([
{
tool_use_id: "tu_1",
content: "File does not exist",
is_error: true,
},
]),
);
expect(logged[0]).toContain("↳ ❌");
expect(logged[0]).toContain("File does not exist");
});
test("skips empty content", () => {
const formatter = createCiOutputFormatter();
formatter(makeUserToolResult([{ tool_use_id: "tu_1", content: null }]));
expect(logged).toHaveLength(0);
});
});
describe("tool result correlation (issue #1)", () => {
test("correlates results back to tool calls for parallel tools", () => {
const formatter = createCiOutputFormatter();
// Assistant sends parallel tool calls
formatter(
makeAssistantMessage([
{
type: "tool_use",
id: "tu_1",
name: "Read",
input: { file_path: "src/a.ts" },
},
{
type: "tool_use",
id: "tu_2",
name: "Read",
input: { file_path: "src/b.ts" },
},
]),
);
// User message with results
formatter(
makeUserToolResult([
{ tool_use_id: "tu_1", content: "contents of a" },
{ tool_use_id: "tu_2", content: "contents of b" },
]),
);
// Results should include tool labels
expect(logged[2]).toContain("[Read: src/a.ts]");
expect(logged[2]).toContain("contents of a");
expect(logged[3]).toContain("[Read: src/b.ts]");
expect(logged[3]).toContain("contents of b");
});
test("omits correlation prefix for single tool calls", () => {
const formatter = createCiOutputFormatter();
formatter(
makeAssistantMessage([
{
type: "tool_use",
id: "tu_1",
name: "Read",
input: { file_path: "src/a.ts" },
},
]),
);
formatter(
makeUserToolResult([{ tool_use_id: "tu_1", content: "file contents" }]),
);
// Single result still has prefix since the map tracks it
expect(logged[1]).toContain("[Read: src/a.ts]");
});
});
describe("subagent messages (issue #2)", () => {
test("formats task_started", () => {
const formatter = createCiOutputFormatter();
formatter({
type: "system",
subtype: "task_started",
task_id: "task_1",
description: "Find auth implementation",
uuid: "00000000-0000-0000-0000-000000000010",
session_id: "test",
} as unknown as SDKMessage);
expect(logged[0]).toContain("Task started");
expect(logged[0]).toContain("Find auth implementation");
});
test("formats task_progress", () => {
const formatter = createCiOutputFormatter();
formatter({
type: "system",
subtype: "task_progress",
task_id: "task_1",
description: "searching",
usage: { total_tokens: 5000, tool_uses: 8, duration_ms: 15000 },
last_tool_name: "Grep",
uuid: "00000000-0000-0000-0000-000000000011",
session_id: "test",
} as unknown as SDKMessage);
expect(logged[0]).toContain("8 tool uses");
expect(logged[0]).toContain("last: Grep");
});
test("formats task_progress without last_tool_name", () => {
const formatter = createCiOutputFormatter();
formatter({
type: "system",
subtype: "task_progress",
task_id: "task_1",
description: "starting",
usage: { total_tokens: 100, tool_uses: 0, duration_ms: 1000 },
uuid: "00000000-0000-0000-0000-000000000011",
session_id: "test",
} as unknown as SDKMessage);
expect(logged[0]).toContain("0 tool uses");
expect(logged[0]).not.toContain("last:");
});
test("formats task_notification", () => {
const formatter = createCiOutputFormatter();
formatter({
type: "system",
subtype: "task_notification",
task_id: "task_1",
status: "completed",
output_file: "/tmp/output",
summary: "Found 3 auth-related files in src/auth/",
uuid: "00000000-0000-0000-0000-000000000012",
session_id: "test",
} as unknown as SDKMessage);
expect(logged[0]).toContain("Task completed");
expect(logged[0]).toContain("Found 3 auth-related files");
});
});
describe("result messages", () => {
test("formats success result", () => {
const formatter = createCiOutputFormatter();
formatter(
makeResultSuccess({
num_turns: 46,
total_cost_usd: 1.65,
duration_ms: 455000,
}),
);
expect(logged[0]).toBe("✅ Done — 46 turns, $1.65, 7m35s");
});
test("formats error result", () => {
const formatter = createCiOutputFormatter();
formatter(
makeResultError(["API timeout", "retry failed"], {
num_turns: 5,
total_cost_usd: 0.15,
duration_ms: 30000,
}),
);
expect(logged[0]).toContain("❌ Failed");
expect(logged[0]).toContain("error_during_execution");
expect(logged[0]).toContain("API timeout, retry failed");
});
test("includes permission denials in result", () => {
const formatter = createCiOutputFormatter();
formatter(
makeResultSuccess({
num_turns: 10,
total_cost_usd: 0.42,
duration_ms: 65000,
permission_denials: [
{
tool_name: "Bash",
tool_use_id: "id1",
tool_input: { command: "bun test" },
},
],
}),
);
expect(logged[0]).toContain("1 permission denial");
expect(logged[0]).toContain("Bash(bun)");
});
});
describe("ignored message types", () => {
test("skips stream_event messages", () => {
const formatter = createCiOutputFormatter();
formatter({
type: "stream_event",
event: {},
parent_tool_use_id: null,
uuid: "00000000-0000-0000-0000-000000000020",
session_id: "test",
} as unknown as SDKMessage);
expect(logged).toHaveLength(0);
});
test("skips tool_progress messages", () => {
const formatter = createCiOutputFormatter();
formatter({
type: "tool_progress",
tool_use_id: "tu_1",
tool_name: "Bash",
parent_tool_use_id: null,
elapsed_time_seconds: 5,
uuid: "00000000-0000-0000-0000-000000000021",
session_id: "test",
} as unknown as SDKMessage);
expect(logged).toHaveLength(0);
});
test("skips system status messages", () => {
const formatter = createCiOutputFormatter();
formatter({
type: "system",
subtype: "status",
status: "ready",
uuid: "00000000-0000-0000-0000-000000000022",
session_id: "test",
} as unknown as SDKMessage);
expect(logged).toHaveLength(0);
});
});
});