claude-code-action/base-action/test/parse-sdk-options.test.ts
Sangyeon Cho fab4258c6e
fix: pass OpenTelemetry environment variables to Claude Code subprocess (#886)
* fix: pass OpenTelemetry environment variables to Claude Code subprocess

Environment variables set in workflow's step `env:` block were not being
passed to the Claude Code subprocess because composite actions only forward
explicitly referenced environment variables.

This fix adds references for telemetry-related environment variables:
- CLAUDE_CODE_ENABLE_TELEMETRY
- OTEL_METRICS_EXPORTER
- OTEL_LOGS_EXPORTER
- OTEL_EXPORTER_OTLP_PROTOCOL
- OTEL_EXPORTER_OTLP_ENDPOINT
- OTEL_METRIC_EXPORT_INTERVAL
- OTEL_LOGS_EXPORT_INTERVAL
- OTEL_RESOURCE_ATTRIBUTES

Co-Authored-By: 조상연[플레이스 AI] <sang-yeon.cho@navercorp.com>
Co-Authored-By: csy1204 <josang1204@gmail.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add tests for OTEL environment variables passthrough

Verify that telemetry-related environment variables are correctly
passed through to sdkOptions.env when set in process.env.

Co-Authored-By: 조상연[플레이스 AI] <sang-yeon.cho@navercorp.com>
Co-Authored-By: csy1204 <josang1204@gmail.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add missing OTEL_EXPORTER_OTLP_HEADERS environment variable

Add OTEL_EXPORTER_OTLP_HEADERS to the list of OpenTelemetry environment
variables passed through to the Claude Code subprocess. This variable is
needed for authentication when connecting to OTLP endpoints that require
bearer tokens or other credentials.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: 조상연[플레이스 AI] <sang-yeon.cho@navercorp.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 14:01:29 -08:00

371 lines
13 KiB
TypeScript

#!/usr/bin/env bun
import { describe, test, expect } from "bun:test";
import { parseSdkOptions } from "../src/parse-sdk-options";
import type { ClaudeOptions } from "../src/run-claude";
describe("parseSdkOptions", () => {
describe("allowedTools merging", () => {
test("should extract allowedTools from claudeArgs", () => {
const options: ClaudeOptions = {
claudeArgs: '--allowedTools "Edit,Read,Write"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
});
test("should extract allowedTools from claudeArgs with MCP tools", () => {
const options: ClaudeOptions = {
claudeArgs:
'--allowedTools "Edit,Read,mcp__github_comment__update_claude_comment"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.allowedTools).toEqual([
"Edit",
"Read",
"mcp__github_comment__update_claude_comment",
]);
});
test("should accumulate multiple --allowedTools flags from claudeArgs", () => {
// This simulates tag mode adding its tools, then user adding their own
const options: ClaudeOptions = {
claudeArgs:
'--allowedTools "Edit,Read,mcp__github_comment__update_claude_comment" --model "claude-3" --allowedTools "Bash(npm install),mcp__github__get_issue"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.allowedTools).toEqual([
"Edit",
"Read",
"mcp__github_comment__update_claude_comment",
"Bash(npm install)",
"mcp__github__get_issue",
]);
});
test("should merge allowedTools from both claudeArgs and direct options", () => {
const options: ClaudeOptions = {
claudeArgs: '--allowedTools "Edit,Read"',
allowedTools: "Write,Glob",
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.allowedTools).toEqual([
"Edit",
"Read",
"Write",
"Glob",
]);
});
test("should deduplicate allowedTools when merging", () => {
const options: ClaudeOptions = {
claudeArgs: '--allowedTools "Edit,Read"',
allowedTools: "Edit,Write",
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
});
test("should use only direct options when claudeArgs has no allowedTools", () => {
const options: ClaudeOptions = {
claudeArgs: '--model "claude-3-5-sonnet"',
allowedTools: "Edit,Read",
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read"]);
});
test("should return undefined allowedTools when neither source has it", () => {
const options: ClaudeOptions = {
claudeArgs: '--model "claude-3-5-sonnet"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.allowedTools).toBeUndefined();
});
test("should remove allowedTools from extraArgs after extraction", () => {
const options: ClaudeOptions = {
claudeArgs: '--allowedTools "Edit,Read" --model "claude-3-5-sonnet"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["allowedTools"]).toBeUndefined();
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
});
test("should handle hyphenated --allowed-tools flag", () => {
const options: ClaudeOptions = {
claudeArgs: '--allowed-tools "Edit,Read,Write"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.allowedTools).toEqual(["Edit", "Read", "Write"]);
expect(result.sdkOptions.extraArgs?.["allowed-tools"]).toBeUndefined();
});
test("should accumulate multiple --allowed-tools flags (hyphenated)", () => {
// This is the exact scenario from issue #746
const options: ClaudeOptions = {
claudeArgs:
'--allowed-tools "Bash(git log:*)" "Bash(git diff:*)" "Bash(git fetch:*)" "Bash(gh pr:*)"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.allowedTools).toEqual([
"Bash(git log:*)",
"Bash(git diff:*)",
"Bash(git fetch:*)",
"Bash(gh pr:*)",
]);
});
test("should handle mixed camelCase and hyphenated allowedTools flags", () => {
const options: ClaudeOptions = {
claudeArgs: '--allowedTools "Edit,Read" --allowed-tools "Write,Glob"',
};
const result = parseSdkOptions(options);
// Both should be merged - note: order depends on which key is found first
expect(result.sdkOptions.allowedTools).toContain("Edit");
expect(result.sdkOptions.allowedTools).toContain("Read");
expect(result.sdkOptions.allowedTools).toContain("Write");
expect(result.sdkOptions.allowedTools).toContain("Glob");
});
});
describe("disallowedTools merging", () => {
test("should extract disallowedTools from claudeArgs", () => {
const options: ClaudeOptions = {
claudeArgs: '--disallowedTools "Bash,Write"',
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.disallowedTools).toEqual(["Bash", "Write"]);
expect(result.sdkOptions.extraArgs?.["disallowedTools"]).toBeUndefined();
});
test("should merge disallowedTools from both sources", () => {
const options: ClaudeOptions = {
claudeArgs: '--disallowedTools "Bash"',
disallowedTools: "Write",
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.disallowedTools).toEqual(["Bash", "Write"]);
});
});
describe("mcp-config merging", () => {
test("should pass through single mcp-config in extraArgs", () => {
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{"command":"cmd1"}}}'`,
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(
'{"mcpServers":{"server1":{"command":"cmd1"}}}',
);
});
test("should merge multiple mcp-config flags with inline JSON", () => {
// Simulates action prepending its config, then user providing their own
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node","args":["server.js"]}}}' --mcp-config '{"mcpServers":{"user_server":{"command":"custom","args":["run"]}}}'`,
};
const result = parseSdkOptions(options);
const mcpConfig = JSON.parse(
result.sdkOptions.extraArgs?.["mcp-config"] as string,
);
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
expect(mcpConfig.mcpServers).toHaveProperty("user_server");
expect(mcpConfig.mcpServers.github_comment.command).toBe("node");
expect(mcpConfig.mcpServers.user_server.command).toBe("custom");
});
test("should merge three mcp-config flags", () => {
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{"command":"cmd1"}}}' --mcp-config '{"mcpServers":{"server2":{"command":"cmd2"}}}' --mcp-config '{"mcpServers":{"server3":{"command":"cmd3"}}}'`,
};
const result = parseSdkOptions(options);
const mcpConfig = JSON.parse(
result.sdkOptions.extraArgs?.["mcp-config"] as string,
);
expect(mcpConfig.mcpServers).toHaveProperty("server1");
expect(mcpConfig.mcpServers).toHaveProperty("server2");
expect(mcpConfig.mcpServers).toHaveProperty("server3");
});
test("should handle mcp-config file path when no inline JSON exists", () => {
const options: ClaudeOptions = {
claudeArgs: `--mcp-config /tmp/user-mcp-config.json`,
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["mcp-config"]).toBe(
"/tmp/user-mcp-config.json",
);
});
test("should merge inline JSON configs when file path is also present", () => {
// When action provides inline JSON and user provides a file path,
// the inline JSON configs should be merged (file paths cannot be merged at parse time)
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '{"mcpServers":{"github_comment":{"command":"node"}}}' --mcp-config '{"mcpServers":{"github_ci":{"command":"node"}}}' --mcp-config /tmp/user-config.json`,
};
const result = parseSdkOptions(options);
// The inline JSON configs should be merged
const mcpConfig = JSON.parse(
result.sdkOptions.extraArgs?.["mcp-config"] as string,
);
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
expect(mcpConfig.mcpServers).toHaveProperty("github_ci");
});
test("should handle mcp-config with other flags", () => {
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '{"mcpServers":{"server1":{}}}' --model claude-3-5-sonnet --mcp-config '{"mcpServers":{"server2":{}}}'`,
};
const result = parseSdkOptions(options);
const mcpConfig = JSON.parse(
result.sdkOptions.extraArgs?.["mcp-config"] as string,
);
expect(mcpConfig.mcpServers).toHaveProperty("server1");
expect(mcpConfig.mcpServers).toHaveProperty("server2");
expect(result.sdkOptions.extraArgs?.["model"]).toBe("claude-3-5-sonnet");
});
test("should handle real-world scenario: action config + user config", () => {
// This is the exact scenario from the bug report
const actionConfig = JSON.stringify({
mcpServers: {
github_comment: {
command: "node",
args: ["github-comment-server.js"],
},
github_ci: { command: "node", args: ["github-ci-server.js"] },
},
});
const userConfig = JSON.stringify({
mcpServers: {
my_custom_server: { command: "python", args: ["server.py"] },
},
});
const options: ClaudeOptions = {
claudeArgs: `--mcp-config '${actionConfig}' --mcp-config '${userConfig}'`,
};
const result = parseSdkOptions(options);
const mcpConfig = JSON.parse(
result.sdkOptions.extraArgs?.["mcp-config"] as string,
);
// All servers should be present
expect(mcpConfig.mcpServers).toHaveProperty("github_comment");
expect(mcpConfig.mcpServers).toHaveProperty("github_ci");
expect(mcpConfig.mcpServers).toHaveProperty("my_custom_server");
});
});
describe("other extraArgs passthrough", () => {
test("should pass through json-schema in extraArgs", () => {
const options: ClaudeOptions = {
claudeArgs: `--json-schema '{"type":"object"}'`,
};
const result = parseSdkOptions(options);
expect(result.sdkOptions.extraArgs?.["json-schema"]).toBe(
'{"type":"object"}',
);
expect(result.hasJsonSchema).toBe(true);
});
});
describe("environment variables passthrough", () => {
test("should include OTEL environment variables in sdkOptions.env", () => {
// Set up test environment variables
const originalEnv = { ...process.env };
process.env.CLAUDE_CODE_ENABLE_TELEMETRY = "1";
process.env.OTEL_METRICS_EXPORTER = "otlp";
process.env.OTEL_LOGS_EXPORTER = "otlp";
process.env.OTEL_EXPORTER_OTLP_PROTOCOL = "http/json";
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://example.com";
process.env.OTEL_EXPORTER_OTLP_HEADERS =
"Authorization=Bearer test-token";
process.env.OTEL_METRIC_EXPORT_INTERVAL = "10000";
process.env.OTEL_LOGS_EXPORT_INTERVAL = "5000";
process.env.OTEL_RESOURCE_ATTRIBUTES = "department=test";
try {
const options: ClaudeOptions = {};
const result = parseSdkOptions(options);
// Verify OTEL env vars are passed through to sdkOptions.env
expect(result.sdkOptions.env?.CLAUDE_CODE_ENABLE_TELEMETRY).toBe("1");
expect(result.sdkOptions.env?.OTEL_METRICS_EXPORTER).toBe("otlp");
expect(result.sdkOptions.env?.OTEL_LOGS_EXPORTER).toBe("otlp");
expect(result.sdkOptions.env?.OTEL_EXPORTER_OTLP_PROTOCOL).toBe(
"http/json",
);
expect(result.sdkOptions.env?.OTEL_EXPORTER_OTLP_ENDPOINT).toBe(
"https://example.com",
);
expect(result.sdkOptions.env?.OTEL_EXPORTER_OTLP_HEADERS).toBe(
"Authorization=Bearer test-token",
);
expect(result.sdkOptions.env?.OTEL_METRIC_EXPORT_INTERVAL).toBe(
"10000",
);
expect(result.sdkOptions.env?.OTEL_LOGS_EXPORT_INTERVAL).toBe("5000");
expect(result.sdkOptions.env?.OTEL_RESOURCE_ATTRIBUTES).toBe(
"department=test",
);
} finally {
// Restore original environment
process.env = originalEnv;
}
});
test("should set CLAUDE_CODE_ENTRYPOINT in sdkOptions.env", () => {
const options: ClaudeOptions = {};
const result = parseSdkOptions(options);
expect(result.sdkOptions.env?.CLAUDE_CODE_ENTRYPOINT).toBe(
"claude-code-github-action",
);
});
});
});