通知機能(notifySuccess/notifyError/playWarningSound)追加に伴い、 テストの vi.mock を修正。重複モックの統合、vitest 環境変数の追加、 GH API の recursive パラメータ修正、check:release に macOS 通知を追加。
555 lines
15 KiB
TypeScript
555 lines
15 KiB
TypeScript
/**
|
|
* Pipeline execution mode integration tests.
|
|
*
|
|
* Tests various --pipeline mode option combinations including:
|
|
* - --task, --issue, --skip-git, --auto-pr, --piece (name/path), --provider, --model
|
|
* - Exit codes for different failure scenarios
|
|
*
|
|
* Mocked: git (child_process), GitHub API, UI, notifications, session, phase-runner, config
|
|
* Not mocked: executePipeline, executeTask, PieceEngine, runAgent, rule evaluation
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { setMockScenario, resetScenario } from '../infra/mock/index.js';
|
|
|
|
// --- Mocks ---
|
|
|
|
const {
|
|
mockFetchIssue,
|
|
mockFormatIssueAsTask,
|
|
mockCheckGhCli,
|
|
mockCreatePullRequest,
|
|
mockPushBranch,
|
|
} = vi.hoisted(() => ({
|
|
mockFetchIssue: vi.fn(),
|
|
mockFormatIssueAsTask: vi.fn(),
|
|
mockCheckGhCli: vi.fn(),
|
|
mockCreatePullRequest: vi.fn(),
|
|
mockPushBranch: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../agents/ai-judge.js', async (importOriginal) => {
|
|
const original = await importOriginal<typeof import('../agents/ai-judge.js')>();
|
|
return {
|
|
...original,
|
|
callAiJudge: vi.fn().mockImplementation(async (content: string, conditions: { index: number; text: string }[]) => {
|
|
// Simple text matching: return index of first condition whose text appears in content
|
|
for (let i = 0; i < conditions.length; i++) {
|
|
if (content.includes(conditions[i]!.text)) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock('node:child_process', () => ({
|
|
execFileSync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../infra/github/issue.js', () => ({
|
|
fetchIssue: mockFetchIssue,
|
|
formatIssueAsTask: mockFormatIssueAsTask,
|
|
checkGhCli: mockCheckGhCli,
|
|
}));
|
|
|
|
vi.mock('../infra/github/pr.js', () => ({
|
|
createPullRequest: mockCreatePullRequest,
|
|
pushBranch: mockPushBranch,
|
|
buildPrBody: vi.fn().mockReturnValue('PR body'),
|
|
}));
|
|
|
|
vi.mock('../infra/task/git.js', () => ({
|
|
stageAndCommit: vi.fn().mockReturnValue('abc1234'),
|
|
getCurrentBranch: vi.fn().mockReturnValue('main'),
|
|
}));
|
|
|
|
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(),
|
|
StreamDisplay: vi.fn().mockImplementation(() => ({
|
|
createHandler: () => vi.fn(),
|
|
flush: vi.fn(),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
notifySuccess: vi.fn(),
|
|
notifyError: vi.fn(),
|
|
generateSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
createSessionLog: vi.fn().mockReturnValue({
|
|
startTime: new Date().toISOString(),
|
|
iterations: 0,
|
|
}),
|
|
finalizeSessionLog: vi.fn().mockImplementation((log, status) => ({ ...log, status })),
|
|
initNdjsonLog: vi.fn().mockReturnValue('/tmp/test.ndjson'),
|
|
appendNdjsonLine: vi.fn(),
|
|
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
|
|
}));
|
|
|
|
vi.mock('../infra/config/paths.js', async (importOriginal) => {
|
|
const original = await importOriginal<typeof import('../infra/config/paths.js')>();
|
|
return {
|
|
...original,
|
|
loadPersonaSessions: vi.fn().mockReturnValue({}),
|
|
updatePersonaSession: vi.fn(),
|
|
loadWorktreeSessions: vi.fn().mockReturnValue({}),
|
|
updateWorktreeSession: vi.fn(),
|
|
getProjectConfigDir: vi.fn().mockImplementation((cwd: string) => join(cwd, '.takt')),
|
|
};
|
|
});
|
|
|
|
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
|
|
const original = await importOriginal<typeof import('../infra/config/global/globalConfig.js')>();
|
|
return {
|
|
...original,
|
|
loadGlobalConfig: vi.fn().mockReturnValue({
|
|
language: 'en',
|
|
enableBuiltinPieces: true,
|
|
disabledBuiltins: [],
|
|
}),
|
|
getLanguage: vi.fn().mockReturnValue('en'),
|
|
getDisabledBuiltins: vi.fn().mockReturnValue([]),
|
|
};
|
|
});
|
|
|
|
vi.mock('../infra/config/project/projectConfig.js', async (importOriginal) => {
|
|
const original = await importOriginal<typeof import('../infra/config/project/projectConfig.js')>();
|
|
return {
|
|
...original,
|
|
loadProjectConfig: vi.fn().mockReturnValue({}),
|
|
};
|
|
});
|
|
|
|
vi.mock('../shared/context.js', () => ({
|
|
isQuietMode: vi.fn().mockReturnValue(true),
|
|
}));
|
|
|
|
vi.mock('../shared/prompt/index.js', () => ({
|
|
selectOption: vi.fn().mockResolvedValue('stop'),
|
|
promptInput: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
vi.mock('../core/piece/phase-runner.js', () => ({
|
|
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
|
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
|
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
|
}));
|
|
|
|
// --- Imports (after mocks) ---
|
|
|
|
import { executePipeline } from '../features/pipeline/index.js';
|
|
import {
|
|
EXIT_ISSUE_FETCH_FAILED,
|
|
EXIT_PIECE_FAILED,
|
|
EXIT_PR_CREATION_FAILED,
|
|
} from '../shared/exitCodes.js';
|
|
|
|
// --- Test helpers ---
|
|
|
|
function createTestPieceDir(): { dir: string; piecePath: string } {
|
|
const dir = mkdtempSync(join(tmpdir(), 'takt-it-pm-'));
|
|
mkdirSync(join(dir, '.takt', 'reports', 'test-report-dir'), { recursive: true });
|
|
|
|
const personasDir = join(dir, 'personas');
|
|
mkdirSync(personasDir, { recursive: true });
|
|
writeFileSync(join(personasDir, 'planner.md'), 'You are a planner.');
|
|
writeFileSync(join(personasDir, 'coder.md'), 'You are a coder.');
|
|
writeFileSync(join(personasDir, 'reviewer.md'), 'You are a reviewer.');
|
|
|
|
const pieceYaml = `
|
|
name: it-pipeline
|
|
description: Pipeline test piece
|
|
max_movements: 10
|
|
initial_movement: plan
|
|
|
|
movements:
|
|
- name: plan
|
|
persona: ./personas/planner.md
|
|
rules:
|
|
- condition: Requirements are clear
|
|
next: implement
|
|
- condition: Requirements unclear
|
|
next: ABORT
|
|
instruction: "{task}"
|
|
|
|
- name: implement
|
|
persona: ./personas/coder.md
|
|
rules:
|
|
- condition: Implementation complete
|
|
next: review
|
|
- condition: Cannot proceed
|
|
next: plan
|
|
instruction: "{task}"
|
|
|
|
- name: review
|
|
persona: ./personas/reviewer.md
|
|
rules:
|
|
- condition: All checks passed
|
|
next: COMPLETE
|
|
- condition: Issues found
|
|
next: implement
|
|
instruction: "{task}"
|
|
`;
|
|
|
|
const piecePath = join(dir, 'piece.yaml');
|
|
writeFileSync(piecePath, pieceYaml);
|
|
|
|
return { dir, piecePath };
|
|
}
|
|
|
|
function happyScenario(): void {
|
|
setMockScenario([
|
|
{ persona: 'planner', status: 'done', content: '[PLAN:1]\n\nRequirements are clear.' },
|
|
{ persona: 'coder', status: 'done', content: '[IMPLEMENT:1]\n\nImplementation complete.' },
|
|
{ persona: 'reviewer', status: 'done', content: '[REVIEW:1]\n\nAll checks passed.' },
|
|
]);
|
|
}
|
|
|
|
describe('Pipeline Modes IT: --task + --piece path', () => {
|
|
let testDir: string;
|
|
let piecePath: string;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const setup = createTestPieceDir();
|
|
testDir = setup.dir;
|
|
piecePath = setup.piecePath;
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetScenario();
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should return exit code 0 on successful pipeline', async () => {
|
|
happyScenario();
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Add a feature',
|
|
piece: piecePath,
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it('should return EXIT_PIECE_FAILED (3) on ABORT', async () => {
|
|
setMockScenario([
|
|
{ persona: 'planner', status: 'done', content: '[PLAN:2]\n\nRequirements unclear.' },
|
|
]);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Vague task',
|
|
piece: piecePath,
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(EXIT_PIECE_FAILED);
|
|
});
|
|
});
|
|
|
|
describe('Pipeline Modes IT: --task + --piece name (builtin)', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const setup = createTestPieceDir();
|
|
testDir = setup.dir;
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetScenario();
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should load and execute builtin minimal piece by name', async () => {
|
|
setMockScenario([
|
|
{ persona: 'planner', status: 'done', content: 'Requirements are clear and implementation is possible.' },
|
|
{ persona: 'coder', status: 'done', content: 'Implementation complete' },
|
|
{ persona: 'ai-antipattern-reviewer', status: 'done', content: 'No AI-specific issues' },
|
|
{ persona: 'supervisor', status: 'done', content: 'All checks passed' },
|
|
]);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Add a feature',
|
|
piece: 'default-mini',
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it('should return EXIT_PIECE_FAILED for non-existent piece name', async () => {
|
|
const exitCode = await executePipeline({
|
|
task: 'Test task',
|
|
piece: 'non-existent-piece-xyz',
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(EXIT_PIECE_FAILED);
|
|
});
|
|
});
|
|
|
|
describe('Pipeline Modes IT: --issue', () => {
|
|
let testDir: string;
|
|
let piecePath: string;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const setup = createTestPieceDir();
|
|
testDir = setup.dir;
|
|
piecePath = setup.piecePath;
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetScenario();
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should fetch issue and execute piece', async () => {
|
|
mockCheckGhCli.mockReturnValue({ available: true });
|
|
mockFetchIssue.mockReturnValue({
|
|
number: 42,
|
|
title: 'Fix the bug',
|
|
body: 'Details here',
|
|
});
|
|
mockFormatIssueAsTask.mockReturnValue('Fix the bug\n\nDetails here');
|
|
happyScenario();
|
|
|
|
const exitCode = await executePipeline({
|
|
issueNumber: 42,
|
|
piece: piecePath,
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockFetchIssue).toHaveBeenCalledWith(42);
|
|
});
|
|
|
|
it('should return EXIT_ISSUE_FETCH_FAILED when gh CLI unavailable', async () => {
|
|
mockCheckGhCli.mockReturnValue({ available: false, error: 'gh not found' });
|
|
|
|
const exitCode = await executePipeline({
|
|
issueNumber: 42,
|
|
piece: piecePath,
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(EXIT_ISSUE_FETCH_FAILED);
|
|
});
|
|
|
|
it('should return EXIT_ISSUE_FETCH_FAILED when issue fetch throws', async () => {
|
|
mockCheckGhCli.mockReturnValue({ available: true });
|
|
mockFetchIssue.mockImplementation(() => {
|
|
throw new Error('Issue not found');
|
|
});
|
|
|
|
const exitCode = await executePipeline({
|
|
issueNumber: 999,
|
|
piece: piecePath,
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(EXIT_ISSUE_FETCH_FAILED);
|
|
});
|
|
|
|
it('should return EXIT_ISSUE_FETCH_FAILED when neither --issue nor --task specified', async () => {
|
|
const exitCode = await executePipeline({
|
|
piece: piecePath,
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(EXIT_ISSUE_FETCH_FAILED);
|
|
});
|
|
});
|
|
|
|
describe('Pipeline Modes IT: --auto-pr', () => {
|
|
let testDir: string;
|
|
let piecePath: string;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const setup = createTestPieceDir();
|
|
testDir = setup.dir;
|
|
piecePath = setup.piecePath;
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetScenario();
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should create PR on success when --auto-pr is set (without --skip-git)', async () => {
|
|
happyScenario();
|
|
mockCreatePullRequest.mockReturnValue({ success: true, url: 'https://github.com/test/pr/1' });
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Add a feature',
|
|
piece: piecePath,
|
|
autoPr: true,
|
|
skipGit: false,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockCreatePullRequest).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return EXIT_PR_CREATION_FAILED when PR creation fails', async () => {
|
|
happyScenario();
|
|
mockCreatePullRequest.mockReturnValue({ success: false, error: 'Rate limited' });
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Add a feature',
|
|
piece: piecePath,
|
|
autoPr: true,
|
|
skipGit: false,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(EXIT_PR_CREATION_FAILED);
|
|
});
|
|
|
|
it('should skip PR creation when --auto-pr and --skip-git are both set', async () => {
|
|
happyScenario();
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Add a feature',
|
|
piece: piecePath,
|
|
autoPr: true,
|
|
skipGit: true,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(mockCreatePullRequest).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Pipeline Modes IT: --provider and --model overrides', () => {
|
|
let testDir: string;
|
|
let piecePath: string;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const setup = createTestPieceDir();
|
|
testDir = setup.dir;
|
|
piecePath = setup.piecePath;
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetScenario();
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should pass provider override to piece execution', async () => {
|
|
happyScenario();
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Test task',
|
|
piece: piecePath,
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it('should pass model override to piece execution', async () => {
|
|
happyScenario();
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Test task',
|
|
piece: piecePath,
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
model: 'opus',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Pipeline Modes IT: review → fix loop', () => {
|
|
let testDir: string;
|
|
let piecePath: string;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
const setup = createTestPieceDir();
|
|
testDir = setup.dir;
|
|
piecePath = setup.piecePath;
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetScenario();
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('should handle review → implement → review loop', async () => {
|
|
setMockScenario([
|
|
{ persona: 'planner', status: 'done', content: '[PLAN:1]\n\nClear.' },
|
|
{ persona: 'coder', status: 'done', content: '[IMPLEMENT:1]\n\nDone.' },
|
|
// First review: issues found → back to implement
|
|
{ persona: 'reviewer', status: 'done', content: '[REVIEW:2]\n\nIssues found.' },
|
|
// Fix
|
|
{ persona: 'coder', status: 'done', content: '[IMPLEMENT:1]\n\nFixed.' },
|
|
// Second review: passed
|
|
{ persona: 'reviewer', status: 'done', content: '[REVIEW:1]\n\nAll checks passed.' },
|
|
]);
|
|
|
|
const exitCode = await executePipeline({
|
|
task: 'Task with fix loop',
|
|
piece: piecePath,
|
|
autoPr: false,
|
|
skipGit: true,
|
|
cwd: testDir,
|
|
provider: 'mock',
|
|
});
|
|
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
});
|