From cf9a59c41c359400f31187fc3f9f2a9176355622 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:02:16 +0900 Subject: [PATCH] =?UTF-8?q?=E4=B8=80=E6=99=82=E7=9A=84=E3=81=AB=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/fixtures/pieces/broken.yaml | 5 + e2e/fixtures/pieces/mock-max-iter.yaml | 27 +++ e2e/fixtures/pieces/mock-no-match.yaml | 15 ++ e2e/fixtures/pieces/mock-two-step.yaml | 27 +++ e2e/fixtures/scenarios/max-iter-loop.json | 18 ++ e2e/fixtures/scenarios/no-match.json | 6 + e2e/fixtures/scenarios/one-entry-only.json | 6 + e2e/fixtures/scenarios/run-three-tasks.json | 14 ++ e2e/fixtures/scenarios/run-with-failure.json | 14 ++ e2e/fixtures/scenarios/two-step-done.json | 10 + e2e/specs/cli-catalog.e2e.ts | 85 +++++++++ e2e/specs/cli-clear.e2e.ts | 55 ++++++ e2e/specs/cli-help.e2e.ts | 73 ++++++++ e2e/specs/cli-prompt.e2e.ts | 76 ++++++++ e2e/specs/cli-switch.e2e.ts | 70 +++++++ e2e/specs/error-handling.e2e.ts | 157 ++++++++++++++++ e2e/specs/piece-error-handling.e2e.ts | 124 +++++++++++++ e2e/specs/provider-error.e2e.ts | 133 +++++++++++++ e2e/specs/quiet-mode.e2e.ts | 72 ++++++++ e2e/specs/run-multiple-tasks.e2e.ts | 185 +++++++++++++++++++ e2e/specs/task-content-file.e2e.ts | 136 ++++++++++++++ vitest.config.e2e.mock.ts | 11 ++ 22 files changed, 1319 insertions(+) create mode 100644 e2e/fixtures/pieces/broken.yaml create mode 100644 e2e/fixtures/pieces/mock-max-iter.yaml create mode 100644 e2e/fixtures/pieces/mock-no-match.yaml create mode 100644 e2e/fixtures/pieces/mock-two-step.yaml create mode 100644 e2e/fixtures/scenarios/max-iter-loop.json create mode 100644 e2e/fixtures/scenarios/no-match.json create mode 100644 e2e/fixtures/scenarios/one-entry-only.json create mode 100644 e2e/fixtures/scenarios/run-three-tasks.json create mode 100644 e2e/fixtures/scenarios/run-with-failure.json create mode 100644 e2e/fixtures/scenarios/two-step-done.json create mode 100644 e2e/specs/cli-catalog.e2e.ts create mode 100644 e2e/specs/cli-clear.e2e.ts create mode 100644 e2e/specs/cli-help.e2e.ts create mode 100644 e2e/specs/cli-prompt.e2e.ts create mode 100644 e2e/specs/cli-switch.e2e.ts create mode 100644 e2e/specs/error-handling.e2e.ts create mode 100644 e2e/specs/piece-error-handling.e2e.ts create mode 100644 e2e/specs/provider-error.e2e.ts create mode 100644 e2e/specs/quiet-mode.e2e.ts create mode 100644 e2e/specs/run-multiple-tasks.e2e.ts create mode 100644 e2e/specs/task-content-file.e2e.ts diff --git a/e2e/fixtures/pieces/broken.yaml b/e2e/fixtures/pieces/broken.yaml new file mode 100644 index 0000000..3cc9642 --- /dev/null +++ b/e2e/fixtures/pieces/broken.yaml @@ -0,0 +1,5 @@ +name: broken + this is not valid YAML + - indentation: [wrong + movements: + broken: {{{ diff --git a/e2e/fixtures/pieces/mock-max-iter.yaml b/e2e/fixtures/pieces/mock-max-iter.yaml new file mode 100644 index 0000000..5e1f061 --- /dev/null +++ b/e2e/fixtures/pieces/mock-max-iter.yaml @@ -0,0 +1,27 @@ +name: e2e-mock-max-iter +description: Piece with max_iterations=2 that loops between two steps + +max_iterations: 2 + +initial_movement: step-a + +movements: + - name: step-a + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + {task} + rules: + - condition: Done + next: step-b + + - name: step-b + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + Continue the task. + rules: + - condition: Done + next: step-a diff --git a/e2e/fixtures/pieces/mock-no-match.yaml b/e2e/fixtures/pieces/mock-no-match.yaml new file mode 100644 index 0000000..69e4771 --- /dev/null +++ b/e2e/fixtures/pieces/mock-no-match.yaml @@ -0,0 +1,15 @@ +name: e2e-mock-no-match +description: Piece with a strict rule condition that will not match mock output + +max_iterations: 3 + +movements: + - name: execute + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + {task} + rules: + - condition: SpecificMatchThatWillNotOccur + next: COMPLETE diff --git a/e2e/fixtures/pieces/mock-two-step.yaml b/e2e/fixtures/pieces/mock-two-step.yaml new file mode 100644 index 0000000..c302fd0 --- /dev/null +++ b/e2e/fixtures/pieces/mock-two-step.yaml @@ -0,0 +1,27 @@ +name: e2e-mock-two-step +description: Two-step sequential piece for E2E testing + +max_iterations: 5 + +initial_movement: step-1 + +movements: + - name: step-1 + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + {task} + rules: + - condition: Done + next: step-2 + + - name: step-2 + edit: true + persona: ../agents/test-coder.md + permission_mode: edit + instruction_template: | + Continue the task. + rules: + - condition: Done + next: COMPLETE diff --git a/e2e/fixtures/scenarios/max-iter-loop.json b/e2e/fixtures/scenarios/max-iter-loop.json new file mode 100644 index 0000000..0befd97 --- /dev/null +++ b/e2e/fixtures/scenarios/max-iter-loop.json @@ -0,0 +1,18 @@ +[ + { + "status": "done", + "content": "Step A output." + }, + { + "status": "done", + "content": "Step B output." + }, + { + "status": "done", + "content": "Step A output again." + }, + { + "status": "done", + "content": "Step B output again." + } +] diff --git a/e2e/fixtures/scenarios/no-match.json b/e2e/fixtures/scenarios/no-match.json new file mode 100644 index 0000000..c70694c --- /dev/null +++ b/e2e/fixtures/scenarios/no-match.json @@ -0,0 +1,6 @@ +[ + { + "status": "error", + "content": "Simulated failure: API error during execution" + } +] diff --git a/e2e/fixtures/scenarios/one-entry-only.json b/e2e/fixtures/scenarios/one-entry-only.json new file mode 100644 index 0000000..c406d0f --- /dev/null +++ b/e2e/fixtures/scenarios/one-entry-only.json @@ -0,0 +1,6 @@ +[ + { + "status": "done", + "content": "Only entry in scenario." + } +] diff --git a/e2e/fixtures/scenarios/run-three-tasks.json b/e2e/fixtures/scenarios/run-three-tasks.json new file mode 100644 index 0000000..4ed3b69 --- /dev/null +++ b/e2e/fixtures/scenarios/run-three-tasks.json @@ -0,0 +1,14 @@ +[ + { + "status": "done", + "content": "Task 1 completed successfully." + }, + { + "status": "done", + "content": "Task 2 completed successfully." + }, + { + "status": "done", + "content": "Task 3 completed successfully." + } +] diff --git a/e2e/fixtures/scenarios/run-with-failure.json b/e2e/fixtures/scenarios/run-with-failure.json new file mode 100644 index 0000000..ab16316 --- /dev/null +++ b/e2e/fixtures/scenarios/run-with-failure.json @@ -0,0 +1,14 @@ +[ + { + "status": "done", + "content": "Task 1 completed successfully." + }, + { + "status": "error", + "content": "Task 2 encountered an error." + }, + { + "status": "done", + "content": "Task 3 completed successfully." + } +] diff --git a/e2e/fixtures/scenarios/two-step-done.json b/e2e/fixtures/scenarios/two-step-done.json new file mode 100644 index 0000000..7ced60d --- /dev/null +++ b/e2e/fixtures/scenarios/two-step-done.json @@ -0,0 +1,10 @@ +[ + { + "status": "done", + "content": "Step 1 output text completed." + }, + { + "status": "done", + "content": "Step 2 output text completed." + } +] diff --git a/e2e/specs/cli-catalog.e2e.ts b/e2e/specs/cli-catalog.e2e.ts new file mode 100644 index 0000000..881cde1 --- /dev/null +++ b/e2e/specs/cli-catalog.e2e.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +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'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-catalog-')); + 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: Catalog command (takt catalog)', () => { + 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 list all facet types when no argument given', () => { + // Given: a local repo with isolated env + + // When: running takt catalog + const result = runTakt({ + args: ['catalog'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains facet type sections + expect(result.exitCode).toBe(0); + const output = result.stdout.toLowerCase(); + expect(output).toMatch(/persona/); + }); + + it('should list facets for a specific type', () => { + // Given: a local repo with isolated env + + // When: running takt catalog personas + const result = runTakt({ + args: ['catalog', 'personas'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains persona names + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/coder/i); + }); + + it('should error for an invalid facet type', () => { + // Given: a local repo with isolated env + + // When: running takt catalog with an invalid type + const result = runTakt({ + args: ['catalog', 'invalidtype'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains an error or lists valid types + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/invalid|not found|valid types|unknown/i); + }); +}); diff --git a/e2e/specs/cli-clear.e2e.ts b/e2e/specs/cli-clear.e2e.ts new file mode 100644 index 0000000..81ccad7 --- /dev/null +++ b/e2e/specs/cli-clear.e2e.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +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'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-clear-')); + 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: Clear sessions command (takt clear)', () => { + 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 clear sessions without error', () => { + // Given: a local repo with isolated env + + // When: running takt clear + const result = runTakt({ + args: ['clear'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: exits cleanly + expect(result.exitCode).toBe(0); + const output = result.stdout.toLowerCase(); + expect(output).toMatch(/clear|session|removed|no session/); + }); +}); diff --git a/e2e/specs/cli-help.e2e.ts b/e2e/specs/cli-help.e2e.ts new file mode 100644 index 0000000..c375f23 --- /dev/null +++ b/e2e/specs/cli-help.e2e.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +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'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-help-')); + 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: Help command (takt --help)', () => { + 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 display subcommand list with --help', () => { + // Given: a local repo with isolated env + + // When: running takt --help + const result = runTakt({ + args: ['--help'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output lists subcommands + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/run/); + expect(result.stdout).toMatch(/add/); + expect(result.stdout).toMatch(/list/); + expect(result.stdout).toMatch(/eject/); + }); + + it('should display run subcommand help with takt run --help', () => { + // Given: a local repo with isolated env + + // When: running takt run --help + const result = runTakt({ + args: ['run', '--help'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains run command description + expect(result.exitCode).toBe(0); + const output = result.stdout.toLowerCase(); + expect(output).toMatch(/run|task|pending/); + }); +}); diff --git a/e2e/specs/cli-prompt.e2e.ts b/e2e/specs/cli-prompt.e2e.ts new file mode 100644 index 0000000..47b78fe --- /dev/null +++ b/e2e/specs/cli-prompt.e2e.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +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-prompt-')); + 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: Prompt preview command (takt prompt)', () => { + 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 output prompt preview header and movement info for a piece', () => { + // Given: a piece file path + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + // When: running takt prompt with piece path + const result = runTakt({ + args: ['prompt', piecePath], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: output contains "Prompt Preview" header and movement info + // (may fail on Phase 3 for pieces with tag-based rules, but header is still output) + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/Prompt Preview|Movement 1/i); + }); + + it('should report not found for a nonexistent piece name', () => { + // Given: a nonexistent piece name + + // When: running takt prompt with invalid piece + const result = runTakt({ + args: ['prompt', 'nonexistent-piece-xyz'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: reports piece not found + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found/i); + }); +}); diff --git a/e2e/specs/cli-switch.e2e.ts b/e2e/specs/cli-switch.e2e.ts new file mode 100644 index 0000000..f9d05e8 --- /dev/null +++ b/e2e/specs/cli-switch.e2e.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +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'; + +function createLocalRepo(): { path: string; cleanup: () => void } { + const repoPath = mkdtempSync(join(tmpdir(), 'takt-e2e-switch-')); + 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: Switch piece command (takt switch)', () => { + 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 switch piece when a valid piece name is given', () => { + // Given: a local repo with isolated env + + // When: running takt switch default + const result = runTakt({ + args: ['switch', 'default'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: exits successfully + expect(result.exitCode).toBe(0); + const output = result.stdout.toLowerCase(); + expect(output).toMatch(/default|switched|piece/); + }); + + it('should error when a nonexistent piece name is given', () => { + // Given: a local repo with isolated env + + // When: running takt switch with a nonexistent piece name + const result = runTakt({ + args: ['switch', 'nonexistent-piece-xyz'], + cwd: repo.path, + env: isolatedEnv.env, + }); + + // Then: error output + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found|error|does not exist/i); + }); +}); diff --git a/e2e/specs/error-handling.e2e.ts b/e2e/specs/error-handling.e2e.ts new file mode 100644 index 0000000..9c6cb0d --- /dev/null +++ b/e2e/specs/error-handling.e2e.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +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-error-')); + 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: Error handling edge cases (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 error when --piece points to a nonexistent file path', () => { + // Given: a nonexistent piece file path + + // When: running with a bad piece path + const result = runTakt({ + args: [ + '--task', 'test', + '--piece', '/nonexistent/path/to/piece.yaml', + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits with error + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found|does not exist|ENOENT/i); + }, 240_000); + + it('should report error when --piece specifies a nonexistent piece name', () => { + // Given: a nonexistent piece name + + // When: running with a bad piece name + const result = runTakt({ + args: [ + '--task', 'test', + '--piece', 'nonexistent-piece-name-xyz', + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: output contains error about piece not found + // Note: takt reports the error but currently exits with code 0 + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found/i); + }, 240_000); + + it('should error when --pipeline is used without --task or --issue', () => { + // Given: pipeline mode with no task or issue + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + // When: running in pipeline mode without a task + const result = runTakt({ + args: [ + '--pipeline', + '--piece', piecePath, + '--skip-git', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits with error (should not hang in interactive mode due to TAKT_NO_TTY=1) + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/task|issue|required/i); + }, 240_000); + + it('should error when --create-worktree receives an invalid value', () => { + // Given: invalid worktree value + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + // When: running with invalid worktree option + const result = runTakt({ + args: [ + '--task', 'test', + '--piece', piecePath, + '--create-worktree', 'invalid-value', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits with error or warning about invalid value + const combined = result.stdout + result.stderr; + const hasError = result.exitCode !== 0 || combined.match(/invalid|error|must be/i); + expect(hasError).toBeTruthy(); + }, 240_000); + + it('should error when piece file contains invalid YAML', () => { + // Given: a broken YAML piece file + const brokenPiecePath = resolve(__dirname, '../fixtures/pieces/broken.yaml'); + + // When: running with the broken piece + const result = runTakt({ + args: [ + '--task', 'test', + '--piece', brokenPiecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits with error about parsing + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/parse|invalid|error|validation/i); + }, 240_000); +}); diff --git a/e2e/specs/piece-error-handling.e2e.ts b/e2e/specs/piece-error-handling.e2e.ts new file mode 100644 index 0000000..3654bdd --- /dev/null +++ b/e2e/specs/piece-error-handling.e2e.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +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-piece-err-')); + 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: Piece error handling (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 abort when agent returns error status', () => { + // Given: a piece and a scenario that returns error status + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-no-match.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/no-match.json'); + + // When: executing the piece + const result = runTakt({ + args: [ + '--task', 'Test error status abort', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: piece aborts with a non-zero exit code + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/failed|aborted|error/i); + }, 240_000); + + it('should abort when max_iterations is reached', () => { + // Given: a piece with max_iterations=2 that loops between step-a and step-b + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-max-iter.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/max-iter-loop.json'); + + // When: executing the piece + const result = runTakt({ + args: [ + '--task', 'Test max iterations', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: piece aborts due to iteration limit + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/Max iterations|iteration|aborted/i); + }, 240_000); + + it('should pass previous response between sequential steps', () => { + // Given: a two-step piece and a scenario with distinct step outputs + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-two-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/two-step-done.json'); + + // When: executing the piece + const result = runTakt({ + args: [ + '--task', 'Test previous response passing', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: piece completes successfully (both steps execute) + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Piece completed'); + }, 240_000); +}); diff --git a/e2e/specs/provider-error.e2e.ts b/e2e/specs/provider-error.e2e.ts new file mode 100644 index 0000000..0f14542 --- /dev/null +++ b/e2e/specs/provider-error.e2e.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +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-provider-')); + 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: Provider error handling (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 override config provider with --provider flag', () => { + // Given: config.yaml has provider: claude, but CLI flag specifies mock + writeFileSync( + join(isolatedEnv.taktDir, 'config.yaml'), + [ + 'provider: claude', + 'language: en', + 'log_level: info', + 'default_piece: default', + ].join('\n'), + ); + + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + + // When: running with --provider mock + const result = runTakt({ + args: [ + '--task', 'Test provider override', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: executes successfully with mock provider + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Piece completed'); + }, 240_000); + + it('should use default mock response when scenario entries are exhausted', () => { + // Given: a two-step piece with only 1 scenario entry + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-two-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/one-entry-only.json'); + + // When: executing the piece (step-2 will have no scenario entry) + const result = runTakt({ + args: [ + '--task', 'Test scenario exhaustion', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: does not crash; either completes or aborts gracefully + const combined = result.stdout + result.stderr; + expect(combined).not.toContain('UnhandledPromiseRejection'); + expect(combined).not.toContain('SIGTERM'); + }, 240_000); + + it('should error when scenario file does not exist', () => { + // Given: TAKT_MOCK_SCENARIO pointing to a non-existent file + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + // When: executing with a bad scenario path + const result = runTakt({ + args: [ + '--task', 'Test bad scenario', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: '/nonexistent/path/scenario.json', + }, + timeout: 240_000, + }); + + // Then: exits with error and clear message + expect(result.exitCode).not.toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/[Ss]cenario file not found|ENOENT/); + }, 240_000); +}); diff --git a/e2e/specs/quiet-mode.e2e.ts b/e2e/specs/quiet-mode.e2e.ts new file mode 100644 index 0000000..085fb04 --- /dev/null +++ b/e2e/specs/quiet-mode.e2e.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +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-quiet-')); + 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: Quiet mode (--quiet)', () => { + 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 suppress AI stream output in quiet mode', () => { + // Given: a simple piece and scenario + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + + // When: running with --quiet flag + const result = runTakt({ + args: [ + '--task', 'Test quiet mode', + '--piece', piecePath, + '--create-worktree', 'no', + '--provider', 'mock', + '--quiet', + ], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: completes successfully; mock content should not appear in output + expect(result.exitCode).toBe(0); + // In quiet mode, the raw mock response text should be suppressed + expect(result.stdout).not.toContain('Mock response for persona'); + }, 240_000); +}); diff --git a/e2e/specs/run-multiple-tasks.e2e.ts b/e2e/specs/run-multiple-tasks.e2e.ts new file mode 100644 index 0000000..14b1e7b --- /dev/null +++ b/e2e/specs/run-multiple-tasks.e2e.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +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-run-multi-')); + 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: Run multiple tasks (takt run)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + + // Override config to use mock provider + writeFileSync( + join(isolatedEnv.taktDir, 'config.yaml'), + [ + 'provider: mock', + 'language: en', + 'log_level: info', + 'default_piece: default', + ].join('\n'), + ); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should execute all pending tasks sequentially', () => { + // Given: 3 pending tasks in tasks.yaml + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/run-three-tasks.json'); + const now = new Date().toISOString(); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: task-1', + ' status: pending', + ' content: "E2E task 1"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' - name: task-2', + ' status: pending', + ' content: "E2E task 2"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' - name: task-3', + ' status: pending', + ' content: "E2E task 3"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: all 3 tasks complete + expect(result.exitCode).toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toContain('task-1'); + expect(combined).toContain('task-2'); + expect(combined).toContain('task-3'); + }, 240_000); + + it('should continue remaining tasks when one task fails', () => { + // Given: 3 tasks where the 2nd will fail (error status) + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/run-with-failure.json'); + const now = new Date().toISOString(); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: task-ok-1', + ' status: pending', + ' content: "Should succeed"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' - name: task-fail', + ' status: pending', + ' content: "Should fail"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ' - name: task-ok-2', + ' status: pending', + ' content: "Should succeed after failure"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: exit code is non-zero (failure occurred), but task-ok-2 was still attempted + const combined = result.stdout + result.stderr; + expect(combined).toContain('task-ok-1'); + expect(combined).toContain('task-fail'); + expect(combined).toContain('task-ok-2'); + }, 240_000); + + it('should exit cleanly when no pending tasks exist', () => { + // Given: an empty tasks.yaml + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + 'tasks: []\n', + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: exits cleanly with code 0 + expect(result.exitCode).toBe(0); + }, 240_000); +}); diff --git a/e2e/specs/task-content-file.e2e.ts b/e2e/specs/task-content-file.e2e.ts new file mode 100644 index 0000000..d826d86 --- /dev/null +++ b/e2e/specs/task-content-file.e2e.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +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-contentfile-')); + 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: Task content_file reference (mock)', () => { + let isolatedEnv: IsolatedEnv; + let repo: { path: string; cleanup: () => void }; + + const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml'); + + beforeEach(() => { + isolatedEnv = createIsolatedEnv(); + repo = createLocalRepo(); + + writeFileSync( + join(isolatedEnv.taktDir, 'config.yaml'), + [ + 'provider: mock', + 'language: en', + 'log_level: info', + 'default_piece: default', + ].join('\n'), + ); + }); + + afterEach(() => { + try { repo.cleanup(); } catch { /* best-effort */ } + try { isolatedEnv.cleanup(); } catch { /* best-effort */ } + }); + + it('should execute task using content_file reference', () => { + // Given: a task with content_file pointing to an existing file + const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json'); + const now = new Date().toISOString(); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + + // Create the content file + writeFileSync( + join(repo.path, 'task-content.txt'), + 'Create a noop file for E2E testing.', + 'utf-8', + ); + + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: content-file-task', + ' status: pending', + ' content_file: "./task-content.txt"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: { + ...isolatedEnv.env, + TAKT_MOCK_SCENARIO: scenarioPath, + }, + timeout: 240_000, + }); + + // Then: task executes successfully + expect(result.exitCode).toBe(0); + const combined = result.stdout + result.stderr; + expect(combined).toContain('content-file-task'); + }, 240_000); + + it('should fail when content_file references a nonexistent file', () => { + // Given: a task with content_file pointing to a nonexistent file + const now = new Date().toISOString(); + + mkdirSync(join(repo.path, '.takt'), { recursive: true }); + + writeFileSync( + join(repo.path, '.takt', 'tasks.yaml'), + [ + 'tasks:', + ' - name: bad-content-file-task', + ' status: pending', + ' content_file: "./nonexistent-content.txt"', + ` piece: "${piecePath}"`, + ` created_at: "${now}"`, + ' started_at: null', + ' completed_at: null', + ].join('\n'), + 'utf-8', + ); + + // When: running takt run + const result = runTakt({ + args: ['run', '--provider', 'mock'], + cwd: repo.path, + env: isolatedEnv.env, + timeout: 240_000, + }); + + // Then: task fails with a meaningful error + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/not found|ENOENT|missing|error/i); + }, 240_000); +}); diff --git a/vitest.config.e2e.mock.ts b/vitest.config.e2e.mock.ts index 246ed68..4817180 100644 --- a/vitest.config.e2e.mock.ts +++ b/vitest.config.e2e.mock.ts @@ -11,6 +11,17 @@ export default defineConfig({ 'e2e/specs/list-non-interactive.e2e.ts', 'e2e/specs/multi-step-parallel.e2e.ts', 'e2e/specs/run-sigint-graceful.e2e.ts', + 'e2e/specs/piece-error-handling.e2e.ts', + 'e2e/specs/run-multiple-tasks.e2e.ts', + 'e2e/specs/provider-error.e2e.ts', + 'e2e/specs/error-handling.e2e.ts', + 'e2e/specs/cli-catalog.e2e.ts', + 'e2e/specs/cli-prompt.e2e.ts', + 'e2e/specs/cli-switch.e2e.ts', + 'e2e/specs/cli-help.e2e.ts', + 'e2e/specs/cli-clear.e2e.ts', + 'e2e/specs/quiet-mode.e2e.ts', + 'e2e/specs/task-content-file.e2e.ts', ], environment: 'node', globals: false,