From e1bfbbada1bd1da6d9b338b6e2e07c932042b525 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Thu, 12 Feb 2026 08:50:02 +0900 Subject: [PATCH] takt: takt-e2e (#249) --- e2e/fixtures/pieces/mock-cycle-detect.yaml | 37 +++++ .../scenarios/cycle-detect-abort.json | 12 ++ e2e/fixtures/scenarios/cycle-detect-pass.json | 8 ++ e2e/fixtures/scenarios/report-judge.json | 4 +- e2e/specs/cycle-detection.e2e.ts | 125 +++++++++++++++++ e2e/specs/model-override.e2e.ts | 92 +++++++++++++ e2e/specs/multi-step-sequential.e2e.ts | 94 +++++++++++++ e2e/specs/pipeline-local-repo.e2e.ts | 107 +++++++++++++++ e2e/specs/report-file-output.e2e.ts | 85 ++++++++++++ e2e/specs/session-log.e2e.ts | 118 ++++++++++++++++ e2e/specs/task-status-persistence.e2e.ts | 126 ++++++++++++++++++ vitest.config.e2e.mock.ts | 8 ++ 12 files changed, 814 insertions(+), 2 deletions(-) create mode 100644 e2e/fixtures/pieces/mock-cycle-detect.yaml create mode 100644 e2e/fixtures/scenarios/cycle-detect-abort.json create mode 100644 e2e/fixtures/scenarios/cycle-detect-pass.json create mode 100644 e2e/specs/cycle-detection.e2e.ts create mode 100644 e2e/specs/model-override.e2e.ts create mode 100644 e2e/specs/multi-step-sequential.e2e.ts create mode 100644 e2e/specs/pipeline-local-repo.e2e.ts create mode 100644 e2e/specs/report-file-output.e2e.ts create mode 100644 e2e/specs/session-log.e2e.ts create mode 100644 e2e/specs/task-status-persistence.e2e.ts diff --git a/e2e/fixtures/pieces/mock-cycle-detect.yaml b/e2e/fixtures/pieces/mock-cycle-detect.yaml new file mode 100644 index 0000000..c98ea48 --- /dev/null +++ b/e2e/fixtures/pieces/mock-cycle-detect.yaml @@ -0,0 +1,37 @@ +name: e2e-cycle-detect +description: Piece with loop_monitors for cycle detection E2E testing + +max_movements: 20 +initial_movement: review + +loop_monitors: + - cycle: [review, fix] + threshold: 2 + judge: + persona: ../agents/test-reviewer-b.md + rules: + - condition: continue + next: review + - condition: abort_loop + next: ABORT + +movements: + - name: review + persona: ../agents/test-reviewer-a.md + instruction_template: | + Review the code. + rules: + - condition: approved + next: COMPLETE + - condition: needs_fix + next: fix + + - name: fix + persona: ../agents/test-coder.md + edit: true + permission_mode: edit + instruction_template: | + Fix the issues found in review. + rules: + - condition: fixed + next: review diff --git a/e2e/fixtures/scenarios/cycle-detect-abort.json b/e2e/fixtures/scenarios/cycle-detect-abort.json new file mode 100644 index 0000000..8eb40f9 --- /dev/null +++ b/e2e/fixtures/scenarios/cycle-detect-abort.json @@ -0,0 +1,12 @@ +[ + {"persona": "agents/test-reviewer-a", "status": "done", "content": "[REVIEW:2]\n\nNeeds fix."}, + {"persona": "conductor", "status": "done", "content": "[REVIEW:2]"}, + {"persona": "agents/test-coder", "status": "done", "content": "[FIX:1]\n\nFixed."}, + {"persona": "conductor", "status": "done", "content": "[FIX:1]"}, + {"persona": "agents/test-reviewer-a", "status": "done", "content": "[REVIEW:2]\n\nStill needs fix."}, + {"persona": "conductor", "status": "done", "content": "[REVIEW:2]"}, + {"persona": "agents/test-coder", "status": "done", "content": "[FIX:1]\n\nFixed again."}, + {"persona": "conductor", "status": "done", "content": "[FIX:1]"}, + {"persona": "agents/test-reviewer-b", "status": "done", "content": "[_LOOP_JUDGE_REVIEW_FIX:2]\n\nAbort this loop."}, + {"persona": "conductor", "status": "done", "content": "[_LOOP_JUDGE_REVIEW_FIX:2]"} +] diff --git a/e2e/fixtures/scenarios/cycle-detect-pass.json b/e2e/fixtures/scenarios/cycle-detect-pass.json new file mode 100644 index 0000000..e0ccf07 --- /dev/null +++ b/e2e/fixtures/scenarios/cycle-detect-pass.json @@ -0,0 +1,8 @@ +[ + {"persona": "agents/test-reviewer-a", "status": "done", "content": "[REVIEW:2]\n\nNeeds fix."}, + {"persona": "conductor", "status": "done", "content": "[REVIEW:2]"}, + {"persona": "agents/test-coder", "status": "done", "content": "[FIX:1]\n\nFixed."}, + {"persona": "conductor", "status": "done", "content": "[FIX:1]"}, + {"persona": "agents/test-reviewer-a", "status": "done", "content": "[REVIEW:1]\n\nApproved."}, + {"persona": "conductor", "status": "done", "content": "[REVIEW:1]"} +] diff --git a/e2e/fixtures/scenarios/report-judge.json b/e2e/fixtures/scenarios/report-judge.json index 7277cc2..aacb7d4 100644 --- a/e2e/fixtures/scenarios/report-judge.json +++ b/e2e/fixtures/scenarios/report-judge.json @@ -1,11 +1,11 @@ [ { - "persona": "test-reporter", + "persona": "agents/test-reporter", "status": "done", "content": "Work completed." }, { - "persona": "test-reporter", + "persona": "agents/test-reporter", "status": "done", "content": "Report summary: OK" }, diff --git a/e2e/specs/cycle-detection.e2e.ts b/e2e/specs/cycle-detection.e2e.ts new file mode 100644 index 0000000..b45f466 --- /dev/null +++ b/e2e/specs/cycle-detection.e2e.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync, readdirSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-cycle-detect-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +function readSessionRecords(repoPath: string): Array> { + const runsDir = join(repoPath, '.takt', 'runs'); + const runDirs = readdirSync(runsDir).sort(); + + for (const runDir of runDirs) { + const logsDir = join(runsDir, runDir, 'logs'); + const logFiles = readdirSync(logsDir).filter((file) => file.endsWith('.jsonl')); + for (const file of logFiles) { + const content = readFileSync(join(logsDir, file), 'utf-8').trim(); + if (!content) continue; + const records = content.split('\n').map((line) => JSON.parse(line) as Record); + if (records[0]?.type === 'piece_start') { + return records; + } + } + } + + throw new Error('Session NDJSON log not found'); +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Cycle detection via loop_monitors (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + }); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should abort when cycle threshold is reached and judge selects ABORT', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-cycle-detect.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/cycle-detect-abort.json'); + + const result = runTakt({ + args: [ + '--task', 'Test cycle detection abort', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).not.toBe(0); + + const records = readSessionRecords(repo.path); + const judgeStep = records.find((r) => r.type === 'step_complete' && r.step === '_loop_judge_review_fix'); + const abort = records.find((r) => r.type === 'piece_abort'); + + expect(judgeStep).toBeDefined(); + expect(abort).toBeDefined(); + }, 240_000); + + it('should complete when cycle threshold is not reached', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-cycle-detect.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/cycle-detect-pass.json'); + + const result = runTakt({ + args: [ + '--task', 'Test cycle detection pass', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + + const records = readSessionRecords(repo.path); + expect(records.some((r) => r.type === 'piece_complete')).toBe(true); + expect(records.some((r) => r.type === 'piece_abort')).toBe(false); + }, 240_000); +}); diff --git a/e2e/specs/model-override.e2e.ts b/e2e/specs/model-override.e2e.ts new file mode 100644 index 0000000..d48e3b3 --- /dev/null +++ b/e2e/specs/model-override.e2e.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-model-override-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: --model option override (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should complete direct task execution with --model', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + + const result = runTakt({ + args: [ + '--task', 'Test model override direct', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + '--model', 'mock-model-override', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Piece completed'); + }, 240_000); + + it('should complete pipeline --skip-git execution with --model', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + + const result = runTakt({ + args: [ + '--pipeline', + '--task', 'Test model override pipeline', + '--piece', piecePath, + '--skip-git', + '--provider', 'mock', + '--model', 'mock-model-override', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('completed'); + }, 240_000); +}); diff --git a/e2e/specs/multi-step-sequential.e2e.ts b/e2e/specs/multi-step-sequential.e2e.ts new file mode 100644 index 0000000..fa7e6a3 --- /dev/null +++ b/e2e/specs/multi-step-sequential.e2e.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync, readdirSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-sequential-step-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +function readSessionRecords(repoPath: string): Array> { + const runsDir = join(repoPath, '.takt', 'runs'); + const runDirs = readdirSync(runsDir).sort(); + + for (const runDir of runDirs) { + const logsDir = join(runsDir, runDir, 'logs'); + const logFiles = readdirSync(logsDir).filter((file) => file.endsWith('.jsonl')); + for (const file of logFiles) { + const content = readFileSync(join(logsDir, file), 'utf-8').trim(); + if (!content) continue; + const records = content.split('\n').map((line) => JSON.parse(line) as Record); + if (records[0]?.type === 'piece_start') { + return records; + } + } + } + + throw new Error('Session NDJSON log not found'); +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Sequential multi-step session log transitions (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should record step_complete for both step-1 and step-2', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-two-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/two-step-done.json'); + + const result = runTakt({ + args: [ + '--task', 'Test sequential transitions', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + + const records = readSessionRecords(repo.path); + const completedSteps = records + .filter((r) => r.type === 'step_complete') + .map((r) => String(r.step)); + + expect(completedSteps).toContain('step-1'); + expect(completedSteps).toContain('step-2'); + expect(records.some((r) => r.type === 'piece_complete')).toBe(true); + }, 240_000); +}); diff --git a/e2e/specs/pipeline-local-repo.e2e.ts b/e2e/specs/pipeline-local-repo.e2e.ts new file mode 100644 index 0000000..695dbd7 --- /dev/null +++ b/e2e/specs/pipeline-local-repo.e2e.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-pipeline-local-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +function createNonGitDir(): { path: string; cleanup: () => void } { + const dirPath = mkdtempSync(join(tmpdir(), 'takt-e2e-pipeline-nongit-')); + writeFileSync(join(dirPath, 'README.md'), '# non-git\n'); + return { + path: dirPath, + cleanup: () => { + try { rmSync(dirPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Pipeline --skip-git on local/non-git directories (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should execute pipeline with --skip-git in a local git repository', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + + const result = runTakt({ + args: [ + '--pipeline', + '--task', 'Pipeline local repo test', + '--piece', piecePath, + '--skip-git', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('completed'); + }, 240_000); + + it('should execute pipeline with --skip-git in a non-git directory', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + const dir = createNonGitDir(); + + try { + const result = runTakt({ + args: [ + '--pipeline', + '--task', 'Pipeline non-git test', + '--piece', piecePath, + '--skip-git', + '--provider', 'mock', + ], + cwd: dir.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('completed'); + } finally { + dir.cleanup(); + } + }, 240_000); +}); diff --git a/e2e/specs/report-file-output.e2e.ts b/e2e/specs/report-file-output.e2e.ts new file mode 100644 index 0000000..8fbec0c --- /dev/null +++ b/e2e/specs/report-file-output.e2e.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync, existsSync, readdirSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { + createIsolatedEnv, + updateIsolatedConfig, + type IsolatedEnv, +} from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-report-file-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Report file output (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + }); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should write report file to .takt/runs/*/reports with expected content', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/report-judge.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/report-judge.json'); + + const result = runTakt({ + args: [ + '--task', 'Test report output', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + + const runsDir = join(repo.path, '.takt', 'runs'); + expect(existsSync(runsDir)).toBe(true); + + const runDirs = readdirSync(runsDir).sort(); + expect(runDirs.length).toBeGreaterThan(0); + + const latestRun = runDirs[runDirs.length - 1]!; + const reportPath = join(runsDir, latestRun, 'reports', 'report.md'); + + expect(existsSync(reportPath)).toBe(true); + const report = readFileSync(reportPath, 'utf-8'); + expect(report).toContain('Report summary: OK'); + }, 240_000); +}); diff --git a/e2e/specs/session-log.e2e.ts b/e2e/specs/session-log.e2e.ts new file mode 100644 index 0000000..1b1280c --- /dev/null +++ b/e2e/specs/session-log.e2e.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync, readdirSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-session-log-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +function readSessionRecords(repoPath: string): Array> { + const runsDir = join(repoPath, '.takt', 'runs'); + const runDirs = readdirSync(runsDir).sort(); + + for (const runDir of runDirs) { + const logsDir = join(runsDir, runDir, 'logs'); + const logFiles = readdirSync(logsDir).filter((file) => file.endsWith('.jsonl')); + for (const file of logFiles) { + const content = readFileSync(join(logsDir, file), 'utf-8').trim(); + if (!content) continue; + const records = content.split('\n').map((line) => JSON.parse(line) as Record); + if (records[0]?.type === 'piece_start') { + return records; + } + } + } + + throw new Error('Session NDJSON log not found'); +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Session NDJSON log output (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should write piece_start, step_complete, and piece_complete on success', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + + const result = runTakt({ + args: [ + '--task', 'Test session log success', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + + const records = readSessionRecords(repo.path); + expect(records.some((r) => r.type === 'piece_start')).toBe(true); + expect(records.some((r) => r.type === 'step_complete')).toBe(true); + expect(records.some((r) => r.type === 'piece_complete')).toBe(true); + }, 240_000); + + it('should write piece_abort with reason on failure', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-no-match.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/no-match.json'); + + const result = runTakt({ + args: [ + '--task', 'Test session log abort', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).not.toBe(0); + + const records = readSessionRecords(repo.path); + const abortRecord = records.find((r) => r.type === 'piece_abort'); + expect(abortRecord).toBeDefined(); + expect(typeof abortRecord?.reason).toBe('string'); + expect((abortRecord?.reason as string).length).toBeGreaterThan(0); + }, 240_000); +}); diff --git a/e2e/specs/task-status-persistence.e2e.ts b/e2e/specs/task-status-persistence.e2e.ts new file mode 100644 index 0000000..161f2c0 --- /dev/null +++ b/e2e/specs/task-status-persistence.e2e.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { parse as parseYaml } from 'yaml'; +import { createIsolatedEnv, updateIsolatedConfig, type IsolatedEnv } from '../helpers/isolated-env'; +import { runTakt } from '../helpers/takt-runner'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-task-status-')); + execFileSync('git', ['init'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd: repoPath, stdio: 'pipe' }); + writeFileSync(join(repoPath, 'README.md'), '# test\n'); + execFileSync('git', ['add', '.'], { cwd: repoPath, stdio: 'pipe' }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: repoPath, stdio: 'pipe' }); + return { + path: repoPath, + cleanup: () => { + try { rmSync(repoPath, { recursive: true, force: true }); } catch { /* best-effort */ } + }, + }; +} + +function writeSinglePendingTask(repoPath: string, piecePath: string): void { + const now = new Date().toISOString(); + mkdirSync(join(repoPath, '.takt'), { recursive: true }); + writeFileSync( + join(repoPath, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: task-1', + ' status: pending', + ' content: "Task 1"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); +} + +// E2E更新時は docs/testing/e2e.md も更新すること +describe('E2E: Task status persistence in tasks.yaml (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + + updateIsolatedConfig(isolatedEnv.taktDir, { + provider: 'mock', + }); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should remove task record after successful completion', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + + writeSinglePendingTask(repo.path, piecePath); + + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + + const tasksContent = readFileSync(join(repo.path, '.takt', 'tasks.yaml'), 'utf-8'); + const tasks = parseYaml(tasksContent) as { tasks: Array> }; + expect(Array.isArray(tasks.tasks)).toBe(true); + expect(tasks.tasks.length).toBe(0); + }, 240_000); + + it('should persist failed status and failure details on failure', () => { + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-no-match.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/no-match.json'); + + writeSinglePendingTask(repo.path, piecePath); + + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + expect(result.exitCode).toBe(0); + + const tasksContent = readFileSync(join(repo.path, '.takt', 'tasks.yaml'), 'utf-8'); + const tasks = parseYaml(tasksContent) as { + tasks: Array<{ + status: string; + started_at: string | null; + completed_at: string | null; + failure?: { error?: string }; + }>; + }; + + expect(tasks.tasks.length).toBe(1); + expect(tasks.tasks[0]?.status).toBe('failed'); + expect(tasks.tasks[0]?.started_at).toBeTruthy(); + expect(tasks.tasks[0]?.completed_at).toBeTruthy(); + expect(tasks.tasks[0]?.failure?.error).toBeTruthy(); + }, 240_000); +}); diff --git a/vitest.config.e2e.mock.ts b/vitest.config.e2e.mock.ts index 12bc9fc..3d6abf5 100644 --- a/vitest.config.e2e.mock.ts +++ b/vitest.config.e2e.mock.ts @@ -5,14 +5,21 @@ export default defineConfig({ include: [ 'e2e/specs/direct-task.e2e.ts', 'e2e/specs/pipeline-skip-git.e2e.ts', + 'e2e/specs/pipeline-local-repo.e2e.ts', 'e2e/specs/report-judge.e2e.ts', + 'e2e/specs/report-file-output.e2e.ts', 'e2e/specs/add.e2e.ts', 'e2e/specs/watch.e2e.ts', 'e2e/specs/list-non-interactive.e2e.ts', 'e2e/specs/multi-step-parallel.e2e.ts', + 'e2e/specs/multi-step-sequential.e2e.ts', 'e2e/specs/run-sigint-graceful.e2e.ts', 'e2e/specs/piece-error-handling.e2e.ts', + 'e2e/specs/cycle-detection.e2e.ts', 'e2e/specs/run-multiple-tasks.e2e.ts', + 'e2e/specs/task-status-persistence.e2e.ts', + 'e2e/specs/session-log.e2e.ts', + 'e2e/specs/model-override.e2e.ts', 'e2e/specs/provider-error.e2e.ts', 'e2e/specs/error-handling.e2e.ts', 'e2e/specs/cli-catalog.e2e.ts', @@ -23,6 +30,7 @@ export default defineConfig({ 'e2e/specs/cli-config.e2e.ts', 'e2e/specs/cli-reset-categories.e2e.ts', 'e2e/specs/cli-export-cc.e2e.ts', + 'e2e/specs/eject.e2e.ts', 'e2e/specs/quiet-mode.e2e.ts', 'e2e/specs/task-content-file.e2e.ts', ],