feat: added auth checks and first-push checks.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const mockVersion = vi.fn();
|
||||
const mockEnv = vi.fn();
|
||||
const mockCheckIsRepo = vi.fn();
|
||||
const mockRevparse = vi.fn();
|
||||
const mockStatus = vi.fn();
|
||||
@@ -32,6 +33,7 @@ vi.mock('fs/promises', () => ({
|
||||
|
||||
vi.mock('simple-git', () => ({
|
||||
simpleGit: vi.fn(() => ({
|
||||
env: mockEnv,
|
||||
version: mockVersion,
|
||||
checkIsRepo: mockCheckIsRepo,
|
||||
revparse: mockRevparse,
|
||||
@@ -63,6 +65,26 @@ describe('GitEngine', () => {
|
||||
mockStat.mockResolvedValue({});
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
mockCheckIsRepo.mockResolvedValue(false);
|
||||
mockEnv.mockImplementation(() => ({
|
||||
env: mockEnv,
|
||||
version: mockVersion,
|
||||
checkIsRepo: mockCheckIsRepo,
|
||||
revparse: mockRevparse,
|
||||
status: mockStatus,
|
||||
diff: mockDiff,
|
||||
show: mockShow,
|
||||
log: mockLog,
|
||||
init: mockInit,
|
||||
raw: mockRaw,
|
||||
add: mockAdd,
|
||||
commit: mockCommit,
|
||||
fetch: mockFetch,
|
||||
pull: mockPull,
|
||||
push: mockPush,
|
||||
getRemotes: mockGetRemotes,
|
||||
addRemote: mockAddRemote,
|
||||
remote: mockRemote,
|
||||
}));
|
||||
gitEngine = new GitEngine();
|
||||
});
|
||||
|
||||
@@ -389,6 +411,7 @@ describe('GitEngine', () => {
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockGetRemotes).toHaveBeenCalledWith(true);
|
||||
expect(mockAddRemote).toHaveBeenCalledWith('origin', 'https://github.com/example/repo.git');
|
||||
expect(mockRaw).toHaveBeenCalledWith(['config', '--local', 'push.autoSetupRemote', 'true']);
|
||||
});
|
||||
|
||||
it('should succeed when there is nothing to commit after staging', async () => {
|
||||
@@ -464,6 +487,8 @@ describe('GitEngine', () => {
|
||||
|
||||
const result = await gitEngine.fetch('/tmp/project');
|
||||
|
||||
expect(mockEnv).toHaveBeenCalledWith('GIT_TERMINAL_PROMPT', '0');
|
||||
expect(mockEnv).toHaveBeenCalledWith('GCM_INTERACTIVE', 'never');
|
||||
expect(mockFetch).toHaveBeenCalledWith(['--prune']);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
@@ -486,6 +511,19 @@ describe('GitEngine', () => {
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should auto-set upstream and retry push when no upstream branch exists', async () => {
|
||||
mockPush
|
||||
.mockRejectedValueOnce(new Error('fatal: The current branch main has no upstream branch.'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
mockStatus.mockResolvedValue({ current: 'main' });
|
||||
|
||||
const result = await gitEngine.push('/tmp/project');
|
||||
|
||||
expect(mockPush).toHaveBeenNthCalledWith(1);
|
||||
expect(mockPush).toHaveBeenNthCalledWith(2, ['-u', 'origin', 'main']);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should stage all files and commit with message', async () => {
|
||||
mockAdd.mockResolvedValue(undefined);
|
||||
mockCommit.mockResolvedValue(undefined);
|
||||
@@ -504,5 +542,95 @@ describe('GitEngine', () => {
|
||||
expect(mockCommit).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: false, error: 'Commit message is required.' });
|
||||
});
|
||||
|
||||
it('should return auth-required code with OS guidance when fetch authentication fails', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('fatal: Authentication failed for https://example.com/repo.git'));
|
||||
mockGetRemotes.mockResolvedValue([]);
|
||||
|
||||
const result = await gitEngine.fetch('/tmp/project');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.code).toBe('auth-required');
|
||||
expect(result.error).toContain('Authentication required for remote Git action');
|
||||
expect(result.error).not.toContain('Original error');
|
||||
expect(Array.isArray(result.guidance)).toBe(true);
|
||||
expect(result.guidance!.length).toBeGreaterThan(0);
|
||||
expect(result.guidance!.join(' ')).not.toContain('GitHub detected');
|
||||
expect(result.guidance!.join(' ')).toContain('token');
|
||||
expect(result.guidance!.join(' ')).toContain('password');
|
||||
});
|
||||
|
||||
it('should include GitHub-specific guidance when remote points to github.com', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('fatal: Authentication failed'));
|
||||
mockGetRemotes.mockResolvedValue([
|
||||
{
|
||||
name: 'origin',
|
||||
refs: {
|
||||
fetch: 'https://github.com/example/repo.git',
|
||||
push: 'https://github.com/example/repo.git',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await gitEngine.fetch('/tmp/project');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.code).toBe('auth-required');
|
||||
expect(result.error).toContain('Detected provider: GitHub');
|
||||
expect(result.guidance).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('Personal Access Token'),
|
||||
expect.stringContaining('repo'),
|
||||
expect.stringContaining('git config --global credential.helper'),
|
||||
expect.stringContaining('git remote set-url origin'),
|
||||
]));
|
||||
});
|
||||
|
||||
it('should include GitLab-specific token guidance when remote points to gitlab', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('fatal: Authentication failed'));
|
||||
mockGetRemotes.mockResolvedValue([
|
||||
{
|
||||
name: 'origin',
|
||||
refs: {
|
||||
fetch: 'https://gitlab.com/example/repo.git',
|
||||
push: 'https://gitlab.com/example/repo.git',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await gitEngine.fetch('/tmp/project');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.code).toBe('auth-required');
|
||||
expect(result.error).toContain('Detected provider: GitLab');
|
||||
expect(result.guidance).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('User Settings > Access Tokens'),
|
||||
expect.stringContaining('read_repository'),
|
||||
expect.stringContaining('write_repository'),
|
||||
]));
|
||||
});
|
||||
|
||||
it('should include Gitea/Forgejo token guidance when remote appears self-hosted gitea/forgejo', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('fatal: Authentication failed'));
|
||||
mockGetRemotes.mockResolvedValue([
|
||||
{
|
||||
name: 'origin',
|
||||
refs: {
|
||||
fetch: 'https://forgejo.example.com/my/repo.git',
|
||||
push: 'https://forgejo.example.com/my/repo.git',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await gitEngine.fetch('/tmp/project');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.code).toBe('auth-required');
|
||||
expect(result.error).toContain('Detected provider: Gitea/Forgejo');
|
||||
expect(result.guidance).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('Settings > Applications'),
|
||||
expect.stringContaining('Access Tokens'),
|
||||
expect.stringContaining('repository'),
|
||||
]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -393,4 +393,63 @@ describe('GitSidebar', () => {
|
||||
{ type: 'post', id: 'post-1', isTransient: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows auth guidance when fetch fails due to authentication', async () => {
|
||||
(window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({
|
||||
isRepo: true,
|
||||
rootPath: '/repo/path',
|
||||
currentBranch: 'main',
|
||||
hasRemote: true,
|
||||
});
|
||||
(window as any).electronAPI.git.fetch = vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
code: 'auth-required',
|
||||
error: 'Authentication required for remote Git action. Detected provider: GitHub.',
|
||||
guidance: [
|
||||
'Create a GitHub Personal Access Token.',
|
||||
'Retry with username + token.',
|
||||
],
|
||||
});
|
||||
|
||||
render(<GitSidebar />);
|
||||
|
||||
const fetchButton = await screen.findByRole('button', { name: /fetch/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(fetchButton);
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/authentication required/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/create a github personal access token/i)).toHaveLength(1);
|
||||
expect(screen.getByText(/retry with username \+ token/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows in-progress feedback while push is running', async () => {
|
||||
let resolvePush: ((value: { success: boolean }) => void) | null = null;
|
||||
(window as any).electronAPI.git.getRepoState = vi.fn().mockResolvedValue({
|
||||
isRepo: true,
|
||||
rootPath: '/repo/path',
|
||||
currentBranch: 'main',
|
||||
hasRemote: true,
|
||||
});
|
||||
(window as any).electronAPI.git.push = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvePush = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<GitSidebar />);
|
||||
|
||||
const pushButton = await screen.findByRole('button', { name: /push/i });
|
||||
await act(async () => {
|
||||
fireEvent.click(pushButton);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/pushing commits to remote/i);
|
||||
expect(screen.getByRole('button', { name: /pushing/i })).toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
resolvePush?.({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user