takt/src/__tests__/taskExecution.test.ts
Tomohisa Takaoka a08adadfb3
fix: PR creation failure handling + use default branch for base (#345)
* fix: mark task as failed when PR creation fails

Previously, when PR creation failed (e.g. invalid base branch),
the task was still marked as 'completed' even though the PR was
not created. This fix ensures:

- postExecutionFlow returns prFailed/prError on failure
- executeAndCompleteTask marks the task as failed when PR fails
- selectAndExecuteTask runs postExecution before persisting result

The pipeline path (executePipeline) already handled this correctly
via EXIT_PR_CREATION_FAILED.

* fix: use detectDefaultBranch instead of getCurrentBranch for PR base

Previously, baseBranch for PR creation was set to HEAD's current branch
via getCurrentBranch(). When the user was on a feature branch like
'codex/pr-16-review', PRs were created with --base codex/pr-16-review,
which fails because it doesn't exist on the remote.

Now uses detectDefaultBranch() (via git symbolic-ref refs/remotes/origin/HEAD)
to always use the actual default branch (main/master) as the PR base.

Affected paths:
- resolveTask.ts (takt run)
- selectAndExecute.ts (interactive mode)
- pipeline/execute.ts (takt pipeline)
2026-02-22 20:37:14 +09:00

224 lines
7.4 KiB
TypeScript

/**
* Tests for execute task option propagation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { TaskInfo } from '../infra/task/index.js';
const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockResolvePieceConfigValues, mockResolveConfigValueWithSource, mockBuildTaskResult, mockPersistTaskResult, mockPersistTaskError, mockPostExecutionFlow } =
vi.hoisted(() => ({
mockResolveTaskExecution: vi.fn(),
mockExecutePiece: vi.fn(),
mockLoadPieceByIdentifier: vi.fn(),
mockResolvePieceConfigValues: vi.fn(),
mockResolveConfigValueWithSource: vi.fn(),
mockBuildTaskResult: vi.fn(),
mockPersistTaskResult: vi.fn(),
mockPersistTaskError: vi.fn(),
mockPostExecutionFlow: vi.fn(),
}));
vi.mock('../features/tasks/execute/resolveTask.js', () => ({
resolveTaskExecution: (...args: unknown[]) => mockResolveTaskExecution(...args),
resolveTaskIssue: vi.fn(),
}));
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
executePiece: (...args: unknown[]) => mockExecutePiece(...args),
}));
vi.mock('../features/tasks/execute/taskResultHandler.js', () => ({
buildTaskResult: (...args: unknown[]) => mockBuildTaskResult(...args),
persistTaskResult: (...args: unknown[]) => mockPersistTaskResult(...args),
persistTaskError: (...args: unknown[]) => mockPersistTaskError(...args),
}));
vi.mock('../features/tasks/execute/postExecution.js', () => ({
postExecutionFlow: (...args: unknown[]) => mockPostExecutionFlow(...args),
}));
vi.mock('../infra/config/index.js', () => ({
loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args),
isPiecePath: () => false,
resolvePieceConfigValues: (...args: unknown[]) => mockResolvePieceConfigValues(...args),
resolveConfigValueWithSource: (...args: unknown[]) => mockResolveConfigValueWithSource(...args),
}));
vi.mock('../shared/ui/index.js', () => ({
header: vi.fn(),
info: vi.fn(),
error: vi.fn(),
status: vi.fn(),
success: vi.fn(),
blankLine: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
getErrorMessage: vi.fn((error: unknown) => String(error)),
}));
vi.mock('../shared/i18n/index.js', () => ({
getLabel: vi.fn((key: string) => key),
}));
import { executeAndCompleteTask } from '../features/tasks/execute/taskExecution.js';
const createTask = (name: string): TaskInfo => ({
name,
content: `Task: ${name}`,
filePath: `/tasks/${name}.yaml`,
createdAt: '2026-02-16T00:00:00.000Z',
status: 'pending',
data: { task: `Task: ${name}` },
});
describe('executeAndCompleteTask', () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoadPieceByIdentifier.mockReturnValue({
name: 'default',
movements: [],
});
mockResolvePieceConfigValues.mockReturnValue({
language: 'en',
provider: 'claude',
model: undefined,
personaProviders: {},
providerProfiles: {},
notificationSound: true,
notificationSoundEvents: {},
concurrency: 1,
taskPollIntervalMs: 500,
});
mockResolveConfigValueWithSource.mockReturnValue({
value: {
claude: { sandbox: { allowUnsandboxedCommands: true } },
},
source: 'project',
});
mockBuildTaskResult.mockReturnValue({ success: true });
mockResolveTaskExecution.mockResolvedValue({
execCwd: '/project',
execPiece: 'default',
isWorktree: false,
autoPr: false,
taskPrompt: undefined,
reportDirName: undefined,
branch: undefined,
worktreePath: undefined,
baseBranch: undefined,
startMovement: undefined,
retryNote: undefined,
issueNumber: undefined,
});
mockExecutePiece.mockResolvedValue({ success: true });
});
it('should pass taskDisplayLabel from parallel options into executePiece', async () => {
// Given: Parallel execution passes an issue-style taskDisplayLabel.
const task = createTask('task-with-issue');
const taskDisplayLabel = '#12345';
const abortController = new AbortController();
// When
await executeAndCompleteTask(task, {} as never, '/project', 'default', undefined, {
abortSignal: abortController.signal,
taskPrefix: taskDisplayLabel,
taskColorIndex: 0,
taskDisplayLabel,
});
// Then: executePiece receives the propagated display label.
expect(mockExecutePiece).toHaveBeenCalledTimes(1);
const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as {
taskDisplayLabel?: string;
taskPrefix?: string;
providerOptions?: unknown;
providerOptionsSource?: string;
};
expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel);
expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel);
expect(pieceExecutionOptions?.providerOptions).toEqual({
claude: { sandbox: { allowUnsandboxedCommands: true } },
});
expect(pieceExecutionOptions?.providerOptionsSource).toBe('project');
});
it('should mark task as failed when PR creation fails', async () => {
// Given: worktree mode with autoPr enabled, PR creation fails
const task = createTask('task-with-pr-failure');
mockResolveTaskExecution.mockResolvedValue({
execCwd: '/worktree/clone',
execPiece: 'default',
isWorktree: true,
autoPr: true,
draftPr: false,
taskPrompt: undefined,
reportDirName: undefined,
branch: 'takt/task-with-pr-failure',
worktreePath: '/worktree/clone',
baseBranch: 'main',
startMovement: undefined,
retryNote: undefined,
issueNumber: undefined,
});
mockExecutePiece.mockResolvedValue({ success: true });
mockPostExecutionFlow.mockResolvedValue({ prFailed: true, prError: 'Base ref must be a branch' });
// When
const result = await executeAndCompleteTask(task, {} as never, '/project', 'default');
// Then: task should be marked as failed
expect(result).toBe(false);
expect(mockBuildTaskResult).toHaveBeenCalledWith(
expect.objectContaining({
runResult: expect.objectContaining({
success: false,
reason: 'PR creation failed: Base ref must be a branch',
}),
}),
);
});
it('should mark task as completed when PR creation succeeds', async () => {
// Given: worktree mode with autoPr enabled, PR creation succeeds
const task = createTask('task-with-pr-success');
mockResolveTaskExecution.mockResolvedValue({
execCwd: '/worktree/clone',
execPiece: 'default',
isWorktree: true,
autoPr: true,
draftPr: false,
taskPrompt: undefined,
reportDirName: undefined,
branch: 'takt/task-with-pr-success',
worktreePath: '/worktree/clone',
baseBranch: 'main',
startMovement: undefined,
retryNote: undefined,
issueNumber: undefined,
});
mockExecutePiece.mockResolvedValue({ success: true });
mockPostExecutionFlow.mockResolvedValue({ prUrl: 'https://github.com/org/repo/pull/1' });
// When
const result = await executeAndCompleteTask(task, {} as never, '/project', 'default');
// Then: task should be marked as completed
expect(result).toBe(true);
expect(mockBuildTaskResult).toHaveBeenCalledWith(
expect.objectContaining({
runResult: expect.objectContaining({ success: true }),
prUrl: 'https://github.com/org/repo/pull/1',
}),
);
});
});