takt/src/__tests__/pipelineExecution.test.ts
nrslib 6a28929497 fix: 通知ユーティリティのテストモック追加と check:release の通知対応
通知機能(notifySuccess/notifyError/playWarningSound)追加に伴い、
テストの vi.mock を修正。重複モックの統合、vitest 環境変数の追加、
GH API の recursive パラメータ修正、check:release に macOS 通知を追加。
2026-02-23 14:34:20 +09:00

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',
})],
}),
);
});
});
});