takt/src/__tests__/runAllTasks-concurrency.test.ts
nrs 6d0bac9d07
[#367] abstract-git-provider (#375)
* takt: abstract-git-provider

* takt: abstract-git-provider

* takt: abstract-git-provider

* fix: pushBranch のインポートパスを infra/task に修正

Git provider 抽象化により pushBranch が infra/github から infra/task に
移動したため、taskSyncAction とテストのインポートパスを更新。
2026-02-26 01:09:29 +09:00

850 lines
27 KiB
TypeScript

/**
* Tests for runAllTasks concurrency support (worker pool)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { TaskInfo } from '../infra/task/index.js';
const { mockLoadConfigRaw } = vi.hoisted(() => ({
mockLoadConfigRaw: vi.fn(() => ({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
concurrency: 1,
taskPollIntervalMs: 500,
})),
}));
// Mock dependencies before importing the module under test
vi.mock('../infra/config/index.js', () => ({
loadPieceByIdentifier: vi.fn(),
isPiecePath: vi.fn(() => false),
loadConfig: (...args: unknown[]) => {
const raw = mockLoadConfigRaw(...args) as Record<string, unknown>;
if ('global' in raw && 'project' in raw) {
return raw;
}
return {
global: raw,
project: { piece: 'default' },
};
},
resolvePieceConfigValues: (_projectDir: string, keys: readonly string[]) => {
const raw = mockLoadConfigRaw() as Record<string, unknown>;
const config = ('global' in raw && 'project' in raw)
? { ...raw.global as Record<string, unknown>, ...raw.project as Record<string, unknown> }
: { ...raw, piece: 'default', provider: 'claude', verbose: false };
const result: Record<string, unknown> = {};
for (const key of keys) {
result[key] = config[key];
}
return result;
},
resolveConfigValueWithSource: (_projectDir: string, key: string) => {
const raw = mockLoadConfigRaw() as Record<string, unknown>;
const config = ('global' in raw && 'project' in raw)
? { ...raw.global as Record<string, unknown>, ...raw.project as Record<string, unknown> }
: { ...raw, piece: 'default', provider: 'claude', verbose: false };
return { value: config[key], source: 'project' };
},
}));
const mockLoadConfig = mockLoadConfigRaw;
const {
mockClaimNextTasks,
mockCompleteTask,
mockFailTask,
mockRecoverInterruptedRunningTasks,
mockListAllTaskItems,
mockNotifySuccess,
mockNotifyError,
mockSendSlackNotification,
mockGetSlackWebhookUrl,
} = vi.hoisted(() => ({
mockClaimNextTasks: vi.fn(),
mockCompleteTask: vi.fn(),
mockFailTask: vi.fn(),
mockRecoverInterruptedRunningTasks: vi.fn(),
mockListAllTaskItems: vi.fn().mockReturnValue([]),
mockNotifySuccess: vi.fn(),
mockNotifyError: vi.fn(),
mockSendSlackNotification: vi.fn(),
mockGetSlackWebhookUrl: vi.fn(),
}));
vi.mock('../infra/task/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
TaskRunner: vi.fn().mockImplementation(() => ({
claimNextTasks: mockClaimNextTasks,
completeTask: mockCompleteTask,
failTask: mockFailTask,
recoverInterruptedRunningTasks: mockRecoverInterruptedRunningTasks,
listAllTaskItems: mockListAllTaskItems,
})),
}));
vi.mock('../infra/task/clone.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createSharedClone: vi.fn(),
removeClone: vi.fn(),
}));
vi.mock('../infra/task/branchList.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
detectDefaultBranch: vi.fn(() => 'main'),
}));
vi.mock('../infra/task/git.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
getCurrentBranch: vi.fn(() => 'main'),
}));
vi.mock('../infra/task/autoCommit.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
autoCommitAndPush: vi.fn(),
}));
vi.mock('../infra/task/summarize.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
summarizeTaskName: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({
header: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
status: 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((e) => e.message),
notifySuccess: mockNotifySuccess,
notifyError: mockNotifyError,
sendSlackNotification: mockSendSlackNotification,
getSlackWebhookUrl: mockGetSlackWebhookUrl,
}));
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
executePiece: vi.fn(() => Promise.resolve({ success: true })),
}));
vi.mock('../shared/context.js', () => ({
isQuietMode: vi.fn(() => false),
}));
vi.mock('../shared/constants.js', () => ({
DEFAULT_PIECE_NAME: 'default',
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../infra/github/index.js', () => ({
buildPrBody: vi.fn(),
}));
vi.mock('../infra/claude/query-manager.js', () => ({
interruptAllQueries: vi.fn(),
}));
vi.mock('../agents/ai-judge.js', () => ({
callAiJudge: vi.fn(),
}));
vi.mock('../shared/exitCodes.js', () => ({
EXIT_SIGINT: 130,
}));
vi.mock('../shared/i18n/index.js', () => ({
getLabel: vi.fn((key: string) => key),
}));
import { info, header, status, success, error as errorFn } from '../shared/ui/index.js';
import { runAllTasks } from '../features/tasks/index.js';
import { executePiece } from '../features/tasks/execute/pieceExecution.js';
import { loadPieceByIdentifier } from '../infra/config/index.js';
const mockInfo = vi.mocked(info);
const mockHeader = vi.mocked(header);
const mockStatus = vi.mocked(status);
const mockSuccess = vi.mocked(success);
const mockError = vi.mocked(errorFn);
const mockExecutePiece = vi.mocked(executePiece);
const mockLoadPieceByIdentifier = vi.mocked(loadPieceByIdentifier);
function createTask(name: string): TaskInfo {
return {
name,
content: `Task: ${name}`,
filePath: `/tasks/${name}.yaml`,
createdAt: '2026-02-09T00:00:00.000Z',
status: 'pending',
data: null,
};
}
beforeEach(() => {
vi.clearAllMocks();
mockRecoverInterruptedRunningTasks.mockReturnValue(0);
});
describe('runAllTasks concurrency', () => {
describe('sequential execution (concurrency=1)', () => {
beforeEach(() => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: true, runAbort: true },
concurrency: 1,
taskPollIntervalMs: 500,
});
});
it('should show no-tasks message when no tasks exist', async () => {
// Given: No pending tasks
mockClaimNextTasks.mockReturnValue([]);
// When
await runAllTasks('/project');
// Then
expect(mockInfo).toHaveBeenCalledWith('No pending tasks in .takt/tasks.yaml');
});
it('should execute tasks sequentially via worker pool when concurrency is 1', async () => {
// Given: Two tasks available sequentially
const task1 = createTask('task-1');
const task2 = createTask('task-2');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([task2])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: Worker pool uses claimNextTasks for fetching more tasks
expect(mockClaimNextTasks).toHaveBeenCalled();
expect(mockStatus).toHaveBeenCalledWith('Total', '2');
});
});
describe('parallel execution (concurrency>1)', () => {
beforeEach(() => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: true, runAbort: true },
concurrency: 3,
taskPollIntervalMs: 500,
});
});
it('should display concurrency info when concurrency > 1', async () => {
// Given: Tasks available
const task1 = createTask('task-1');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then
expect(mockInfo).toHaveBeenCalledWith('Concurrency: 3');
});
it('should execute tasks using worker pool when concurrency > 1', async () => {
// Given: 3 tasks available
const task1 = createTask('task-1');
const task2 = createTask('task-2');
const task3 = createTask('task-3');
mockClaimNextTasks
.mockReturnValueOnce([task1, task2, task3])
.mockReturnValueOnce([]);
// In parallel mode, task start messages go through TaskPrefixWriter → process.stdout.write
const stdoutChunks: string[] = [];
const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
stdoutChunks.push(String(chunk));
return true;
});
// When
await runAllTasks('/project');
writeSpy.mockRestore();
// Then: Task names displayed with prefix in stdout
const allOutput = stdoutChunks.join('');
expect(allOutput).toContain('[task]');
expect(allOutput).toContain('=== Task: task-1 ===');
expect(allOutput).toContain('[task]');
expect(allOutput).toContain('=== Task: task-2 ===');
expect(allOutput).toContain('[task]');
expect(allOutput).toContain('=== Task: task-3 ===');
expect(mockStatus).toHaveBeenCalledWith('Total', '3');
});
it('should fill slots as tasks complete (worker pool behavior)', async () => {
// Given: 5 tasks, concurrency=3
// Worker pool should start 3, then fill slots as tasks complete
const tasks = Array.from({ length: 5 }, (_, i) => createTask(`task-${i + 1}`));
mockClaimNextTasks
.mockReturnValueOnce(tasks.slice(0, 3))
.mockReturnValueOnce(tasks.slice(3, 5))
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: All 5 tasks executed
expect(mockStatus).toHaveBeenCalledWith('Total', '5');
});
});
describe('default concurrency', () => {
it('should default to sequential when concurrency is not set', async () => {
// Given: Config without explicit concurrency (defaults to 1)
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: false,
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: No concurrency info displayed
const concurrencyInfoCalls = mockInfo.mock.calls.filter(
(call) => typeof call[0] === 'string' && call[0].startsWith('Concurrency:')
);
expect(concurrencyInfoCalls).toHaveLength(0);
expect(mockNotifySuccess).not.toHaveBeenCalled();
expect(mockNotifyError).not.toHaveBeenCalled();
});
});
describe('parallel execution behavior', () => {
const fakePieceConfig = {
name: 'default',
movements: [{ name: 'implement', personaDisplayName: 'coder' }],
initialMovement: 'implement',
maxMovements: 10,
};
beforeEach(() => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
concurrency: 3,
taskPollIntervalMs: 500,
});
// Return a valid piece config so executeTask reaches executePiece
mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never);
});
it('should run tasks concurrently, not sequentially', async () => {
// Given: 2 tasks with delayed execution to verify concurrency
const task1 = createTask('slow-1');
const task2 = createTask('slow-2');
const executionOrder: string[] = [];
// Each task takes 50ms — if sequential, total > 100ms; if parallel, total ~50ms
mockExecutePiece.mockImplementation((_config, task) => {
executionOrder.push(`start:${task}`);
return new Promise((resolve) => {
setTimeout(() => {
executionOrder.push(`end:${task}`);
resolve({ success: true });
}, 50);
});
});
mockClaimNextTasks
.mockReturnValueOnce([task1, task2])
.mockReturnValueOnce([]);
// When
const startTime = Date.now();
await runAllTasks('/project');
const elapsed = Date.now() - startTime;
// Then: Both tasks started before either completed (concurrent execution)
expect(executionOrder[0]).toBe('start:Task: slow-1');
expect(executionOrder[1]).toBe('start:Task: slow-2');
// Elapsed time should be closer to 50ms than 100ms (allowing margin for CI)
expect(elapsed).toBeLessThan(150);
});
it('should fill slots immediately when a task completes (no batch waiting)', async () => {
// Given: 3 tasks, concurrency=2, task1 finishes quickly, task2 takes longer
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
concurrency: 2,
taskPollIntervalMs: 500,
});
const task1 = createTask('fast');
const task2 = createTask('slow');
const task3 = createTask('after-fast');
const executionOrder: string[] = [];
mockExecutePiece.mockImplementation((_config, task) => {
executionOrder.push(`start:${task}`);
const delay = (task as string).includes('slow') ? 80 : 20;
return new Promise((resolve) => {
setTimeout(() => {
executionOrder.push(`end:${task}`);
resolve({ success: true });
}, delay);
});
});
mockClaimNextTasks
.mockReturnValueOnce([task1, task2])
.mockReturnValueOnce([task3])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: task3 starts before task2 finishes (slot filled immediately)
const task3StartIdx = executionOrder.indexOf('start:Task: after-fast');
const task2EndIdx = executionOrder.indexOf('end:Task: slow');
expect(task3StartIdx).toBeLessThan(task2EndIdx);
expect(mockStatus).toHaveBeenCalledWith('Total', '3');
});
it('should count partial failures correctly', async () => {
// Given: 3 tasks, 1 fails, 2 succeed
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runAbort: true },
concurrency: 3,
taskPollIntervalMs: 500,
});
const task1 = createTask('pass-1');
const task2 = createTask('fail-1');
const task3 = createTask('pass-2');
let callIndex = 0;
mockExecutePiece.mockImplementation(() => {
callIndex++;
// Second call fails
return Promise.resolve({ success: callIndex !== 2 });
});
mockClaimNextTasks
.mockReturnValueOnce([task1, task2, task3])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: Correct success/fail counts
expect(mockStatus).toHaveBeenCalledWith('Total', '3');
expect(mockStatus).toHaveBeenCalledWith('Success', '2', undefined);
expect(mockStatus).toHaveBeenCalledWith('Failed', '1', 'red');
expect(mockNotifySuccess).not.toHaveBeenCalled();
expect(mockNotifyError).toHaveBeenCalledTimes(1);
});
it('should persist failure reason and movement when piece aborts', async () => {
const task1 = createTask('fail-with-detail');
mockExecutePiece.mockResolvedValue({
success: false,
reason: 'blocked_by_review',
lastMovement: 'review',
lastMessage: 'security check failed',
});
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockFailTask).toHaveBeenCalledWith(expect.objectContaining({
response: 'blocked_by_review',
failureMovement: 'review',
failureLastMessage: 'security check failed',
}));
});
it('should pass abortSignal and taskPrefix to executePiece in parallel mode', async () => {
// Given: One task in parallel mode
const task1 = createTask('parallel-task');
mockExecutePiece.mockResolvedValue({ success: true });
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: executePiece received abortSignal and taskPrefix options
expect(mockExecutePiece).toHaveBeenCalledTimes(1);
const callArgs = mockExecutePiece.mock.calls[0];
const pieceOptions = callArgs?.[3]; // 4th argument is options
expect(pieceOptions).toHaveProperty('abortSignal');
expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal);
expect(pieceOptions).toHaveProperty('taskPrefix', 'parallel-task');
});
it('should pass abortSignal but not taskPrefix in sequential mode', async () => {
// Given: Sequential mode
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: true, runAbort: true },
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('sequential-task');
mockExecutePiece.mockResolvedValue({ success: true });
mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never);
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then: executePiece should have abortSignal but not taskPrefix
expect(mockExecutePiece).toHaveBeenCalledTimes(1);
const callArgs = mockExecutePiece.mock.calls[0];
const pieceOptions = callArgs?.[3];
expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal);
expect(pieceOptions?.taskPrefix).toBeUndefined();
});
it('should only notify once at run completion when multiple tasks succeed', async () => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: true },
concurrency: 3,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
const task2 = createTask('task-2');
const task3 = createTask('task-3');
mockClaimNextTasks
.mockReturnValueOnce([task1, task2, task3])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockNotifySuccess).toHaveBeenCalledTimes(1);
expect(mockNotifyError).not.toHaveBeenCalled();
});
it('should not notify run completion when runComplete is explicitly false', async () => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runComplete: false },
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockNotifySuccess).not.toHaveBeenCalled();
expect(mockNotifyError).not.toHaveBeenCalled();
});
it('should notify run completion by default when notification_sound_events is not set', async () => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockNotifySuccess).toHaveBeenCalledTimes(1);
expect(mockNotifySuccess).toHaveBeenCalledWith('TAKT', 'run.notifyComplete');
expect(mockNotifyError).not.toHaveBeenCalled();
});
it('should notify run abort by default when notification_sound_events is not set', async () => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' });
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockNotifySuccess).not.toHaveBeenCalled();
expect(mockNotifyError).toHaveBeenCalledTimes(1);
expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort');
});
it('should not notify run abort when runAbort is explicitly false', async () => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runAbort: false },
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' });
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
await runAllTasks('/project');
expect(mockNotifySuccess).not.toHaveBeenCalled();
expect(mockNotifyError).not.toHaveBeenCalled();
});
it('should notify run abort and rethrow when worker pool throws', async () => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
notificationSound: true,
notificationSoundEvents: { runAbort: true },
concurrency: 1,
taskPollIntervalMs: 500,
});
const task1 = createTask('task-1');
const poolError = new Error('worker pool crashed');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockImplementationOnce(() => {
throw poolError;
});
await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed');
expect(mockNotifyError).toHaveBeenCalledTimes(1);
expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort');
});
});
describe('Slack webhook notification', () => {
const webhookUrl = 'https://hooks.slack.com/services/T00/B00/xxx';
const fakePieceConfig = {
name: 'default',
movements: [{ name: 'implement', personaDisplayName: 'coder' }],
initialMovement: 'implement',
maxMovements: 10,
};
beforeEach(() => {
mockLoadConfig.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
concurrency: 1,
taskPollIntervalMs: 500,
});
mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never);
});
it('should send Slack notification on success when webhook URL is set', async () => {
// Given
mockGetSlackWebhookUrl.mockReturnValue(webhookUrl);
const task1 = createTask('task-1');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
mockListAllTaskItems.mockReturnValue([
{
kind: 'completed',
name: 'task-1',
createdAt: '2026-02-19T00:00:00.000Z',
filePath: '/tasks/task-1.yaml',
content: 'Task: task-1',
startedAt: '2026-02-19T00:00:00.000Z',
completedAt: '2026-02-19T00:00:30.000Z',
branch: 'feat/task-1',
prUrl: 'https://github.com/org/repo/pull/10',
data: { task: 'task-1', piece: 'default', issue: 42 },
},
]);
// When
await runAllTasks('/project');
// Then
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
const [url, message] = mockSendSlackNotification.mock.calls[0]! as [string, string];
expect(url).toBe(webhookUrl);
expect(message).toContain('TAKT Run');
expect(message).toContain('total=1');
expect(message).toContain('success=1');
expect(message).toContain('failed=0');
expect(message).toContain('task-1');
expect(message).toContain('piece=default');
expect(message).toContain('issue=#42');
expect(message).toContain('duration=30s');
expect(message).toContain('pr=https://github.com/org/repo/pull/10');
});
it('should send Slack notification on failure when webhook URL is set', async () => {
// Given
mockGetSlackWebhookUrl.mockReturnValue(webhookUrl);
const task1 = createTask('task-1');
mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' });
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
mockListAllTaskItems.mockReturnValue([
{
kind: 'failed',
name: 'task-1',
createdAt: '2026-02-19T00:00:00.000Z',
filePath: '/tasks/task-1.yaml',
content: 'Task: task-1',
startedAt: '2026-02-19T00:00:00.000Z',
completedAt: '2026-02-19T00:00:45.000Z',
branch: 'feat/task-1',
data: { task: 'task-1', piece: 'review' },
failure: { movement: 'ai_review', error: 'Lint failed', last_message: 'Fix attempt timed out' },
},
]);
// When
await runAllTasks('/project');
// Then
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
const [url, message] = mockSendSlackNotification.mock.calls[0]! as [string, string];
expect(url).toBe(webhookUrl);
expect(message).toContain('TAKT Run');
expect(message).toContain('total=1');
expect(message).toContain('failed=1');
expect(message).toContain('task-1');
expect(message).toContain('piece=review');
expect(message).toContain('duration=45s');
expect(message).toContain('movement=ai_review');
expect(message).toContain('error=Lint failed');
});
it('should send Slack notification on exception when webhook URL is set', async () => {
// Given
mockGetSlackWebhookUrl.mockReturnValue(webhookUrl);
const task1 = createTask('task-1');
const poolError = new Error('worker pool crashed');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockImplementationOnce(() => {
throw poolError;
});
mockListAllTaskItems.mockReturnValue([
{
kind: 'completed',
name: 'task-1',
createdAt: '2026-02-19T00:00:00.000Z',
filePath: '/tasks/task-1.yaml',
content: 'Task: task-1',
startedAt: '2026-02-19T00:00:00.000Z',
completedAt: '2026-02-19T00:00:15.000Z',
data: { task: 'task-1', piece: 'default' },
},
]);
// When / Then
await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed');
expect(mockSendSlackNotification).toHaveBeenCalledOnce();
const [url, message] = mockSendSlackNotification.mock.calls[0]! as [string, string];
expect(url).toBe(webhookUrl);
expect(message).toContain('TAKT Run');
expect(message).toContain('task-1');
expect(message).toContain('piece=default');
expect(message).toContain('duration=15s');
});
it('should not send Slack notification when webhook URL is not set', async () => {
// Given
mockGetSlackWebhookUrl.mockReturnValue(undefined);
const task1 = createTask('task-1');
mockClaimNextTasks
.mockReturnValueOnce([task1])
.mockReturnValueOnce([]);
// When
await runAllTasks('/project');
// Then
expect(mockSendSlackNotification).not.toHaveBeenCalled();
});
});
});