Files
claude-code-action/test/comment-logic.test.ts
claude[bot] 1d100f7832 feat: add workflow cancellation detection and update comment
When a workflow is cancelled, the comment now shows "Claude's task was cancelled" 
instead of the generic "Claude encountered an error" message. This provides clearer 
feedback to users about why the workflow stopped.

Changes:
- Add CLAUDE_CANCELLED environment variable using cancelled() function in action.yml
- Implement cancellation detection logic in update-comment-link.ts
- Update comment-logic.ts to show specific cancellation message
- Add comprehensive tests for cancellation handling

Fixes #123

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
2025-06-04 01:03:26 +00:00

466 lines
17 KiB
TypeScript

import { describe, it, expect } from "bun:test";
import { updateCommentBody } from "../src/github/operations/comment-logic";
describe("updateCommentBody", () => {
const baseInput = {
currentBody: "Initial comment body",
actionFailed: false,
executionDetails: null,
jobUrl: "https://github.com/owner/repo/actions/runs/123",
branchName: undefined,
triggerUsername: undefined,
};
describe("working message replacement", () => {
it("includes success message header with duration", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working…",
executionDetails: { duration_ms: 74000 }, // 1m 14s
triggerUsername: "trigger-user",
};
const result = updateCommentBody(input);
expect(result).toContain(
"**Claude finished @trigger-user's task in 1m 14s**",
);
expect(result).not.toContain("Claude Code is working");
});
it("includes error message header with duration", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working...",
actionFailed: true,
executionDetails: { duration_ms: 45000 }, // 45s
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude encountered an error after 45s**");
});
it("includes error details when provided", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working...",
actionFailed: true,
executionDetails: { duration_ms: 45000 },
errorDetails: "Failed to fetch issue data",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude encountered an error after 45s**");
expect(result).toContain("[View job]");
expect(result).toContain("```\nFailed to fetch issue data\n```");
// Ensure error details come after the header/links
const errorIndex = result.indexOf("```");
const headerIndex = result.indexOf("**Claude encountered an error");
expect(errorIndex).toBeGreaterThan(headerIndex);
});
it("handles username extraction from content when not provided", () => {
const input = {
...baseInput,
currentBody:
"Claude Code is working… <img src='spinner.gif' />\n\nI'll work on this task @testuser",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude finished @testuser's task**");
});
});
describe("job link", () => {
it("includes job link in header", () => {
const input = {
...baseInput,
currentBody: "Some comment",
};
const result = updateCommentBody(input);
expect(result).toContain(`—— [View job](${baseInput.jobUrl})`);
});
it("always includes job link in header, even if present in body", () => {
const input = {
...baseInput,
currentBody: `Some comment with [View job run](${baseInput.jobUrl})`,
triggerUsername: "testuser",
};
const result = updateCommentBody(input);
// Check it's in the header with the new format
expect(result).toContain(`—— [View job](${baseInput.jobUrl})`);
// The old link in body is removed
expect(result).not.toContain("View job run");
});
});
describe("branch link", () => {
it("adds branch name with link to header when provided", () => {
const input = {
...baseInput,
branchName: "claude/issue-123-20240101_120000",
};
const result = updateCommentBody(input);
expect(result).toContain(
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/tree/claude/issue-123-20240101_120000)",
);
});
it("extracts branch name from branchLink if branchName not provided", () => {
const input = {
...baseInput,
branchLink:
"\n[View branch](https://github.com/owner/repo/tree/branch-name)",
};
const result = updateCommentBody(input);
expect(result).toContain(
"• [`branch-name`](https://github.com/owner/repo/tree/branch-name)",
);
});
it("removes old branch links from body", () => {
const input = {
...baseInput,
currentBody:
"Some comment with [View branch](https://github.com/owner/repo/tree/branch-name)",
branchName: "new-branch-name",
};
const result = updateCommentBody(input);
expect(result).toContain(
"• [`new-branch-name`](https://github.com/owner/repo/tree/new-branch-name)",
);
expect(result).not.toContain("View branch");
});
});
describe("PR link", () => {
it("adds PR link to header when provided", () => {
const input = {
...baseInput,
prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)",
};
const result = updateCommentBody(input);
expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/pr-url)",
);
});
it("moves PR link from body to header", () => {
const input = {
...baseInput,
currentBody:
"Some comment with [Create a PR](https://github.com/owner/repo/pr-url)",
};
const result = updateCommentBody(input);
expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/pr-url)",
);
// Original Create a PR link is removed from body
expect(result).not.toContain("[Create a PR]");
});
it("handles both body and provided PR links", () => {
const input = {
...baseInput,
currentBody:
"Some comment with [Create a PR](https://github.com/owner/repo/pr-url-from-body)",
prLink:
"\n[Create a PR](https://github.com/owner/repo/pr-url-provided)",
};
const result = updateCommentBody(input);
// Prefers the link found in content over the provided one
expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/pr-url-from-body)",
);
});
it("handles complex PR URLs with encoded characters", () => {
const complexUrl =
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A%20important%20bug%20fix&body=Fixes%20%23123%0A%0A%23%23%20Description%0AThis%20PR%20fixes%20an%20important%20bug%20that%20was%20causing%20issues%20with%20the%20application.%0A%0AGenerated%20with%20%5BClaude%20Code%5D(https%3A%2F%2Fclaude.ai%2Fcode)";
const input = {
...baseInput,
currentBody: `Some comment with [Create a PR](${complexUrl})`,
};
const result = updateCommentBody(input);
expect(result).toContain(`• [Create PR ➔](${complexUrl})`);
// Original link should be removed from body
expect(result).not.toContain("[Create a PR]");
});
it("handles PR links with encoded URLs containing parentheses", () => {
const complexUrl =
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A%20bug%20fix&body=Generated%20with%20%5BClaude%20Code%5D(https%3A%2F%2Fclaude.ai%2Fcode)";
const input = {
...baseInput,
currentBody: `This PR was created.\n\n[Create a PR](${complexUrl})`,
};
const result = updateCommentBody(input);
expect(result).toContain(`• [Create PR ➔](${complexUrl})`);
// Original link should be removed from body completely
expect(result).not.toContain("[Create a PR]");
// Body content shouldn't have stray closing parens
expect(result).toContain("This PR was created.");
// Body part should be clean with no stray parens
const bodyAfterSeparator = result.split("---")[1]?.trim();
expect(bodyAfterSeparator).toBe("This PR was created.");
});
it("handles PR links with unencoded spaces and special characters", () => {
const unEncodedUrl =
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix: update welcome message&body=Generated with [Claude Code](https://claude.ai/code)";
const expectedEncodedUrl =
"https://github.com/owner/repo/compare/main...feature-branch?quick_pull=1&title=fix%3A+update+welcome+message&body=Generated+with+%5BClaude+Code%5D%28https%3A%2F%2Fclaude.ai%2Fcode%29";
const input = {
...baseInput,
currentBody: `This PR was created.\n\n[Create a PR](${unEncodedUrl})`,
};
const result = updateCommentBody(input);
expect(result).toContain(`• [Create PR ➔](${expectedEncodedUrl})`);
// Original link should be removed from body completely
expect(result).not.toContain("[Create a PR]");
// Body content should be preserved
expect(result).toContain("This PR was created.");
});
it("falls back to prLink parameter when PR link in content cannot be encoded", () => {
const invalidUrl = "not-a-valid-url-at-all";
const fallbackPrUrl = "https://github.com/owner/repo/pull/123";
const input = {
...baseInput,
currentBody: `This PR was created.\n\n[Create a PR](${invalidUrl})`,
prLink: `\n[Create a PR](${fallbackPrUrl})`,
};
const result = updateCommentBody(input);
expect(result).toContain(`• [Create PR ➔](${fallbackPrUrl})`);
// Original link with invalid URL should still be in body since encoding failed
expect(result).toContain("[Create a PR](not-a-valid-url-at-all)");
expect(result).toContain("This PR was created.");
});
});
describe("execution details", () => {
it("includes duration in header for success", () => {
const input = {
...baseInput,
executionDetails: {
cost_usd: 0.13382595,
duration_ms: 31033,
duration_api_ms: 31034,
},
triggerUsername: "testuser",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude finished @testuser's task in 31s**");
});
it("formats duration in minutes and seconds in header", () => {
const input = {
...baseInput,
executionDetails: {
duration_ms: 75000, // 1 minute 15 seconds
},
triggerUsername: "testuser",
};
const result = updateCommentBody(input);
expect(result).toContain(
"**Claude finished @testuser's task in 1m 15s**",
);
});
it("includes duration in error header", () => {
const input = {
...baseInput,
actionFailed: true,
executionDetails: {
duration_ms: 45000, // 45 seconds
},
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude encountered an error after 45s**");
});
it("handles missing duration gracefully", () => {
const input = {
...baseInput,
executionDetails: {
cost_usd: 0.25,
},
triggerUsername: "testuser",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude finished @testuser's task**");
expect(result).not.toContain(" in ");
});
});
describe("combined updates", () => {
it("combines all updates in correct order", () => {
const input = {
...baseInput,
currentBody:
"Claude Code is working…\n\n### Todo List:\n- [x] Read README.md\n- [x] Add disclaimer",
actionFailed: false,
branchName: "claude-branch-123",
prLink: "\n[Create a PR](https://github.com/owner/repo/pr-url)",
executionDetails: {
cost_usd: 0.01,
duration_ms: 65000, // 1 minute 5 seconds
},
triggerUsername: "trigger-user",
};
const result = updateCommentBody(input);
// Check the header structure
expect(result).toContain(
"**Claude finished @trigger-user's task in 1m 5s**",
);
expect(result).toContain("—— [View job]");
expect(result).toContain(
"• [`claude-branch-123`](https://github.com/owner/repo/tree/claude-branch-123)",
);
expect(result).toContain("• [Create PR ➔]");
// Check order - header comes before separator with blank line
const headerIndex = result.indexOf("**Claude finished");
const blankLineAndSeparatorPattern = /\n\n---\n/;
expect(result).toMatch(blankLineAndSeparatorPattern);
const separatorIndex = result.indexOf("---");
const todoIndex = result.indexOf("### Todo List:");
expect(headerIndex).toBeLessThan(separatorIndex);
expect(separatorIndex).toBeLessThan(todoIndex);
// Check content is preserved
expect(result).toContain("### Todo List:");
expect(result).toContain("- [x] Read README.md");
expect(result).toContain("- [x] Add disclaimer");
});
it("handles PR link extraction from content", () => {
const input = {
...baseInput,
currentBody:
"Claude Code is working…\n\nI've made changes.\n[Create a PR](https://github.com/owner/repo/pr-url-in-content)\n\n@john-doe",
branchName: "feature-branch",
triggerUsername: "john-doe",
};
const result = updateCommentBody(input);
// PR link should be moved to header
expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/pr-url-in-content)",
);
// Original link should be removed from body
expect(result).not.toContain("[Create a PR]");
// Username should come from argument, not extraction
expect(result).toContain("**Claude finished @john-doe's task**");
// Content should be preserved
expect(result).toContain("I've made changes.");
});
it("includes PR link for new branches (issues and closed PRs)", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working… <img src='spinner.gif' />",
branchName: "claude/pr-456-20240101_120000",
prLink:
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
triggerUsername: "jane-doe",
};
const result = updateCommentBody(input);
// Should include the PR link in the formatted style
expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
);
expect(result).toContain("**Claude finished @jane-doe's task**");
});
it("includes both branch link and PR link for new branches", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working…",
branchName: "claude/issue-123-20240101_120000",
branchLink:
"\n[View branch](https://github.com/owner/repo/tree/claude/issue-123-20240101_120000)",
prLink:
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
};
const result = updateCommentBody(input);
// Should include both links in formatted style
expect(result).toContain(
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/tree/claude/issue-123-20240101_120000)",
);
expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
);
});
});
describe("cancellation handling", () => {
it("shows cancellation message when actionCancelled is true", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working…",
actionCancelled: true,
executionDetails: { duration_ms: 30000 }, // 30s
triggerUsername: "test-user",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude's task was cancelled after 30s**");
expect(result).not.toContain("Claude encountered an error");
expect(result).not.toContain("Claude finished");
});
it("shows cancellation message without duration when duration is missing", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working…",
actionCancelled: true,
triggerUsername: "test-user",
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude's task was cancelled**");
expect(result).not.toContain("after");
});
it("prioritizes cancellation over failure", () => {
const input = {
...baseInput,
currentBody: "Claude Code is working…",
actionCancelled: true,
actionFailed: true, // Both are true, cancellation should take precedence
executionDetails: { duration_ms: 45000 },
};
const result = updateCommentBody(input);
expect(result).toContain("**Claude's task was cancelled after 45s**");
expect(result).not.toContain("Claude encountered an error");
});
});
});