Implemented GitIntegration component showing live git status per workspace: - Display current branch with tracking info (ahead/behind) - Show staged, unstaged, and untracked files with status icons - Display recent commits with hash, time, and message - Detect and highlight merge conflicts - Keyboard shortcuts: [I] toggle view, [r] refresh, [c] clear - Full test coverage (17 tests passing) - Integrated into main TUI app with view mode toggle 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Worker <noreply@anthropic.com>
357 lines
9.4 KiB
TypeScript
357 lines
9.4 KiB
TypeScript
/**
|
|
* Tests for GitIntegration Component
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
// Mock blessed module before importing GitIntegration
|
|
vi.mock('blessed', () => {
|
|
const mockBoxInstance = {
|
|
setContent: vi.fn(),
|
|
setLabel: vi.fn(),
|
|
focus: vi.fn(),
|
|
key: vi.fn(),
|
|
show: vi.fn(),
|
|
hide: vi.fn(),
|
|
screen: {
|
|
render: vi.fn(),
|
|
},
|
|
visible: false,
|
|
};
|
|
|
|
const mockBox = vi.fn(() => {
|
|
// Return a new object each time to simulate separate instances
|
|
return {
|
|
...mockBoxInstance,
|
|
screen: { render: vi.fn() },
|
|
};
|
|
});
|
|
|
|
return {
|
|
default: {
|
|
box: mockBox,
|
|
},
|
|
box: mockBox,
|
|
};
|
|
});
|
|
|
|
// Import after mocking
|
|
import { GitIntegration } from './GitIntegration.js';
|
|
import { GitEvent, GitStatusEvent, GitCommitEvent, GitFileChange } from '../../types.js';
|
|
|
|
// Helper to create mock screen
|
|
function createMockScreen() {
|
|
return {
|
|
render: vi.fn(),
|
|
} as any;
|
|
}
|
|
|
|
describe('GitIntegration', () => {
|
|
let screen: any;
|
|
let gitIntegration: GitIntegration;
|
|
|
|
beforeEach(() => {
|
|
screen = createMockScreen();
|
|
gitIntegration = new GitIntegration({
|
|
parent: screen,
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: 20,
|
|
});
|
|
});
|
|
|
|
describe('initialization', () => {
|
|
it('should create GitIntegration component', () => {
|
|
expect(gitIntegration).toBeDefined();
|
|
expect(gitIntegration.getElement()).toBeDefined();
|
|
});
|
|
|
|
it('should start with no conflicts', () => {
|
|
expect(gitIntegration.hasConflicts()).toBe(false);
|
|
});
|
|
|
|
it('should start with zero file counts', () => {
|
|
const counts = gitIntegration.getFileCounts();
|
|
expect(counts.staged).toBe(0);
|
|
expect(counts.unstaged).toBe(0);
|
|
expect(counts.untracked).toBe(0);
|
|
});
|
|
|
|
it('should start with no commits', () => {
|
|
expect(gitIntegration.getCommitsCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('updateGitEvents', () => {
|
|
it('should update with status event', () => {
|
|
const statusEvent: GitStatusEvent = {
|
|
id: 'ge-1',
|
|
type: 'status',
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
branch: 'main',
|
|
staged: [
|
|
{ path: 'file1.ts', status: 'modified', staged: true },
|
|
{ path: 'file2.ts', status: 'added', staged: true },
|
|
],
|
|
unstaged: [
|
|
{ path: 'file3.ts', status: 'modified', staged: false },
|
|
],
|
|
untracked: ['file4.ts'],
|
|
};
|
|
|
|
gitIntegration.updateGitEvents([statusEvent]);
|
|
|
|
expect(gitIntegration.getCurrentBranch()).toBe('main');
|
|
const counts = gitIntegration.getFileCounts();
|
|
expect(counts.staged).toBe(2);
|
|
expect(counts.unstaged).toBe(1);
|
|
expect(counts.untracked).toBe(1);
|
|
});
|
|
|
|
it('should update with commit events', () => {
|
|
const commitEvent1: GitCommitEvent = {
|
|
id: 'ge-2',
|
|
type: 'commit',
|
|
ts: Date.now() - 1000,
|
|
worker: 'w-test',
|
|
hash: 'abc1234567890',
|
|
message: 'First commit',
|
|
branch: 'main',
|
|
};
|
|
|
|
const commitEvent2: GitCommitEvent = {
|
|
id: 'ge-3',
|
|
type: 'commit',
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
hash: 'def0987654321',
|
|
message: 'Second commit',
|
|
branch: 'main',
|
|
};
|
|
|
|
gitIntegration.updateGitEvents([commitEvent1, commitEvent2]);
|
|
|
|
expect(gitIntegration.getCommitsCount()).toBe(2);
|
|
});
|
|
|
|
it('should limit recent commits to maxCommits', () => {
|
|
const commits: GitCommitEvent[] = [];
|
|
for (let i = 0; i < 10; i++) {
|
|
commits.push({
|
|
id: `ge-${i}`,
|
|
type: 'commit',
|
|
ts: Date.now() - (10 - i) * 1000,
|
|
worker: 'w-test',
|
|
hash: `hash${i}`,
|
|
message: `Commit ${i}`,
|
|
branch: 'main',
|
|
});
|
|
}
|
|
|
|
const gitIntWithLimit = new GitIntegration({
|
|
parent: screen,
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: 20,
|
|
maxCommits: 5,
|
|
});
|
|
|
|
gitIntWithLimit.updateGitEvents(commits);
|
|
expect(gitIntWithLimit.getCommitsCount()).toBe(5);
|
|
});
|
|
|
|
it('should detect conflicts from unmerged files', () => {
|
|
const statusEvent: GitStatusEvent = {
|
|
id: 'ge-1',
|
|
type: 'status',
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
branch: 'feature-branch',
|
|
staged: [
|
|
{ path: 'conflicted.ts', status: 'unmerged', staged: true },
|
|
],
|
|
unstaged: [],
|
|
untracked: [],
|
|
};
|
|
|
|
gitIntegration.updateGitEvents([statusEvent]);
|
|
|
|
expect(gitIntegration.hasConflicts()).toBe(true);
|
|
});
|
|
|
|
it('should detect conflicts from unstaged unmerged files', () => {
|
|
const statusEvent: GitStatusEvent = {
|
|
id: 'ge-1',
|
|
type: 'status',
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
branch: 'feature-branch',
|
|
staged: [],
|
|
unstaged: [
|
|
{ path: 'conflicted.ts', status: 'unmerged', staged: false },
|
|
],
|
|
untracked: [],
|
|
};
|
|
|
|
gitIntegration.updateGitEvents([statusEvent]);
|
|
|
|
expect(gitIntegration.hasConflicts()).toBe(true);
|
|
});
|
|
|
|
it('should use latest status when multiple status events provided', () => {
|
|
const statusEvent1: GitStatusEvent = {
|
|
id: 'ge-1',
|
|
type: 'status',
|
|
ts: Date.now() - 2000,
|
|
worker: 'w-test',
|
|
branch: 'old-branch',
|
|
staged: [],
|
|
unstaged: [],
|
|
untracked: [],
|
|
};
|
|
|
|
const statusEvent2: GitStatusEvent = {
|
|
id: 'ge-2',
|
|
type: 'status',
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
branch: 'new-branch',
|
|
staged: [{ path: 'new.ts', status: 'added', staged: true }],
|
|
unstaged: [],
|
|
untracked: [],
|
|
};
|
|
|
|
gitIntegration.updateGitEvents([statusEvent1, statusEvent2]);
|
|
|
|
expect(gitIntegration.getCurrentBranch()).toBe('new-branch');
|
|
expect(gitIntegration.getFileCounts().staged).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('setWorkspace', () => {
|
|
it('should set workspace for a worker', () => {
|
|
// Should not throw
|
|
expect(() => {
|
|
gitIntegration.setWorkspace('w-test', '/home/coder/FABRIC');
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('clearHistory', () => {
|
|
it('should clear all git state', () => {
|
|
const statusEvent: GitStatusEvent = {
|
|
id: 'ge-1',
|
|
type: 'status',
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
branch: 'main',
|
|
staged: [{ path: 'file.ts', status: 'modified', staged: true }],
|
|
unstaged: [],
|
|
untracked: [],
|
|
};
|
|
|
|
gitIntegration.updateGitEvents([statusEvent]);
|
|
expect(gitIntegration.getCurrentBranch()).toBe('main');
|
|
|
|
gitIntegration.clearHistory();
|
|
|
|
expect(gitIntegration.getCurrentBranch()).toBeUndefined();
|
|
expect(gitIntegration.hasConflicts()).toBe(false);
|
|
expect(gitIntegration.getCommitsCount()).toBe(0);
|
|
const counts = gitIntegration.getFileCounts();
|
|
expect(counts.staged).toBe(0);
|
|
expect(counts.unstaged).toBe(0);
|
|
expect(counts.untracked).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('visibility', () => {
|
|
it('should show and hide panel', () => {
|
|
// Verify methods are callable without throwing
|
|
expect(() => {
|
|
gitIntegration.show();
|
|
gitIntegration.hide();
|
|
}).not.toThrow();
|
|
|
|
// Methods should be defined
|
|
expect(gitIntegration.show).toBeDefined();
|
|
expect(gitIntegration.hide).toBeDefined();
|
|
expect(gitIntegration.isVisible).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle empty events array', () => {
|
|
gitIntegration.updateGitEvents([]);
|
|
expect(gitIntegration.getCurrentBranch()).toBeUndefined();
|
|
expect(gitIntegration.getCommitsCount()).toBe(0);
|
|
});
|
|
|
|
it('should handle status with tracking info', () => {
|
|
const statusEvent: GitStatusEvent = {
|
|
id: 'ge-1',
|
|
type: 'status',
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
branch: 'feature',
|
|
staged: [],
|
|
unstaged: [],
|
|
untracked: [],
|
|
tracking: 'origin/feature',
|
|
ahead: 3,
|
|
behind: 1,
|
|
};
|
|
|
|
gitIntegration.updateGitEvents([statusEvent]);
|
|
expect(gitIntegration.getCurrentBranch()).toBe('feature');
|
|
});
|
|
|
|
it('should handle commit with file changes', () => {
|
|
const commitEvent: GitCommitEvent = {
|
|
id: 'ge-1',
|
|
type: 'commit',
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
hash: 'abc123',
|
|
message: 'Add feature\n\nDetailed description',
|
|
branch: 'main',
|
|
author: 'John Doe',
|
|
email: 'john@example.com',
|
|
files: [
|
|
{ path: 'src/feature.ts', status: 'added', staged: true },
|
|
{ path: 'src/index.ts', status: 'modified', staged: true },
|
|
],
|
|
};
|
|
|
|
gitIntegration.updateGitEvents([commitEvent]);
|
|
expect(gitIntegration.getCommitsCount()).toBe(1);
|
|
});
|
|
|
|
it('should handle file with renamed status', () => {
|
|
const statusEvent: GitStatusEvent = {
|
|
id: 'ge-1',
|
|
type: 'status',
|
|
ts: Date.now(),
|
|
worker: 'w-test',
|
|
branch: 'main',
|
|
staged: [
|
|
{
|
|
path: 'new-name.ts',
|
|
status: 'renamed',
|
|
staged: true,
|
|
originalPath: 'old-name.ts',
|
|
},
|
|
],
|
|
unstaged: [],
|
|
untracked: [],
|
|
};
|
|
|
|
gitIntegration.updateGitEvents([statusEvent]);
|
|
const counts = gitIntegration.getFileCounts();
|
|
expect(counts.staged).toBe(1);
|
|
});
|
|
});
|
|
});
|