379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { parse as parseYaml } from 'yaml';
|
|
|
|
const mockCheckCliStatus = vi.fn();
|
|
const mockFetchPrReviewComments = vi.fn();
|
|
|
|
vi.mock('../features/interactive/index.js', () => ({
|
|
interactiveMode: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../shared/prompt/index.js', () => ({
|
|
promptInput: vi.fn(),
|
|
confirm: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../shared/ui/index.js', () => ({
|
|
success: vi.fn(),
|
|
info: vi.fn(),
|
|
blankLine: vi.fn(),
|
|
error: vi.fn(),
|
|
withProgress: vi.fn(async (_start, _done, operation) => operation()),
|
|
}));
|
|
|
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
createLogger: () => ({
|
|
info: vi.fn(),
|
|
debug: vi.fn(),
|
|
error: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({
|
|
determinePiece: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
summarizeTaskName: vi.fn().mockResolvedValue('test-task'),
|
|
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
|
}));
|
|
|
|
vi.mock('../infra/task/clone-base-branch.js', () => ({
|
|
branchExists: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../infra/git/index.js', () => ({
|
|
getGitProvider: () => ({
|
|
createIssue: vi.fn(),
|
|
checkCliStatus: (...args: unknown[]) => mockCheckCliStatus(...args),
|
|
fetchPrReviewComments: (...args: unknown[]) => mockFetchPrReviewComments(...args),
|
|
}),
|
|
}));
|
|
|
|
const mockIsIssueReference = vi.fn((s: string) => /^#\d+$/.test(s));
|
|
const mockResolveIssueTask = vi.fn();
|
|
const mockParseIssueNumbers = vi.fn((args: string[]) => {
|
|
const numbers: number[] = [];
|
|
for (const arg of args) {
|
|
const match = arg.match(/^#(\d+)$/);
|
|
if (match?.[1]) {
|
|
numbers.push(Number.parseInt(match[1], 10));
|
|
}
|
|
}
|
|
return numbers;
|
|
});
|
|
const mockFormatPrReviewAsTask = vi.fn();
|
|
|
|
vi.mock('../infra/github/index.js', () => ({
|
|
isIssueReference: (...args: unknown[]) => mockIsIssueReference(...args),
|
|
resolveIssueTask: (...args: unknown[]) => mockResolveIssueTask(...args),
|
|
parseIssueNumbers: (...args: unknown[]) => mockParseIssueNumbers(...args),
|
|
formatPrReviewAsTask: (...args: unknown[]) => mockFormatPrReviewAsTask(...args),
|
|
}));
|
|
|
|
import { interactiveMode } from '../features/interactive/index.js';
|
|
import { promptInput, confirm } from '../shared/prompt/index.js';
|
|
import { error, info } from '../shared/ui/index.js';
|
|
import { determinePiece } from '../features/tasks/execute/selectAndExecute.js';
|
|
import { addTask } from '../features/tasks/index.js';
|
|
import { getCurrentBranch } from '../infra/task/index.js';
|
|
import { branchExists } from '../infra/task/clone-base-branch.js';
|
|
import type { PrReviewData } from '../infra/git/index.js';
|
|
|
|
const mockInteractiveMode = vi.mocked(interactiveMode);
|
|
const mockPromptInput = vi.mocked(promptInput);
|
|
const mockConfirm = vi.mocked(confirm);
|
|
const mockInfo = vi.mocked(info);
|
|
const mockError = vi.mocked(error);
|
|
const mockDeterminePiece = vi.mocked(determinePiece);
|
|
const mockGetCurrentBranch = vi.mocked(getCurrentBranch);
|
|
const mockBranchExists = vi.mocked(branchExists);
|
|
|
|
let testDir: string;
|
|
|
|
function loadTasks(dir: string): { tasks: Array<Record<string, unknown>> } {
|
|
const raw = fs.readFileSync(path.join(dir, '.takt', 'tasks.yaml'), 'utf-8');
|
|
return parseYaml(raw) as { tasks: Array<Record<string, unknown>> };
|
|
}
|
|
|
|
function addTaskWithPrOption(cwd: string, task: string, prNumber: number): Promise<void> {
|
|
return addTask(cwd, task, { prNumber });
|
|
}
|
|
|
|
function createMockPrReview(overrides: Partial<PrReviewData & { baseRefName?: string }> = {}): PrReviewData {
|
|
return {
|
|
number: 456,
|
|
title: 'Fix auth bug',
|
|
body: 'PR description',
|
|
url: 'https://github.com/org/repo/pull/456',
|
|
headRefName: 'feature/fix-auth-bug',
|
|
comments: [{ author: 'commenter', body: 'Please update tests' }],
|
|
reviews: [{ author: 'reviewer', body: 'Fix null check' }],
|
|
files: ['src/auth.ts'],
|
|
...overrides,
|
|
} as PrReviewData;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-'));
|
|
mockDeterminePiece.mockResolvedValue('default');
|
|
mockConfirm.mockResolvedValue(false);
|
|
mockGetCurrentBranch.mockReturnValue('main');
|
|
mockBranchExists.mockReturnValue(true);
|
|
mockCheckCliStatus.mockReturnValue({ available: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (testDir && fs.existsSync(testDir)) {
|
|
fs.rmSync(testDir, { recursive: true });
|
|
}
|
|
});
|
|
|
|
describe('addTask', () => {
|
|
function readOrderContent(dir: string, taskDir: unknown): string {
|
|
return fs.readFileSync(path.join(dir, String(taskDir), 'order.md'), 'utf-8');
|
|
}
|
|
|
|
it('should show usage and exit when task is missing', async () => {
|
|
await addTask(testDir);
|
|
|
|
expect(mockInfo).toHaveBeenCalledWith('Usage: takt add <task>');
|
|
expect(mockDeterminePiece).not.toHaveBeenCalled();
|
|
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('should show usage and exit when task is blank', async () => {
|
|
await addTask(testDir, ' ');
|
|
|
|
expect(mockInfo).toHaveBeenCalledWith('Usage: takt add <task>');
|
|
expect(mockDeterminePiece).not.toHaveBeenCalled();
|
|
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('should save plain text task without interactive mode', async () => {
|
|
await addTask(testDir, ' JWT認証を実装する ');
|
|
|
|
expect(mockInteractiveMode).not.toHaveBeenCalled();
|
|
const task = loadTasks(testDir).tasks[0]!;
|
|
expect(task.content).toBeUndefined();
|
|
expect(task.task_dir).toBeTypeOf('string');
|
|
expect(readOrderContent(testDir, task.task_dir)).toContain('JWT認証を実装する');
|
|
expect(task.piece).toBe('default');
|
|
expect(task.worktree).toBe(true);
|
|
});
|
|
|
|
it('should include worktree settings when enabled', async () => {
|
|
mockConfirm.mockResolvedValue(true);
|
|
mockPromptInput.mockResolvedValueOnce('/custom/path').mockResolvedValueOnce('feat/branch');
|
|
|
|
await addTask(testDir, 'Task content');
|
|
|
|
const task = loadTasks(testDir).tasks[0]!;
|
|
expect(task.worktree).toBe('/custom/path');
|
|
expect(task.branch).toBe('feat/branch');
|
|
expect(task.auto_pr).toBe(true);
|
|
});
|
|
|
|
it('should set base_branch when current branch is not main/master and user confirms', async () => {
|
|
mockGetCurrentBranch.mockReturnValue('feat/awesome');
|
|
mockConfirm.mockResolvedValueOnce(true);
|
|
mockPromptInput.mockResolvedValueOnce('').mockResolvedValueOnce('');
|
|
mockConfirm.mockResolvedValueOnce(false);
|
|
|
|
await addTask(testDir, 'Task content');
|
|
|
|
const task = loadTasks(testDir).tasks[0]!;
|
|
expect(task.base_branch).toBe('feat/awesome');
|
|
});
|
|
|
|
it('should not set base_branch when current branch prompt is declined', async () => {
|
|
mockGetCurrentBranch.mockReturnValue('feat/awesome');
|
|
mockConfirm.mockResolvedValueOnce(false);
|
|
mockPromptInput.mockResolvedValueOnce('').mockResolvedValueOnce('');
|
|
|
|
await addTask(testDir, 'Task content');
|
|
|
|
const task = loadTasks(testDir).tasks[0]!;
|
|
expect(task.base_branch).toBeUndefined();
|
|
expect(mockBranchExists).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should skip base branch prompt when current branch detection fails', async () => {
|
|
mockGetCurrentBranch.mockImplementationOnce(() => {
|
|
throw new Error('not a git repository');
|
|
});
|
|
|
|
await addTask(testDir, 'Task content');
|
|
|
|
expect(mockConfirm).toHaveBeenCalledTimes(1);
|
|
expect(mockConfirm).toHaveBeenCalledWith('Auto-create PR?', true);
|
|
expect(mockConfirm).not.toHaveBeenCalledWith(expect.stringContaining('現在のブランチ'));
|
|
const task = loadTasks(testDir).tasks[0]!;
|
|
expect(task.base_branch).toBeUndefined();
|
|
});
|
|
|
|
it('should reprompt when base branch does not exist', async () => {
|
|
mockGetCurrentBranch.mockReturnValue('feat/missing');
|
|
mockConfirm.mockResolvedValueOnce(true);
|
|
mockBranchExists.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
|
mockPromptInput
|
|
.mockResolvedValueOnce('develop')
|
|
.mockResolvedValueOnce('')
|
|
.mockResolvedValueOnce('');
|
|
mockConfirm.mockResolvedValueOnce(false);
|
|
|
|
await addTask(testDir, 'Task content');
|
|
|
|
const task = loadTasks(testDir).tasks[0]!;
|
|
expect(task.base_branch).toBe('develop');
|
|
expect(mockError).toHaveBeenCalledWith('Base branch does not exist: feat/missing');
|
|
});
|
|
|
|
it('should create task from issue reference without interactive mode', async () => {
|
|
mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout');
|
|
|
|
await addTask(testDir, '#99');
|
|
|
|
expect(mockInteractiveMode).not.toHaveBeenCalled();
|
|
expect(mockIsIssueReference).toHaveBeenCalledWith('#99');
|
|
expect(mockParseIssueNumbers).toHaveBeenCalledWith(['#99']);
|
|
expect(mockResolveIssueTask).toHaveBeenCalledWith('#99');
|
|
expect(mockCheckCliStatus).not.toHaveBeenCalled();
|
|
const task = loadTasks(testDir).tasks[0]!;
|
|
expect(task.content).toBeUndefined();
|
|
expect(readOrderContent(testDir, task.task_dir)).toContain('Fix login timeout');
|
|
expect(task.issue).toBe(99);
|
|
});
|
|
|
|
it('should create task from PR review comments with PR-specific task settings', async () => {
|
|
const prReview = createMockPrReview();
|
|
const formattedTask = '## PR #456 Review Comments: Fix auth bug';
|
|
mockFetchPrReviewComments.mockReturnValue(prReview);
|
|
mockFormatPrReviewAsTask.mockReturnValue(formattedTask);
|
|
|
|
await addTaskWithPrOption(testDir, 'placeholder', 456);
|
|
|
|
expect(mockCheckCliStatus).toHaveBeenCalled();
|
|
expect(mockCheckCliStatus.mock.invocationCallOrder[0]).toBeLessThan(
|
|
mockFetchPrReviewComments.mock.invocationCallOrder[0],
|
|
);
|
|
expect(mockFetchPrReviewComments).toHaveBeenCalledWith(456);
|
|
expect(mockFormatPrReviewAsTask).toHaveBeenCalledWith(prReview);
|
|
expect(mockIsIssueReference).not.toHaveBeenCalled();
|
|
expect(mockParseIssueNumbers).not.toHaveBeenCalled();
|
|
expect(mockResolveIssueTask).not.toHaveBeenCalled();
|
|
expect(mockPromptInput).not.toHaveBeenCalled();
|
|
expect(mockConfirm).not.toHaveBeenCalled();
|
|
expect(mockDeterminePiece).toHaveBeenCalledTimes(1);
|
|
const task = loadTasks(testDir).tasks[0]!;
|
|
expect(task.content).toBeUndefined();
|
|
expect(task.branch).toBe('feature/fix-auth-bug');
|
|
expect(task.auto_pr).toBe(false);
|
|
expect(task.worktree).toBe(true);
|
|
expect(task.draft_pr).toBeUndefined();
|
|
expect(readOrderContent(testDir, task.task_dir)).toContain(formattedTask);
|
|
});
|
|
|
|
it('should store PR base_ref as base_branch when adding with --pr', async () => {
|
|
const prReview = createMockPrReview({ baseRefName: 'release/main' });
|
|
const formattedTask = '## PR #456 Review Comments: Fix auth bug';
|
|
mockFetchPrReviewComments.mockReturnValue(prReview);
|
|
mockFormatPrReviewAsTask.mockReturnValue(formattedTask);
|
|
|
|
await addTaskWithPrOption(testDir, 'placeholder', 456);
|
|
|
|
const task = loadTasks(testDir).tasks[0]!;
|
|
expect(task.base_branch).toBe('release/main');
|
|
});
|
|
|
|
it('should not create a PR task when PR has no review comments', async () => {
|
|
const prReview = createMockPrReview({ comments: [], reviews: [] });
|
|
mockFetchPrReviewComments.mockReturnValue(prReview);
|
|
|
|
await addTaskWithPrOption(testDir, 'placeholder', 456);
|
|
|
|
expect(mockCheckCliStatus).toHaveBeenCalled();
|
|
expect(mockFetchPrReviewComments).toHaveBeenCalledWith(456);
|
|
expect(mockFormatPrReviewAsTask).not.toHaveBeenCalled();
|
|
expect(mockDeterminePiece).not.toHaveBeenCalled();
|
|
expect(mockError).toHaveBeenCalled();
|
|
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('should show error and not create task when fetchPrReviewComments throws', async () => {
|
|
mockFetchPrReviewComments.mockImplementation(() => { throw new Error('network timeout'); });
|
|
|
|
await addTaskWithPrOption(testDir, 'placeholder', 456);
|
|
|
|
expect(mockCheckCliStatus).toHaveBeenCalled();
|
|
expect(mockFetchPrReviewComments).toHaveBeenCalledWith(456);
|
|
expect(mockFormatPrReviewAsTask).not.toHaveBeenCalled();
|
|
expect(mockDeterminePiece).not.toHaveBeenCalled();
|
|
expect(mockError).toHaveBeenCalledWith(expect.stringContaining('network timeout'));
|
|
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('should not create a PR task when CLI is unavailable', async () => {
|
|
mockCheckCliStatus.mockReturnValue({ available: false, error: 'gh CLI is not available' });
|
|
|
|
await addTaskWithPrOption(testDir, 'placeholder', 456);
|
|
|
|
expect(mockFetchPrReviewComments).not.toHaveBeenCalled();
|
|
expect(mockFormatPrReviewAsTask).not.toHaveBeenCalled();
|
|
expect(mockDeterminePiece).not.toHaveBeenCalled();
|
|
expect(mockError).toHaveBeenCalled();
|
|
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('should not perform issue parsing when PR task text looks like issue reference', async () => {
|
|
const prReview = createMockPrReview();
|
|
const formattedTask = '## PR #456 Review Comments: Fix auth bug';
|
|
mockFetchPrReviewComments.mockReturnValue(prReview);
|
|
mockFormatPrReviewAsTask.mockReturnValue(formattedTask);
|
|
|
|
await addTaskWithPrOption(testDir, '#99', 456);
|
|
|
|
expect(mockIsIssueReference).not.toHaveBeenCalled();
|
|
|
|
expect(mockParseIssueNumbers).not.toHaveBeenCalled();
|
|
expect(mockResolveIssueTask).not.toHaveBeenCalled();
|
|
expect(mockCheckCliStatus).toHaveBeenCalled();
|
|
expect(mockFetchPrReviewComments).toHaveBeenCalledWith(456);
|
|
expect(mockFormatPrReviewAsTask).toHaveBeenCalledWith(prReview);
|
|
const task = loadTasks(testDir).tasks[0]!;
|
|
expect(task.content).toBeUndefined();
|
|
expect(task.branch).toBe('feature/fix-auth-bug');
|
|
expect(task.auto_pr).toBe(false);
|
|
});
|
|
|
|
it('should not create task when piece selection is cancelled', async () => {
|
|
mockDeterminePiece.mockResolvedValue(null);
|
|
|
|
await addTask(testDir, 'Task content');
|
|
|
|
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
|
});
|
|
|
|
it('should not save PR task when piece selection is cancelled', async () => {
|
|
const prReview = createMockPrReview();
|
|
const formattedTask = '## PR #456 Review Comments: Fix auth bug';
|
|
mockDeterminePiece.mockResolvedValue(null);
|
|
mockFetchPrReviewComments.mockReturnValue(prReview);
|
|
mockFormatPrReviewAsTask.mockReturnValue(formattedTask);
|
|
|
|
await addTaskWithPrOption(testDir, 'placeholder', 456);
|
|
|
|
expect(mockCheckCliStatus).toHaveBeenCalled();
|
|
expect(mockFetchPrReviewComments).toHaveBeenCalledWith(456);
|
|
expect(mockFormatPrReviewAsTask).toHaveBeenCalledWith(prReview);
|
|
expect(mockDeterminePiece).toHaveBeenCalledTimes(1);
|
|
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
|
});
|
|
});
|