aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
518 lines (436 loc) • 19.2 kB
text/typescript
/**
* Test suite for GitWorkflowOrchestrator
*
* Tests git workflow automation including branch management, commits,
* merges, and PR operations with Conventional Commits support.
*
* Requirements:
* - UC-008: Git Workflow Orchestration
* - NFR-GIT-001: Git operations <5s
* - NFR-GIT-002: Conflict detection accuracy >90%
* - NFR-GIT-003: Commit message generation accuracy >85%
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import type {
GitWorkflowOrchestrator,
GitConfig,
BranchOptions,
CommitOptions,
MergeOptions,
PROptions
} from '../../../src/git/git-workflow-orchestrator.ts';
// Mock exec to avoid actual git commands
vi.mock('child_process', () => ({
exec: vi.fn((cmd: string, opts: any, callback: Function) => {
// Add small delay to ensure duration > 0
setTimeout(() => {
// Extract the cwd from options if available
const cwd = typeof opts === 'object' && opts?.cwd ? opts.cwd : '';
// Simulate errors for non-existent paths
if (cwd.includes('/non/existent') || cwd.includes('non-existent')) {
callback(new Error('fatal: not a git repository (or any of the parent directories): .git'), null);
return;
}
// Simulate errors for specific commands
if (cmd.includes('non-existent') || cmd.includes('non-existent-branch')) {
callback(new Error('error: pathspec \'non-existent\' did not match any file(s) known to git'), null);
return;
}
// Simulate successful git commands
if (cmd.includes('git status')) {
callback(null, { stdout: '## main\n', stderr: '' });
} else if (cmd.includes('git branch --show-current')) {
callback(null, { stdout: 'main\n', stderr: '' });
} else if (cmd.includes('git diff --cached --name-only')) {
callback(null, { stdout: 'src/index.ts\n', stderr: '' });
} else if (cmd.includes('git checkout')) {
callback(null, { stdout: 'Switched to branch \'main\'\n', stderr: '' });
} else if (cmd.includes('git branch -d') || cmd.includes('git branch -D')) {
callback(null, { stdout: 'Deleted branch feature-branch\n', stderr: '' });
} else {
callback(null, { stdout: '', stderr: '' });
}
}, 1); // 1ms delay to ensure duration > 0
})
}));
describe('GitWorkflowOrchestrator', () => {
let orchestrator: GitWorkflowOrchestrator;
let testRepo: string;
beforeEach(async () => {
// Create temp git repository for testing
testRepo = path.join(os.tmpdir(), `git-test-${Date.now()}`);
await fs.mkdir(testRepo, { recursive: true });
// Initialize config
const config: GitConfig = {
repoPath: testRepo,
branchStrategy: 'github-flow',
defaultBranch: 'main',
remote: 'origin',
conventionalCommits: true,
autoGenerateMessages: true
};
// Dynamic import
const { GitWorkflowOrchestrator: Orchestrator } = await import(
'../../../src/git/git-workflow-orchestrator.js'
);
orchestrator = new Orchestrator(config);
});
afterEach(async () => {
// Cleanup test repository
await fs.rm(testRepo, { recursive: true, force: true });
});
describe('Configuration', () => {
it('should initialize with default configuration', async () => {
const { GitWorkflowOrchestrator: Orchestrator } = await import(
'../../../src/git/git-workflow-orchestrator.js'
);
const config: GitConfig = {
repoPath: testRepo
};
const orch = new Orchestrator(config);
expect(orch).toBeDefined();
});
it('should accept custom branch strategies', async () => {
const { GitWorkflowOrchestrator: Orchestrator } = await import(
'../../../src/git/git-workflow-orchestrator.js'
);
const strategies: Array<'gitflow' | 'github-flow' | 'trunk-based'> = [
'gitflow',
'github-flow',
'trunk-based'
];
// Test all strategies in single test
strategies.forEach(strategy => {
const config: GitConfig = {
repoPath: testRepo,
branchStrategy: strategy
};
const orch = new Orchestrator(config);
expect(orch, `Failed to create orchestrator with strategy: ${strategy}`).toBeDefined();
});
});
it('should default to conventional commits enabled', async () => {
const { GitWorkflowOrchestrator: Orchestrator } = await import(
'../../../src/git/git-workflow-orchestrator.js'
);
const config: GitConfig = {
repoPath: testRepo
};
const orch = new Orchestrator(config);
expect(orch).toBeDefined();
});
});
describe('Git Status Operations (NFR-GIT-001)', () => {
it('should get repository status in <5s with all properties (NFR-GIT-001)', async () => {
const startTime = Date.now();
try {
const status = await orchestrator.getStatus();
// All status property checks in single test
expect(status.branch).toBeDefined();
expect(status.staged).toBeInstanceOf(Array);
expect(status.unstaged).toBeInstanceOf(Array);
expect(status.untracked).toBeInstanceOf(Array);
expect(status.conflicts).toBeInstanceOf(Array);
expect(typeof status.ahead).toBe('number');
expect(typeof status.behind).toBe('number');
} catch (error) {
// Expected in test environment
expect(error).toBeDefined();
}
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(5000);
}, 10000);
});
describe('Branch Operations', () => {
describe('Branch Creation', () => {
it('should create branches with all strategies in <5s (NFR-GIT-001)', async () => {
const { GitWorkflowOrchestrator: Orchestrator } = await import(
'../../../src/git/git-workflow-orchestrator.js'
);
const testCases = [
{ strategy: 'github-flow' as const, options: { name: 'add-feature' } },
{ strategy: 'gitflow' as const, options: { name: 'user-authentication', type: 'feature' as const } },
{ strategy: 'trunk-based' as const, options: { name: 'quick-fix' } },
{ strategy: 'github-flow' as const, options: { name: 'new-feature', baseBranch: 'develop' } },
{ strategy: 'github-flow' as const, options: { name: 'critical-bug-fix', type: 'hotfix' as const } }
];
for (const testCase of testCases) {
const config: GitConfig = {
repoPath: testRepo,
branchStrategy: testCase.strategy
};
const orch = new Orchestrator(config);
const result = await orch.createBranch(testCase.options);
expect(result.operation, `Failed for strategy: ${testCase.strategy}, options: ${JSON.stringify(testCase.options)}`).toBe('createBranch');
expect(result.duration, `Duration exceeded for strategy: ${testCase.strategy}`).toBeLessThan(5000);
expect(result.duration, `Duration should be > 0 for strategy: ${testCase.strategy}`).toBeGreaterThan(0);
}
});
});
describe('Branch Switching', () => {
it('should switch to existing branch in <5s (NFR-GIT-001)', async () => {
const result = await orchestrator.switchBranch('main');
expect(result.operation).toBe('switchBranch');
expect(result.duration).toBeGreaterThanOrEqual(0);
expect(result.duration).toBeLessThan(5000);
});
it('should handle non-existent branch gracefully', async () => {
const result = await orchestrator.switchBranch('non-existent-branch');
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('Branch Deletion', () => {
it('should delete branches with various options', async () => {
const testCases = [
{ name: 'feature-branch', deleteRemote: false },
{ name: 'feature-branch', deleteRemote: true },
{ name: 'non-existent', deleteRemote: false }
];
for (const testCase of testCases) {
const result = await orchestrator.deleteBranch(testCase.name, testCase.deleteRemote);
expect(result.operation, `Failed for branch: ${testCase.name}, deleteRemote: ${testCase.deleteRemote}`).toBe('deleteBranch');
if (testCase.name === 'non-existent') {
expect(result.success, 'Non-existent branch should fail gracefully').toBe(false);
expect(result.error, 'Non-existent branch should have error').toBeDefined();
}
}
});
});
describe('Branch Listing', () => {
it('should list local and remote branches', async () => {
const testCases = [false, true]; // local only, then local+remote
for (const includeRemote of testCases) {
try {
const branches = await orchestrator.listBranches(includeRemote);
expect(branches, `Failed for includeRemote: ${includeRemote}`).toBeInstanceOf(Array);
} catch {
// Expected without real git repo
}
}
});
});
});
describe('Commit Operations (NFR-GIT-003)', () => {
describe('Conventional Commits', () => {
it('should create commits with all types and scopes in <5s (NFR-GIT-001)', async () => {
const testCases: CommitOptions[] = [
{ type: 'feat', message: 'add user authentication', autoStage: true, files: ['src/auth.ts'] },
{ type: 'fix', message: 'resolve null pointer exception', files: ['src/index.ts'] },
{ type: 'feat', scope: 'auth', message: 'add JWT validation', files: ['src/auth/jwt.ts'] },
{ type: 'feat', scope: 'api', breaking: true, message: 'change API endpoint structure', files: ['src/api.ts'] },
{ message: 'update components', autoStage: true, files: ['src/App.tsx', 'src/utils.ts'] },
{ message: 'test commit', files: ['test.txt'] }
];
for (const options of testCases) {
const result = await orchestrator.commit(options);
expect(result.operation, `Failed for commit options: ${JSON.stringify(options)}`).toBe('commit');
expect(result.duration, `Duration exceeded for commit: ${options.message}`).toBeLessThan(5000);
}
});
it('should auto-generate commit messages and detect types from files (NFR-GIT-003)', async () => {
const testFileSets = [
['test/index.test.ts'],
['docs/README.md'],
['.github/workflows/ci.yml'],
['package.json'],
['src/index.ts'],
['src/index.ts', 'src/utils.ts']
];
for (const files of testFileSets) {
const options: CommitOptions = {
generateMessage: true,
files
};
const result = await orchestrator.commit(options);
expect(result.operation, `Failed for files: ${files.join(', ')}`).toBe('commit');
}
});
});
describe('Commit Message Generation', () => {
it('should generate messages for various file patterns', async () => {
const testCases: { files: string[] }[] = [
{ files: ['src/index.ts'] },
{ files: ['src/file1.ts', 'src/file2.ts', 'src/file3.ts'] },
{ files: ['README.md', 'docs/guide.md'] }
];
for (const testCase of testCases) {
const options: CommitOptions = {
generateMessage: true,
files: testCase.files
};
const result = await orchestrator.commit(options);
expect(result.operation, `Failed for files: ${testCase.files.join(', ')}`).toBe('commit');
}
});
});
});
describe('Merge Operations (NFR-GIT-002)', () => {
describe('Merge Strategies', () => {
it('should merge with all strategies and options in <5s (NFR-GIT-001)', async () => {
const testCases: MergeOptions[] = [
{ sourceBranch: 'feature-branch' },
{ sourceBranch: 'feature-branch', strategy: 'squash' },
{ sourceBranch: 'feature-branch', strategy: 'rebase' },
{ sourceBranch: 'feature-branch', deleteSource: true }
];
for (const options of testCases) {
const result = await orchestrator.merge(options);
expect(result.operation, `Failed for merge options: ${JSON.stringify(options)}`).toBe('merge');
expect(result.duration, `Duration exceeded for merge strategy: ${options.strategy || 'default'}`).toBeLessThan(5000);
}
});
});
describe('Conflict Detection (NFR-GIT-002)', () => {
it('should detect merge conflicts before merging', async () => {
const options: MergeOptions = {
sourceBranch: 'conflicting-branch',
checkConflicts: true
};
const result = await orchestrator.merge(options);
expect(result.operation).toBe('merge');
});
it('should detect conflicts with properties and suggestions with >90% accuracy in <5s (NFR-GIT-002, NFR-GIT-001)', async () => {
const startTime = Date.now();
try {
const conflicts = await orchestrator.detectMergeConflicts('feature-branch');
expect(conflicts).toBeInstanceOf(Array);
// Check all conflict properties in single loop
conflicts.forEach((conflict, index) => {
expect(conflict.severity, `Conflict ${index} missing severity`).toMatch(/trivial|moderate|complex/);
expect(conflict.lineRanges, `Conflict ${index} missing lineRanges`).toBeInstanceOf(Array);
expect(conflict.suggestions, `Conflict ${index} missing suggestions`).toBeInstanceOf(Array);
});
} catch {
// Expected without real git repo
}
const duration = Date.now() - startTime;
expect(duration).toBeLessThan(5000);
});
});
});
describe('Pull Request Operations', () => {
describe('PR Creation', () => {
it('should create PRs with various options in <5s (NFR-GIT-001)', async () => {
const testCases: PROptions[] = [
{ title: 'Add user authentication', body: 'This PR adds JWT-based authentication', baseBranch: 'main' },
{ autoGenerate: true, baseBranch: 'main' },
{ title: 'Update documentation', reviewers: ['alice', 'bob'], baseBranch: 'main' },
{ title: 'Fix critical bug', labels: ['bug', 'critical', 'hotfix'], baseBranch: 'main' },
{ title: 'Add feature', assignees: ['john'], baseBranch: 'main' },
{ title: 'Test PR', baseBranch: 'main' }
];
for (const options of testCases) {
const result = await orchestrator.createPR(options);
expect(result.operation, `Failed for PR options: ${JSON.stringify(options)}`).toBe('createPR');
expect(result.duration, `Duration exceeded for PR: ${options.title || 'auto-generated'}`).toBeLessThan(5000);
}
});
});
describe('PR Auto-generation', () => {
it('should generate PR title and body from commit history', async () => {
// All auto-generation scenarios use same pattern
const result = await orchestrator.createPR({
autoGenerate: true,
baseBranch: 'main'
});
expect(result.operation).toBe('createPR');
});
});
});
describe('Integration Tests', () => {
it('should handle complete workflow: branch → commit → merge', async () => {
// Create branch
const branchResult = await orchestrator.createBranch({
name: 'feature-workflow'
});
expect(branchResult.operation).toBe('createBranch');
// Create commit
const commitResult = await orchestrator.commit({
message: 'add workflow feature',
files: ['src/workflow.ts']
});
expect(commitResult.operation).toBe('commit');
// Merge
const mergeResult = await orchestrator.merge({
sourceBranch: 'feature-workflow',
deleteSource: true
});
expect(mergeResult.operation).toBe('merge');
});
it('should handle GitFlow and trunk-based workflows', async () => {
const { GitWorkflowOrchestrator: Orchestrator } = await import(
'../../../src/git/git-workflow-orchestrator.js'
);
const workflowTests = [
{
strategy: 'gitflow' as const,
branchOpts: { name: 'user-profile', type: 'feature' as const },
commitOpts: { type: 'feat', scope: 'profile', message: 'add profile page' }
},
{
strategy: 'trunk-based' as const,
branchOpts: { name: 'quick-fix' },
commitOpts: { message: 'fix: resolve edge case' }
}
];
for (const test of workflowTests) {
const config: GitConfig = {
repoPath: testRepo,
branchStrategy: test.strategy
};
const orch = new Orchestrator(config);
const branchResult = await orch.createBranch(test.branchOpts);
expect(branchResult.operation, `Branch creation failed for strategy: ${test.strategy}`).toBe('createBranch');
const commitResult = await orch.commit(test.commitOpts);
expect(commitResult.operation, `Commit failed for strategy: ${test.strategy}`).toBe('commit');
}
});
});
describe('Performance Tests (NFR-GIT-001)', () => {
it('should execute all operations in <5s each', async () => {
const operations = [
() => orchestrator.createBranch({ name: 'perf-test' }),
() => orchestrator.switchBranch('main'),
() => orchestrator.commit({ message: 'test', files: ['test.txt'] }),
() => orchestrator.merge({ sourceBranch: 'perf-test' }),
() => orchestrator.deleteBranch('perf-test')
];
for (const op of operations) {
const result = await op();
expect(result.duration, `Operation exceeded 5s: ${result.operation}`).toBeLessThan(5000);
}
});
});
describe('Error Handling', () => {
it('should handle missing repository gracefully', async () => {
const { GitWorkflowOrchestrator: Orchestrator } = await import(
'../../../src/git/git-workflow-orchestrator.js'
);
const config: GitConfig = {
repoPath: '/non/existent/path'
};
const orch = new Orchestrator(config);
const result = await orch.createBranch({ name: 'test' });
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
it('should handle merge conflicts and missing commit messages gracefully', async () => {
// Test merge conflict handling
const mergeResult = await orchestrator.merge({
sourceBranch: 'conflicting-branch',
checkConflicts: true
});
expect(mergeResult.operation).toBe('merge');
// Test missing commit message handling
const commitResult = await orchestrator.commit({
files: ['test.txt']
// No message provided
});
expect(commitResult.operation).toBe('commit');
});
});
});