takt/src/__tests__/pipelineExecution.test.ts
nrslib b9a2a0329b auto PR のベースブランチをブランチ作成前の現在ブランチに設定
createPullRequest の全呼び出し箇所で base が未指定だったため、
PR が常にリポジトリデフォルトブランチ(main)向けに作成されていた。
ブランチ作成/clone作成の直前に getCurrentBranch() で元ブランチを
取得し、PR作成時に base として渡すように修正。
2026-02-08 07:51:03 +09:00

466 lines
13 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();
vi.mock('../features/tasks/index.js', () => ({
executeTask: mockExecuteTask,
}));
// Mock loadGlobalConfig
const mockLoadGlobalConfig = vi.fn();
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => ({ ...(await importOriginal<Record<string, unknown>>()),
loadGlobalConfig: mockLoadGlobalConfig,
}));
// 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 debug logger
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
const { executePipeline } = await import('../features/pipeline/index.js');
describe('executePipeline', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: git operations succeed
mockExecFileSync.mockReturnValue('abc1234\n');
// Default: no pipeline config
mockLoadGlobalConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude',
});
});
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('should pass baseBranch as base to createPullRequest', async () => {
// Given: getCurrentBranch returns 'develop' before branch creation
mockExecFileSync.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return '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 () => {
mockLoadGlobalConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude',
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 () => {
mockLoadGlobalConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude',
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 () => {
mockLoadGlobalConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude',
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);
});
});
});