Merge remote-tracking branch 'e2e-repo/takt/20260209T2213-add-e2e-tests' into develop

This commit is contained in:
nrslib 2026-02-10 08:24:14 +09:00
commit d73643dcd9
22 changed files with 1319 additions and 0 deletions

View File

@ -0,0 +1,5 @@
name: broken
this is not valid YAML
- indentation: [wrong
movements:
broken: {{{

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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."
}
]

View File

@ -0,0 +1,6 @@
[
{
"status": "error",
"content": "Simulated failure: API error during execution"
}
]

View File

@ -0,0 +1,6 @@
[
{
"status": "done",
"content": "Only entry in scenario."
}
]

View File

@ -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."
}
]

View File

@ -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."
}
]

View File

@ -0,0 +1,10 @@
[
{
"status": "done",
"content": "Step 1 output text completed."
},
{
"status": "done",
"content": "Step 2 output text completed."
}
]

View File

@ -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);
});
});

View File

@ -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/);
});
});

73
e2e/specs/cli-help.e2e.ts Normal file
View File

@ -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/);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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,