takt/src/__tests__/it-piece-loader.test.ts
2026-02-04 11:07:41 +09:00

382 lines
11 KiB
TypeScript

/**
* Piece loader integration tests.
*
* Tests the 3-tier piece resolution (project-local → user → builtin)
* and YAML parsing including special rule syntax (ai(), all(), any()).
*
* Mocked: globalConfig (for language/builtins)
* Not mocked: loadPiece, parsePiece, rule parsing
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
// --- Mocks ---
vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
}));
// --- Imports (after mocks) ---
import { loadPiece } from '../infra/config/index.js';
// --- Test helpers ---
function createTestDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'takt-it-wfl-'));
mkdirSync(join(dir, '.takt'), { recursive: true });
return dir;
}
describe('Piece Loader IT: builtin piece loading', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
const builtinNames = ['default', 'minimal', 'expert', 'expert-cqrs', 'research', 'magi', 'review-only', 'review-fix-minimal'];
for (const name of builtinNames) {
it(`should load builtin piece: ${name}`, () => {
const config = loadPiece(name, testDir);
expect(config).not.toBeNull();
expect(config!.name).toBe(name);
expect(config!.movements.length).toBeGreaterThan(0);
expect(config!.initialMovement).toBeDefined();
expect(config!.maxIterations).toBeGreaterThan(0);
});
}
it('should return null for non-existent piece', () => {
const config = loadPiece('non-existent-piece-xyz', testDir);
expect(config).toBeNull();
});
});
describe('Piece Loader IT: project-local piece override', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should load project-local piece from .takt/pieces/', () => {
const piecesDir = join(testDir, '.takt', 'pieces');
mkdirSync(piecesDir, { recursive: true });
const agentsDir = join(testDir, 'agents');
mkdirSync(agentsDir, { recursive: true });
writeFileSync(join(agentsDir, 'custom.md'), 'Custom agent');
writeFileSync(join(piecesDir, 'custom-wf.yaml'), `
name: custom-wf
description: Custom project piece
max_iterations: 5
initial_movement: start
movements:
- name: start
agent: ./agents/custom.md
rules:
- condition: Done
next: COMPLETE
instruction: "Do the work"
`);
const config = loadPiece('custom-wf', testDir);
expect(config).not.toBeNull();
expect(config!.name).toBe('custom-wf');
expect(config!.movements.length).toBe(1);
expect(config!.movements[0]!.name).toBe('start');
});
});
describe('Piece Loader IT: agent path resolution', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should resolve relative agent paths from piece YAML location', () => {
const config = loadPiece('minimal', testDir);
expect(config).not.toBeNull();
for (const movement of config!.movements) {
if (movement.agentPath) {
// Agent paths should be resolved to absolute paths
expect(movement.agentPath).toMatch(/^\//);
// Agent files should exist
expect(existsSync(movement.agentPath)).toBe(true);
}
if (movement.parallel) {
for (const sub of movement.parallel) {
if (sub.agentPath) {
expect(sub.agentPath).toMatch(/^\//);
expect(existsSync(sub.agentPath)).toBe(true);
}
}
}
}
});
});
describe('Piece Loader IT: rule syntax parsing', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should parse all() aggregate conditions from default piece', () => {
const config = loadPiece('default', testDir);
expect(config).not.toBeNull();
// Find the parallel reviewers movement
const reviewersStep = config!.movements.find(
(s) => s.parallel && s.parallel.length > 0,
);
expect(reviewersStep).toBeDefined();
// Should have aggregate rules
const allRule = reviewersStep!.rules?.find(
(r) => r.isAggregateCondition && r.aggregateType === 'all',
);
expect(allRule).toBeDefined();
expect(allRule!.aggregateConditionText).toBe('approved');
});
it('should parse any() aggregate conditions from default piece', () => {
const config = loadPiece('default', testDir);
expect(config).not.toBeNull();
const reviewersStep = config!.movements.find(
(s) => s.parallel && s.parallel.length > 0,
);
const anyRule = reviewersStep!.rules?.find(
(r) => r.isAggregateCondition && r.aggregateType === 'any',
);
expect(anyRule).toBeDefined();
expect(anyRule!.aggregateConditionText).toBe('needs_fix');
});
it('should parse standard rules with next movement', () => {
const config = loadPiece('minimal', testDir);
expect(config).not.toBeNull();
const implementStep = config!.movements.find((s) => s.name === 'implement');
expect(implementStep).toBeDefined();
expect(implementStep!.rules).toBeDefined();
expect(implementStep!.rules!.length).toBeGreaterThan(0);
// Each rule should have condition and next
for (const rule of implementStep!.rules!) {
expect(typeof rule.condition).toBe('string');
expect(rule.condition.length).toBeGreaterThan(0);
}
});
});
describe('Piece Loader IT: piece config validation', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should set max_iterations from YAML', () => {
const config = loadPiece('minimal', testDir);
expect(config).not.toBeNull();
expect(typeof config!.maxIterations).toBe('number');
expect(config!.maxIterations).toBeGreaterThan(0);
});
it('should set initial_movement from YAML', () => {
const config = loadPiece('minimal', testDir);
expect(config).not.toBeNull();
expect(typeof config!.initialMovement).toBe('string');
// initial_movement should reference an existing movement
const movementNames = config!.movements.map((s) => s.name);
expect(movementNames).toContain(config!.initialMovement);
});
it('should preserve edit property on movements (review-only has no edit: true)', () => {
const config = loadPiece('review-only', testDir);
expect(config).not.toBeNull();
// review-only: no movement should have edit: true
for (const movement of config!.movements) {
expect(movement.edit).not.toBe(true);
if (movement.parallel) {
for (const sub of movement.parallel) {
expect(sub.edit).not.toBe(true);
}
}
}
// expert: implement movement should have edit: true
const expertConfig = loadPiece('expert', testDir);
expect(expertConfig).not.toBeNull();
const implementStep = expertConfig!.movements.find((s) => s.name === 'implement');
expect(implementStep).toBeDefined();
expect(implementStep!.edit).toBe(true);
});
it('should set passPreviousResponse from YAML', () => {
const config = loadPiece('minimal', testDir);
expect(config).not.toBeNull();
// At least some movements should have passPreviousResponse set
const movementsWithPassPrev = config!.movements.filter((s) => s.passPreviousResponse === true);
expect(movementsWithPassPrev.length).toBeGreaterThan(0);
});
});
describe('Piece Loader IT: parallel movement loading', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should load parallel sub-movements from default piece', () => {
const config = loadPiece('default', testDir);
expect(config).not.toBeNull();
const parallelStep = config!.movements.find(
(s) => s.parallel && s.parallel.length > 0,
);
expect(parallelStep).toBeDefined();
expect(parallelStep!.parallel!.length).toBeGreaterThanOrEqual(2);
// Each sub-movement should have required fields
for (const sub of parallelStep!.parallel!) {
expect(sub.name).toBeDefined();
expect(sub.agent).toBeDefined();
expect(sub.rules).toBeDefined();
}
});
it('should load 4 parallel reviewers from expert piece', () => {
const config = loadPiece('expert', testDir);
expect(config).not.toBeNull();
const parallelStep = config!.movements.find(
(s) => s.parallel && s.parallel.length === 4,
);
expect(parallelStep).toBeDefined();
const subNames = parallelStep!.parallel!.map((s) => s.name);
expect(subNames).toContain('arch-review');
expect(subNames).toContain('frontend-review');
expect(subNames).toContain('security-review');
expect(subNames).toContain('qa-review');
});
});
describe('Piece Loader IT: report config loading', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should load single report config', () => {
const config = loadPiece('default', testDir);
expect(config).not.toBeNull();
// default piece: plan movement has a report config
const planStep = config!.movements.find((s) => s.name === 'plan');
expect(planStep).toBeDefined();
expect(planStep!.report).toBeDefined();
});
it('should load multi-report config from expert piece', () => {
const config = loadPiece('expert', testDir);
expect(config).not.toBeNull();
// implement movement has multi-report: [Scope, Decisions]
const implementStep = config!.movements.find((s) => s.name === 'implement');
expect(implementStep).toBeDefined();
expect(implementStep!.report).toBeDefined();
expect(Array.isArray(implementStep!.report)).toBe(true);
expect((implementStep!.report as unknown[]).length).toBe(2);
});
});
describe('Piece Loader IT: invalid YAML handling', () => {
let testDir: string;
beforeEach(() => {
testDir = createTestDir();
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should throw for piece file with invalid YAML', () => {
const piecesDir = join(testDir, '.takt', 'pieces');
mkdirSync(piecesDir, { recursive: true });
writeFileSync(join(piecesDir, 'broken.yaml'), `
name: broken
this is not: valid yaml: [[[[
- bad: {
`);
expect(() => loadPiece('broken', testDir)).toThrow();
});
it('should throw for piece missing required fields', () => {
const piecesDir = join(testDir, '.takt', 'pieces');
mkdirSync(piecesDir, { recursive: true });
writeFileSync(join(piecesDir, 'incomplete.yaml'), `
name: incomplete
description: Missing movements
`);
expect(() => loadPiece('incomplete', testDir)).toThrow();
});
});