agent-ecosystem/test/main/services/editor/GitStatusService.test.ts
iliya 5b0c7d13fc feat: add project editor with drag & drop file management
- Backend: ProjectFileService with file CRUD, search, git status, file watcher
- IPC: 12 editor channels with security validation and path containment
- Store: editorSlice with multi-tab management, draft persistence, conflict detection
- UI: CodeMirror 6 editor, file tree with DnD, search-in-files, context menus
- Move: fs.rename with EXDEV fallback, full path remapping across all caches
- Tests: comprehensive coverage for services, IPC handlers, store, and utilities
2026-02-28 23:40:41 +02:00

217 lines
6.3 KiB
TypeScript

/**
* Tests for GitStatusService — caching, error handling, status mapping.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock simple-git
const mockStatus = vi.fn();
const mockRevparse = vi.fn();
const mockEnv = vi.fn();
vi.mock('simple-git', () => ({
simpleGit: vi.fn(() => {
const git = {
status: mockStatus,
revparse: mockRevparse,
env: mockEnv,
};
mockEnv.mockReturnValue(git);
return git;
}),
}));
vi.mock('@shared/utils/logger', () => ({
createLogger: () => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
}),
}));
import { simpleGit } from 'simple-git';
import {
GitStatusService,
mapStatusResult,
} from '../../../../src/main/services/editor/GitStatusService';
import type { StatusResult } from 'simple-git';
// =============================================================================
// Helpers
// =============================================================================
function createMockStatusResult(overrides: Partial<StatusResult> = {}): StatusResult {
return {
not_added: [],
conflicted: [],
created: [],
deleted: [],
ignored: [],
modified: [],
renamed: [],
staged: [],
files: [],
ahead: 0,
behind: 0,
current: 'main',
tracking: 'origin/main',
detached: false,
isClean: () => true,
...overrides,
} as StatusResult;
}
// =============================================================================
// Tests
// =============================================================================
describe('GitStatusService', () => {
let service: GitStatusService;
beforeEach(() => {
vi.resetAllMocks();
service = new GitStatusService();
});
describe('init', () => {
it('initializes simple-git with project root and GIT_OPTIONAL_LOCKS=0', () => {
service.init('/Users/test/project');
expect(vi.mocked(simpleGit)).toHaveBeenCalledWith({
baseDir: '/Users/test/project',
timeout: { block: 10_000 },
});
expect(mockEnv).toHaveBeenCalledWith('GIT_OPTIONAL_LOCKS', '0');
});
});
describe('getStatus', () => {
it('returns empty non-repo result when not initialized', async () => {
const result = await service.getStatus();
expect(result).toEqual({ files: [], isGitRepo: false, branch: null });
});
it('returns isGitRepo: false for non-git directories', async () => {
mockRevparse.mockRejectedValue(new Error('not a git repo'));
service.init('/Users/test/not-a-repo');
const result = await service.getStatus();
expect(result.isGitRepo).toBe(false);
expect(result.files).toEqual([]);
expect(result.branch).toBeNull();
});
it('returns file statuses for a git repo', async () => {
mockRevparse.mockResolvedValue('true');
mockStatus.mockResolvedValue(
createMockStatusResult({
modified: ['src/index.ts'],
not_added: ['new-file.txt'],
deleted: ['old.ts'],
current: 'feature-branch',
})
);
service.init('/Users/test/project');
const result = await service.getStatus();
expect(result.isGitRepo).toBe(true);
expect(result.branch).toBe('feature-branch');
expect(result.files).toContainEqual({ path: 'src/index.ts', status: 'modified' });
expect(result.files).toContainEqual({ path: 'new-file.txt', status: 'untracked' });
expect(result.files).toContainEqual({ path: 'old.ts', status: 'deleted' });
});
it('caches results within TTL (5s)', async () => {
mockRevparse.mockResolvedValue('true');
mockStatus.mockResolvedValue(createMockStatusResult({ modified: ['a.ts'] }));
service.init('/Users/test/project');
// First call → hits git
await service.getStatus();
expect(mockStatus).toHaveBeenCalledTimes(1);
// Second call within TTL → cached
await service.getStatus();
expect(mockStatus).toHaveBeenCalledTimes(1);
});
it('invalidateCache forces re-fetch', async () => {
mockRevparse.mockResolvedValue('true');
mockStatus.mockResolvedValue(createMockStatusResult({ modified: ['a.ts'] }));
service.init('/Users/test/project');
await service.getStatus();
expect(mockStatus).toHaveBeenCalledTimes(1);
service.invalidateCache();
await service.getStatus();
expect(mockStatus).toHaveBeenCalledTimes(2);
});
it('returns empty result on git error (graceful degradation)', async () => {
mockRevparse.mockResolvedValue('true');
mockStatus.mockRejectedValue(new Error('git timeout'));
service.init('/Users/test/project');
const result = await service.getStatus();
expect(result).toEqual({ files: [], isGitRepo: false, branch: null });
});
});
describe('destroy', () => {
it('resets all internal state', async () => {
mockRevparse.mockResolvedValue('true');
mockStatus.mockResolvedValue(createMockStatusResult());
service.init('/Users/test/project');
await service.getStatus();
service.destroy();
// After destroy, should return empty result (no git instance)
const result = await service.getStatus();
expect(result).toEqual({ files: [], isGitRepo: false, branch: null });
});
});
});
describe('mapStatusResult', () => {
it('maps all status categories', () => {
const statusResult = createMockStatusResult({
modified: ['a.ts'],
not_added: ['b.ts'],
staged: ['c.ts'],
deleted: ['d.ts'],
conflicted: ['e.ts'],
renamed: [{ from: 'old.ts', to: 'new.ts' }] as StatusResult['renamed'],
});
const files = mapStatusResult(statusResult);
expect(files).toContainEqual({ path: 'a.ts', status: 'modified' });
expect(files).toContainEqual({ path: 'b.ts', status: 'untracked' });
expect(files).toContainEqual({ path: 'c.ts', status: 'staged' });
expect(files).toContainEqual({ path: 'd.ts', status: 'deleted' });
expect(files).toContainEqual({ path: 'e.ts', status: 'conflict' });
expect(files).toContainEqual({
path: 'new.ts',
status: 'renamed',
renamedFrom: 'old.ts',
});
});
it('returns empty array for clean repo', () => {
const statusResult = createMockStatusResult();
const files = mapStatusResult(statusResult);
expect(files).toEqual([]);
});
});