lamplighter-mcp
Version:
An intelligent context engine for AI-assisted software development
234 lines (201 loc) • 8.37 kB
text/typescript
import { CodebaseAnalyzer } from '../src/modules/codebaseAnalyzer';
import * as fsPromises from 'fs/promises';
import * as fs from 'fs'; // Import base fs for types
import * as path from 'path';
// Mock fs/promises module
jest.mock('fs/promises');
const mockedFs = jest.mocked(fsPromises);
// Helper to create Dirent objects for readdir mock
const createDirent = (name: string, isDirectory: boolean): fs.Dirent => ({
name,
isFile: () => !isDirectory,
isDirectory: () => isDirectory,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
} as fs.Dirent); // Use type assertion if necessary, or ensure all fields match
// Helper to create Stats objects for stat mock
const createStats = (isDirectory: boolean): fs.Stats => ({
isFile: () => !isDirectory,
isDirectory: () => isDirectory,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
dev: 0,
ino: 0,
mode: 0,
nlink: 0,
uid: 0,
gid: 0,
rdev: 0,
size: 0,
blksize: 0,
blocks: 0,
atimeMs: 0,
mtimeMs: 0,
ctimeMs: 0,
birthtimeMs: 0,
atime: new Date(),
mtime: new Date(),
ctime: new Date(),
birthtime: new Date(),
} as fs.Stats); // Use type assertion
describe('CodebaseAnalyzer', () => {
const defaultContextDir = './lamplighter_context';
const summaryFileName = 'codebase_summary.md';
const defaultSummaryPath = path.join(defaultContextDir, summaryFileName);
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementations
mockedFs.mkdir.mockResolvedValue(undefined);
mockedFs.writeFile.mockResolvedValue(undefined);
mockedFs.access.mockResolvedValue(undefined); // Assume files exist by default
// Reset readdir and stat mocks too if they are complex
mockedFs.readdir.mockReset();
mockedFs.stat.mockReset();
});
const setupMockFs = (structure: Record<string, any>) => {
const getPathData = (targetPath: string): { item: any; isDir: boolean } | null => {
const normalizedTargetPath = path.normalize(targetPath.toString());
// Function to traverse the mock structure
const traverse = (structureObj: any, pathParts: string[]): any => {
if (!pathParts.length) {
return structureObj; // Reached the target
}
const currentPart = pathParts[0];
if (structureObj && typeof structureObj === 'object' && currentPart in structureObj) {
return traverse(structureObj[currentPart], pathParts.slice(1));
} else {
return null; // Part not found
}
};
// Find the root key in the structure (e.g., '/project')
const rootKey = Object.keys(structure).find(key => normalizedTargetPath.startsWith(path.normalize(key)));
if (!rootKey) {
return null; // Root path not found in structure
}
const relativePath = path.relative(rootKey, normalizedTargetPath);
const parts = relativePath.split(path.sep).filter(p => p && p !== '.');
let item;
if (relativePath === '.' || relativePath === '') {
item = structure[rootKey]; // Requesting the root itself
} else {
item = traverse(structure[rootKey], parts);
}
if (item !== null && item !== undefined) {
return { item, isDir: typeof item === 'object' };
}
return null;
};
mockedFs.readdir.mockImplementation(async (dirPath): Promise<fs.Dirent[]> => {
const data = getPathData(dirPath.toString());
if (!data || !data.isDir) {
throw Object.assign(new Error(`ENOENT: no such file or directory, scandir '${dirPath}'`), { code: 'ENOENT' });
}
return Object.keys(data.item).map(name => createDirent(name, typeof data.item[name] === 'object'));
});
mockedFs.stat.mockImplementation(async (itemPath): Promise<fs.Stats> => {
const data = getPathData(itemPath.toString());
if (data === null) {
throw Object.assign(new Error(`ENOENT: no such file or directory, stat '${itemPath}'`), { code: 'ENOENT' });
}
return createStats(data.isDir);
});
mockedFs.access.mockImplementation(async (filePath) => {
// Just rely on getPathData to see if the path resolves to anything
const data = getPathData(filePath.toString());
if (data === null) {
throw Object.assign(new Error(`ENOENT: no such file or directory, access '${filePath}'`), { code: 'ENOENT' });
}
});
};
it('should analyze a simple project structure and detect package.json', async () => {
const projectRoot = '/project';
const mockStructure = {
[projectRoot]: {
'src': {
'server.ts': 'file_content'
},
'package.json': 'file_content'
}
};
setupMockFs(mockStructure);
const analyzer = new CodebaseAnalyzer();
await analyzer.analyze(projectRoot);
expect(mockedFs.writeFile).toHaveBeenCalledTimes(1);
expect(mockedFs.writeFile).toHaveBeenCalledWith(defaultSummaryPath, expect.any(String), 'utf-8');
const summaryContent = mockedFs.writeFile.mock.calls[0][1];
expect(summaryContent).toContain('nodejs** (detected from: package.json)');
expect(summaryContent).toContain('📁 src/');
expect(summaryContent).toContain('📄 server.ts');
expect(summaryContent).toContain('📄 package.json');
});
it('should analyze nested directories and detect tsconfig.json', async () => {
const projectRoot = '/project';
const mockStructure = {
[projectRoot]: {
'src': {
'modules': {
'logger.ts': 'file_content'
}
},
'tsconfig.json': 'file_content'
}
};
setupMockFs(mockStructure);
const analyzer = new CodebaseAnalyzer();
await analyzer.analyze(projectRoot);
const summaryContent = mockedFs.writeFile.mock.calls[0][1];
expect(summaryContent).toContain('typescript** (detected from: tsconfig.json)');
expect(summaryContent).toContain('📁 src/');
expect(summaryContent).toContain('📁 modules/');
expect(summaryContent).toContain('📄 logger.ts');
expect(summaryContent).toContain('📄 tsconfig.json');
expect(summaryContent).not.toContain('nodejs');
});
it('should handle an empty directory', async () => {
const projectRoot = '/emptyProject';
const mockStructure = {
[projectRoot]: {}
};
setupMockFs(mockStructure);
const analyzer = new CodebaseAnalyzer();
await analyzer.analyze(projectRoot);
expect(mockedFs.writeFile).toHaveBeenCalledTimes(1);
const summaryContent = mockedFs.writeFile.mock.calls[0][1];
expect(summaryContent).toContain('# Codebase Summary');
expect(summaryContent).toContain('## Tech Stack'); // Should still have headers
expect(summaryContent).toContain('## Directory Structure');
});
it('should handle errors during file operations gracefully', async () => {
const projectRoot = '/projectWithError';
const mockStructure = {
[projectRoot]: {
'src': {}
}
};
setupMockFs(mockStructure);
const readError = new Error('Permission denied');
mockedFs.readdir.mockImplementation(async (dirPath) => {
const normalizedPath = path.normalize(dirPath.toString());
if (normalizedPath === projectRoot) {
throw readError;
}
// Simulate findFilesByExtension also failing if it tries to read other dirs
// Or just throw a generic ENOENT if unexpected path is read
throw Object.assign(new Error(`ENOENT: unexpected readdir for ${dirPath}`), { code: 'ENOENT' });
});
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const analyzer = new CodebaseAnalyzer();
await expect(analyzer.analyze(projectRoot)).resolves.toBeUndefined();
// Check if writeFile was still called with a basic summary containing error info
expect(mockedFs.writeFile).toHaveBeenCalledTimes(1);
const summaryContent = mockedFs.writeFile.mock.calls[0][1];
expect(summaryContent).toContain('# Codebase Summary');
consoleErrorSpy.mockRestore();
});
});