takt: github-issue-198-e2e-config-yaml (#208)
This commit is contained in:
parent
194610018a
commit
6e67f864f5
@ -565,6 +565,12 @@ model: sonnet # Default model (optional)
|
|||||||
branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow)
|
branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow)
|
||||||
prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate)
|
prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate)
|
||||||
notification_sound: true # Enable/disable notification sounds
|
notification_sound: true # Enable/disable notification sounds
|
||||||
|
notification_sound_events: # Optional per-event toggles
|
||||||
|
iteration_limit: false
|
||||||
|
piece_complete: true
|
||||||
|
piece_abort: true
|
||||||
|
run_complete: true # Enabled by default; set false to disable
|
||||||
|
run_abort: true # Enabled by default; set false to disable
|
||||||
concurrency: 1 # Parallel task count for takt run (1-10, default: 1 = sequential)
|
concurrency: 1 # Parallel task count for takt run (1-10, default: 1 = sequential)
|
||||||
task_poll_interval_ms: 500 # Polling interval for new tasks during takt run (100-5000, default: 500)
|
task_poll_interval_ms: 500 # Polling interval for new tasks during takt run (100-5000, default: 500)
|
||||||
interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, default: 3)
|
interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, default: 3)
|
||||||
|
|||||||
@ -565,6 +565,12 @@ model: sonnet # デフォルトモデル(オプション)
|
|||||||
branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速)
|
branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速)
|
||||||
prevent_sleep: false # macOS の実行中スリープ防止(caffeinate)
|
prevent_sleep: false # macOS の実行中スリープ防止(caffeinate)
|
||||||
notification_sound: true # 通知音の有効/無効
|
notification_sound: true # 通知音の有効/無効
|
||||||
|
notification_sound_events: # タイミング別の通知音制御
|
||||||
|
iteration_limit: false
|
||||||
|
piece_complete: true
|
||||||
|
piece_abort: true
|
||||||
|
run_complete: true # 未設定時は有効。false を指定すると無効
|
||||||
|
run_abort: true # 未設定時は有効。false を指定すると無効
|
||||||
concurrency: 1 # takt run の並列タスク数(1-10、デフォルト: 1 = 逐次実行)
|
concurrency: 1 # takt run の並列タスク数(1-10、デフォルト: 1 = 逐次実行)
|
||||||
task_poll_interval_ms: 500 # takt run 中の新タスク検出ポーリング間隔(100-5000、デフォルト: 500)
|
task_poll_interval_ms: 500 # takt run 中の新タスク検出ポーリング間隔(100-5000、デフォルト: 500)
|
||||||
interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数(0-10、デフォルト: 3)
|
interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数(0-10、デフォルト: 3)
|
||||||
|
|||||||
@ -13,6 +13,13 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
|
|||||||
- リポジトリクローン: `$(os.tmpdir())/takt-e2e-repo-<random>/`
|
- リポジトリクローン: `$(os.tmpdir())/takt-e2e-repo-<random>/`
|
||||||
- 実行環境: `$(os.tmpdir())/takt-e2e-<runId>-<random>/`
|
- 実行環境: `$(os.tmpdir())/takt-e2e-<runId>-<random>/`
|
||||||
|
|
||||||
|
## E2E用config.yaml
|
||||||
|
- E2Eのグローバル設定は `e2e/fixtures/config.e2e.yaml` を基準に生成する。
|
||||||
|
- `createIsolatedEnv()` は毎回一時ディレクトリ配下(`$TAKT_CONFIG_DIR/config.yaml`)にこの基準設定を書き出す。
|
||||||
|
- 通知音は `notification_sound_events` でタイミング別に制御し、E2E既定では道中(`iteration_limit` / `piece_complete` / `piece_abort`)をOFF、全体終了時(`run_complete` / `run_abort`)のみONにする。
|
||||||
|
- 各スペックで `provider` や `concurrency` を変更する場合は、`updateIsolatedConfig()` を使って差分のみ上書きする。
|
||||||
|
- `~/.takt/config.yaml` はE2Eでは参照されないため、通常実行の設定には影響しない。
|
||||||
|
|
||||||
## 実行コマンド
|
## 実行コマンド
|
||||||
- `npm run test:e2e`: E2E全体を実行。
|
- `npm run test:e2e`: E2E全体を実行。
|
||||||
- `npm run test:e2e:mock`: mock固定のE2Eのみ実行。
|
- `npm run test:e2e:mock`: mock固定のE2Eのみ実行。
|
||||||
|
|||||||
11
e2e/fixtures/config.e2e.yaml
Normal file
11
e2e/fixtures/config.e2e.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
provider: claude
|
||||||
|
language: en
|
||||||
|
log_level: info
|
||||||
|
default_piece: default
|
||||||
|
notification_sound: true
|
||||||
|
notification_sound_events:
|
||||||
|
iteration_limit: false
|
||||||
|
piece_complete: false
|
||||||
|
piece_abort: false
|
||||||
|
run_complete: true
|
||||||
|
run_abort: true
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||||
|
|
||||||
export interface IsolatedEnv {
|
export interface IsolatedEnv {
|
||||||
runId: string;
|
runId: string;
|
||||||
@ -9,6 +11,73 @@ export interface IsolatedEnv {
|
|||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type E2EConfig = Record<string, unknown>;
|
||||||
|
type NotificationSoundEvents = Record<string, unknown>;
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const E2E_CONFIG_FIXTURE_PATH = resolve(__dirname, '../fixtures/config.e2e.yaml');
|
||||||
|
|
||||||
|
function readE2EFixtureConfig(): E2EConfig {
|
||||||
|
const raw = readFileSync(E2E_CONFIG_FIXTURE_PATH, 'utf-8');
|
||||||
|
const parsed = parseYaml(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw new Error(`Invalid E2E config fixture: ${E2E_CONFIG_FIXTURE_PATH}`);
|
||||||
|
}
|
||||||
|
return parsed as E2EConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfigFile(taktDir: string, config: E2EConfig): void {
|
||||||
|
writeFileSync(join(taktDir, 'config.yaml'), `${stringifyYaml(config)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNotificationSoundEvents(
|
||||||
|
source: E2EConfig,
|
||||||
|
sourceName: string,
|
||||||
|
): NotificationSoundEvents | undefined {
|
||||||
|
const value = source.notification_sound_events;
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid notification_sound_events in ${sourceName}: expected object`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value as NotificationSoundEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeIsolatedConfig(
|
||||||
|
fixture: E2EConfig,
|
||||||
|
current: E2EConfig,
|
||||||
|
patch: E2EConfig,
|
||||||
|
): E2EConfig {
|
||||||
|
const merged: E2EConfig = { ...fixture, ...current, ...patch };
|
||||||
|
const fixtureEvents = parseNotificationSoundEvents(fixture, 'fixture');
|
||||||
|
const currentEvents = parseNotificationSoundEvents(current, 'current config');
|
||||||
|
const patchEvents = parseNotificationSoundEvents(patch, 'patch');
|
||||||
|
if (!fixtureEvents && !currentEvents && !patchEvents) {
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
merged.notification_sound_events = {
|
||||||
|
...(fixtureEvents ?? {}),
|
||||||
|
...(currentEvents ?? {}),
|
||||||
|
...(patchEvents ?? {}),
|
||||||
|
};
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateIsolatedConfig(taktDir: string, patch: E2EConfig): void {
|
||||||
|
const current = readE2EFixtureConfig();
|
||||||
|
const configPath = join(taktDir, 'config.yaml');
|
||||||
|
const raw = readFileSync(configPath, 'utf-8');
|
||||||
|
const parsed = parseYaml(raw);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
throw new Error(`Invalid isolated config: ${configPath}`);
|
||||||
|
}
|
||||||
|
writeConfigFile(taktDir, mergeIsolatedConfig(current, parsed as E2EConfig, patch));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an isolated environment for E2E testing.
|
* Create an isolated environment for E2E testing.
|
||||||
*
|
*
|
||||||
@ -24,18 +93,12 @@ export function createIsolatedEnv(): IsolatedEnv {
|
|||||||
const gitConfigPath = join(baseDir, '.gitconfig');
|
const gitConfigPath = join(baseDir, '.gitconfig');
|
||||||
|
|
||||||
// Create TAKT config directory and config.yaml
|
// Create TAKT config directory and config.yaml
|
||||||
// Use TAKT_E2E_PROVIDER to match config provider with the actual provider being tested
|
|
||||||
const configProvider = process.env.TAKT_E2E_PROVIDER ?? 'claude';
|
|
||||||
mkdirSync(taktDir, { recursive: true });
|
mkdirSync(taktDir, { recursive: true });
|
||||||
writeFileSync(
|
const baseConfig = readE2EFixtureConfig();
|
||||||
join(taktDir, 'config.yaml'),
|
const config = process.env.TAKT_E2E_PROVIDER
|
||||||
[
|
? { ...baseConfig, provider: process.env.TAKT_E2E_PROVIDER }
|
||||||
`provider: ${configProvider}`,
|
: baseConfig;
|
||||||
'language: en',
|
writeConfigFile(taktDir, config);
|
||||||
'log_level: info',
|
|
||||||
'default_piece: default',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create isolated Git config file
|
// Create isolated Git config file
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
@ -58,11 +121,7 @@ export function createIsolatedEnv(): IsolatedEnv {
|
|||||||
taktDir,
|
taktDir,
|
||||||
env,
|
env,
|
||||||
cleanup: () => {
|
cleanup: () => {
|
||||||
try {
|
|
||||||
rmSync(baseDir, { recursive: true, force: true });
|
rmSync(baseDir, { recursive: true, force: true });
|
||||||
} catch {
|
|
||||||
// Best-effort cleanup; ignore errors (e.g., already deleted)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
import { join, dirname, resolve } from 'node:path';
|
import { join, dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { parse as parseYaml } from 'yaml';
|
import { parse as parseYaml } from 'yaml';
|
||||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
import {
|
||||||
|
createIsolatedEnv,
|
||||||
|
updateIsolatedConfig,
|
||||||
|
type IsolatedEnv,
|
||||||
|
} from '../helpers/isolated-env';
|
||||||
import { createTestRepo, type TestRepo } from '../helpers/test-repo';
|
import { createTestRepo, type TestRepo } from '../helpers/test-repo';
|
||||||
import { runTakt } from '../helpers/takt-runner';
|
import { runTakt } from '../helpers/takt-runner';
|
||||||
|
|
||||||
@ -22,16 +26,10 @@ describe('E2E: Add task from GitHub issue (takt add)', () => {
|
|||||||
testRepo = createTestRepo();
|
testRepo = createTestRepo();
|
||||||
|
|
||||||
// Use mock provider to stabilize summarizer
|
// Use mock provider to stabilize summarizer
|
||||||
writeFileSync(
|
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||||
join(isolatedEnv.taktDir, 'config.yaml'),
|
provider: 'mock',
|
||||||
[
|
model: 'mock-model',
|
||||||
'provider: mock',
|
});
|
||||||
'model: mock-model',
|
|
||||||
'language: en',
|
|
||||||
'log_level: info',
|
|
||||||
'default_piece: default',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const createOutput = execFileSync(
|
const createOutput = execFileSync(
|
||||||
'gh',
|
'gh',
|
||||||
|
|||||||
@ -5,7 +5,11 @@ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
import {
|
||||||
|
createIsolatedEnv,
|
||||||
|
updateIsolatedConfig,
|
||||||
|
type IsolatedEnv,
|
||||||
|
} from '../helpers/isolated-env';
|
||||||
import { runTakt } from '../helpers/takt-runner';
|
import { runTakt } from '../helpers/takt-runner';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@ -44,15 +48,9 @@ describe('E2E: Provider error handling (mock)', () => {
|
|||||||
|
|
||||||
it('should override config provider with --provider flag', () => {
|
it('should override config provider with --provider flag', () => {
|
||||||
// Given: config.yaml has provider: claude, but CLI flag specifies mock
|
// Given: config.yaml has provider: claude, but CLI flag specifies mock
|
||||||
writeFileSync(
|
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||||
join(isolatedEnv.taktDir, 'config.yaml'),
|
provider: 'claude',
|
||||||
[
|
});
|
||||||
'provider: claude',
|
|
||||||
'language: en',
|
|
||||||
'log_level: info',
|
|
||||||
'default_piece: default',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
const piecePath = resolve(__dirname, '../fixtures/pieces/mock-single-step.yaml');
|
||||||
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
|
const scenarioPath = resolve(__dirname, '../fixtures/scenarios/execute-done.json');
|
||||||
|
|||||||
@ -5,7 +5,11 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
import {
|
||||||
|
createIsolatedEnv,
|
||||||
|
updateIsolatedConfig,
|
||||||
|
type IsolatedEnv,
|
||||||
|
} from '../helpers/isolated-env';
|
||||||
import { runTakt } from '../helpers/takt-runner';
|
import { runTakt } from '../helpers/takt-runner';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@ -39,15 +43,9 @@ describe('E2E: Run multiple tasks (takt run)', () => {
|
|||||||
repo = createLocalRepo();
|
repo = createLocalRepo();
|
||||||
|
|
||||||
// Override config to use mock provider
|
// Override config to use mock provider
|
||||||
writeFileSync(
|
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||||
join(isolatedEnv.taktDir, 'config.yaml'),
|
provider: 'mock',
|
||||||
[
|
});
|
||||||
'provider: mock',
|
|
||||||
'language: en',
|
|
||||||
'log_level: info',
|
|
||||||
'default_piece: default',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@ -3,7 +3,11 @@ import { spawn } from 'node:child_process';
|
|||||||
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||||
import { join, resolve, dirname } from 'node:path';
|
import { join, resolve, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
import {
|
||||||
|
createIsolatedEnv,
|
||||||
|
updateIsolatedConfig,
|
||||||
|
type IsolatedEnv,
|
||||||
|
} from '../helpers/isolated-env';
|
||||||
import { createTestRepo, type TestRepo } from '../helpers/test-repo';
|
import { createTestRepo, type TestRepo } from '../helpers/test-repo';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@ -50,18 +54,12 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => {
|
|||||||
isolatedEnv = createIsolatedEnv();
|
isolatedEnv = createIsolatedEnv();
|
||||||
testRepo = createTestRepo();
|
testRepo = createTestRepo();
|
||||||
|
|
||||||
writeFileSync(
|
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||||
join(isolatedEnv.taktDir, 'config.yaml'),
|
provider: 'mock',
|
||||||
[
|
model: 'mock-model',
|
||||||
'provider: mock',
|
concurrency: 2,
|
||||||
'model: mock-model',
|
task_poll_interval_ms: 100,
|
||||||
'language: en',
|
});
|
||||||
'log_level: info',
|
|
||||||
'default_piece: default',
|
|
||||||
'concurrency: 2',
|
|
||||||
'task_poll_interval_ms: 100',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@ -5,7 +5,11 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { createIsolatedEnv, type IsolatedEnv } from '../helpers/isolated-env';
|
import {
|
||||||
|
createIsolatedEnv,
|
||||||
|
updateIsolatedConfig,
|
||||||
|
type IsolatedEnv,
|
||||||
|
} from '../helpers/isolated-env';
|
||||||
import { runTakt } from '../helpers/takt-runner';
|
import { runTakt } from '../helpers/takt-runner';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@ -38,15 +42,9 @@ describe('E2E: Task content_file reference (mock)', () => {
|
|||||||
isolatedEnv = createIsolatedEnv();
|
isolatedEnv = createIsolatedEnv();
|
||||||
repo = createLocalRepo();
|
repo = createLocalRepo();
|
||||||
|
|
||||||
writeFileSync(
|
updateIsolatedConfig(isolatedEnv.taktDir, {
|
||||||
join(isolatedEnv.taktDir, 'config.yaml'),
|
provider: 'mock',
|
||||||
[
|
});
|
||||||
'provider: mock',
|
|
||||||
'language: en',
|
|
||||||
'log_level: info',
|
|
||||||
'default_piece: default',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
import { injectProviderArgs } from '../../e2e/helpers/takt-runner.js';
|
import { injectProviderArgs } from '../../e2e/helpers/takt-runner.js';
|
||||||
import { createIsolatedEnv } from '../../e2e/helpers/isolated-env.js';
|
import {
|
||||||
|
createIsolatedEnv,
|
||||||
|
updateIsolatedConfig,
|
||||||
|
} from '../../e2e/helpers/isolated-env.js';
|
||||||
|
|
||||||
describe('injectProviderArgs', () => {
|
describe('injectProviderArgs', () => {
|
||||||
it('should prepend --provider when provider is specified', () => {
|
it('should prepend --provider when provider is specified', () => {
|
||||||
@ -70,4 +75,112 @@ describe('createIsolatedEnv', () => {
|
|||||||
expect(isolated.env.GIT_CONFIG_GLOBAL).toBeDefined();
|
expect(isolated.env.GIT_CONFIG_GLOBAL).toBeDefined();
|
||||||
expect(isolated.env.GIT_CONFIG_GLOBAL).toContain('takt-e2e-');
|
expect(isolated.env.GIT_CONFIG_GLOBAL).toContain('takt-e2e-');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should create config.yaml from E2E fixture with notification_sound timing controls', () => {
|
||||||
|
const isolated = createIsolatedEnv();
|
||||||
|
cleanups.push(isolated.cleanup);
|
||||||
|
|
||||||
|
const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8');
|
||||||
|
const config = parseYaml(configRaw) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(config.language).toBe('en');
|
||||||
|
expect(config.log_level).toBe('info');
|
||||||
|
expect(config.default_piece).toBe('default');
|
||||||
|
expect(config.notification_sound).toBe(true);
|
||||||
|
expect(config.notification_sound_events).toEqual({
|
||||||
|
iteration_limit: false,
|
||||||
|
piece_complete: false,
|
||||||
|
piece_abort: false,
|
||||||
|
run_complete: true,
|
||||||
|
run_abort: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should override provider in config.yaml when TAKT_E2E_PROVIDER is set', () => {
|
||||||
|
process.env = { ...originalEnv, TAKT_E2E_PROVIDER: 'mock' };
|
||||||
|
const isolated = createIsolatedEnv();
|
||||||
|
cleanups.push(isolated.cleanup);
|
||||||
|
|
||||||
|
const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8');
|
||||||
|
const config = parseYaml(configRaw) as Record<string, unknown>;
|
||||||
|
expect(config.provider).toBe('mock');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve base settings when updateIsolatedConfig applies patch', () => {
|
||||||
|
const isolated = createIsolatedEnv();
|
||||||
|
cleanups.push(isolated.cleanup);
|
||||||
|
|
||||||
|
updateIsolatedConfig(isolated.taktDir, {
|
||||||
|
provider: 'mock',
|
||||||
|
concurrency: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8');
|
||||||
|
const config = parseYaml(configRaw) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(config.provider).toBe('mock');
|
||||||
|
expect(config.concurrency).toBe(2);
|
||||||
|
expect(config.notification_sound).toBe(true);
|
||||||
|
expect(config.notification_sound_events).toEqual({
|
||||||
|
iteration_limit: false,
|
||||||
|
piece_complete: false,
|
||||||
|
piece_abort: false,
|
||||||
|
run_complete: true,
|
||||||
|
run_abort: true,
|
||||||
|
});
|
||||||
|
expect(config.language).toBe('en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deep-merge notification_sound_events patch and preserve unspecified keys', () => {
|
||||||
|
const isolated = createIsolatedEnv();
|
||||||
|
cleanups.push(isolated.cleanup);
|
||||||
|
|
||||||
|
updateIsolatedConfig(isolated.taktDir, {
|
||||||
|
notification_sound_events: {
|
||||||
|
run_complete: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const configRaw = readFileSync(`${isolated.taktDir}/config.yaml`, 'utf-8');
|
||||||
|
const config = parseYaml(configRaw) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(config.notification_sound_events).toEqual({
|
||||||
|
iteration_limit: false,
|
||||||
|
piece_complete: false,
|
||||||
|
piece_abort: false,
|
||||||
|
run_complete: false,
|
||||||
|
run_abort: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when patch.notification_sound_events is not an object', () => {
|
||||||
|
const isolated = createIsolatedEnv();
|
||||||
|
cleanups.push(isolated.cleanup);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
updateIsolatedConfig(isolated.taktDir, {
|
||||||
|
notification_sound_events: true,
|
||||||
|
});
|
||||||
|
}).toThrow('Invalid notification_sound_events in patch: expected object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when current config notification_sound_events is invalid', () => {
|
||||||
|
const isolated = createIsolatedEnv();
|
||||||
|
cleanups.push(isolated.cleanup);
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
`${isolated.taktDir}/config.yaml`,
|
||||||
|
[
|
||||||
|
'language: en',
|
||||||
|
'log_level: info',
|
||||||
|
'default_piece: default',
|
||||||
|
'notification_sound: true',
|
||||||
|
'notification_sound_events: true',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
updateIsolatedConfig(isolated.taktDir, { provider: 'mock' });
|
||||||
|
}).toThrow('Invalid notification_sound_events in current config: expected object');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -287,6 +287,59 @@ describe('loadGlobalConfig', () => {
|
|||||||
expect(config.notificationSound).toBeUndefined();
|
expect(config.notificationSound).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should load notification_sound_events config from config.yaml', () => {
|
||||||
|
const taktDir = join(testHomeDir, '.takt');
|
||||||
|
mkdirSync(taktDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
getGlobalConfigPath(),
|
||||||
|
[
|
||||||
|
'language: en',
|
||||||
|
'notification_sound_events:',
|
||||||
|
' iteration_limit: false',
|
||||||
|
' piece_complete: true',
|
||||||
|
' piece_abort: true',
|
||||||
|
' run_complete: true',
|
||||||
|
' run_abort: false',
|
||||||
|
].join('\n'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
expect(config.notificationSoundEvents).toEqual({
|
||||||
|
iterationLimit: false,
|
||||||
|
pieceComplete: true,
|
||||||
|
pieceAbort: true,
|
||||||
|
runComplete: true,
|
||||||
|
runAbort: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save and reload notification_sound_events config', () => {
|
||||||
|
const taktDir = join(testHomeDir, '.takt');
|
||||||
|
mkdirSync(taktDir, { recursive: true });
|
||||||
|
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||||
|
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
config.notificationSoundEvents = {
|
||||||
|
iterationLimit: false,
|
||||||
|
pieceComplete: true,
|
||||||
|
pieceAbort: false,
|
||||||
|
runComplete: true,
|
||||||
|
runAbort: true,
|
||||||
|
};
|
||||||
|
saveGlobalConfig(config);
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
|
||||||
|
const reloaded = loadGlobalConfig();
|
||||||
|
expect(reloaded.notificationSoundEvents).toEqual({
|
||||||
|
iterationLimit: false,
|
||||||
|
pieceComplete: true,
|
||||||
|
pieceAbort: false,
|
||||||
|
runComplete: true,
|
||||||
|
runAbort: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should load interactive_preview_movements config from config.yaml', () => {
|
it('should load interactive_preview_movements config from config.yaml', () => {
|
||||||
const taktDir = join(testHomeDir, '.takt');
|
const taktDir = join(testHomeDir, '.takt');
|
||||||
mkdirSync(taktDir, { recursive: true });
|
mkdirSync(taktDir, { recursive: true });
|
||||||
|
|||||||
@ -282,6 +282,22 @@ describe('executePiece: notification sound behavior', () => {
|
|||||||
|
|
||||||
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should NOT call notifySuccess when piece_complete event is disabled', async () => {
|
||||||
|
mockLoadGlobalConfig.mockReturnValue({
|
||||||
|
provider: 'claude',
|
||||||
|
notificationSound: true,
|
||||||
|
notificationSoundEvents: { pieceComplete: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
MockPieceEngine.latestInstance!.complete();
|
||||||
|
await resultPromise;
|
||||||
|
|
||||||
|
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('notifyError on piece:abort', () => {
|
describe('notifyError on piece:abort', () => {
|
||||||
@ -320,6 +336,22 @@ describe('executePiece: notification sound behavior', () => {
|
|||||||
|
|
||||||
expect(mockNotifyError).not.toHaveBeenCalled();
|
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should NOT call notifyError when piece_abort event is disabled', async () => {
|
||||||
|
mockLoadGlobalConfig.mockReturnValue({
|
||||||
|
provider: 'claude',
|
||||||
|
notificationSound: true,
|
||||||
|
notificationSoundEvents: { pieceAbort: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
MockPieceEngine.latestInstance!.abort();
|
||||||
|
await resultPromise;
|
||||||
|
|
||||||
|
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('playWarningSound on iteration limit', () => {
|
describe('playWarningSound on iteration limit', () => {
|
||||||
@ -361,5 +393,22 @@ describe('executePiece: notification sound behavior', () => {
|
|||||||
|
|
||||||
expect(mockPlayWarningSound).not.toHaveBeenCalled();
|
expect(mockPlayWarningSound).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should NOT call playWarningSound when iteration_limit event is disabled', async () => {
|
||||||
|
mockLoadGlobalConfig.mockReturnValue({
|
||||||
|
provider: 'claude',
|
||||||
|
notificationSound: true,
|
||||||
|
notificationSoundEvents: { iterationLimit: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
await MockPieceEngine.latestInstance!.triggerIterationLimit();
|
||||||
|
MockPieceEngine.latestInstance!.abort();
|
||||||
|
await resultPromise;
|
||||||
|
|
||||||
|
expect(mockPlayWarningSound).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -21,10 +21,21 @@ vi.mock('../infra/config/index.js', () => ({
|
|||||||
import { loadGlobalConfig } from '../infra/config/index.js';
|
import { loadGlobalConfig } from '../infra/config/index.js';
|
||||||
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
|
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
|
||||||
|
|
||||||
const mockClaimNextTasks = vi.fn();
|
const {
|
||||||
const mockCompleteTask = vi.fn();
|
mockClaimNextTasks,
|
||||||
const mockFailTask = vi.fn();
|
mockCompleteTask,
|
||||||
const mockRecoverInterruptedRunningTasks = vi.fn();
|
mockFailTask,
|
||||||
|
mockRecoverInterruptedRunningTasks,
|
||||||
|
mockNotifySuccess,
|
||||||
|
mockNotifyError,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockClaimNextTasks: vi.fn(),
|
||||||
|
mockCompleteTask: vi.fn(),
|
||||||
|
mockFailTask: vi.fn(),
|
||||||
|
mockRecoverInterruptedRunningTasks: vi.fn(),
|
||||||
|
mockNotifySuccess: vi.fn(),
|
||||||
|
mockNotifyError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
||||||
...(await importOriginal<Record<string, unknown>>()),
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
@ -75,6 +86,8 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
}),
|
}),
|
||||||
getErrorMessage: vi.fn((e) => e.message),
|
getErrorMessage: vi.fn((e) => e.message),
|
||||||
|
notifySuccess: mockNotifySuccess,
|
||||||
|
notifyError: mockNotifyError,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
|
vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
|
||||||
@ -149,6 +162,8 @@ describe('runAllTasks concurrency', () => {
|
|||||||
language: 'en',
|
language: 'en',
|
||||||
defaultPiece: 'default',
|
defaultPiece: 'default',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
|
notificationSound: true,
|
||||||
|
notificationSoundEvents: { runComplete: true, runAbort: true },
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
taskPollIntervalMs: 500,
|
taskPollIntervalMs: 500,
|
||||||
});
|
});
|
||||||
@ -190,6 +205,8 @@ describe('runAllTasks concurrency', () => {
|
|||||||
language: 'en',
|
language: 'en',
|
||||||
defaultPiece: 'default',
|
defaultPiece: 'default',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
|
notificationSound: true,
|
||||||
|
notificationSoundEvents: { runComplete: true, runAbort: true },
|
||||||
concurrency: 3,
|
concurrency: 3,
|
||||||
taskPollIntervalMs: 500,
|
taskPollIntervalMs: 500,
|
||||||
});
|
});
|
||||||
@ -266,6 +283,7 @@ describe('runAllTasks concurrency', () => {
|
|||||||
language: 'en',
|
language: 'en',
|
||||||
defaultPiece: 'default',
|
defaultPiece: 'default',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
|
notificationSound: false,
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
taskPollIntervalMs: 500,
|
taskPollIntervalMs: 500,
|
||||||
});
|
});
|
||||||
@ -283,6 +301,8 @@ describe('runAllTasks concurrency', () => {
|
|||||||
(call) => typeof call[0] === 'string' && call[0].startsWith('Concurrency:')
|
(call) => typeof call[0] === 'string' && call[0].startsWith('Concurrency:')
|
||||||
);
|
);
|
||||||
expect(concurrencyInfoCalls).toHaveLength(0);
|
expect(concurrencyInfoCalls).toHaveLength(0);
|
||||||
|
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||||
|
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -384,6 +404,16 @@ describe('runAllTasks concurrency', () => {
|
|||||||
|
|
||||||
it('should count partial failures correctly', async () => {
|
it('should count partial failures correctly', async () => {
|
||||||
// Given: 3 tasks, 1 fails, 2 succeed
|
// Given: 3 tasks, 1 fails, 2 succeed
|
||||||
|
mockLoadGlobalConfig.mockReturnValue({
|
||||||
|
language: 'en',
|
||||||
|
defaultPiece: 'default',
|
||||||
|
logLevel: 'info',
|
||||||
|
notificationSound: true,
|
||||||
|
notificationSoundEvents: { runAbort: true },
|
||||||
|
concurrency: 3,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
|
});
|
||||||
|
|
||||||
const task1 = createTask('pass-1');
|
const task1 = createTask('pass-1');
|
||||||
const task2 = createTask('fail-1');
|
const task2 = createTask('fail-1');
|
||||||
const task3 = createTask('pass-2');
|
const task3 = createTask('pass-2');
|
||||||
@ -406,6 +436,8 @@ describe('runAllTasks concurrency', () => {
|
|||||||
expect(mockStatus).toHaveBeenCalledWith('Total', '3');
|
expect(mockStatus).toHaveBeenCalledWith('Total', '3');
|
||||||
expect(mockStatus).toHaveBeenCalledWith('Success', '2', undefined);
|
expect(mockStatus).toHaveBeenCalledWith('Success', '2', undefined);
|
||||||
expect(mockStatus).toHaveBeenCalledWith('Failed', '1', 'red');
|
expect(mockStatus).toHaveBeenCalledWith('Failed', '1', 'red');
|
||||||
|
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||||
|
expect(mockNotifyError).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should persist failure reason and movement when piece aborts', async () => {
|
it('should persist failure reason and movement when piece aborts', async () => {
|
||||||
@ -458,6 +490,8 @@ describe('runAllTasks concurrency', () => {
|
|||||||
language: 'en',
|
language: 'en',
|
||||||
defaultPiece: 'default',
|
defaultPiece: 'default',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
|
notificationSound: true,
|
||||||
|
notificationSoundEvents: { runComplete: true, runAbort: true },
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
taskPollIntervalMs: 500,
|
taskPollIntervalMs: 500,
|
||||||
});
|
});
|
||||||
@ -480,5 +514,145 @@ describe('runAllTasks concurrency', () => {
|
|||||||
expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal);
|
expect(pieceOptions?.abortSignal).toBeInstanceOf(AbortSignal);
|
||||||
expect(pieceOptions?.taskPrefix).toBeUndefined();
|
expect(pieceOptions?.taskPrefix).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should only notify once at run completion when multiple tasks succeed', async () => {
|
||||||
|
mockLoadGlobalConfig.mockReturnValue({
|
||||||
|
language: 'en',
|
||||||
|
defaultPiece: 'default',
|
||||||
|
logLevel: 'info',
|
||||||
|
notificationSound: true,
|
||||||
|
notificationSoundEvents: { runComplete: true },
|
||||||
|
concurrency: 3,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const task1 = createTask('task-1');
|
||||||
|
const task2 = createTask('task-2');
|
||||||
|
const task3 = createTask('task-3');
|
||||||
|
|
||||||
|
mockClaimNextTasks
|
||||||
|
.mockReturnValueOnce([task1, task2, task3])
|
||||||
|
.mockReturnValueOnce([]);
|
||||||
|
|
||||||
|
await runAllTasks('/project');
|
||||||
|
|
||||||
|
expect(mockNotifySuccess).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not notify run completion when runComplete is explicitly false', async () => {
|
||||||
|
mockLoadGlobalConfig.mockReturnValue({
|
||||||
|
language: 'en',
|
||||||
|
defaultPiece: 'default',
|
||||||
|
logLevel: 'info',
|
||||||
|
notificationSound: true,
|
||||||
|
notificationSoundEvents: { runComplete: false },
|
||||||
|
concurrency: 1,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const task1 = createTask('task-1');
|
||||||
|
mockClaimNextTasks
|
||||||
|
.mockReturnValueOnce([task1])
|
||||||
|
.mockReturnValueOnce([]);
|
||||||
|
|
||||||
|
await runAllTasks('/project');
|
||||||
|
|
||||||
|
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||||
|
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify run completion by default when notification_sound_events is not set', async () => {
|
||||||
|
mockLoadGlobalConfig.mockReturnValue({
|
||||||
|
language: 'en',
|
||||||
|
defaultPiece: 'default',
|
||||||
|
logLevel: 'info',
|
||||||
|
notificationSound: true,
|
||||||
|
concurrency: 1,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const task1 = createTask('task-1');
|
||||||
|
mockClaimNextTasks
|
||||||
|
.mockReturnValueOnce([task1])
|
||||||
|
.mockReturnValueOnce([]);
|
||||||
|
|
||||||
|
await runAllTasks('/project');
|
||||||
|
|
||||||
|
expect(mockNotifySuccess).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockNotifySuccess).toHaveBeenCalledWith('TAKT', 'run.notifyComplete');
|
||||||
|
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify run abort by default when notification_sound_events is not set', async () => {
|
||||||
|
mockLoadGlobalConfig.mockReturnValue({
|
||||||
|
language: 'en',
|
||||||
|
defaultPiece: 'default',
|
||||||
|
logLevel: 'info',
|
||||||
|
notificationSound: true,
|
||||||
|
concurrency: 1,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const task1 = createTask('task-1');
|
||||||
|
mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' });
|
||||||
|
mockClaimNextTasks
|
||||||
|
.mockReturnValueOnce([task1])
|
||||||
|
.mockReturnValueOnce([]);
|
||||||
|
|
||||||
|
await runAllTasks('/project');
|
||||||
|
|
||||||
|
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||||
|
expect(mockNotifyError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not notify run abort when runAbort is explicitly false', async () => {
|
||||||
|
mockLoadGlobalConfig.mockReturnValue({
|
||||||
|
language: 'en',
|
||||||
|
defaultPiece: 'default',
|
||||||
|
logLevel: 'info',
|
||||||
|
notificationSound: true,
|
||||||
|
notificationSoundEvents: { runAbort: false },
|
||||||
|
concurrency: 1,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const task1 = createTask('task-1');
|
||||||
|
mockExecutePiece.mockResolvedValueOnce({ success: false, reason: 'failed' });
|
||||||
|
mockClaimNextTasks
|
||||||
|
.mockReturnValueOnce([task1])
|
||||||
|
.mockReturnValueOnce([]);
|
||||||
|
|
||||||
|
await runAllTasks('/project');
|
||||||
|
|
||||||
|
expect(mockNotifySuccess).not.toHaveBeenCalled();
|
||||||
|
expect(mockNotifyError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify run abort and rethrow when worker pool throws', async () => {
|
||||||
|
mockLoadGlobalConfig.mockReturnValue({
|
||||||
|
language: 'en',
|
||||||
|
defaultPiece: 'default',
|
||||||
|
logLevel: 'info',
|
||||||
|
notificationSound: true,
|
||||||
|
notificationSoundEvents: { runAbort: true },
|
||||||
|
concurrency: 1,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const task1 = createTask('task-1');
|
||||||
|
const poolError = new Error('worker pool crashed');
|
||||||
|
|
||||||
|
mockClaimNextTasks
|
||||||
|
.mockReturnValueOnce([task1])
|
||||||
|
.mockImplementationOnce(() => {
|
||||||
|
throw poolError;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(runAllTasks('/project')).rejects.toThrow('worker pool crashed');
|
||||||
|
expect(mockNotifyError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockNotifyError).toHaveBeenCalledWith('TAKT', 'run.notifyAbort');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -33,6 +33,20 @@ export interface PipelineConfig {
|
|||||||
prBodyTemplate?: string;
|
prBodyTemplate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Notification sound toggles per event timing */
|
||||||
|
export interface NotificationSoundEventsConfig {
|
||||||
|
/** Warning when iteration limit is reached */
|
||||||
|
iterationLimit?: boolean;
|
||||||
|
/** Success notification when piece execution completes */
|
||||||
|
pieceComplete?: boolean;
|
||||||
|
/** Error notification when piece execution aborts */
|
||||||
|
pieceAbort?: boolean;
|
||||||
|
/** Success notification when runAllTasks finishes without failures */
|
||||||
|
runComplete?: boolean;
|
||||||
|
/** Error notification when runAllTasks finishes with failures or aborts */
|
||||||
|
runAbort?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/** Global configuration for takt */
|
/** Global configuration for takt */
|
||||||
export interface GlobalConfig {
|
export interface GlobalConfig {
|
||||||
language: Language;
|
language: Language;
|
||||||
@ -69,6 +83,8 @@ export interface GlobalConfig {
|
|||||||
preventSleep?: boolean;
|
preventSleep?: boolean;
|
||||||
/** Enable notification sounds (default: true when undefined) */
|
/** Enable notification sounds (default: true when undefined) */
|
||||||
notificationSound?: boolean;
|
notificationSound?: boolean;
|
||||||
|
/** Notification sound toggles per event timing */
|
||||||
|
notificationSoundEvents?: NotificationSoundEventsConfig;
|
||||||
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
|
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
|
||||||
interactivePreviewMovements?: number;
|
interactivePreviewMovements?: number;
|
||||||
/** Number of tasks to run concurrently in takt run (default: 1 = sequential) */
|
/** Number of tasks to run concurrently in takt run (default: 1 = sequential) */
|
||||||
|
|||||||
@ -369,6 +369,14 @@ export const GlobalConfigSchema = z.object({
|
|||||||
prevent_sleep: z.boolean().optional(),
|
prevent_sleep: z.boolean().optional(),
|
||||||
/** Enable notification sounds (default: true when undefined) */
|
/** Enable notification sounds (default: true when undefined) */
|
||||||
notification_sound: z.boolean().optional(),
|
notification_sound: z.boolean().optional(),
|
||||||
|
/** Notification sound toggles per event timing */
|
||||||
|
notification_sound_events: z.object({
|
||||||
|
iteration_limit: z.boolean().optional(),
|
||||||
|
piece_complete: z.boolean().optional(),
|
||||||
|
piece_abort: z.boolean().optional(),
|
||||||
|
run_complete: z.boolean().optional(),
|
||||||
|
run_abort: z.boolean().optional(),
|
||||||
|
}).optional(),
|
||||||
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
|
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
|
||||||
interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3),
|
interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3),
|
||||||
/** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */
|
/** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */
|
||||||
|
|||||||
@ -296,6 +296,10 @@ export async function executePiece(
|
|||||||
const isWorktree = cwd !== projectCwd;
|
const isWorktree = cwd !== projectCwd;
|
||||||
const globalConfig = loadGlobalConfig();
|
const globalConfig = loadGlobalConfig();
|
||||||
const shouldNotify = globalConfig.notificationSound !== false;
|
const shouldNotify = globalConfig.notificationSound !== false;
|
||||||
|
const notificationSoundEvents = globalConfig.notificationSoundEvents;
|
||||||
|
const shouldNotifyIterationLimit = shouldNotify && notificationSoundEvents?.iterationLimit !== false;
|
||||||
|
const shouldNotifyPieceComplete = shouldNotify && notificationSoundEvents?.pieceComplete !== false;
|
||||||
|
const shouldNotifyPieceAbort = shouldNotify && notificationSoundEvents?.pieceAbort !== false;
|
||||||
const currentProvider = globalConfig.provider ?? 'claude';
|
const currentProvider = globalConfig.provider ?? 'claude';
|
||||||
|
|
||||||
// Prevent macOS idle sleep if configured
|
// Prevent macOS idle sleep if configured
|
||||||
@ -333,7 +337,7 @@ export async function executePiece(
|
|||||||
);
|
);
|
||||||
out.info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement }));
|
out.info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement }));
|
||||||
|
|
||||||
if (shouldNotify) {
|
if (shouldNotifyIterationLimit) {
|
||||||
playWarningSound();
|
playWarningSound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -613,7 +617,7 @@ export async function executePiece(
|
|||||||
|
|
||||||
out.success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`);
|
out.success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`);
|
||||||
out.info(`Session log: ${ndjsonLogPath}`);
|
out.info(`Session log: ${ndjsonLogPath}`);
|
||||||
if (shouldNotify) {
|
if (shouldNotifyPieceComplete) {
|
||||||
notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) }));
|
notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -661,7 +665,7 @@ export async function executePiece(
|
|||||||
|
|
||||||
out.error(`Piece aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`);
|
out.error(`Piece aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`);
|
||||||
out.info(`Session log: ${ndjsonLogPath}`);
|
out.info(`Session log: ${ndjsonLogPath}`);
|
||||||
if (shouldNotify) {
|
if (shouldNotifyPieceAbort) {
|
||||||
notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason }));
|
notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,7 +12,8 @@ import {
|
|||||||
status,
|
status,
|
||||||
blankLine,
|
blankLine,
|
||||||
} from '../../../shared/ui/index.js';
|
} from '../../../shared/ui/index.js';
|
||||||
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
import { createLogger, getErrorMessage, notifyError, notifySuccess } from '../../../shared/utils/index.js';
|
||||||
|
import { getLabel } from '../../../shared/i18n/index.js';
|
||||||
import { executePiece } from './pieceExecution.js';
|
import { executePiece } from './pieceExecution.js';
|
||||||
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
||||||
import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
|
import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
|
||||||
@ -241,6 +242,10 @@ export async function runAllTasks(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const taskRunner = new TaskRunner(cwd);
|
const taskRunner = new TaskRunner(cwd);
|
||||||
const globalConfig = loadGlobalConfig();
|
const globalConfig = loadGlobalConfig();
|
||||||
|
const shouldNotifyRunComplete = globalConfig.notificationSound !== false
|
||||||
|
&& globalConfig.notificationSoundEvents?.runComplete !== false;
|
||||||
|
const shouldNotifyRunAbort = globalConfig.notificationSound !== false
|
||||||
|
&& globalConfig.notificationSoundEvents?.runAbort !== false;
|
||||||
const concurrency = globalConfig.concurrency;
|
const concurrency = globalConfig.concurrency;
|
||||||
const recovered = taskRunner.recoverInterruptedRunningTasks();
|
const recovered = taskRunner.recoverInterruptedRunningTasks();
|
||||||
if (recovered > 0) {
|
if (recovered > 0) {
|
||||||
@ -260,6 +265,7 @@ export async function runAllTasks(
|
|||||||
info(`Concurrency: ${concurrency}`);
|
info(`Concurrency: ${concurrency}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs);
|
const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs);
|
||||||
|
|
||||||
const totalCount = result.success + result.fail;
|
const totalCount = result.success + result.fail;
|
||||||
@ -269,6 +275,20 @@ export async function runAllTasks(
|
|||||||
status('Success', String(result.success), result.success === totalCount ? 'green' : undefined);
|
status('Success', String(result.success), result.success === totalCount ? 'green' : undefined);
|
||||||
if (result.fail > 0) {
|
if (result.fail > 0) {
|
||||||
status('Failed', String(result.fail), 'red');
|
status('Failed', String(result.fail), 'red');
|
||||||
|
if (shouldNotifyRunAbort) {
|
||||||
|
notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: String(result.fail) }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldNotifyRunComplete) {
|
||||||
|
notifySuccess('TAKT', getLabel('run.notifyComplete', undefined, { total: String(totalCount) }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (shouldNotifyRunAbort) {
|
||||||
|
notifyError('TAKT', getLabel('run.notifyAbort', undefined, { failed: getErrorMessage(e) }));
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -110,6 +110,13 @@ export class GlobalConfigManager {
|
|||||||
branchNameStrategy: parsed.branch_name_strategy,
|
branchNameStrategy: parsed.branch_name_strategy,
|
||||||
preventSleep: parsed.prevent_sleep,
|
preventSleep: parsed.prevent_sleep,
|
||||||
notificationSound: parsed.notification_sound,
|
notificationSound: parsed.notification_sound,
|
||||||
|
notificationSoundEvents: parsed.notification_sound_events ? {
|
||||||
|
iterationLimit: parsed.notification_sound_events.iteration_limit,
|
||||||
|
pieceComplete: parsed.notification_sound_events.piece_complete,
|
||||||
|
pieceAbort: parsed.notification_sound_events.piece_abort,
|
||||||
|
runComplete: parsed.notification_sound_events.run_complete,
|
||||||
|
runAbort: parsed.notification_sound_events.run_abort,
|
||||||
|
} : undefined,
|
||||||
interactivePreviewMovements: parsed.interactive_preview_movements,
|
interactivePreviewMovements: parsed.interactive_preview_movements,
|
||||||
concurrency: parsed.concurrency,
|
concurrency: parsed.concurrency,
|
||||||
taskPollIntervalMs: parsed.task_poll_interval_ms,
|
taskPollIntervalMs: parsed.task_poll_interval_ms,
|
||||||
@ -185,6 +192,27 @@ export class GlobalConfigManager {
|
|||||||
if (config.notificationSound !== undefined) {
|
if (config.notificationSound !== undefined) {
|
||||||
raw.notification_sound = config.notificationSound;
|
raw.notification_sound = config.notificationSound;
|
||||||
}
|
}
|
||||||
|
if (config.notificationSoundEvents) {
|
||||||
|
const eventRaw: Record<string, unknown> = {};
|
||||||
|
if (config.notificationSoundEvents.iterationLimit !== undefined) {
|
||||||
|
eventRaw.iteration_limit = config.notificationSoundEvents.iterationLimit;
|
||||||
|
}
|
||||||
|
if (config.notificationSoundEvents.pieceComplete !== undefined) {
|
||||||
|
eventRaw.piece_complete = config.notificationSoundEvents.pieceComplete;
|
||||||
|
}
|
||||||
|
if (config.notificationSoundEvents.pieceAbort !== undefined) {
|
||||||
|
eventRaw.piece_abort = config.notificationSoundEvents.pieceAbort;
|
||||||
|
}
|
||||||
|
if (config.notificationSoundEvents.runComplete !== undefined) {
|
||||||
|
eventRaw.run_complete = config.notificationSoundEvents.runComplete;
|
||||||
|
}
|
||||||
|
if (config.notificationSoundEvents.runAbort !== undefined) {
|
||||||
|
eventRaw.run_abort = config.notificationSoundEvents.runAbort;
|
||||||
|
}
|
||||||
|
if (Object.keys(eventRaw).length > 0) {
|
||||||
|
raw.notification_sound_events = eventRaw;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (config.interactivePreviewMovements !== undefined) {
|
if (config.interactivePreviewMovements !== undefined) {
|
||||||
raw.interactive_preview_movements = config.interactivePreviewMovements;
|
raw.interactive_preview_movements = config.interactivePreviewMovements;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,3 +58,7 @@ piece:
|
|||||||
notifyAbort: "Aborted: {reason}"
|
notifyAbort: "Aborted: {reason}"
|
||||||
sigintGraceful: "Ctrl+C: Aborting piece..."
|
sigintGraceful: "Ctrl+C: Aborting piece..."
|
||||||
sigintForce: "Ctrl+C: Force exit"
|
sigintForce: "Ctrl+C: Force exit"
|
||||||
|
|
||||||
|
run:
|
||||||
|
notifyComplete: "Run complete ({total} tasks)"
|
||||||
|
notifyAbort: "Run finished with errors ({failed})"
|
||||||
|
|||||||
@ -58,3 +58,7 @@ piece:
|
|||||||
notifyAbort: "中断: {reason}"
|
notifyAbort: "中断: {reason}"
|
||||||
sigintGraceful: "Ctrl+C: ピースを中断しています..."
|
sigintGraceful: "Ctrl+C: ピースを中断しています..."
|
||||||
sigintForce: "Ctrl+C: 強制終了します"
|
sigintForce: "Ctrl+C: 強制終了します"
|
||||||
|
|
||||||
|
run:
|
||||||
|
notifyComplete: "run完了 ({total} tasks)"
|
||||||
|
notifyAbort: "runはエラー終了 ({failed})"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user