takt/src/__tests__/taskSyncAction.test.ts
2026-02-20 11:58:48 +09:00

272 lines
8.8 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('node:fs', () => ({
existsSync: vi.fn(),
}));
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({
success: vi.fn(),
error: vi.fn(),
}));
vi.mock('../shared/utils/index.js', () => ({
createLogger: vi.fn(() => ({
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
getErrorMessage: vi.fn((err) => String(err)),
}));
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
executeTask: vi.fn(),
}));
vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({
determinePiece: vi.fn(),
}));
vi.mock('../shared/constants.js', () => ({
DEFAULT_PIECE_NAME: 'default',
}));
import * as fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import { error as logError, success } from '../shared/ui/index.js';
import { executeTask } from '../features/tasks/execute/taskExecution.js';
import { determinePiece } from '../features/tasks/execute/selectAndExecute.js';
import { syncBranchWithRoot } from '../features/tasks/list/taskSyncAction.js';
import type { TaskListItem } from '../infra/task/index.js';
const mockExistsSync = vi.mocked(fs.existsSync);
const mockExecFileSync = vi.mocked(execFileSync);
const mockExecuteTask = vi.mocked(executeTask);
const mockDeterminePiece = vi.mocked(determinePiece);
const mockLogError = vi.mocked(logError);
const mockSuccess = vi.mocked(success);
function makeTask(overrides: Partial<TaskListItem> = {}): TaskListItem {
return {
kind: 'completed',
name: 'test-task',
createdAt: '2026-01-01T00:00:00Z',
filePath: '/project/.takt/tasks.yaml',
content: 'Implement feature X',
worktreePath: '/project-worktrees/test-task',
...overrides,
};
}
const PROJECT_DIR = '/project';
describe('syncBranchWithRoot', () => {
beforeEach(() => {
vi.clearAllMocks();
mockExistsSync.mockReturnValue(true);
mockDeterminePiece.mockResolvedValue('default');
});
it('throws when called with a non-task BranchActionTarget', async () => {
const branchTarget = {
info: { branch: 'some-branch', commit: 'abc123' },
originalInstruction: 'Do something',
};
await expect(
syncBranchWithRoot(PROJECT_DIR, branchTarget as never),
).rejects.toThrow('Sync requires a task target.');
});
it('returns false and logs error when worktreePath is missing', async () => {
const task = makeTask({ worktreePath: undefined });
const result = await syncBranchWithRoot(PROJECT_DIR, task);
expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalledWith(
expect.stringContaining('Worktree directory does not exist'),
);
expect(mockExecFileSync).not.toHaveBeenCalled();
});
it('returns false and logs error when worktreePath does not exist on disk', async () => {
const task = makeTask();
mockExistsSync.mockReturnValue(false);
const result = await syncBranchWithRoot(PROJECT_DIR, task);
expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalledWith(
expect.stringContaining('Worktree directory does not exist'),
);
});
it('returns false and logs error when git fetch fails', async () => {
const task = makeTask();
mockExecFileSync.mockImplementationOnce(() => { throw new Error('fetch error'); });
const result = await syncBranchWithRoot(PROJECT_DIR, task);
expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalledWith(expect.stringContaining('Failed to fetch from root'));
expect(mockExecuteTask).not.toHaveBeenCalled();
});
it('returns true and shows "Synced." when merge succeeds without conflicts', async () => {
const task = makeTask();
mockExecFileSync.mockReturnValue('' as never);
const result = await syncBranchWithRoot(PROJECT_DIR, task);
expect(result).toBe(true);
expect(mockSuccess).toHaveBeenCalledWith('Synced.');
expect(mockExecuteTask).not.toHaveBeenCalled();
});
it('calls executeTask with conflict resolution instruction when merge has conflicts', async () => {
const task = makeTask();
mockExecFileSync
.mockReturnValueOnce('' as never)
.mockImplementationOnce(() => { throw new Error('CONFLICT'); });
mockExecuteTask.mockResolvedValue(true);
const result = await syncBranchWithRoot(PROJECT_DIR, task);
expect(result).toBe(true);
expect(mockSuccess).toHaveBeenCalledWith('Conflicts resolved.');
expect(mockExecuteTask).toHaveBeenCalledWith(
expect.objectContaining({
cwd: task.worktreePath,
projectCwd: PROJECT_DIR,
pieceIdentifier: 'default',
task: expect.stringContaining('Git merge has stopped due to merge conflicts.'),
}),
);
});
it('includes original task content in conflict resolution instruction', async () => {
const task = makeTask({ content: 'Implement feature X' });
mockExecFileSync
.mockReturnValueOnce('' as never)
.mockImplementationOnce(() => { throw new Error('CONFLICT'); });
mockExecuteTask.mockResolvedValue(true);
await syncBranchWithRoot(PROJECT_DIR, task);
expect(mockExecuteTask).toHaveBeenCalledWith(
expect.objectContaining({
task: expect.stringContaining('Implement feature X'),
}),
);
});
it('uses task piece when available for AI resolution', async () => {
const task = makeTask({ data: { piece: 'custom-piece' } });
mockExecFileSync
.mockReturnValueOnce('' as never)
.mockImplementationOnce(() => { throw new Error('CONFLICT'); });
mockDeterminePiece.mockResolvedValue('custom-piece');
mockExecuteTask.mockResolvedValue(true);
await syncBranchWithRoot(PROJECT_DIR, task);
expect(mockDeterminePiece).toHaveBeenCalledWith(PROJECT_DIR, 'custom-piece');
});
it('uses DEFAULT_PIECE_NAME when task.data.piece is not set', async () => {
const task = makeTask({ data: undefined });
mockExecFileSync
.mockReturnValueOnce('' as never)
.mockImplementationOnce(() => { throw new Error('CONFLICT'); });
mockExecuteTask.mockResolvedValue(true);
await syncBranchWithRoot(PROJECT_DIR, task);
expect(mockDeterminePiece).toHaveBeenCalledWith(PROJECT_DIR, 'default');
});
it('aborts merge and returns false when AI resolution fails', async () => {
const task = makeTask();
mockExecFileSync
.mockReturnValueOnce('' as never)
.mockImplementationOnce(() => { throw new Error('CONFLICT'); })
.mockReturnValueOnce('' as never);
mockExecuteTask.mockResolvedValue(false);
const result = await syncBranchWithRoot(PROJECT_DIR, task);
expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalledWith(
expect.stringContaining('Failed to resolve conflicts'),
);
expect(mockExecFileSync).toHaveBeenCalledWith(
'git', ['merge', '--abort'],
expect.objectContaining({ cwd: task.worktreePath }),
);
});
it('aborts merge and returns false when determinePiece returns null', async () => {
const task = makeTask();
mockExecFileSync
.mockReturnValueOnce('' as never)
.mockImplementationOnce(() => { throw new Error('CONFLICT'); })
.mockReturnValueOnce('' as never);
mockDeterminePiece.mockResolvedValue(null);
const result = await syncBranchWithRoot(PROJECT_DIR, task);
expect(result).toBe(false);
expect(mockExecuteTask).not.toHaveBeenCalled();
expect(mockExecFileSync).toHaveBeenCalledWith(
'git', ['merge', '--abort'],
expect.objectContaining({ cwd: task.worktreePath }),
);
});
it('does not throw when git merge --abort itself fails', async () => {
const task = makeTask();
mockExecFileSync
.mockReturnValueOnce('' as never)
.mockImplementationOnce(() => { throw new Error('CONFLICT'); })
.mockImplementationOnce(() => { throw new Error('abort failed'); });
mockDeterminePiece.mockResolvedValue(null);
const result = await syncBranchWithRoot(PROJECT_DIR, task);
expect(result).toBe(false);
});
it('fetches from projectDir using local path ref', async () => {
const task = makeTask();
mockExecFileSync.mockReturnValue('' as never);
await syncBranchWithRoot(PROJECT_DIR, task);
expect(mockExecFileSync).toHaveBeenCalledWith(
'git',
['fetch', PROJECT_DIR, 'HEAD:refs/remotes/root/sync-target'],
expect.objectContaining({ cwd: task.worktreePath }),
);
});
it('passes agentOverrides to executeTask', async () => {
const task = makeTask();
mockExecFileSync
.mockReturnValueOnce('' as never)
.mockImplementationOnce(() => { throw new Error('CONFLICT'); });
mockExecuteTask.mockResolvedValue(true);
const options = { provider: 'anthropic' as never };
await syncBranchWithRoot(PROJECT_DIR, task, options);
expect(mockExecuteTask).toHaveBeenCalledWith(
expect.objectContaining({ agentOverrides: options }),
);
});
});