1313 lines
42 KiB
TypeScript
1313 lines
42 KiB
TypeScript
/**
|
|
* Tests for config functions
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { randomUUID } from 'node:crypto';
|
|
import {
|
|
getBuiltinPiece,
|
|
loadAllPieces,
|
|
loadPiece,
|
|
listPieces,
|
|
loadPersonaPromptFromPath,
|
|
setCurrentPiece,
|
|
getProjectConfigDir,
|
|
getBuiltinPersonasDir,
|
|
loadInputHistory,
|
|
saveInputHistory,
|
|
addToInputHistory,
|
|
getInputHistoryPath,
|
|
MAX_INPUT_HISTORY,
|
|
// Persona session functions
|
|
type PersonaSessionData,
|
|
loadPersonaSessions,
|
|
updatePersonaSession,
|
|
getPersonaSessionsPath,
|
|
// Worktree session functions
|
|
getWorktreeSessionsDir,
|
|
encodeWorktreePath,
|
|
getWorktreeSessionPath,
|
|
loadWorktreeSessions,
|
|
updateWorktreeSession,
|
|
getLanguage,
|
|
loadProjectConfig,
|
|
saveProjectConfig,
|
|
isVerboseMode,
|
|
resolveConfigValue,
|
|
invalidateGlobalConfigCache,
|
|
} from '../infra/config/index.js';
|
|
|
|
describe('getBuiltinPiece', () => {
|
|
it('should return builtin piece when it exists in resources', () => {
|
|
const piece = getBuiltinPiece('default', process.cwd());
|
|
expect(piece).not.toBeNull();
|
|
expect(piece!.name).toBe('default');
|
|
});
|
|
|
|
it('should resolve builtin instruction_template without projectCwd', () => {
|
|
const piece = getBuiltinPiece('default', process.cwd());
|
|
expect(piece).not.toBeNull();
|
|
|
|
const planMovement = piece!.movements.find((movement) => movement.name === 'plan');
|
|
expect(planMovement).toBeDefined();
|
|
expect(planMovement!.instructionTemplate).not.toBe('plan');
|
|
});
|
|
|
|
it('should return null for non-existent piece names', () => {
|
|
expect(getBuiltinPiece('nonexistent-piece', process.cwd())).toBeNull();
|
|
expect(getBuiltinPiece('unknown', process.cwd())).toBeNull();
|
|
expect(getBuiltinPiece('', process.cwd())).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('default piece parallel reviewers movement', () => {
|
|
it('should have a reviewers movement with parallel sub-movements', () => {
|
|
const piece = getBuiltinPiece('default', process.cwd());
|
|
expect(piece).not.toBeNull();
|
|
|
|
const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers');
|
|
expect(reviewersMovement).toBeDefined();
|
|
expect(reviewersMovement!.parallel).toBeDefined();
|
|
expect(reviewersMovement!.parallel).toHaveLength(2);
|
|
});
|
|
|
|
it('should have arch-review and qa-review as parallel sub-movements', () => {
|
|
const piece = getBuiltinPiece('default', process.cwd());
|
|
const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!;
|
|
const subMovementNames = reviewersMovement.parallel!.map((s) => s.name);
|
|
|
|
expect(subMovementNames).toContain('arch-review');
|
|
expect(subMovementNames).toContain('qa-review');
|
|
});
|
|
|
|
it('should have aggregate conditions on the reviewers parent movement', () => {
|
|
const piece = getBuiltinPiece('default', process.cwd());
|
|
const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!;
|
|
|
|
expect(reviewersMovement.rules).toBeDefined();
|
|
expect(reviewersMovement.rules).toHaveLength(2);
|
|
|
|
const allRule = reviewersMovement.rules!.find((r) => r.isAggregateCondition && r.aggregateType === 'all');
|
|
expect(allRule).toBeDefined();
|
|
expect(allRule!.aggregateConditionText).toBe('approved');
|
|
expect(allRule!.next).toBe('supervise');
|
|
|
|
const anyRule = reviewersMovement.rules!.find((r) => r.isAggregateCondition && r.aggregateType === 'any');
|
|
expect(anyRule).toBeDefined();
|
|
expect(anyRule!.aggregateConditionText).toBe('needs_fix');
|
|
expect(anyRule!.next).toBe('fix');
|
|
});
|
|
|
|
it('should have matching conditions on sub-movements for aggregation', () => {
|
|
const piece = getBuiltinPiece('default', process.cwd());
|
|
const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!;
|
|
|
|
for (const subMovement of reviewersMovement.parallel!) {
|
|
expect(subMovement.rules).toBeDefined();
|
|
const conditions = subMovement.rules!.map((r) => r.condition);
|
|
expect(conditions).toContain('approved');
|
|
expect(conditions).toContain('needs_fix');
|
|
}
|
|
});
|
|
|
|
it('should have ai_review transitioning to reviewers movement', () => {
|
|
const piece = getBuiltinPiece('default', process.cwd());
|
|
const aiReviewMovement = piece!.movements.find((s) => s.name === 'ai_review')!;
|
|
|
|
const approveRule = aiReviewMovement.rules!.find((r) => r.next === 'reviewers');
|
|
expect(approveRule).toBeDefined();
|
|
});
|
|
|
|
it('should have ai_fix transitioning to ai_review movement', () => {
|
|
const piece = getBuiltinPiece('default', process.cwd());
|
|
const aiFixMovement = piece!.movements.find((s) => s.name === 'ai_fix')!;
|
|
|
|
const fixedRule = aiFixMovement.rules!.find((r) => r.next === 'ai_review');
|
|
expect(fixedRule).toBeDefined();
|
|
});
|
|
|
|
it('should have fix movement transitioning back to reviewers', () => {
|
|
const piece = getBuiltinPiece('default', process.cwd());
|
|
const fixMovement = piece!.movements.find((s) => s.name === 'fix')!;
|
|
|
|
const fixedRule = fixMovement.rules!.find((r) => r.next === 'reviewers');
|
|
expect(fixedRule).toBeDefined();
|
|
});
|
|
|
|
it('should not have old separate review/security_review/improve movements', () => {
|
|
const piece = getBuiltinPiece('default', process.cwd());
|
|
const movementNames = piece!.movements.map((s) => s.name);
|
|
|
|
expect(movementNames).not.toContain('review');
|
|
expect(movementNames).not.toContain('security_review');
|
|
expect(movementNames).not.toContain('improve');
|
|
expect(movementNames).not.toContain('security_fix');
|
|
});
|
|
|
|
it('should have sub-movements with correct agents', () => {
|
|
const piece = getBuiltinPiece('default', process.cwd());
|
|
const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!;
|
|
|
|
const archReview = reviewersMovement.parallel!.find((s) => s.name === 'arch-review')!;
|
|
expect(archReview.persona).toContain('architecture-reviewer');
|
|
|
|
const qaReview = reviewersMovement.parallel!.find((s) => s.name === 'qa-review')!;
|
|
expect(qaReview.persona).toContain('qa-reviewer');
|
|
});
|
|
|
|
it('should have output contracts configured on sub-movements', () => {
|
|
const piece = getBuiltinPiece('default', process.cwd());
|
|
const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!;
|
|
|
|
const archReview = reviewersMovement.parallel!.find((s) => s.name === 'arch-review')!;
|
|
expect(archReview.outputContracts).toBeDefined();
|
|
|
|
const qaReview = reviewersMovement.parallel!.find((s) => s.name === 'qa-review')!;
|
|
expect(qaReview.outputContracts).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('loadAllPieces', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should load project-local pieces when cwd is provided', () => {
|
|
const piecesDir = join(testDir, '.takt', 'pieces');
|
|
mkdirSync(piecesDir, { recursive: true });
|
|
|
|
const samplePiece = `
|
|
name: test-piece
|
|
description: Test piece
|
|
max_movements: 10
|
|
movements:
|
|
- name: step1
|
|
persona: coder
|
|
instruction: "{task}"
|
|
rules:
|
|
- condition: Task completed
|
|
next: COMPLETE
|
|
`;
|
|
writeFileSync(join(piecesDir, 'test.yaml'), samplePiece);
|
|
|
|
const pieces = loadAllPieces(testDir);
|
|
|
|
expect(pieces.has('test')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('loadPiece (builtin fallback)', () => {
|
|
it('should load builtin piece when user piece does not exist', () => {
|
|
const piece = loadPiece('default', process.cwd());
|
|
expect(piece).not.toBeNull();
|
|
expect(piece!.name).toBe('default');
|
|
});
|
|
|
|
it('should return null for non-existent piece', () => {
|
|
const piece = loadPiece('does-not-exist', process.cwd());
|
|
expect(piece).toBeNull();
|
|
});
|
|
|
|
it('should load builtin pieces like default-mini, research, e2e-test', () => {
|
|
const mini = loadPiece('default-mini', process.cwd());
|
|
expect(mini).not.toBeNull();
|
|
expect(mini!.name).toBe('default-mini');
|
|
|
|
const research = loadPiece('research', process.cwd());
|
|
expect(research).not.toBeNull();
|
|
expect(research!.name).toBe('research');
|
|
|
|
const e2eTest = loadPiece('e2e-test', process.cwd());
|
|
expect(e2eTest).not.toBeNull();
|
|
expect(e2eTest!.name).toBe('e2e-test');
|
|
});
|
|
});
|
|
|
|
describe('listPieces (builtin fallback)', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should include builtin pieces', () => {
|
|
const pieces = listPieces(testDir);
|
|
expect(pieces).toContain('default');
|
|
expect(pieces).toContain('default-mini');
|
|
expect(pieces).toContain('e2e-test');
|
|
});
|
|
|
|
it('should return sorted list', () => {
|
|
const pieces = listPieces(testDir);
|
|
const sorted = [...pieces].sort();
|
|
expect(pieces).toEqual(sorted);
|
|
});
|
|
});
|
|
|
|
describe('loadAllPieces (builtin fallback)', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should include builtin pieces in the map', () => {
|
|
const pieces = loadAllPieces(testDir);
|
|
expect(pieces.has('default')).toBe(true);
|
|
expect(pieces.has('default-mini')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('loadPersonaPromptFromPath (builtin paths)', () => {
|
|
it('should load persona prompt from builtin resources path', () => {
|
|
const lang = getLanguage();
|
|
const builtinPersonasDir = getBuiltinPersonasDir(lang);
|
|
const personaPath = join(builtinPersonasDir, 'coder.md');
|
|
|
|
if (existsSync(personaPath)) {
|
|
const prompt = loadPersonaPromptFromPath(personaPath, process.cwd());
|
|
expect(prompt).toBeTruthy();
|
|
expect(typeof prompt).toBe('string');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('setCurrentPiece', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should save piece name to config.yaml', () => {
|
|
setCurrentPiece(testDir, 'my-piece');
|
|
|
|
const config = loadProjectConfig(testDir);
|
|
|
|
expect(config.piece).toBe('my-piece');
|
|
});
|
|
|
|
it('should create config directory if not exists', () => {
|
|
const configDir = getProjectConfigDir(testDir);
|
|
expect(existsSync(configDir)).toBe(false);
|
|
|
|
setCurrentPiece(testDir, 'test');
|
|
|
|
expect(existsSync(configDir)).toBe(true);
|
|
});
|
|
|
|
it('should overwrite existing piece name', () => {
|
|
setCurrentPiece(testDir, 'first');
|
|
setCurrentPiece(testDir, 'second');
|
|
|
|
const piece = loadProjectConfig(testDir).piece;
|
|
|
|
expect(piece).toBe('second');
|
|
});
|
|
});
|
|
|
|
describe('loadProjectConfig provider_options', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should normalize provider_options into providerOptions (camelCase)', () => {
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
|
'piece: default',
|
|
'provider_options:',
|
|
' codex:',
|
|
' network_access: true',
|
|
' claude:',
|
|
' sandbox:',
|
|
' allow_unsandboxed_commands: true',
|
|
].join('\n'));
|
|
|
|
const config = loadProjectConfig(testDir);
|
|
|
|
expect(config.providerOptions).toEqual({
|
|
codex: { networkAccess: true },
|
|
claude: { sandbox: { allowUnsandboxedCommands: true } },
|
|
});
|
|
});
|
|
|
|
it('should apply TAKT_PROVIDER_OPTIONS_* env overrides for project config', () => {
|
|
const original = process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS;
|
|
process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = 'false';
|
|
|
|
const config = loadProjectConfig(testDir);
|
|
expect(config.providerOptions).toEqual({
|
|
codex: { networkAccess: false },
|
|
});
|
|
|
|
if (original === undefined) {
|
|
delete process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS;
|
|
} else {
|
|
process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = original;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('analytics config resolution', () => {
|
|
let testDir: string;
|
|
let originalTaktConfigDir: string | undefined;
|
|
let originalAnalyticsEnabled: string | undefined;
|
|
let originalAnalyticsEventsPath: string | undefined;
|
|
let originalAnalyticsRetentionDays: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
originalTaktConfigDir = process.env.TAKT_CONFIG_DIR;
|
|
originalAnalyticsEnabled = process.env.TAKT_ANALYTICS_ENABLED;
|
|
originalAnalyticsEventsPath = process.env.TAKT_ANALYTICS_EVENTS_PATH;
|
|
originalAnalyticsRetentionDays = process.env.TAKT_ANALYTICS_RETENTION_DAYS;
|
|
process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt');
|
|
delete process.env.TAKT_ANALYTICS_ENABLED;
|
|
delete process.env.TAKT_ANALYTICS_EVENTS_PATH;
|
|
delete process.env.TAKT_ANALYTICS_RETENTION_DAYS;
|
|
invalidateGlobalConfigCache();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalTaktConfigDir === undefined) {
|
|
delete process.env.TAKT_CONFIG_DIR;
|
|
} else {
|
|
process.env.TAKT_CONFIG_DIR = originalTaktConfigDir;
|
|
}
|
|
if (originalAnalyticsEnabled === undefined) {
|
|
delete process.env.TAKT_ANALYTICS_ENABLED;
|
|
} else {
|
|
process.env.TAKT_ANALYTICS_ENABLED = originalAnalyticsEnabled;
|
|
}
|
|
if (originalAnalyticsEventsPath === undefined) {
|
|
delete process.env.TAKT_ANALYTICS_EVENTS_PATH;
|
|
} else {
|
|
process.env.TAKT_ANALYTICS_EVENTS_PATH = originalAnalyticsEventsPath;
|
|
}
|
|
if (originalAnalyticsRetentionDays === undefined) {
|
|
delete process.env.TAKT_ANALYTICS_RETENTION_DAYS;
|
|
} else {
|
|
process.env.TAKT_ANALYTICS_RETENTION_DAYS = originalAnalyticsRetentionDays;
|
|
}
|
|
invalidateGlobalConfigCache();
|
|
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should normalize project analytics config from snake_case', () => {
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
|
'piece: default',
|
|
'analytics:',
|
|
' enabled: false',
|
|
' events_path: .takt/project-analytics/events',
|
|
' retention_days: 7',
|
|
].join('\n'));
|
|
|
|
const config = loadProjectConfig(testDir);
|
|
|
|
expect(config.analytics).toEqual({
|
|
enabled: false,
|
|
eventsPath: '.takt/project-analytics/events',
|
|
retentionDays: 7,
|
|
});
|
|
});
|
|
|
|
it('should apply TAKT_ANALYTICS_* env overrides for project config', () => {
|
|
process.env.TAKT_ANALYTICS_ENABLED = 'true';
|
|
process.env.TAKT_ANALYTICS_EVENTS_PATH = '/tmp/project-analytics';
|
|
process.env.TAKT_ANALYTICS_RETENTION_DAYS = '5';
|
|
|
|
const config = loadProjectConfig(testDir);
|
|
expect(config.analytics).toEqual({
|
|
enabled: true,
|
|
eventsPath: '/tmp/project-analytics',
|
|
retentionDays: 5,
|
|
});
|
|
});
|
|
|
|
it('should merge analytics as project > global in resolveConfigValue', () => {
|
|
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
|
mkdirSync(globalConfigDir, { recursive: true });
|
|
writeFileSync(join(globalConfigDir, 'config.yaml'), [
|
|
'language: ja',
|
|
'analytics:',
|
|
' enabled: true',
|
|
' events_path: /tmp/global-analytics',
|
|
' retention_days: 30',
|
|
].join('\n'));
|
|
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), [
|
|
'piece: default',
|
|
'analytics:',
|
|
' events_path: /tmp/project-analytics',
|
|
' retention_days: 14',
|
|
].join('\n'));
|
|
|
|
const analytics = resolveConfigValue(testDir, 'analytics');
|
|
expect(analytics).toEqual({
|
|
enabled: true,
|
|
eventsPath: '/tmp/project-analytics',
|
|
retentionDays: 14,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('isVerboseMode', () => {
|
|
let testDir: string;
|
|
let originalTaktConfigDir: string | undefined;
|
|
let originalTaktVerbose: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
originalTaktConfigDir = process.env.TAKT_CONFIG_DIR;
|
|
originalTaktVerbose = process.env.TAKT_VERBOSE;
|
|
process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt');
|
|
delete process.env.TAKT_VERBOSE;
|
|
invalidateGlobalConfigCache();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalTaktConfigDir === undefined) {
|
|
delete process.env.TAKT_CONFIG_DIR;
|
|
} else {
|
|
process.env.TAKT_CONFIG_DIR = originalTaktConfigDir;
|
|
}
|
|
if (originalTaktVerbose === undefined) {
|
|
delete process.env.TAKT_VERBOSE;
|
|
} else {
|
|
process.env.TAKT_VERBOSE = originalTaktVerbose;
|
|
}
|
|
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should return project verbose when project config has verbose: true', () => {
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: true\n');
|
|
|
|
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
|
mkdirSync(globalConfigDir, { recursive: true });
|
|
writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: false\n');
|
|
|
|
expect(isVerboseMode(testDir)).toBe(true);
|
|
});
|
|
|
|
it('should return project verbose when project config has verbose: false', () => {
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: false\n');
|
|
|
|
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
|
mkdirSync(globalConfigDir, { recursive: true });
|
|
writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: true\n');
|
|
|
|
expect(isVerboseMode(testDir)).toBe(false);
|
|
});
|
|
|
|
it('should fallback to global verbose when project verbose is not set', () => {
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\n');
|
|
|
|
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
|
mkdirSync(globalConfigDir, { recursive: true });
|
|
writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: true\n');
|
|
|
|
expect(isVerboseMode(testDir)).toBe(true);
|
|
});
|
|
|
|
it('should return false when neither project nor global verbose is set', () => {
|
|
expect(isVerboseMode(testDir)).toBe(false);
|
|
});
|
|
|
|
it('should prioritize TAKT_VERBOSE over project and global config', () => {
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: false\n');
|
|
|
|
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
|
|
mkdirSync(globalConfigDir, { recursive: true });
|
|
writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: false\n');
|
|
|
|
process.env.TAKT_VERBOSE = 'true';
|
|
expect(isVerboseMode(testDir)).toBe(true);
|
|
});
|
|
|
|
it('should throw on TAKT_VERBOSE=0', () => {
|
|
process.env.TAKT_VERBOSE = '0';
|
|
expect(() => isVerboseMode(testDir)).toThrow('TAKT_VERBOSE must be one of: true, false');
|
|
});
|
|
|
|
it('should throw on invalid TAKT_VERBOSE value', () => {
|
|
process.env.TAKT_VERBOSE = 'yes';
|
|
expect(() => isVerboseMode(testDir)).toThrow('TAKT_VERBOSE must be one of: true, false');
|
|
});
|
|
});
|
|
|
|
describe('loadInputHistory', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should return empty array when no history exists', () => {
|
|
const history = loadInputHistory(testDir);
|
|
|
|
expect(history).toEqual([]);
|
|
});
|
|
|
|
it('should load saved history entries', () => {
|
|
const configDir = getProjectConfigDir(testDir);
|
|
mkdirSync(configDir, { recursive: true });
|
|
const entries = ['"first entry"', '"second entry"'];
|
|
writeFileSync(getInputHistoryPath(testDir), entries.join('\n'));
|
|
|
|
const history = loadInputHistory(testDir);
|
|
|
|
expect(history).toEqual(['first entry', 'second entry']);
|
|
});
|
|
|
|
it('should handle multi-line entries', () => {
|
|
const configDir = getProjectConfigDir(testDir);
|
|
mkdirSync(configDir, { recursive: true });
|
|
const multiLine = 'line1\nline2\nline3';
|
|
writeFileSync(getInputHistoryPath(testDir), JSON.stringify(multiLine));
|
|
|
|
const history = loadInputHistory(testDir);
|
|
|
|
expect(history).toHaveLength(1);
|
|
expect(history[0]).toBe('line1\nline2\nline3');
|
|
});
|
|
});
|
|
|
|
describe('saveInputHistory', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should save history entries', () => {
|
|
saveInputHistory(testDir, ['entry1', 'entry2']);
|
|
|
|
const content = readFileSync(getInputHistoryPath(testDir), 'utf-8');
|
|
expect(content).toBe('"entry1"\n"entry2"');
|
|
});
|
|
|
|
it('should create config directory if not exists', () => {
|
|
const configDir = getProjectConfigDir(testDir);
|
|
expect(existsSync(configDir)).toBe(false);
|
|
|
|
saveInputHistory(testDir, ['test']);
|
|
|
|
expect(existsSync(configDir)).toBe(true);
|
|
});
|
|
|
|
it('should preserve multi-line entries', () => {
|
|
const multiLine = 'line1\nline2';
|
|
saveInputHistory(testDir, [multiLine]);
|
|
|
|
const history = loadInputHistory(testDir);
|
|
|
|
expect(history[0]).toBe('line1\nline2');
|
|
});
|
|
});
|
|
|
|
describe('addToInputHistory', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should add new entry to history', () => {
|
|
addToInputHistory(testDir, 'first');
|
|
addToInputHistory(testDir, 'second');
|
|
|
|
const history = loadInputHistory(testDir);
|
|
|
|
expect(history).toEqual(['first', 'second']);
|
|
});
|
|
|
|
it('should not add consecutive duplicates', () => {
|
|
addToInputHistory(testDir, 'same');
|
|
addToInputHistory(testDir, 'same');
|
|
|
|
const history = loadInputHistory(testDir);
|
|
|
|
expect(history).toEqual(['same']);
|
|
});
|
|
|
|
it('should allow non-consecutive duplicates', () => {
|
|
addToInputHistory(testDir, 'first');
|
|
addToInputHistory(testDir, 'second');
|
|
addToInputHistory(testDir, 'first');
|
|
|
|
const history = loadInputHistory(testDir);
|
|
|
|
expect(history).toEqual(['first', 'second', 'first']);
|
|
});
|
|
});
|
|
|
|
describe('saveInputHistory - edge cases', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should trim history to MAX_INPUT_HISTORY entries', () => {
|
|
const entries = Array.from({ length: 150 }, (_, i) => `entry${i}`);
|
|
saveInputHistory(testDir, entries);
|
|
|
|
const history = loadInputHistory(testDir);
|
|
|
|
expect(history).toHaveLength(MAX_INPUT_HISTORY);
|
|
// First 50 entries should be trimmed, keeping entries 50-149
|
|
expect(history[0]).toBe('entry50');
|
|
expect(history[MAX_INPUT_HISTORY - 1]).toBe('entry149');
|
|
});
|
|
|
|
it('should handle empty history array', () => {
|
|
saveInputHistory(testDir, []);
|
|
|
|
const history = loadInputHistory(testDir);
|
|
|
|
expect(history).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('loadInputHistory - edge cases', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should skip invalid JSON entries', () => {
|
|
const configDir = getProjectConfigDir(testDir);
|
|
mkdirSync(configDir, { recursive: true });
|
|
// Mix of valid JSON and invalid entries
|
|
const content = '"valid entry"\ninvalid json\n"another valid"';
|
|
writeFileSync(getInputHistoryPath(testDir), content);
|
|
|
|
const history = loadInputHistory(testDir);
|
|
|
|
// Invalid entries should be skipped
|
|
expect(history).toEqual(['valid entry', 'another valid']);
|
|
});
|
|
|
|
it('should handle completely corrupted file', () => {
|
|
const configDir = getProjectConfigDir(testDir);
|
|
mkdirSync(configDir, { recursive: true });
|
|
// All invalid JSON
|
|
const content = 'not json\nalso not json\nstill not json';
|
|
writeFileSync(getInputHistoryPath(testDir), content);
|
|
|
|
const history = loadInputHistory(testDir);
|
|
|
|
// All entries should be skipped
|
|
expect(history).toEqual([]);
|
|
});
|
|
|
|
it('should handle file with only whitespace lines', () => {
|
|
const configDir = getProjectConfigDir(testDir);
|
|
mkdirSync(configDir, { recursive: true });
|
|
const content = ' \n\n \n';
|
|
writeFileSync(getInputHistoryPath(testDir), content);
|
|
|
|
const history = loadInputHistory(testDir);
|
|
|
|
expect(history).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('saveProjectConfig - gitignore copy', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should copy .gitignore when creating new config', () => {
|
|
setCurrentPiece(testDir, 'test');
|
|
|
|
const configDir = getProjectConfigDir(testDir);
|
|
const gitignorePath = join(configDir, '.gitignore');
|
|
|
|
expect(existsSync(gitignorePath)).toBe(true);
|
|
});
|
|
|
|
it('should copy .gitignore to existing config directory without one', () => {
|
|
// Create config directory without .gitignore
|
|
const configDir = getProjectConfigDir(testDir);
|
|
mkdirSync(configDir, { recursive: true });
|
|
writeFileSync(join(configDir, 'config.yaml'), 'piece: existing\n');
|
|
|
|
// Save config should still copy .gitignore
|
|
setCurrentPiece(testDir, 'updated');
|
|
|
|
const gitignorePath = join(configDir, '.gitignore');
|
|
expect(existsSync(gitignorePath)).toBe(true);
|
|
});
|
|
|
|
it('should not overwrite existing .gitignore', () => {
|
|
const configDir = getProjectConfigDir(testDir);
|
|
mkdirSync(configDir, { recursive: true });
|
|
const customContent = '# Custom gitignore\nmy-custom-file';
|
|
writeFileSync(join(configDir, '.gitignore'), customContent);
|
|
|
|
setCurrentPiece(testDir, 'test');
|
|
|
|
const gitignorePath = join(configDir, '.gitignore');
|
|
const content = readFileSync(gitignorePath, 'utf-8');
|
|
expect(content).toBe(customContent);
|
|
});
|
|
});
|
|
|
|
// ============ Worktree Sessions ============
|
|
|
|
describe('encodeWorktreePath', () => {
|
|
it('should replace slashes with dashes', () => {
|
|
const encoded = encodeWorktreePath('/project/.takt/worktrees/my-task');
|
|
|
|
expect(encoded).not.toContain('/');
|
|
expect(encoded).toContain('-');
|
|
});
|
|
|
|
it('should handle Windows-style paths', () => {
|
|
const encoded = encodeWorktreePath('C:\\project\\worktrees\\task');
|
|
|
|
expect(encoded).not.toContain('\\');
|
|
expect(encoded).not.toContain(':');
|
|
});
|
|
|
|
it('should produce consistent output for same input', () => {
|
|
const path = '/project/.takt/worktrees/feature-x';
|
|
const encoded1 = encodeWorktreePath(path);
|
|
const encoded2 = encodeWorktreePath(path);
|
|
|
|
expect(encoded1).toBe(encoded2);
|
|
});
|
|
});
|
|
|
|
describe('getWorktreeSessionsDir', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should return path inside .takt directory', () => {
|
|
const sessionsDir = getWorktreeSessionsDir(testDir);
|
|
|
|
expect(sessionsDir).toContain('.takt');
|
|
expect(sessionsDir).toContain('worktree-sessions');
|
|
});
|
|
});
|
|
|
|
describe('getWorktreeSessionPath', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should return .json file path', () => {
|
|
const sessionPath = getWorktreeSessionPath(testDir, '/worktree/path');
|
|
|
|
expect(sessionPath).toMatch(/\.json$/);
|
|
});
|
|
|
|
it('should include encoded worktree path in filename', () => {
|
|
const worktreePath = '/project/.takt/worktrees/my-feature';
|
|
const sessionPath = getWorktreeSessionPath(testDir, worktreePath);
|
|
|
|
expect(sessionPath).toContain('worktree-sessions');
|
|
});
|
|
});
|
|
|
|
describe('loadWorktreeSessions', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should return empty object when no session file exists', () => {
|
|
const sessions = loadWorktreeSessions(testDir, '/some/worktree');
|
|
|
|
expect(sessions).toEqual({});
|
|
});
|
|
|
|
it('should load saved sessions from file', () => {
|
|
const worktreePath = '/project/worktree';
|
|
const sessionsDir = getWorktreeSessionsDir(testDir);
|
|
mkdirSync(sessionsDir, { recursive: true });
|
|
|
|
const sessionPath = getWorktreeSessionPath(testDir, worktreePath);
|
|
const data = {
|
|
personaSessions: { coder: 'session-123', reviewer: 'session-456' },
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
writeFileSync(sessionPath, JSON.stringify(data));
|
|
|
|
const sessions = loadWorktreeSessions(testDir, worktreePath);
|
|
|
|
expect(sessions).toEqual({ coder: 'session-123', reviewer: 'session-456' });
|
|
});
|
|
|
|
it('should return empty object for corrupted JSON', () => {
|
|
const worktreePath = '/project/worktree';
|
|
const sessionsDir = getWorktreeSessionsDir(testDir);
|
|
mkdirSync(sessionsDir, { recursive: true });
|
|
|
|
const sessionPath = getWorktreeSessionPath(testDir, worktreePath);
|
|
writeFileSync(sessionPath, 'not valid json');
|
|
|
|
const sessions = loadWorktreeSessions(testDir, worktreePath);
|
|
|
|
expect(sessions).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('updateWorktreeSession', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should create session file if not exists', () => {
|
|
const worktreePath = '/project/worktree';
|
|
|
|
updateWorktreeSession(testDir, worktreePath, 'coder', 'session-abc');
|
|
|
|
const sessions = loadWorktreeSessions(testDir, worktreePath);
|
|
expect(sessions).toEqual({ coder: 'session-abc' });
|
|
});
|
|
|
|
it('should update existing session', () => {
|
|
const worktreePath = '/project/worktree';
|
|
|
|
updateWorktreeSession(testDir, worktreePath, 'coder', 'session-1');
|
|
updateWorktreeSession(testDir, worktreePath, 'coder', 'session-2');
|
|
|
|
const sessions = loadWorktreeSessions(testDir, worktreePath);
|
|
expect(sessions.coder).toBe('session-2');
|
|
});
|
|
|
|
it('should preserve other agent sessions when updating one', () => {
|
|
const worktreePath = '/project/worktree';
|
|
|
|
updateWorktreeSession(testDir, worktreePath, 'coder', 'coder-session');
|
|
updateWorktreeSession(testDir, worktreePath, 'reviewer', 'reviewer-session');
|
|
|
|
const sessions = loadWorktreeSessions(testDir, worktreePath);
|
|
expect(sessions).toEqual({
|
|
coder: 'coder-session',
|
|
reviewer: 'reviewer-session',
|
|
});
|
|
});
|
|
|
|
it('should create worktree-sessions directory if not exists', () => {
|
|
const worktreePath = '/project/worktree';
|
|
const sessionsDir = getWorktreeSessionsDir(testDir);
|
|
expect(existsSync(sessionsDir)).toBe(false);
|
|
|
|
updateWorktreeSession(testDir, worktreePath, 'coder', 'session-xyz');
|
|
|
|
expect(existsSync(sessionsDir)).toBe(true);
|
|
});
|
|
|
|
it('should keep sessions isolated between different worktrees', () => {
|
|
const worktree1 = '/project/worktree-1';
|
|
const worktree2 = '/project/worktree-2';
|
|
|
|
updateWorktreeSession(testDir, worktree1, 'coder', 'wt1-session');
|
|
updateWorktreeSession(testDir, worktree2, 'coder', 'wt2-session');
|
|
|
|
const sessions1 = loadWorktreeSessions(testDir, worktree1);
|
|
const sessions2 = loadWorktreeSessions(testDir, worktree2);
|
|
|
|
expect(sessions1.coder).toBe('wt1-session');
|
|
expect(sessions2.coder).toBe('wt2-session');
|
|
});
|
|
});
|
|
|
|
describe('provider-based session management', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe('loadPersonaSessions with provider', () => {
|
|
it('should return sessions when provider matches', () => {
|
|
updatePersonaSession(testDir, 'coder', 'session-1', 'claude');
|
|
|
|
const sessions = loadPersonaSessions(testDir, 'claude');
|
|
expect(sessions.coder).toBe('session-1');
|
|
});
|
|
|
|
it('should return empty when provider has changed', () => {
|
|
updatePersonaSession(testDir, 'coder', 'session-1', 'claude');
|
|
|
|
const sessions = loadPersonaSessions(testDir, 'codex');
|
|
expect(sessions).toEqual({});
|
|
});
|
|
|
|
it('should return sessions when no provider is specified (legacy)', () => {
|
|
updatePersonaSession(testDir, 'coder', 'session-1');
|
|
|
|
const sessions = loadPersonaSessions(testDir);
|
|
expect(sessions.coder).toBe('session-1');
|
|
});
|
|
});
|
|
|
|
describe('updatePersonaSession with provider', () => {
|
|
it('should discard old sessions when provider changes', () => {
|
|
updatePersonaSession(testDir, 'coder', 'claude-session', 'claude');
|
|
updatePersonaSession(testDir, 'coder', 'codex-session', 'codex');
|
|
|
|
const sessions = loadPersonaSessions(testDir, 'codex');
|
|
expect(sessions.coder).toBe('codex-session');
|
|
// Old claude sessions should not remain
|
|
expect(Object.keys(sessions)).toHaveLength(1);
|
|
});
|
|
|
|
it('should store provider in session data', () => {
|
|
updatePersonaSession(testDir, 'coder', 'session-1', 'claude');
|
|
|
|
const path = getPersonaSessionsPath(testDir);
|
|
const data = JSON.parse(readFileSync(path, 'utf-8')) as PersonaSessionData;
|
|
expect(data.provider).toBe('claude');
|
|
});
|
|
});
|
|
|
|
describe('loadWorktreeSessions with provider', () => {
|
|
it('should return sessions when provider matches', () => {
|
|
const worktreePath = '/project/worktree';
|
|
updateWorktreeSession(testDir, worktreePath, 'coder', 'session-1', 'claude');
|
|
|
|
const sessions = loadWorktreeSessions(testDir, worktreePath, 'claude');
|
|
expect(sessions.coder).toBe('session-1');
|
|
});
|
|
|
|
it('should return empty when provider has changed', () => {
|
|
const worktreePath = '/project/worktree';
|
|
updateWorktreeSession(testDir, worktreePath, 'coder', 'session-1', 'claude');
|
|
|
|
const sessions = loadWorktreeSessions(testDir, worktreePath, 'codex');
|
|
expect(sessions).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('updateWorktreeSession with provider', () => {
|
|
it('should discard old sessions when provider changes', () => {
|
|
const worktreePath = '/project/worktree';
|
|
updateWorktreeSession(testDir, worktreePath, 'coder', 'claude-session', 'claude');
|
|
updateWorktreeSession(testDir, worktreePath, 'coder', 'codex-session', 'codex');
|
|
|
|
const sessions = loadWorktreeSessions(testDir, worktreePath, 'codex');
|
|
expect(sessions.coder).toBe('codex-session');
|
|
expect(Object.keys(sessions)).toHaveLength(1);
|
|
});
|
|
|
|
it('should store provider in session data', () => {
|
|
const worktreePath = '/project/worktree';
|
|
updateWorktreeSession(testDir, worktreePath, 'coder', 'session-1', 'claude');
|
|
|
|
const sessionPath = getWorktreeSessionPath(testDir, worktreePath);
|
|
const data = JSON.parse(readFileSync(sessionPath, 'utf-8')) as PersonaSessionData;
|
|
expect(data.provider).toBe('claude');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('loadProjectConfig snake_case normalization', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should normalize auto_pr → autoPr and remove snake_case key', () => {
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'auto_pr: true\n');
|
|
|
|
const config = loadProjectConfig(testDir);
|
|
|
|
expect(config.autoPr).toBe(true);
|
|
expect((config as Record<string, unknown>).auto_pr).toBeUndefined();
|
|
});
|
|
|
|
it('should normalize draft_pr → draftPr and remove snake_case key', () => {
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'draft_pr: true\n');
|
|
|
|
const config = loadProjectConfig(testDir);
|
|
|
|
expect(config.draftPr).toBe(true);
|
|
expect((config as Record<string, unknown>).draft_pr).toBeUndefined();
|
|
});
|
|
|
|
it('should normalize base_branch → baseBranch and remove snake_case key', () => {
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'base_branch: main\n');
|
|
|
|
const config = loadProjectConfig(testDir);
|
|
|
|
expect(config.baseBranch).toBe('main');
|
|
expect((config as Record<string, unknown>).base_branch).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('saveProjectConfig snake_case denormalization', () => {
|
|
let testDir: string;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should persist autoPr as auto_pr and reload correctly', () => {
|
|
saveProjectConfig(testDir, { piece: 'default', autoPr: true });
|
|
|
|
const saved = loadProjectConfig(testDir);
|
|
|
|
expect(saved.autoPr).toBe(true);
|
|
expect((saved as Record<string, unknown>).auto_pr).toBeUndefined();
|
|
});
|
|
|
|
it('should persist draftPr as draft_pr and reload correctly', () => {
|
|
saveProjectConfig(testDir, { piece: 'default', draftPr: true });
|
|
|
|
const saved = loadProjectConfig(testDir);
|
|
|
|
expect(saved.draftPr).toBe(true);
|
|
expect((saved as Record<string, unknown>).draft_pr).toBeUndefined();
|
|
});
|
|
|
|
it('should persist baseBranch as base_branch and reload correctly', () => {
|
|
saveProjectConfig(testDir, { piece: 'default', baseBranch: 'main' });
|
|
|
|
const saved = loadProjectConfig(testDir);
|
|
|
|
expect(saved.baseBranch).toBe('main');
|
|
expect((saved as Record<string, unknown>).base_branch).toBeUndefined();
|
|
});
|
|
|
|
it('should not write camelCase keys to YAML file', () => {
|
|
saveProjectConfig(testDir, { piece: 'default', autoPr: true, draftPr: false, baseBranch: 'develop' });
|
|
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
const content = readFileSync(join(projectConfigDir, 'config.yaml'), 'utf-8');
|
|
|
|
expect(content).toContain('auto_pr:');
|
|
expect(content).toContain('draft_pr:');
|
|
expect(content).toContain('base_branch:');
|
|
expect(content).not.toContain('autoPr:');
|
|
expect(content).not.toContain('draftPr:');
|
|
expect(content).not.toContain('baseBranch:');
|
|
});
|
|
});
|
|
|
|
describe('resolveConfigValue autoPr/draftPr/baseBranch from project config', () => {
|
|
let testDir: string;
|
|
let originalTaktConfigDir: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
|
|
mkdirSync(testDir, { recursive: true });
|
|
originalTaktConfigDir = process.env.TAKT_CONFIG_DIR;
|
|
process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt');
|
|
invalidateGlobalConfigCache();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalTaktConfigDir === undefined) {
|
|
delete process.env.TAKT_CONFIG_DIR;
|
|
} else {
|
|
process.env.TAKT_CONFIG_DIR = originalTaktConfigDir;
|
|
}
|
|
invalidateGlobalConfigCache();
|
|
|
|
if (existsSync(testDir)) {
|
|
rmSync(testDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should resolve autoPr from project config written in snake_case YAML', () => {
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'auto_pr: true\n');
|
|
|
|
expect(resolveConfigValue(testDir, 'autoPr')).toBe(true);
|
|
});
|
|
|
|
it('should resolve draftPr from project config written in snake_case YAML', () => {
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'draft_pr: true\n');
|
|
|
|
expect(resolveConfigValue(testDir, 'draftPr')).toBe(true);
|
|
});
|
|
|
|
it('should resolve baseBranch from project config written in snake_case YAML', () => {
|
|
const projectConfigDir = getProjectConfigDir(testDir);
|
|
mkdirSync(projectConfigDir, { recursive: true });
|
|
writeFileSync(join(projectConfigDir, 'config.yaml'), 'base_branch: main\n');
|
|
|
|
expect(resolveConfigValue(testDir, 'baseBranch')).toBe('main');
|
|
});
|
|
});
|