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>()), 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>()), 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> } { const raw = fs.readFileSync(path.join(dir, '.takt', 'tasks.yaml'), 'utf-8'); return parseYaml(raw) as { tasks: Array> }; } function addTaskWithPrOption(cwd: string, task: string, prNumber: number): Promise { return addTask(cwd, task, { prNumber }); } function createMockPrReview(overrides: Partial = {}): 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 '); 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 '); 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); }); });