takt/src/__tests__/listTasks.test.ts
nrslib 3c22b0e17d CI: listTasks テストに git config user 設定を追加
GitHub Actions の runner に git user.name/email が未設定のため
git commit が失敗していた問題を修正
2026-02-06 11:22:04 +09:00

393 lines
13 KiB
TypeScript

/**
* Tests for list-tasks command
*/
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
parseTaktBranches,
extractTaskSlug,
buildListItems,
type BranchInfo,
} from '../infra/task/branchList.js';
import { TaskRunner } from '../infra/task/runner.js';
import type { TaskListItem } from '../infra/task/types.js';
import { isBranchMerged, showFullDiff, type ListAction } from '../features/tasks/index.js';
import { listTasks } from '../features/tasks/list/index.js';
describe('parseTaktBranches', () => {
it('should parse takt/ branches from git branch output', () => {
const output = [
'takt/20260128-fix-auth def4567',
'takt/20260128-add-search 789abcd',
].join('\n');
const result = parseTaktBranches(output);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
branch: 'takt/20260128-fix-auth',
commit: 'def4567',
});
expect(result[1]).toEqual({
branch: 'takt/20260128-add-search',
commit: '789abcd',
});
});
it('should handle empty output', () => {
const result = parseTaktBranches('');
expect(result).toHaveLength(0);
});
it('should handle output with only whitespace lines', () => {
const result = parseTaktBranches(' \n \n');
expect(result).toHaveLength(0);
});
it('should handle single branch', () => {
const output = 'takt/20260128-fix-auth abc1234';
const result = parseTaktBranches(output);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
branch: 'takt/20260128-fix-auth',
commit: 'abc1234',
});
});
it('should skip lines without space separator', () => {
const output = [
'takt/20260128-fix-auth abc1234',
'malformed-line',
].join('\n');
const result = parseTaktBranches(output);
expect(result).toHaveLength(1);
});
});
describe('extractTaskSlug', () => {
it('should extract slug from timestamped branch name', () => {
expect(extractTaskSlug('takt/20260128T032800-fix-auth')).toBe('fix-auth');
});
it('should extract slug from date-only timestamp', () => {
expect(extractTaskSlug('takt/20260128-add-search')).toBe('add-search');
});
it('should extract slug with long timestamp format', () => {
expect(extractTaskSlug('takt/20260128T032800-refactor-api')).toBe('refactor-api');
});
it('should handle branch without timestamp', () => {
expect(extractTaskSlug('takt/my-task')).toBe('my-task');
});
it('should handle branch with only timestamp', () => {
const result = extractTaskSlug('takt/20260128T032800');
// Timestamp is stripped, nothing left, falls back to original name
expect(result).toBe('20260128T032800');
});
it('should handle slug with multiple dashes', () => {
expect(extractTaskSlug('takt/20260128-fix-auth-bug-in-login')).toBe('fix-auth-bug-in-login');
});
});
describe('buildListItems', () => {
it('should build items with correct task slug and originalInstruction', () => {
const branches: BranchInfo[] = [
{
branch: 'takt/20260128-fix-auth',
commit: 'abc123',
},
];
const items = buildListItems('/project', branches, 'main');
expect(items).toHaveLength(1);
expect(items[0]!.taskSlug).toBe('fix-auth');
expect(items[0]!.info).toBe(branches[0]);
// filesChanged will be 0 since we don't have a real git repo
expect(items[0]!.filesChanged).toBe(0);
// originalInstruction will be empty since git command fails on non-existent repo
expect(items[0]!.originalInstruction).toBe('');
});
it('should handle multiple branches', () => {
const branches: BranchInfo[] = [
{
branch: 'takt/20260128-fix-auth',
commit: 'abc123',
},
{
branch: 'takt/20260128-add-search',
commit: 'def456',
},
];
const items = buildListItems('/project', branches, 'main');
expect(items).toHaveLength(2);
expect(items[0]!.taskSlug).toBe('fix-auth');
expect(items[1]!.taskSlug).toBe('add-search');
});
it('should handle empty branch list', () => {
const items = buildListItems('/project', [], 'main');
expect(items).toHaveLength(0);
});
});
describe('ListAction type', () => {
it('should include diff, instruct, try, merge, delete (no skip)', () => {
const actions: ListAction[] = ['diff', 'instruct', 'try', 'merge', 'delete'];
expect(actions).toHaveLength(5);
expect(actions).toContain('diff');
expect(actions).toContain('instruct');
expect(actions).toContain('try');
expect(actions).toContain('merge');
expect(actions).toContain('delete');
expect(actions).not.toContain('skip');
});
});
describe('showFullDiff', () => {
it('should not throw for non-existent project dir', () => {
// spawnSync will fail gracefully; showFullDiff catches errors
expect(() => showFullDiff('/non-existent-dir', 'main', 'some-branch')).not.toThrow();
});
it('should not throw for non-existent branch', () => {
expect(() => showFullDiff('/tmp', 'main', 'non-existent-branch-xyz')).not.toThrow();
});
it('should warn when diff fails', () => {
const warnSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
showFullDiff('/non-existent-dir', 'main', 'some-branch');
warnSpy.mockRestore();
// No assertion needed — the test verifies it doesn't throw
});
});
describe('isBranchMerged', () => {
it('should return false for non-existent project dir', () => {
// git merge-base will fail on non-existent dir
const result = isBranchMerged('/non-existent-dir', 'some-branch');
expect(result).toBe(false);
});
it('should return false for non-existent branch', () => {
const result = isBranchMerged('/tmp', 'non-existent-branch-xyz');
expect(result).toBe(false);
});
});
describe('TaskRunner.listFailedTasks', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should return empty array for empty failed directory', () => {
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toEqual([]);
});
it('should parse failed task directories correctly', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
const taskDir = path.join(failedDir, '2025-01-15T12-34-56_my-task');
fs.mkdirSync(taskDir, { recursive: true });
fs.writeFileSync(path.join(taskDir, 'my-task.md'), 'Fix the login bug\nMore details here');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: taskDir,
content: 'Fix the login bug',
});
});
it('should skip malformed directory names', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
// No underscore → malformed, should be skipped
fs.mkdirSync(path.join(failedDir, 'malformed-name'), { recursive: true });
// Valid one
const validDir = path.join(failedDir, '2025-01-15T12-34-56_valid-task');
fs.mkdirSync(validDir, { recursive: true });
fs.writeFileSync(path.join(validDir, 'valid-task.md'), 'Content');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('valid-task');
});
it('should extract task content from task file in directory', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
const taskDir = path.join(failedDir, '2025-02-01T00-00-00_content-test');
fs.mkdirSync(taskDir, { recursive: true });
// report.md and log.json should be skipped; the actual task file should be read
fs.writeFileSync(path.join(taskDir, 'report.md'), 'Report content');
fs.writeFileSync(path.join(taskDir, 'log.json'), '{}');
fs.writeFileSync(path.join(taskDir, 'content-test.yaml'), 'task: Do something important');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]!.content).toBe('task: Do something important');
});
it('should return empty content when no task file exists', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
const taskDir = path.join(failedDir, '2025-02-01T00-00-00_no-task-file');
fs.mkdirSync(taskDir, { recursive: true });
// Only report.md and log.json, no actual task file
fs.writeFileSync(path.join(taskDir, 'report.md'), 'Report content');
fs.writeFileSync(path.join(taskDir, 'log.json'), '{}');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]!.content).toBe('');
});
it('should handle task name with underscores', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
const taskDir = path.join(failedDir, '2025-01-15T12-34-56_my_task_name');
fs.mkdirSync(taskDir, { recursive: true });
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(1);
expect(result[0]!.name).toBe('my_task_name');
});
it('should skip non-directory entries', () => {
const failedDir = path.join(tmpDir, '.takt', 'failed');
fs.mkdirSync(failedDir, { recursive: true });
// Create a file (not a directory) in the failed dir
fs.writeFileSync(path.join(failedDir, '2025-01-15T12-34-56_file-task'), 'content');
const runner = new TaskRunner(tmpDir);
const result = runner.listFailedTasks();
expect(result).toHaveLength(0);
});
});
describe('TaskRunner.listPendingTaskItems', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should return empty array when no pending tasks', () => {
const runner = new TaskRunner(tmpDir);
const result = runner.listPendingTaskItems();
expect(result).toEqual([]);
});
it('should convert TaskInfo to TaskListItem with kind=pending', () => {
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Fix the login bug\nMore details here');
const runner = new TaskRunner(tmpDir);
const result = runner.listPendingTaskItems();
expect(result).toHaveLength(1);
expect(result[0]!.kind).toBe('pending');
expect(result[0]!.name).toBe('my-task');
expect(result[0]!.content).toBe('Fix the login bug');
});
it('should truncate content to first line (max 80 chars)', () => {
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
const longLine = 'A'.repeat(120) + '\nSecond line';
fs.writeFileSync(path.join(tasksDir, 'long-task.md'), longLine);
const runner = new TaskRunner(tmpDir);
const result = runner.listPendingTaskItems();
expect(result).toHaveLength(1);
expect(result[0]!.content).toBe('A'.repeat(80));
});
});
describe('listTasks non-interactive JSON output', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-json-'));
// Initialize as a git repo so detectDefaultBranch works
execFileSync('git', ['init', '--initial-branch', 'main'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir, stdio: 'pipe' });
execFileSync('git', ['commit', '--allow-empty', '-m', 'init'], { cwd: tmpDir, stdio: 'pipe' });
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('should output JSON as object with branches, pendingTasks, and failedTasks keys', async () => {
// Given: a pending task and a failed task
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(tasksDir, 'my-task.md'), 'Do something');
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_failed-task');
fs.mkdirSync(failedDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'failed-task.md'), 'This failed');
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// When: listTasks is called in non-interactive JSON mode
await listTasks(tmpDir, undefined, {
enabled: true,
format: 'json',
});
// Then: output is an object with branches, pendingTasks, failedTasks
expect(logSpy).toHaveBeenCalledTimes(1);
const output = JSON.parse(logSpy.mock.calls[0]![0] as string);
expect(output).toHaveProperty('branches');
expect(output).toHaveProperty('pendingTasks');
expect(output).toHaveProperty('failedTasks');
expect(Array.isArray(output.branches)).toBe(true);
expect(Array.isArray(output.pendingTasks)).toBe(true);
expect(Array.isArray(output.failedTasks)).toBe(true);
expect(output.pendingTasks).toHaveLength(1);
expect(output.pendingTasks[0].name).toBe('my-task');
expect(output.failedTasks).toHaveLength(1);
expect(output.failedTasks[0].name).toBe('failed-task');
logSpy.mockRestore();
});
});