UNPKG

@alvinveroy/codecompass

Version:

AI-powered MCP server for codebase navigation and LLM prompt optimization

392 lines (343 loc) 21.2 kB
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import { type ExecException } from 'child_process'; // For type annotation // 1. Mock 'child_process' and replace 'exec' with a vi.fn() created IN THE FACTORY. vi.mock('child_process', async (importOriginal) => { const _actualCp = await importOriginal<typeof import('child_process')>(); return { ..._actualCp, exec: vi.fn(), // This vi.fn() is created when the factory runs. }; }); vi.mock('fs/promises', () => { const accessMock = vi.fn(); const readFileMock = vi.fn(); const readdirMock = vi.fn(); const statMock = vi.fn(); return { __esModule: true, default: { access: accessMock, readFile: readFileMock, readdir: readdirMock, stat: statMock }, access: accessMock, readFile: readFileMock, readdir: readdirMock, stat: statMock, }; }); // Mock 'isomorphic-git' // isomorphic-git exports named functions. We mock them directly. vi.mock('isomorphic-git', async (importOriginal) => { const _actual = await importOriginal<typeof import('isomorphic-git')>(); return { resolveRef: vi.fn(), listFiles: vi.fn(), log: vi.fn(), readCommit: vi.fn(), walk: vi.fn(), readBlob: vi.fn(async ({ oid }: { oid: string }) => { // Mock readBlob // Return some consistent mock data based on oid if needed for specific diffs, // otherwise, generic content is fine for `expect.any(String)`. return Promise.resolve({ blob: Buffer.from(`mock file content for ${oid || 'unknown'}`) }); }), TREE: vi.fn((args?: { ref?: string; oid?: string }) => ({ // Simulate Walker object structure expected by SUT's git.TREE({ ref: treeOid }) // The mock TREE needs to return something that walk's `trees` parameter can use. // The important part for the mock is that `trees[0]._id` or similar can be accessed if the test relies on it. // If `args.ref` is passed (as in the new SUT code), use it for identification. _id: args?.ref || args?.oid || 'mock_tree_id_default', ...args })), }; }); vi.mock('util', async (importOriginal) => { const actualUtil = await importOriginal<typeof import('util')>(); // This is the actual mock function that will be used for the promisified exec const internalMockedPromisifiedExec = vi.fn(); // Store it on a temporary global to retrieve it in the test file after imports. // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access (globalThis as any).__test__mockedPromisifiedExec = internalMockedPromisifiedExec; return { __esModule: true, ...actualUtil, // eslint-disable-next-line @typescript-eslint/no-explicit-any promisify: (fnToPromisify: (...args: any[]) => any) => { if (fnToPromisify && (typeof fnToPromisify.name === 'string' && fnToPromisify.name === 'exec' || fnToPromisify === actualChildProcessExecMockInstance)) { return internalMockedPromisifiedExec; } return actualUtil.promisify(fnToPromisify as (...args: never[]) => unknown); }, }; }); // Mock other external dependencies vi.mock('../../lib/config-service', () => { const loggerInstance = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; return { __esModule: true, configService: { COLLECTION_NAME: 'test_collection', FILE_INDEXING_CHUNK_SIZE_CHARS: 100, FILE_INDEXING_CHUNK_OVERLAP_CHARS: 20, }, logger: loggerInstance, }; }); vi.mock('../../lib/ollama'); // Import exec AFTER mocking child_process. This 'exec' will be the vi.fn() from the factory. // We need a reference to this instance for the util.promisify mock. import { exec as actualChildProcessExecMockInstance } from 'child_process'; // Import SUT and other necessary modules AFTER all vi.mock calls import * as repositoryFunctions from '../../lib/repository'; // Import all exports as a namespace import { logger } from '../../lib/config-service'; // configService is mocked, only logger needed here // Import specific fs/promises methods directly // We will import the mocked versions of these functions import { access as mockedFsAccessImported, readFile as mockedFsReadFileImported, readdir as mockedFsReadDirImported, stat as mockedFsStatImported } from 'fs/promises'; // Retrieve the mock function via the globalThis workaround // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access const importedMockExecAsyncFn = (globalThis as any).__test__mockedPromisifiedExec as Mock; // Import named mocks from isomorphic-git import * as git from 'isomorphic-git'; // Import as namespace // import { QdrantClient } from '@qdrant/js-client-rest'; // _QdrantClient if used describe('Repository Utilities', () => { const repoPath = '/test/diff/repo'; // _execMock was unused and potentially causing a lint error, so it's removed. // Renamed for clarity, used in the inner beforeEach const setupGitLogWithTwoCommits = () => { const mockAuthor = { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000, timezoneOffset: 0 }; vi.mocked(git.log).mockResolvedValue([ { oid: 'commit2_oid', commit: { message: 'Second', author: mockAuthor, committer: mockAuthor, parent: ['commit1_oid'], tree: 'tree2' } }, { oid: 'commit1_oid', commit: { message: 'First', author: mockAuthor, committer: mockAuthor, parent: [], tree: 'tree1' } } ] as unknown as import('isomorphic-git').ReadCommitResult[]); }; // Renamed for clarity const setupGitLogWithSingleCommit = () => { const mockAuthor = { name: 'Test', email: 'test@example.com', timestamp: Date.now() / 1000, timezoneOffset: 0 }; vi.mocked(git.log).mockResolvedValue([ { oid: 'commit1_oid', commit: { message: 'First', author: mockAuthor, committer: mockAuthor, parent: [], tree: 'tree1' } } ] as unknown as import('isomorphic-git').ReadCommitResult[]); }; beforeEach(() => { vi.clearAllMocks(); // execMock is vi.fn() from factory, clearAllMocks resets its state (calls, impls) // Reset the imported mock functions vi.mocked(mockedFsAccessImported).mockReset(); vi.mocked(mockedFsReadFileImported).mockReset(); vi.mocked(mockedFsReadDirImported).mockReset(); vi.mocked(mockedFsStatImported).mockReset(); // Reset all isomorphic-git mocks using named imports vi.mocked(git.resolveRef).mockReset(); vi.mocked(git.listFiles).mockReset(); vi.mocked(git.log).mockReset(); vi.mocked(git.readCommit).mockReset(); // vi.mocked(git.diffTrees)?.mockReset(); // diffTrees is no longer directly called by the SUT function being tested here vi.mocked(git.walk).mockReset(); if (git.TREE && typeof (git.TREE as Mock).mockClear === 'function') { (git.TREE as Mock).mockClear(); } (logger.info as Mock).mockClear(); (logger.warn as Mock).mockClear(); (logger.error as Mock).mockClear(); (logger.debug as Mock).mockClear(); }); afterEach(() => { vi.restoreAllMocks(); }); describe('validateGitRepository (direct tests)', () => { // These tests use the original implementation of validateGitRepository it('should return true for a valid repository', async () => { // Configure the specific mock function directly vi.mocked(mockedFsAccessImported).mockResolvedValue(undefined as unknown as void); vi.mocked(git.resolveRef).mockResolvedValue('refs/heads/main'); // Use mockedResolveRef const result = await repositoryFunctions.validateGitRepository(repoPath); expect(result).toBe(true); }); it('should return false if .git access is denied', async () => { // Configure the specific mock function directly vi.mocked(mockedFsAccessImported).mockRejectedValueOnce(new Error('Permission denied')); const result = await repositoryFunctions.validateGitRepository(repoPath); expect(result).toBe(false); }); it('should return false if HEAD cannot be resolved', async () => { // Configure the specific mock function directly vi.mocked(mockedFsAccessImported).mockResolvedValue(undefined as unknown as void); // fs.access passes vi.mocked(git.resolveRef).mockRejectedValueOnce(new Error('No HEAD')); // mockedResolveRef fails const result = await repositoryFunctions.validateGitRepository(repoPath); expect(result).toBe(false); }); }); describe('getRepositoryDiff', () => { let mockInjectedValidator: Mock< (input: string) => Promise<boolean> >; // Setup mocks specifically for tests that expect validateGitRepository to pass // This beforeEach establishes the common "happy path" for validateGitRepository and git.log beforeEach(() => { // This mock will be passed directly to getRepositoryDiff mockInjectedValidator = vi.fn(); vi.mocked(importedMockExecAsyncFn).mockReset(); // Reset our new async mock for execAsync mockInjectedValidator.mockResolvedValue(true); // Default to valid setupGitLogWithTwoCommits(); }); it('should call git diff command and return stdout', async () => { // Remove: execMock.mockImplementationOnce(...) as importedMockExecAsyncFn handles the async behavior now. vi.mocked(importedMockExecAsyncFn).mockResolvedValueOnce({ stdout: 'diff_content_stdout_explicit', stderr: '' }); const result = await repositoryFunctions.getRepositoryDiff(repoPath, mockInjectedValidator); expect(mockInjectedValidator).toHaveBeenCalledWith(repoPath); expect(importedMockExecAsyncFn).toHaveBeenCalledWith('git diff commit1_oid commit2_oid', { cwd: repoPath, maxBuffer: 1024 * 1024 * 5 }); expect(result).toBe('diff_content_stdout_explicit'); }); it('should truncate long diff output', async () => { const MAX_DIFF_LENGTH_FROM_SUT = 10000; const longDiff = 'a'.repeat(MAX_DIFF_LENGTH_FROM_SUT + 1); // Remove: execMock.mockImplementationOnce(...) vi.mocked(importedMockExecAsyncFn).mockResolvedValueOnce({ stdout: longDiff, stderr: '' } as { stdout: string; stderr: string }); const result = await repositoryFunctions.getRepositoryDiff(repoPath, mockInjectedValidator); expect(mockInjectedValidator).toHaveBeenCalledWith(repoPath); // Add assertion for importedMockExecAsyncFn call expect(importedMockExecAsyncFn).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ cwd: repoPath })); expect(result).toBe('a'.repeat(MAX_DIFF_LENGTH_FROM_SUT) + "\n... (diff truncated)"); expect(result).toContain('... (diff truncated)'); }); it('should handle errors from git diff command', async () => { const mockError = new Error('Git command failed') as ExecException; const stderrText = 'stderr from exec callback'; mockError.code = 128; mockError.stderr = stderrText; // Remove: execMock.mockImplementationOnce(...) vi.mocked(importedMockExecAsyncFn).mockRejectedValueOnce(mockError); const result = await repositoryFunctions.getRepositoryDiff(repoPath, mockInjectedValidator); expect(mockInjectedValidator).toHaveBeenCalledWith(repoPath); // Add assertion for importedMockExecAsyncFn call expect(importedMockExecAsyncFn).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ cwd: repoPath })); expect(result).toBe(`Failed to retrieve diff for ${repoPath}: Git command failed`); expect(logger.error).toHaveBeenCalledWith( `Error retrieving git diff for ${repoPath}: Git command failed`, expect.objectContaining({ message: 'Git command failed', code: 128, stderr: stderrText, // Now this should match }) ); }); it('should return "No Git repository found" if validateGitRepository returns false', async () => { mockInjectedValidator.mockResolvedValue(false); // Control our specific mock const result = await repositoryFunctions.getRepositoryDiff(repoPath, mockInjectedValidator); expect(mockInjectedValidator).toHaveBeenCalledWith(repoPath); expect(result).toBe("No Git repository found"); expect(importedMockExecAsyncFn).not.toHaveBeenCalled(); // Check the async mock expect(vi.mocked(git.log)).not.toHaveBeenCalled(); // Use mockedGitLog }); it('should return "No previous commits to compare" if less than 2 commits (single commit)', async () => { // mockInjectedValidator is set to resolve true in beforeEach setupGitLogWithSingleCommit(); const result = await repositoryFunctions.getRepositoryDiff(repoPath, mockInjectedValidator); expect(mockInjectedValidator).toHaveBeenCalledWith(repoPath); expect(result).toBe("No previous commits to compare"); expect(importedMockExecAsyncFn).not.toHaveBeenCalled(); // Check the async mock }); }); describe('getCommitHistoryWithChanges', () => { it('should retrieve commit history with changed files', async () => { // No need to mock validateGitRepository here as it's not called by getCommitHistoryWithChanges // Define a more specific type for mock commit objects type MockCommitAuthor = { name: string; email: string; timestamp: number; timezoneOffset: number }; type MockCommitData = { message: string; author: MockCommitAuthor; committer: MockCommitAuthor; tree: string; parent: string[]; }; type MockReadCommitResult = { oid: string; commit: MockCommitData }; const mockCommits: MockReadCommitResult[] = [ { oid: 'commit2', commit: { message: 'Feat: new feature', author: { name: 'Test Author', email: 'test@example.com', timestamp: 1672531200, timezoneOffset: 0 }, committer: { name: 'Test Committer', email: 'test@example.com', timestamp: 1672531200, timezoneOffset: 0 }, tree: 'tree2_oid', parent: ['commit1_oid'] } }, { oid: 'commit1', commit: { message: 'Initial commit', author: { name: 'Test Author', email: 'test@example.com', timestamp: 1672444800, timezoneOffset: 0 }, committer: { name: 'Test Committer', email: 'test@example.com', timestamp: 1672444800, timezoneOffset: 0 }, tree: 'tree1_oid', parent: [] } }, ]; vi.mocked(git.log).mockResolvedValue(mockCommits as unknown as import('isomorphic-git').ReadCommitResult[]); vi.mocked(git.readCommit).mockImplementation(({ oid }: { oid: string }) => { // Find the commit in our typed mockCommits array const foundCommit = mockCommits.find(c => c.oid === oid || (oid.endsWith('_oid') && c.commit.tree === oid)); // Handle tree oids if passed if (foundCommit) { return Promise.resolve({ oid: foundCommit.oid, commit: { // Ensure all fields expected by ReadCommitResult are present ...foundCommit.commit, // Spread the well-typed commit data } } as unknown as import('isomorphic-git').ReadCommitResult); } // Fallback for 'commit1_oid' if it's a parent OID not directly in mockCommits list by that OID if (oid === 'commit1_oid' && mockCommits[1]) { // Assuming commit1_oid refers to mockCommits[1]'s OID conceptually return Promise.resolve({ oid: mockCommits[1].oid, commit: { ...mockCommits[1].commit } } as unknown as import('isomorphic-git').ReadCommitResult); } // Provide minimal valid structure for author/committer to avoid 'any' return Promise.resolve({ oid: 'unknown', commit: { tree: 'unknown_tree', parent: [], author: { name: 'Unknown' }, committer: { name: 'Unknown' }, message: 'Unknown' } } as unknown as import('isomorphic-git').ReadCommitResult); }); // vi.mocked(git.diffTrees) no longer needed here as SUT uses git.walk for diffing. vi.mocked(git.walk).mockImplementation(async ({ fs: _nodeFsAlias, dir: _dir, gitdir: _gitdir, trees, map }) => { // The `trees` argument will be an array of mocked Walker-like objects from our `git.TREE` mock. // We can inspect `trees[0]._id` and `trees[1]._id` if needed to simulate specific diffs. // trees[0] corresponds to parentCommitData.commit.tree // trees[1] corresponds to commitData.commit.tree interface MockTree { _id: string; /* other properties */ } if (map && trees.length === 1 && (trees[0] as unknown as MockTree)._id === 'mock_tree_id_default') { // Simulate initial commit walk: one file added // The SUT's initial commit logic uses `trees: [git.TREE()]`. Our `git.TREE` mock without args gives `_id: 'mock_tree_id_default'`. const mockEntry = { type: () => 'blob', oid: () => 'blob_oid_initial', mode: () => 0o100644 } as unknown as import('isomorphic-git').WalkerEntry; await map('initial.ts', [mockEntry]); } else if (map && trees.length === 2) { // Simulate two-tree walk for diffing (e.g., between tree1_oid and tree2_oid) // This part needs to align with how the SUT calls git.TREE({ ref: treeOid }) // Our TREE mock sets _id to args.ref. So trees[0]._id will be 'tree1_oid', trees[1]._id will be 'tree2_oid'. interface MockTree { _id: string; /* other properties */ } // Already defined in previous block, but repetition in search is ok if it matches if ((trees[0] as unknown as MockTree)._id === 'tree1_oid' && (trees[1] as unknown as MockTree)._id === 'tree2_oid') { // Simulate one modified file const mockEntryBefore = { type: () => 'blob', oid: () => 'blob_before_oid', mode: () => 0o100644 } as unknown as import('isomorphic-git').WalkerEntry; const mockEntryAfter = { type: () => 'blob', oid: () => 'blob_after_oid', mode: () => 0o100644 } as unknown as import('isomorphic-git').WalkerEntry; await map('file.ts', [mockEntryBefore, mockEntryAfter]); // Simulate one added file const mockEntryAdded = { type: () => 'blob', oid: () => 'blob_added_oid', mode: () => 0o100644 } as unknown as import('isomorphic-git').WalkerEntry; await map('added_file.ts', [null, mockEntryAdded]); // Simulate one deleted file const mockEntryDeleted = { type: () => 'blob', oid: () => 'blob_deleted_oid', mode: () => 0o100644 } as unknown as import('isomorphic-git').WalkerEntry; await map('deleted_file.ts', [mockEntryDeleted, null]); } } return []; // Default return for walk }); const history = await repositoryFunctions.getCommitHistoryWithChanges(repoPath, { count: 2 }); expect(history).toHaveLength(2); // Check commit2 (non-initial commit, uses two-tree walk) expect(history[0].oid).toBe('commit2'); expect(history[0].changedFiles).toEqual( expect.arrayContaining([ expect.objectContaining({ path: 'file.ts', type: 'modify', oldOid: 'blob_before_oid', newOid: 'blob_after_oid' }), // diffText can be omitted if too complex or checked separately expect.objectContaining({ path: 'added_file.ts', type: 'add', oldOid: null, newOid: 'blob_added_oid' }), expect.objectContaining({ path: 'deleted_file.ts', type: 'delete', oldOid: 'blob_deleted_oid', newOid: null }) ]) ); // Optionally, check for diffText presence if crucial for this test history[0].changedFiles.forEach(file => { if (file.type !== 'typechange' && file.type !== 'equal') { // 'equal' type not used here expect(file.diffText).toEqual(expect.any(String)); } }); expect(history[0].changedFiles.length).toBe(3); // Check commit1 (initial commit, uses single-tree walk) expect(history[1].oid).toBe('commit1'); expect(history[1].changedFiles).toEqual( expect.arrayContaining([ expect.objectContaining({ path: 'initial.ts', type: 'add', oldOid: null, newOid: 'blob_oid_initial' }) ]) ); if (history[1].changedFiles[0]) { expect(history[1].changedFiles[0].diffText).toEqual(expect.any(String)); } expect(history[1].changedFiles.length).toBe(1); }); it('should handle errors from git.log', async () => { // Ensure validateGitRepository conditions are met if it's called by SUT, // though getCommitHistoryWithChanges doesn't call validateGitRepository directly. // However, it does use gitLog, which is what we're testing for failure here. vi.mocked(git.log).mockRejectedValue(new Error('Log failed')); // Use mockedGitLog await expect(repositoryFunctions.getCommitHistoryWithChanges(repoPath)).rejects.toThrow('Log failed'); }); }); });