diff --git a/src/__tests__/normalizeRuntime.test.ts b/src/__tests__/normalizeRuntime.test.ts new file mode 100644 index 0000000..c48f007 --- /dev/null +++ b/src/__tests__/normalizeRuntime.test.ts @@ -0,0 +1,60 @@ +/** + * Unit tests for normalizeRuntime() shared normalizer function. + * + * Tests dedup, empty-to-undefined normalization, and passthrough behavior. + */ + +import { describe, it, expect } from 'vitest'; +import { normalizeRuntime } from '../infra/config/configNormalizers.js'; + +describe('normalizeRuntime', () => { + it('should return undefined when input is undefined', () => { + const result = normalizeRuntime(undefined); + expect(result).toBeUndefined(); + }); + + it('should return undefined when prepare is not specified', () => { + const result = normalizeRuntime({}); + expect(result).toBeUndefined(); + }); + + it('should return undefined when prepare is undefined', () => { + const result = normalizeRuntime({ prepare: undefined }); + expect(result).toBeUndefined(); + }); + + it('should return undefined when prepare is empty array', () => { + const result = normalizeRuntime({ prepare: [] }); + expect(result).toBeUndefined(); + }); + + it('should pass through single preset entry', () => { + const result = normalizeRuntime({ prepare: ['node'] }); + expect(result).toEqual({ prepare: ['node'] }); + }); + + it('should pass through multiple preset entries', () => { + const result = normalizeRuntime({ prepare: ['node', 'gradle'] }); + expect(result).toEqual({ prepare: ['node', 'gradle'] }); + }); + + it('should pass through custom script paths', () => { + const result = normalizeRuntime({ prepare: ['./setup.sh'] }); + expect(result).toEqual({ prepare: ['./setup.sh'] }); + }); + + it('should deduplicate repeated entries', () => { + const result = normalizeRuntime({ prepare: ['node', 'node'] }); + expect(result).toEqual({ prepare: ['node'] }); + }); + + it('should deduplicate while preserving first-occurrence order', () => { + const result = normalizeRuntime({ prepare: ['gradle', 'node', 'gradle'] }); + expect(result).toEqual({ prepare: ['gradle', 'node'] }); + }); + + it('should handle mixed presets and custom scripts', () => { + const result = normalizeRuntime({ prepare: ['node', 'gradle', './custom-setup.sh'] }); + expect(result).toEqual({ prepare: ['node', 'gradle', './custom-setup.sh'] }); + }); +}); diff --git a/src/__tests__/projectConfig.test.ts b/src/__tests__/projectConfig.test.ts index 06310c0..27a5964 100644 --- a/src/__tests__/projectConfig.test.ts +++ b/src/__tests__/projectConfig.test.ts @@ -352,4 +352,85 @@ piece_overrides: expect(() => loadProjectConfig(testDir)).not.toThrow(); }); }); + + describe('runtime.prepare round-trip', () => { + it('should load single preset entry', () => { + const configPath = join(testDir, '.takt', 'config.yaml'); + writeFileSync(configPath, 'runtime:\n prepare:\n - node\n', 'utf-8'); + + const loaded = loadProjectConfig(testDir); + expect(loaded.runtime).toEqual({ prepare: ['node'] }); + }); + + it('should load multiple preset entries', () => { + const configPath = join(testDir, '.takt', 'config.yaml'); + writeFileSync(configPath, 'runtime:\n prepare:\n - node\n - gradle\n', 'utf-8'); + + const loaded = loadProjectConfig(testDir); + expect(loaded.runtime).toEqual({ prepare: ['node', 'gradle'] }); + }); + + it('should load custom script paths', () => { + const configPath = join(testDir, '.takt', 'config.yaml'); + writeFileSync(configPath, 'runtime:\n prepare:\n - ./setup.sh\n', 'utf-8'); + + const loaded = loadProjectConfig(testDir); + expect(loaded.runtime).toEqual({ prepare: ['./setup.sh'] }); + }); + + it('should round-trip save and load', () => { + const config: ProjectLocalConfig = { + runtime: { prepare: ['node'] }, + }; + + saveProjectConfig(testDir, config); + const reloaded = loadProjectConfig(testDir); + + expect(reloaded.runtime).toEqual({ prepare: ['node'] }); + }); + + it('should deduplicate entries on load', () => { + const configPath = join(testDir, '.takt', 'config.yaml'); + writeFileSync(configPath, 'runtime:\n prepare:\n - node\n - node\n', 'utf-8'); + + const loaded = loadProjectConfig(testDir); + expect(loaded.runtime).toEqual({ prepare: ['node'] }); + }); + + it('should return undefined when runtime is not specified', () => { + const configPath = join(testDir, '.takt', 'config.yaml'); + writeFileSync(configPath, '{}\n', 'utf-8'); + + const loaded = loadProjectConfig(testDir); + expect(loaded.runtime).toBeUndefined(); + }); + + it('should return undefined when prepare is empty array', () => { + const configPath = join(testDir, '.takt', 'config.yaml'); + writeFileSync(configPath, 'runtime:\n prepare: []\n', 'utf-8'); + + const loaded = loadProjectConfig(testDir); + expect(loaded.runtime).toBeUndefined(); + }); + + it('should not serialize runtime when config has no runtime', () => { + const config: ProjectLocalConfig = {}; + + saveProjectConfig(testDir, config); + + const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8'); + expect(raw).not.toContain('runtime'); + }); + + it('should round-trip mixed presets and custom scripts', () => { + const config: ProjectLocalConfig = { + runtime: { prepare: ['node', 'gradle', './custom-setup.sh'] }, + }; + + saveProjectConfig(testDir, config); + const reloaded = loadProjectConfig(testDir); + + expect(reloaded.runtime).toEqual({ prepare: ['node', 'gradle', './custom-setup.sh'] }); + }); + }); }); diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index a428382..75cbb1c 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -585,6 +585,8 @@ export const ProjectConfigSchema = z.object({ minimal_output: z.boolean().optional(), provider_options: MovementProviderOptionsSchema, provider_profiles: ProviderPermissionProfilesSchema, + /** Project-level runtime environment configuration */ + runtime: RuntimeConfigSchema, /** Number of tasks to run concurrently in takt run (default from global: 1, max: 10) */ concurrency: z.number().int().min(1).max(10).optional(), /** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */ diff --git a/src/infra/config/configNormalizers.ts b/src/infra/config/configNormalizers.ts index 4bdb537..4d3cf11 100644 --- a/src/infra/config/configNormalizers.ts +++ b/src/infra/config/configNormalizers.ts @@ -1,14 +1,17 @@ -/** - * Shared normalizer/denormalizer functions for config snake_case <-> camelCase conversion. - * - * Used by both globalConfig.ts and projectConfig.ts. - */ - -import type { MovementProviderOptions } from '../../core/models/piece-types.js'; +import type { MovementProviderOptions, PieceRuntimeConfig } from '../../core/models/piece-types.js'; import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; import type { PieceOverrides, PersonaProviderEntry, PipelineConfig } from '../../core/models/persisted-global-config.js'; import { validateProviderModelCompatibility } from './providerModelCompatibility.js'; +export function normalizeRuntime( + runtime: { prepare?: string[] } | undefined, +): PieceRuntimeConfig | undefined { + if (!runtime?.prepare || runtime.prepare.length === 0) { + return undefined; + } + return { prepare: [...new Set(runtime.prepare)] }; +} + export function normalizeProviderProfiles( raw: Record }> | undefined, ): ProviderPermissionProfiles | undefined { diff --git a/src/infra/config/global/globalConfigCore.ts b/src/infra/config/global/globalConfigCore.ts index 0953970..b66744e 100644 --- a/src/infra/config/global/globalConfigCore.ts +++ b/src/infra/config/global/globalConfigCore.ts @@ -12,6 +12,7 @@ import { normalizePieceOverrides, denormalizePieceOverrides, denormalizeProviderOptions, + normalizeRuntime, } from '../configNormalizers.js'; import { getGlobalConfigPath } from '../paths.js'; import { applyGlobalConfigEnvOverrides } from '../env/config-env-overrides.js'; @@ -109,9 +110,7 @@ export class GlobalConfigManager { pieceCategoriesFile: parsed.piece_categories_file, providerOptions: normalizedProvider.providerOptions, providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record }> | undefined), - runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0 - ? { prepare: [...new Set(parsed.runtime.prepare)] } - : undefined, + runtime: normalizeRuntime(parsed.runtime), preventSleep: parsed.prevent_sleep, notificationSound: parsed.notification_sound, notificationSoundEvents: parsed.notification_sound_events ? { @@ -230,10 +229,9 @@ export class GlobalConfigManager { if (rawProviderProfiles && Object.keys(rawProviderProfiles).length > 0) { raw.provider_profiles = rawProviderProfiles; } - if (config.runtime?.prepare && config.runtime.prepare.length > 0) { - raw.runtime = { - prepare: [...new Set(config.runtime.prepare)], - }; + const normalizedRuntime = normalizeRuntime(config.runtime); + if (normalizedRuntime) { + raw.runtime = normalizedRuntime; } if (config.preventSleep !== undefined) { raw.prevent_sleep = config.preventSleep; diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 6868fbc..9e4038a 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -24,10 +24,8 @@ import { } from './resource-resolver.js'; type RawStep = z.output; -type RawPiece = z.output; - import type { MovementProviderOptions } from '../../../core/models/piece-types.js'; -import type { PieceRuntimeConfig } from '../../../core/models/piece-types.js'; +import { normalizeRuntime } from '../configNormalizers.js'; import type { PieceOverrides } from '../../../core/models/persisted-global-config.js'; import { applyQualityGateOverrides } from './qualityGateOverrides.js'; import { loadProjectConfig } from '../project/projectConfig.js'; @@ -54,16 +52,6 @@ function normalizeProviderReference( ); } -function normalizeRuntimeConfig(raw: RawPiece['piece_config']): PieceRuntimeConfig | undefined { - const prepare = raw?.runtime?.prepare; - if (!prepare || prepare.length === 0) { - return undefined; - } - return { - prepare: [...new Set(prepare)], - }; -} - /** * Normalize the raw output_contracts field from YAML into internal format. * @@ -399,7 +387,7 @@ export function normalizePieceConfig( const pieceProvider = normalizedPieceProvider.provider; const pieceModel = normalizedPieceProvider.model; const pieceProviderOptions = normalizedPieceProvider.providerOptions; - const pieceRuntime = normalizeRuntimeConfig(parsed.piece_config); + const pieceRuntime = normalizeRuntime(parsed.piece_config?.runtime); const movements: PieceMovement[] = parsed.movements.map((step) => normalizeStepFromRaw(step, pieceDir, sections, pieceProvider, pieceModel, pieceProviderOptions, context, projectOverrides, globalOverrides), diff --git a/src/infra/config/project/projectConfig.ts b/src/infra/config/project/projectConfig.ts index e4f06ec..b4f29f9 100644 --- a/src/infra/config/project/projectConfig.ts +++ b/src/infra/config/project/projectConfig.ts @@ -16,6 +16,7 @@ import { normalizePersonaProviders, normalizePieceOverrides, denormalizePieceOverrides, + normalizeRuntime, } from '../configNormalizers.js'; import { invalidateResolvedConfigCache } from '../resolutionCache.js'; import { MIGRATED_PROJECT_LOCAL_DEFAULTS } from '../migratedProjectLocalDefaults.js'; @@ -101,6 +102,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { task_poll_interval_ms, interactive_preview_movements, piece_overrides, + runtime, ...rest } = parsedConfig; const normalizedProvider = normalizeConfigProviderReference( @@ -141,6 +143,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig { providerOptions: normalizedProvider.providerOptions, providerProfiles: normalizeProviderProfiles(provider_profiles as Record }> | undefined), pieceOverrides: normalizePieceOverrides(piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record } | undefined), + runtime: normalizeRuntime(runtime), }; } @@ -269,6 +272,13 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig } delete savePayload.pieceOverrides; + const normalizedRuntime = normalizeRuntime(config.runtime); + if (normalizedRuntime) { + savePayload.runtime = normalizedRuntime; + } else { + delete savePayload.runtime; + } + const content = stringify(savePayload, { indent: 2 }); writeFileSync(configPath, content, 'utf-8'); invalidateResolvedConfigCache(projectDir); diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index e5a6a5d..8ec543d 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -2,7 +2,7 @@ * Config module type definitions */ -import type { MovementProviderOptions } from '../../core/models/piece-types.js'; +import type { MovementProviderOptions, PieceRuntimeConfig } from '../../core/models/piece-types.js'; import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; import type { AnalyticsConfig, @@ -54,6 +54,8 @@ export interface ProjectLocalConfig { providerProfiles?: ProviderPermissionProfiles; /** Piece-level overrides (quality_gates, etc.) */ pieceOverrides?: PieceOverrides; + /** Runtime environment configuration (project-level override) */ + runtime?: PieceRuntimeConfig; } /** Persona session data for persistence */