feat: start of git integration
This commit is contained in:
177
tests/engine/GitEngine.test.ts
Normal file
177
tests/engine/GitEngine.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const mockVersion = vi.fn();
|
||||
const mockCheckIsRepo = vi.fn();
|
||||
const mockRevparse = vi.fn();
|
||||
const mockStatus = vi.fn();
|
||||
const mockInit = vi.fn();
|
||||
const mockRaw = vi.fn();
|
||||
const mockAdd = vi.fn();
|
||||
const mockGetRemotes = vi.fn();
|
||||
const mockAddRemote = vi.fn();
|
||||
const mockRemote = vi.fn();
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
simpleGit: vi.fn(() => ({
|
||||
version: mockVersion,
|
||||
checkIsRepo: mockCheckIsRepo,
|
||||
revparse: mockRevparse,
|
||||
status: mockStatus,
|
||||
init: mockInit,
|
||||
raw: mockRaw,
|
||||
add: mockAdd,
|
||||
getRemotes: mockGetRemotes,
|
||||
addRemote: mockAddRemote,
|
||||
remote: mockRemote,
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GitEngine } from '../../src/main/engine/GitEngine';
|
||||
|
||||
describe('GitEngine', () => {
|
||||
let gitEngine: GitEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
gitEngine = new GitEngine();
|
||||
});
|
||||
|
||||
describe('checkAvailability', () => {
|
||||
it('should return gitFound true with version when git is available', async () => {
|
||||
mockVersion.mockResolvedValue({
|
||||
major: 2,
|
||||
minor: 49,
|
||||
patch: 0,
|
||||
agent: 'git/version',
|
||||
installed: true,
|
||||
});
|
||||
|
||||
const result = await gitEngine.checkAvailability();
|
||||
|
||||
expect(result).toEqual({ gitFound: true, version: '2.49.0' });
|
||||
});
|
||||
|
||||
it('should return gitFound false when git is not available', async () => {
|
||||
mockVersion.mockRejectedValue(new Error('git: command not found'));
|
||||
|
||||
const result = await gitEngine.checkAvailability();
|
||||
|
||||
expect(result).toEqual({ gitFound: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRepoState', () => {
|
||||
it('should return non-repo state when project is not a git repository', async () => {
|
||||
mockCheckIsRepo.mockResolvedValue(false);
|
||||
|
||||
const result = await gitEngine.getRepoState('/tmp/project');
|
||||
|
||||
expect(result).toEqual({
|
||||
isRepo: false,
|
||||
hasRemote: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return repo details for a valid repository', async () => {
|
||||
mockCheckIsRepo.mockResolvedValue(true);
|
||||
mockRevparse.mockResolvedValue('/tmp/project');
|
||||
mockStatus.mockResolvedValue({
|
||||
current: 'main',
|
||||
tracking: 'origin/main',
|
||||
});
|
||||
|
||||
const result = await gitEngine.getRepoState('/tmp/project');
|
||||
|
||||
expect(result).toEqual({
|
||||
isRepo: true,
|
||||
rootPath: '/tmp/project',
|
||||
currentBranch: 'main',
|
||||
hasRemote: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('should normalize changed files and counts from git status', async () => {
|
||||
mockStatus.mockResolvedValue({
|
||||
not_added: ['new-file.md'],
|
||||
modified: ['edited.md'],
|
||||
deleted: ['removed.md'],
|
||||
renamed: [{ from: 'old.md', to: 'new.md' }],
|
||||
created: ['staged.md'],
|
||||
});
|
||||
|
||||
const result = await gitEngine.getStatus('/tmp/project');
|
||||
|
||||
expect(result.counts).toEqual({
|
||||
untracked: 1,
|
||||
modified: 1,
|
||||
deleted: 1,
|
||||
renamed: 1,
|
||||
staged: 1,
|
||||
total: 5,
|
||||
});
|
||||
expect(result.files).toEqual([
|
||||
{ path: 'new-file.md', status: 'untracked' },
|
||||
{ path: 'edited.md', status: 'modified' },
|
||||
{ path: 'removed.md', status: 'deleted' },
|
||||
{ path: 'new.md', status: 'renamed', previousPath: 'old.md' },
|
||||
{ path: 'staged.md', status: 'staged' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeRepo', () => {
|
||||
it('should initialize git repo, configure lfs and track image patterns', async () => {
|
||||
mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true });
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockRaw.mockResolvedValue('ok');
|
||||
mockAdd.mockResolvedValue(undefined);
|
||||
|
||||
const result = await gitEngine.initializeRepo('/tmp/project');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockInit).toHaveBeenCalled();
|
||||
expect(mockRaw).toHaveBeenCalledWith(['lfs', 'install', '--local']);
|
||||
expect(mockAdd).toHaveBeenCalledWith('.gitattributes');
|
||||
expect(mockAddRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should configure origin remote when a remote url is provided', async () => {
|
||||
mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true });
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockRaw.mockResolvedValue('ok');
|
||||
mockAdd.mockResolvedValue(undefined);
|
||||
mockGetRemotes.mockResolvedValue([]);
|
||||
mockAddRemote.mockResolvedValue(undefined);
|
||||
|
||||
const result = await gitEngine.initializeRepo('/tmp/project', 'https://github.com/example/repo.git');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockGetRemotes).toHaveBeenCalledWith(true);
|
||||
expect(mockAddRemote).toHaveBeenCalledWith('origin', 'https://github.com/example/repo.git');
|
||||
});
|
||||
|
||||
it('should return explicit git missing guidance when git is unavailable', async () => {
|
||||
mockVersion.mockRejectedValue(new Error('git: command not found'));
|
||||
|
||||
const result = await gitEngine.initializeRepo('/tmp/project');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.code).toBe('git-missing');
|
||||
expect(result.error).toContain('install Git');
|
||||
});
|
||||
|
||||
it('should return explicit git-lfs missing guidance when lfs command fails', async () => {
|
||||
mockVersion.mockResolvedValue({ major: 2, minor: 49, patch: 0, agent: 'git/version', installed: true });
|
||||
mockInit.mockResolvedValue(undefined);
|
||||
mockRaw.mockRejectedValue(new Error('git: lfs is not a git command'));
|
||||
|
||||
const result = await gitEngine.initializeRepo('/tmp/project');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.code).toBe('git-lfs-missing');
|
||||
expect(result.error).toContain('install Git LFS');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -139,6 +139,13 @@ const mockPostMediaEngine = {
|
||||
rebuildFromSidecars: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGitEngine = {
|
||||
checkAvailability: vi.fn(),
|
||||
getRepoState: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
initializeRepo: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTaskManager = {
|
||||
getAllTasks: vi.fn(),
|
||||
cancelTask: vi.fn(),
|
||||
@@ -193,6 +200,10 @@ vi.mock('../../src/main/engine/PostMediaEngine', () => ({
|
||||
getPostMediaEngine: vi.fn(() => mockPostMediaEngine),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/GitEngine', () => ({
|
||||
getGitEngine: vi.fn(() => mockGitEngine),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/main/engine/TaskManager', () => ({
|
||||
taskManager: mockTaskManager,
|
||||
TaskProgress: {},
|
||||
@@ -241,6 +252,81 @@ describe('IPC Handlers', () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// ============ Git Handlers ============
|
||||
describe('Git Handlers', () => {
|
||||
describe('git:checkAvailability', () => {
|
||||
it('should return availability from GitEngine', async () => {
|
||||
mockGitEngine.checkAvailability.mockResolvedValue({ gitFound: true, version: '2.49.0' });
|
||||
|
||||
const result = await invokeHandler('git:checkAvailability');
|
||||
|
||||
expect(mockGitEngine.checkAvailability).toHaveBeenCalled();
|
||||
expect(result).toEqual({ gitFound: true, version: '2.49.0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('git:getRepoState', () => {
|
||||
it('should pass project path to GitEngine.getRepoState', async () => {
|
||||
mockGitEngine.getRepoState.mockResolvedValue({
|
||||
isRepo: true,
|
||||
rootPath: '/repo',
|
||||
currentBranch: 'main',
|
||||
hasRemote: true,
|
||||
});
|
||||
|
||||
const result = await invokeHandler('git:getRepoState', '/repo');
|
||||
|
||||
expect(mockGitEngine.getRepoState).toHaveBeenCalledWith('/repo');
|
||||
expect(result).toEqual({
|
||||
isRepo: true,
|
||||
rootPath: '/repo',
|
||||
currentBranch: 'main',
|
||||
hasRemote: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('git:status', () => {
|
||||
it('should pass project path to GitEngine.getStatus', async () => {
|
||||
mockGitEngine.getStatus.mockResolvedValue({
|
||||
files: [{ path: 'file.md', status: 'modified' }],
|
||||
counts: {
|
||||
untracked: 0,
|
||||
modified: 1,
|
||||
deleted: 0,
|
||||
renamed: 0,
|
||||
staged: 0,
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await invokeHandler('git:status', '/repo');
|
||||
|
||||
expect(mockGitEngine.getStatus).toHaveBeenCalledWith('/repo');
|
||||
expect(result.counts.total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('git:init', () => {
|
||||
it('should pass project path to GitEngine.initializeRepo', async () => {
|
||||
mockGitEngine.initializeRepo.mockResolvedValue({ success: true });
|
||||
|
||||
const result = await invokeHandler('git:init', '/repo');
|
||||
|
||||
expect(mockGitEngine.initializeRepo).toHaveBeenCalledWith('/repo');
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should pass optional remote url to GitEngine.initializeRepo', async () => {
|
||||
mockGitEngine.initializeRepo.mockResolvedValue({ success: true });
|
||||
|
||||
await invokeHandler('git:init', '/repo', 'https://github.com/example/repo.git');
|
||||
|
||||
expect(mockGitEngine.initializeRepo).toHaveBeenCalledWith('/repo', 'https://github.com/example/repo.git');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============ Project Handlers ============
|
||||
describe('Project Handlers', () => {
|
||||
describe('projects:create', () => {
|
||||
|
||||
92
tests/renderer/components/GitSidebar.test.tsx
Normal file
92
tests/renderer/components/GitSidebar.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { GitSidebar } from '../../../src/renderer/components/GitSidebar/GitSidebar';
|
||||
import { useAppStore } from '../../../src/renderer/store';
|
||||
|
||||
describe('GitSidebar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
useAppStore.setState({
|
||||
activeProject: {
|
||||
id: 'project-1',
|
||||
name: 'Test Project',
|
||||
slug: 'test-project',
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
(window as any).electronAPI = {
|
||||
...(window as any).electronAPI,
|
||||
app: {
|
||||
...(window as any).electronAPI?.app,
|
||||
getDefaultProjectPath: vi.fn().mockResolvedValue('/repo/path'),
|
||||
},
|
||||
git: {
|
||||
checkAvailability: vi.fn().mockResolvedValue({ gitFound: true, version: '2.49.0' }),
|
||||
getRepoState: vi.fn().mockResolvedValue({ isRepo: false, hasRemote: false }),
|
||||
init: vi.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('shows Initialize Git button when active project is not a git repository', async () => {
|
||||
render(<GitSidebar />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /initialize git/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes repository and refreshes repo state after clicking Initialize Git', async () => {
|
||||
const getRepoStateMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ isRepo: false, hasRemote: false })
|
||||
.mockResolvedValueOnce({ isRepo: true, rootPath: '/repo/path', currentBranch: 'main', hasRemote: true });
|
||||
|
||||
(window as any).electronAPI.git.getRepoState = getRepoStateMock;
|
||||
(window as any).electronAPI.git.init = vi.fn().mockResolvedValue({ success: true });
|
||||
|
||||
render(<GitSidebar />);
|
||||
|
||||
const initButton = await screen.findByRole('button', { name: /initialize git/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(initButton);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((window as any).electronAPI.git.init).toHaveBeenCalledWith('/repo/path');
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText(/git repository ready/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes remote url to init when user provides one', async () => {
|
||||
(window as any).electronAPI.git.init = vi.fn().mockResolvedValue({ success: true });
|
||||
(window as any).electronAPI.git.getRepoState = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ isRepo: false, hasRemote: false })
|
||||
.mockResolvedValueOnce({ isRepo: true, rootPath: '/repo/path', currentBranch: 'main', hasRemote: true });
|
||||
|
||||
render(<GitSidebar />);
|
||||
|
||||
const remoteInput = await screen.findByPlaceholderText(/optional remote repository url/i);
|
||||
await act(async () => {
|
||||
fireEvent.change(remoteInput, { target: { value: 'https://github.com/example/repo.git' } });
|
||||
});
|
||||
|
||||
expect((remoteInput as HTMLInputElement).value).toBe('https://github.com/example/repo.git');
|
||||
|
||||
const initButton = screen.getByRole('button', { name: /initialize git/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(initButton);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((window as any).electronAPI.git.init).toHaveBeenCalledWith('/repo/path', 'https://github.com/example/repo.git');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,12 @@ Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
localStorage: localStorageMock,
|
||||
electronAPI: {
|
||||
git: {
|
||||
checkAvailability: vi.fn(),
|
||||
getRepoState: vi.fn(),
|
||||
getStatus: vi.fn(),
|
||||
init: vi.fn(),
|
||||
},
|
||||
posts: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user