mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 06:54:13 +08:00
feat: add repository_dispatch event support
- Add new progress MCP server for reporting task status via API
- Support repository_dispatch events with task description and progress endpoint
- Introduce isDispatch flag to unify dispatch event handling
- Make GitHub data optional for dispatch events without issues/PRs
- Update prompt generation with dispatch-specific instructions
Enables triggering Claude via repository_dispatch with:
{
"event_type": "claude_task",
"client_payload": {
"description": "Task description",
"progress_endpoint": "https://api.example.com/progress"
}
}
This commit is contained in:
364
base-action/test/stream-handler.test.ts
Normal file
364
base-action/test/stream-handler.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
||||
import {
|
||||
StreamHandler,
|
||||
parseStreamHeaders,
|
||||
type TokenGetter,
|
||||
} from "../src/stream-handler";
|
||||
|
||||
describe("parseStreamHeaders", () => {
|
||||
it("should return empty object for empty input", () => {
|
||||
expect(parseStreamHeaders("")).toEqual({});
|
||||
expect(parseStreamHeaders(undefined)).toEqual({});
|
||||
expect(parseStreamHeaders(" ")).toEqual({});
|
||||
});
|
||||
|
||||
it("should parse single header", () => {
|
||||
const result = parseStreamHeaders('{"X-Correlation-Id": "12345"}');
|
||||
expect(result).toEqual({ "X-Correlation-Id": "12345" });
|
||||
});
|
||||
|
||||
it("should parse multiple headers", () => {
|
||||
const headers = JSON.stringify({
|
||||
"X-Correlation-Id": "12345",
|
||||
"X-Custom-Header": "custom-value",
|
||||
Authorization: "Bearer token123",
|
||||
});
|
||||
|
||||
const result = parseStreamHeaders(headers);
|
||||
expect(result).toEqual({
|
||||
"X-Correlation-Id": "12345",
|
||||
"X-Custom-Header": "custom-value",
|
||||
Authorization: "Bearer token123",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle headers with spaces", () => {
|
||||
const headers = JSON.stringify({
|
||||
"X-Header-One": "value with spaces",
|
||||
"X-Header-Two": "another value",
|
||||
});
|
||||
|
||||
const result = parseStreamHeaders(headers);
|
||||
expect(result).toEqual({
|
||||
"X-Header-One": "value with spaces",
|
||||
"X-Header-Two": "another value",
|
||||
});
|
||||
});
|
||||
|
||||
it("should skip empty lines and comments", () => {
|
||||
const headers = JSON.stringify({
|
||||
"X-Header-One": "value1",
|
||||
"X-Header-Two": "value2",
|
||||
"X-Header-Three": "value3",
|
||||
});
|
||||
|
||||
const result = parseStreamHeaders(headers);
|
||||
expect(result).toEqual({
|
||||
"X-Header-One": "value1",
|
||||
"X-Header-Two": "value2",
|
||||
"X-Header-Three": "value3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should skip lines without colons", () => {
|
||||
const headers = JSON.stringify({
|
||||
"X-Header-One": "value1",
|
||||
"X-Header-Two": "value2",
|
||||
});
|
||||
|
||||
const result = parseStreamHeaders(headers);
|
||||
expect(result).toEqual({
|
||||
"X-Header-One": "value1",
|
||||
"X-Header-Two": "value2",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle headers with colons in values", () => {
|
||||
const headers = JSON.stringify({
|
||||
"X-URL": "https://example.com:8080/path",
|
||||
"X-Time": "10:30:45",
|
||||
});
|
||||
|
||||
const result = parseStreamHeaders(headers);
|
||||
expect(result).toEqual({
|
||||
"X-URL": "https://example.com:8080/path",
|
||||
"X-Time": "10:30:45",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("StreamHandler", () => {
|
||||
let handler: StreamHandler;
|
||||
let mockFetch: ReturnType<typeof mock>;
|
||||
let mockTokenGetter: TokenGetter;
|
||||
const mockEndpoint = "https://test.example.com/stream";
|
||||
const mockToken = "mock-oidc-token";
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock fetch
|
||||
mockFetch = mock(() => Promise.resolve({ ok: true }));
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
// Mock token getter
|
||||
mockTokenGetter = mock(() => Promise.resolve(mockToken));
|
||||
});
|
||||
|
||||
describe("basic functionality", () => {
|
||||
it("should batch lines up to BATCH_SIZE", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// Add 9 lines (less than batch size of 10)
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Should not have sent anything yet
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
// Add the 10th line to trigger flush
|
||||
await handler.addOutput("line 10\n");
|
||||
|
||||
// Should have sent the batch
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(mockEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${mockToken}`,
|
||||
},
|
||||
body: expect.stringContaining(
|
||||
'"output":["line 1","line 2","line 3","line 4","line 5","line 6","line 7","line 8","line 9","line 10"]',
|
||||
),
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it("should flush on timeout", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// Add a few lines
|
||||
await handler.addOutput("line 1\n");
|
||||
await handler.addOutput("line 2\n");
|
||||
|
||||
// Should not have sent anything yet
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
// Wait for the timeout to trigger
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
// Should have sent the batch
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse(call![1].body);
|
||||
expect(body.output).toEqual(["line 1", "line 2"]);
|
||||
});
|
||||
|
||||
it("should include custom headers", async () => {
|
||||
const customHeaders = {
|
||||
"X-Correlation-Id": "12345",
|
||||
"X-Custom": "value",
|
||||
};
|
||||
handler = new StreamHandler(mockEndpoint, customHeaders, mockTokenGetter);
|
||||
|
||||
// Trigger a batch
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(mockEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${mockToken}`,
|
||||
"X-Correlation-Id": "12345",
|
||||
"X-Custom": "value",
|
||||
},
|
||||
body: expect.any(String),
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
});
|
||||
|
||||
it("should include timestamp in payload", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
const beforeTime = new Date().toISOString();
|
||||
|
||||
// Trigger a batch
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
const afterTime = new Date().toISOString();
|
||||
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse(call![1].body);
|
||||
|
||||
expect(body).toHaveProperty("timestamp");
|
||||
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
|
||||
expect(body.timestamp >= beforeTime).toBe(true);
|
||||
expect(body.timestamp <= afterTime).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("token management", () => {
|
||||
it("should fetch token on first request", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// Trigger a flush
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
expect(mockTokenGetter).toHaveBeenCalledWith("claude-code-github-action");
|
||||
expect(mockTokenGetter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should reuse token within 4 minutes", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// First batch
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Second batch immediately (within 4 minutes)
|
||||
for (let i = 11; i <= 20; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Should have only fetched token once
|
||||
expect(mockTokenGetter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should handle token fetch errors", async () => {
|
||||
const errorTokenGetter = mock(() =>
|
||||
Promise.reject(new Error("Token fetch failed")),
|
||||
);
|
||||
handler = new StreamHandler(mockEndpoint, {}, errorTokenGetter);
|
||||
|
||||
// Try to send data
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Should not have made fetch request
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle fetch errors gracefully", async () => {
|
||||
mockFetch.mockImplementation(() =>
|
||||
Promise.reject(new Error("Network error")),
|
||||
);
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// Send data - should not throw
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Should have attempted to fetch
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should continue processing after errors", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// First batch - make it fail
|
||||
let callCount = 0;
|
||||
mockFetch.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return Promise.reject(new Error("First batch failed"));
|
||||
}
|
||||
return Promise.resolve({ ok: true });
|
||||
});
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Second batch - should work
|
||||
for (let i = 11; i <= 20; i++) {
|
||||
await handler.addOutput(`line ${i}\n`);
|
||||
}
|
||||
|
||||
// Should have attempted both batches
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("close functionality", () => {
|
||||
it("should flush remaining data on close", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
// Add some data but not enough to trigger batch
|
||||
await handler.addOutput("line 1\n");
|
||||
await handler.addOutput("line 2\n");
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
|
||||
// Close should flush
|
||||
await handler.close();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse(call![1].body);
|
||||
expect(body.output).toEqual(["line 1", "line 2"]);
|
||||
});
|
||||
|
||||
it("should not accept new data after close", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
await handler.close();
|
||||
|
||||
// Try to add data after close
|
||||
await handler.addOutput("should not be sent\n");
|
||||
|
||||
// Should not have sent anything
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("data handling", () => {
|
||||
it("should filter out empty lines", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
await handler.addOutput("line 1\n\n\nline 2\n\n");
|
||||
await handler.close();
|
||||
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse(call![1].body);
|
||||
expect(body.output).toEqual(["line 1", "line 2"]);
|
||||
});
|
||||
|
||||
it("should handle data without newlines", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
await handler.addOutput("single line");
|
||||
await handler.close();
|
||||
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse(call![1].body);
|
||||
expect(body.output).toEqual(["single line"]);
|
||||
});
|
||||
|
||||
it("should handle multi-line input correctly", async () => {
|
||||
handler = new StreamHandler(mockEndpoint, {}, mockTokenGetter);
|
||||
|
||||
await handler.addOutput("line 1\nline 2\nline 3");
|
||||
await handler.close();
|
||||
|
||||
const call = mockFetch.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const body = JSON.parse(call![1].body);
|
||||
expect(body.output).toEqual(["line 1", "line 2", "line 3"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user