通知機能(notifySuccess/notifyError/playWarningSound)追加に伴い、 テストの vi.mock を修正。重複モックの統合、vitest 環境変数の追加、 GH API の recursive パラメータ修正、check:release に macOS 通知を追加。
812 lines
25 KiB
TypeScript
812 lines
25 KiB
TypeScript
/**
|
|
* Tests for pipeline execution
|
|
*
|
|
* Tests the orchestration logic with mocked dependencies.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
// Mock all external dependencies
|
|
const mockFetchIssue = vi.fn();
|
|
const mockCheckGhCli = vi.fn().mockReturnValue({ available: true });
|
|
vi.mock('../infra/github/issue.js', () => ({
|
|
fetchIssue: mockFetchIssue,
|
|
formatIssueAsTask: vi.fn((issue: { title: string; body: string; number: number }) =>
|
|
`## GitHub Issue #${issue.number}: ${issue.title}\n\n${issue.body}`
|
|
),
|
|
checkGhCli: mockCheckGhCli,
|
|
}));
|
|
|
|
const mockCreatePullRequest = vi.fn();
|
|
const mockPushBranch = vi.fn();
|
|
const mockBuildPrBody = vi.fn(() => 'Default PR body');
|
|
vi.mock('../infra/github/pr.js', () => ({
|
|
createPullRequest: mockCreatePullRequest,
|
|
pushBranch: mockPushBranch,
|
|
buildPrBody: mockBuildPrBody,
|
|
}));
|
|
|
|
const mockExecuteTask = vi.fn();
|
|
const mockConfirmAndCreateWorktree = vi.fn();
|
|
vi.mock('../features/tasks/index.js', () => ({
|
|
executeTask: mockExecuteTask,
|
|
confirmAndCreateWorktree: mockConfirmAndCreateWorktree,
|
|
}));
|
|
|
|
const mockResolveConfigValues = vi.fn();
|
|
vi.mock('../infra/config/index.js', () => ({
|
|
resolveConfigValues: mockResolveConfigValues,
|
|
resolveConfigValue: vi.fn(() => undefined),
|
|
}));
|
|
|
|
// Mock execFileSync for git operations
|
|
const mockExecFileSync = vi.fn();
|
|
vi.mock('node:child_process', () => ({
|
|
execFileSync: mockExecFileSync,
|
|
}));
|
|
|
|
// Mock UI
|
|
vi.mock('../shared/ui/index.js', () => ({
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
status: vi.fn(),
|
|
blankLine: vi.fn(),
|
|
header: vi.fn(),
|
|
section: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn(),
|
|
}));
|
|
// Mock Slack + utils
|
|
const mockGetSlackWebhookUrl = vi.fn<() => string | undefined>(() => undefined);
|
|
const mockSendSlackNotification = vi.fn<(url: string, message: string) => Promise<void>>();
|
|
const mockBuildSlackRunSummary = vi.fn<(params: unknown) => string>(() => 'TAKT Run Summary');
|
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
createLogger: () => ({
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
}),
|
|
notifySuccess: vi.fn(),
|
|
notifyError: vi.fn(),
|
|
playWarningSound: vi.fn(),
|
|
getSlackWebhookUrl: (...args: unknown[]) => mockGetSlackWebhookUrl(...args as []),
|
|
sendSlackNotification: (...args: unknown[]) => mockSendSlackNotification(...(args as [string, string])),
|
|
buildSlackRunSummary: (...args: unknown[]) => mockBuildSlackRunSummary(...(args as [unknown])),
|
|
}));
|
|
|
|
// Mock generateRunId
|
|
vi.mock('../features/tasks/execute/slackSummaryAdapter.js', () => ({
|
|
generateRunId: () => 'run-20260222-000000',
|
|
}));
|
|
|
|
const { executePipeline } = await import('../features/pipeline/index.js');
|
|
|
|
describe('executePipeline', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Default: no Slack webhook
|
|
mockGetSlackWebhookUrl.mockReturnValue(undefined);
|
|
// Default: git operations succeed
|
|
mockExecFileSync.mockReturnValue('abc1234\n');
|
|
// Default: no pipeline config
|
|
mockResolveConfigValues.mockReturnValue({ pipeline: undefined });
|
|
});
|
|
|
|
it('should return exit code 2 when neither --issue nor --task is specified', async () => {
|
|
const exitCode = await executePipeline({
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(2);
|
|
});
|
|
|
|
it('should return exit code 2 when gh CLI is not available', async () => {
|
|
mockCheckGhCli.mockReturnValueOnce({ available: false, error: 'gh not found' });
|
|
|
|
const exitCode = await executePipeline({
|
|
issueNumber: 99,
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(2);
|
|
});
|
|
|
|
it('should return exit code 2 when issue fetch fails', async () => {
|
|
mockFetchIssue.mockImplementationOnce(() => {
|
|
throw new Error('Issue not found');
|
|
});
|
|
|
|
const exitCode = await executePipeline({
|
|
issueNumber: 999,
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(2);
|
|
});
|
|
|
|
it('should return exit code 3 when piece fails', async () => {
|
|
mockFetchIssue.mockReturnValueOnce({
|
|
number: 99,
|
|
title: 'Test issue',
|
|
body: 'Test body',
|
|
labels: [],
|
|
comments: [],
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(false);
|
|
|
|
const exitCode = await executePipeline({
|
|
issueNumber: 99,
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(3);
|
|
});
|
|
|
|
it('should return exit code 0 on successful task-only execution', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockExecuteTask).toHaveBeenCalledWith({
|
|
task: 'Fix the bug',
|
|
cwd: '/tmp/test',
|
|
pieceIdentifier: 'default',
|
|
projectCwd: '/tmp/test',
|
|
agentOverrides: undefined,
|
|
});
|
|
});
|
|
|
|
it('passes provider/model overrides to task execution', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
provider: 'codex',
|
|
model: 'codex-model',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockExecuteTask).toHaveBeenCalledWith({
|
|
task: 'Fix the bug',
|
|
cwd: '/tmp/test',
|
|
pieceIdentifier: 'default',
|
|
projectCwd: '/tmp/test',
|
|
agentOverrides: { provider: 'codex', model: 'codex-model' },
|
|
});
|
|
});
|
|
|
|
it('should return exit code 5 when PR creation fails', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: false, error: 'PR failed' });
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(5);
|
|
});
|
|
|
|
it('should create PR with correct branch when --auto-pr', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
branch: 'fix/my-branch',
|
|
autoPr: true,
|
|
repo: 'owner/repo',
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
|
'/tmp/test',
|
|
expect.objectContaining({
|
|
branch: 'fix/my-branch',
|
|
repo: 'owner/repo',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('draftPr: true の場合、createPullRequest に draft: true が渡される', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
branch: 'fix/my-branch',
|
|
autoPr: true,
|
|
draftPr: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
|
'/tmp/test',
|
|
expect.objectContaining({ draft: true }),
|
|
);
|
|
});
|
|
|
|
it('draftPr: false の場合、createPullRequest に draft: false が渡される', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
branch: 'fix/my-branch',
|
|
autoPr: true,
|
|
draftPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
|
'/tmp/test',
|
|
expect.objectContaining({ draft: false }),
|
|
);
|
|
});
|
|
|
|
it('should pass baseBranch as base to createPullRequest', async () => {
|
|
// Given: detectDefaultBranch returns 'develop' (via symbolic-ref)
|
|
mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => {
|
|
if (args[0] === 'symbolic-ref' && args[1] === 'refs/remotes/origin/HEAD') {
|
|
return 'refs/remotes/origin/develop\n';
|
|
}
|
|
return 'abc1234\n';
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
|
|
|
|
// When
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
branch: 'fix/my-branch',
|
|
autoPr: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
// Then
|
|
expect(exitCode).toBe(0);
|
|
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
|
'/tmp/test',
|
|
expect.objectContaining({
|
|
branch: 'fix/my-branch',
|
|
base: 'develop',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should use --task when both --task and positional task are provided', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'From --task flag',
|
|
piece: 'magi',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockExecuteTask).toHaveBeenCalledWith({
|
|
task: 'From --task flag',
|
|
cwd: '/tmp/test',
|
|
pieceIdentifier: 'magi',
|
|
projectCwd: '/tmp/test',
|
|
agentOverrides: undefined,
|
|
});
|
|
});
|
|
|
|
describe('PipelineConfig template expansion', () => {
|
|
it('should use commit_message_template when configured', async () => {
|
|
mockResolveConfigValues.mockReturnValue({
|
|
pipeline: {
|
|
commitMessageTemplate: 'fix: {title} (#{issue})',
|
|
},
|
|
});
|
|
|
|
mockFetchIssue.mockReturnValueOnce({
|
|
number: 42,
|
|
title: 'Login broken',
|
|
body: 'Cannot login.',
|
|
labels: [],
|
|
comments: [],
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
await executePipeline({
|
|
issueNumber: 42,
|
|
piece: 'default',
|
|
branch: 'test-branch',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
// Verify commit was called with expanded template
|
|
const commitCall = mockExecFileSync.mock.calls.find(
|
|
(call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'commit',
|
|
);
|
|
expect(commitCall).toBeDefined();
|
|
expect((commitCall![1] as string[])[2]).toBe('fix: Login broken (#42)');
|
|
});
|
|
|
|
it('should use default_branch_prefix when configured', async () => {
|
|
mockResolveConfigValues.mockReturnValue({
|
|
pipeline: {
|
|
defaultBranchPrefix: 'feat/',
|
|
},
|
|
});
|
|
|
|
mockFetchIssue.mockReturnValueOnce({
|
|
number: 10,
|
|
title: 'Add feature',
|
|
body: 'Please add.',
|
|
labels: [],
|
|
comments: [],
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
await executePipeline({
|
|
issueNumber: 10,
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
// Verify checkout -b was called with prefix
|
|
const checkoutCall = mockExecFileSync.mock.calls.find(
|
|
(call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'checkout' && (call[1] as string[])[1] === '-b',
|
|
);
|
|
expect(checkoutCall).toBeDefined();
|
|
const branchName = (checkoutCall![1] as string[])[2];
|
|
expect(branchName).toMatch(/^feat\/issue-10-\d+$/);
|
|
});
|
|
|
|
it('should use pr_body_template when configured for PR creation', async () => {
|
|
mockResolveConfigValues.mockReturnValue({
|
|
pipeline: {
|
|
prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}',
|
|
},
|
|
});
|
|
|
|
mockFetchIssue.mockReturnValueOnce({
|
|
number: 50,
|
|
title: 'Fix auth',
|
|
body: 'Auth is broken.',
|
|
labels: [],
|
|
comments: [],
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/pr/1' });
|
|
|
|
await executePipeline({
|
|
issueNumber: 50,
|
|
piece: 'default',
|
|
branch: 'fix-auth',
|
|
autoPr: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
// When prBodyTemplate is set, buildPrBody (mock) should NOT be called
|
|
// Instead, the template is expanded directly
|
|
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
|
'/tmp/test',
|
|
expect.objectContaining({
|
|
body: '## Summary\nAuth is broken.\n\nCloses #50',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should fall back to buildPrBody when no template is configured', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/pr/1' });
|
|
|
|
await executePipeline({
|
|
task: 'Fix bug',
|
|
piece: 'default',
|
|
branch: 'fix-branch',
|
|
autoPr: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
// Should use buildPrBody (the mock)
|
|
expect(mockBuildPrBody).toHaveBeenCalled();
|
|
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
|
'/tmp/test',
|
|
expect.objectContaining({
|
|
body: 'Default PR body',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('--skip-git', () => {
|
|
it('should skip branch creation, commit, push when skipGit is true', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockExecuteTask).toHaveBeenCalledWith({
|
|
task: 'Fix the bug',
|
|
cwd: '/tmp/test',
|
|
pieceIdentifier: 'default',
|
|
projectCwd: '/tmp/test',
|
|
agentOverrides: undefined,
|
|
});
|
|
|
|
// No git operations should have been called
|
|
const gitCalls = mockExecFileSync.mock.calls.filter(
|
|
(call: unknown[]) => call[0] === 'git',
|
|
);
|
|
expect(gitCalls).toHaveLength(0);
|
|
expect(mockPushBranch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should ignore --auto-pr when skipGit is true', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: true,
|
|
skipGit: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockCreatePullRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should still return piece failure exit code when skipGit is true', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(false);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('--create-worktree', () => {
|
|
it('should create worktree and execute task in worktree directory when createWorktree is true', async () => {
|
|
mockConfirmAndCreateWorktree.mockResolvedValueOnce({
|
|
execCwd: '/tmp/test-worktree',
|
|
isWorktree: true,
|
|
branch: 'fix/the-bug',
|
|
baseBranch: 'main',
|
|
taskSlug: 'fix-the-bug',
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
createWorktree: true,
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockConfirmAndCreateWorktree).toHaveBeenCalledWith('/tmp/test', 'Fix the bug', true);
|
|
expect(mockExecuteTask).toHaveBeenCalledWith({
|
|
task: 'Fix the bug',
|
|
cwd: '/tmp/test-worktree',
|
|
pieceIdentifier: 'default',
|
|
projectCwd: '/tmp/test',
|
|
agentOverrides: undefined,
|
|
});
|
|
});
|
|
|
|
it('should not create worktree when createWorktree is false', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
createWorktree: false,
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockConfirmAndCreateWorktree).not.toHaveBeenCalled();
|
|
expect(mockExecuteTask).toHaveBeenCalledWith({
|
|
task: 'Fix the bug',
|
|
cwd: '/tmp/test',
|
|
pieceIdentifier: 'default',
|
|
projectCwd: '/tmp/test',
|
|
agentOverrides: undefined,
|
|
});
|
|
});
|
|
|
|
it('should use original cwd when createWorktree is undefined', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockConfirmAndCreateWorktree).not.toHaveBeenCalled();
|
|
expect(mockExecuteTask).toHaveBeenCalledWith({
|
|
task: 'Fix the bug',
|
|
cwd: '/tmp/test',
|
|
pieceIdentifier: 'default',
|
|
projectCwd: '/tmp/test',
|
|
agentOverrides: undefined,
|
|
});
|
|
});
|
|
|
|
it('should pass provider/model overrides when worktree is created', async () => {
|
|
mockConfirmAndCreateWorktree.mockResolvedValueOnce({
|
|
execCwd: '/tmp/test-worktree',
|
|
isWorktree: true,
|
|
branch: 'fix/the-bug',
|
|
baseBranch: 'main',
|
|
taskSlug: 'fix-the-bug',
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
createWorktree: true,
|
|
provider: 'codex',
|
|
model: 'codex-model',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockExecuteTask).toHaveBeenCalledWith({
|
|
task: 'Fix the bug',
|
|
cwd: '/tmp/test-worktree',
|
|
pieceIdentifier: 'default',
|
|
projectCwd: '/tmp/test',
|
|
agentOverrides: { provider: 'codex', model: 'codex-model' },
|
|
});
|
|
});
|
|
|
|
it('should return exit code 4 when worktree creation fails', async () => {
|
|
mockConfirmAndCreateWorktree.mockRejectedValueOnce(new Error('Failed to create worktree'));
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
createWorktree: true,
|
|
});
|
|
|
|
expect(exitCode).toBe(4);
|
|
});
|
|
|
|
it('should commit in worktree and push via clone→project→origin', async () => {
|
|
mockConfirmAndCreateWorktree.mockResolvedValueOnce({
|
|
execCwd: '/tmp/test-worktree',
|
|
isWorktree: true,
|
|
branch: 'fix/the-bug',
|
|
baseBranch: 'main',
|
|
taskSlug: 'fix-the-bug',
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
createWorktree: true,
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
|
|
// Commit should happen in worktree (execCwd), not project cwd
|
|
const addCall = mockExecFileSync.mock.calls.find(
|
|
(call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'add',
|
|
);
|
|
expect(addCall).toBeDefined();
|
|
expect((addCall![2] as { cwd: string }).cwd).toBe('/tmp/test-worktree');
|
|
|
|
// Clone→project push: git push /tmp/test HEAD from worktree
|
|
const pushToProjectCall = mockExecFileSync.mock.calls.find(
|
|
(call: unknown[]) =>
|
|
call[0] === 'git' &&
|
|
(call[1] as string[])[0] === 'push' &&
|
|
(call[1] as string[])[1] === '/tmp/test',
|
|
);
|
|
expect(pushToProjectCall).toBeDefined();
|
|
expect((pushToProjectCall![2] as { cwd: string }).cwd).toBe('/tmp/test-worktree');
|
|
|
|
// Project→origin push
|
|
expect(mockPushBranch).toHaveBeenCalledWith('/tmp/test', 'fix/the-bug');
|
|
});
|
|
|
|
it('should create PR from project cwd when worktree is used with --auto-pr', async () => {
|
|
mockConfirmAndCreateWorktree.mockResolvedValueOnce({
|
|
execCwd: '/tmp/test-worktree',
|
|
isWorktree: true,
|
|
branch: 'fix/the-bug',
|
|
baseBranch: 'main',
|
|
taskSlug: 'fix-the-bug',
|
|
});
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: true,
|
|
cwd: '/tmp/test',
|
|
createWorktree: true,
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockCreatePullRequest).toHaveBeenCalledWith(
|
|
'/tmp/test',
|
|
expect.objectContaining({
|
|
branch: 'fix/the-bug',
|
|
base: 'main',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should return exit code 4 when git commit/push fails', async () => {
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
// stageAndCommit calls execFileSync('git', ['add', ...]) then ('git', ['commit', ...])
|
|
// Make the commit call throw
|
|
mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => {
|
|
if (args[0] === 'commit') {
|
|
throw new Error('nothing to commit');
|
|
}
|
|
return 'abc1234\n';
|
|
});
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
branch: 'fix/my-branch',
|
|
autoPr: false,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(exitCode).toBe(4);
|
|
});
|
|
|
|
describe('Slack notification', () => {
|
|
it('should not send Slack notification when webhook is not configured', async () => {
|
|
mockGetSlackWebhookUrl.mockReturnValue(undefined);
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(mockSendSlackNotification).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should send success notification when webhook is configured', async () => {
|
|
mockGetSlackWebhookUrl.mockReturnValue('https://hooks.slack.com/test');
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
|
|
await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(mockBuildSlackRunSummary).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
runId: 'run-20260222-000000',
|
|
total: 1,
|
|
success: 1,
|
|
failed: 0,
|
|
concurrency: 1,
|
|
tasks: [expect.objectContaining({
|
|
name: 'pipeline',
|
|
success: true,
|
|
piece: 'default',
|
|
})],
|
|
}),
|
|
);
|
|
expect(mockSendSlackNotification).toHaveBeenCalledWith(
|
|
'https://hooks.slack.com/test',
|
|
'TAKT Run Summary',
|
|
);
|
|
});
|
|
|
|
it('should send failure notification when piece fails', async () => {
|
|
mockGetSlackWebhookUrl.mockReturnValue('https://hooks.slack.com/test');
|
|
mockExecuteTask.mockResolvedValueOnce(false);
|
|
|
|
await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(mockBuildSlackRunSummary).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
success: 0,
|
|
failed: 1,
|
|
tasks: [expect.objectContaining({
|
|
success: false,
|
|
})],
|
|
}),
|
|
);
|
|
expect(mockSendSlackNotification).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should include PR URL in notification when auto-pr succeeds', async () => {
|
|
mockGetSlackWebhookUrl.mockReturnValue('https://hooks.slack.com/test');
|
|
mockExecuteTask.mockResolvedValueOnce(true);
|
|
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/99' });
|
|
|
|
await executePipeline({
|
|
task: 'Fix the bug',
|
|
piece: 'default',
|
|
branch: 'fix/test',
|
|
autoPr: true,
|
|
cwd: '/tmp/test',
|
|
});
|
|
|
|
expect(mockBuildSlackRunSummary).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tasks: [expect.objectContaining({
|
|
prUrl: 'https://github.com/test/pr/99',
|
|
})],
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|