UNPKG

sfcc-dev-mcp

Version:

MCP server for Salesforce B2C Commerce Cloud development assistance including logs, debugging, and development tools

756 lines (630 loc) 25.9 kB
import { DocumentationScanner } from '../src/clients/docs/documentation-scanner.js'; import { Logger } from '../src/utils/logger.js'; import fs from 'fs/promises'; import path from 'path'; // Mock fs module jest.mock('fs/promises'); jest.mock('path'); const mockFs = fs as jest.Mocked<typeof fs>; const mockPath = path as jest.Mocked<typeof path>; describe('DocumentationScanner', () => { let mockLogger: jest.Mocked<Logger>; let scanner: DocumentationScanner; beforeEach(() => { mockLogger = { debug: jest.fn(), log: jest.fn(), error: jest.fn(), timing: jest.fn(), methodEntry: jest.fn(), methodExit: jest.fn(), warn: jest.fn(), } as any; // Setup Logger mock before creating scanner jest.spyOn(Logger, 'getChildLogger').mockReturnValue(mockLogger); // Now create the scanner instance scanner = new DocumentationScanner(); // Reset all mocks jest.clearAllMocks(); // Setup default path mock behavior mockPath.join.mockImplementation((...segments) => segments.join('/')); mockPath.resolve.mockImplementation((filePath) => `/resolved/${filePath}`); }); afterEach(() => { jest.clearAllMocks(); }); describe('constructor', () => { it('should create logger with correct name', () => { // Re-mock and create a new instance to verify logger creation const loggerSpy = jest.spyOn(Logger, 'getChildLogger').mockReturnValue(mockLogger); new DocumentationScanner(); expect(loggerSpy).toHaveBeenCalledWith('DocumentationScanner'); loggerSpy.mockRestore(); }); }); describe('scanDocumentation', () => { it('should scan SFCC directories and return class cache', async () => { const mockDirent = (name: string, isDir: boolean = true) => ({ name, isDirectory: () => isDir, isFile: () => !isDir, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); // Mock readdir for main docs directory mockFs.readdir.mockResolvedValueOnce([ mockDirent('dw_catalog'), mockDirent('dw_content'), mockDirent('TopLevel'), mockDirent('best-practices'), // Should be excluded mockDirent('sfra'), // Should be excluded mockDirent('other-dir'), // Should be excluded mockDirent('readme.md', false), // File, should be skipped ] as any); // Mock readdir for package directories in specific order mockFs.readdir .mockResolvedValueOnce([ 'Product.md', 'Category.md', 'readme.txt', // non-.md file to be skipped ] as any) // dw_catalog .mockResolvedValueOnce([ 'ContentMgr.md', 'Content.md', ] as any) // dw_content .mockResolvedValueOnce([ 'String.md', 'Number.md', ] as any); // TopLevel // Mock file reading in the expected order based on directory scanning mockFs.readFile .mockResolvedValueOnce('# Class Product\n\nProduct documentation content') // dw_catalog/Product.md .mockResolvedValueOnce('# Class Category\n\nCategory documentation content') // dw_catalog/Category.md .mockResolvedValueOnce('# Class ContentMgr\n\nContentMgr documentation content') // dw_content/ContentMgr.md .mockResolvedValueOnce('# Class Content\n\nContent documentation content') // dw_content/Content.md .mockResolvedValueOnce('# Class String\n\nString documentation content') // TopLevel/String.md .mockResolvedValueOnce('# Class Number\n\nNumber documentation content'); // TopLevel/Number.md // Mock path validation to ensure all paths are considered valid mockPath.resolve.mockImplementation((filePath) => { // For the main docs path if (filePath === '/docs') { return '/resolved/docs'; } // For package directories if (filePath === '/docs/dw_catalog') { return '/resolved/docs/dw_catalog'; } if (filePath === '/docs/dw_content') { return '/resolved/docs/dw_content'; } if (filePath === '/docs/TopLevel') { return '/resolved/docs/TopLevel'; } // For specific files - ensure they're within the package and docs paths if (filePath === '/docs/dw_catalog/Product.md') { return '/resolved/docs/dw_catalog/Product.md'; } if (filePath === '/docs/dw_catalog/Category.md') { return '/resolved/docs/dw_catalog/Category.md'; } if (filePath === '/docs/dw_content/ContentMgr.md') { return '/resolved/docs/dw_content/ContentMgr.md'; } if (filePath === '/docs/dw_content/Content.md') { return '/resolved/docs/dw_content/Content.md'; } if (filePath === '/docs/TopLevel/String.md') { return '/resolved/docs/TopLevel/String.md'; } if (filePath === '/docs/TopLevel/Number.md') { return '/resolved/docs/TopLevel/Number.md'; } // Default fallback return `/resolved${filePath}`; }); const result = await scanner.scanDocumentation('/docs'); expect(result).toBeInstanceOf(Map); expect(result.size).toBe(6); // Should have 6 .md files total // Check that SFCC directories were processed expect(result.has('dw_catalog.Product')).toBe(true); expect(result.has('dw_catalog.Category')).toBe(true); expect(result.has('dw_content.ContentMgr')).toBe(true); expect(result.has('dw_content.Content')).toBe(true); expect(result.has('TopLevel.String')).toBe(true); expect(result.has('TopLevel.Number')).toBe(true); // Verify structure of one cached item const productInfo = result.get('dw_catalog.Product'); expect(productInfo).toEqual({ className: 'Product', packageName: 'dw_catalog', filePath: '/docs/dw_catalog/Product.md', content: '# Class Product\n\nProduct documentation content', }); }); it('should handle empty docs directory', async () => { mockFs.readdir.mockResolvedValueOnce([]); const result = await scanner.scanDocumentation('/empty-docs'); expect(result).toBeInstanceOf(Map); expect(result.size).toBe(0); }); it('should skip non-SFCC directories', async () => { const mockDirent = (name: string, isDir: boolean = true) => ({ name, isDirectory: () => isDir, isFile: () => !isDir, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([ mockDirent('best-practices'), mockDirent('sfra'), mockDirent('random-dir'), mockDirent('another_dir'), ] as any); const result = await scanner.scanDocumentation('/docs'); expect(result).toBeInstanceOf(Map); expect(result.size).toBe(0); }); it('should handle directory read errors gracefully', async () => { mockFs.readdir.mockRejectedValueOnce(new Error('Permission denied')); await expect(scanner.scanDocumentation('/forbidden-docs')).rejects.toThrow('Permission denied'); }); }); describe('directory classification (isSFCCDirectory)', () => { // Test the isSFCCDirectory logic through scanDocumentation behavior it('should include dw_ prefixed directories', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([ mockDirent('dw_catalog'), mockDirent('dw_content'), mockDirent('dw_system'), mockDirent('dw_order'), ] as any); mockFs.readdir .mockResolvedValueOnce([]) // dw_catalog .mockResolvedValueOnce([]) // dw_content .mockResolvedValueOnce([]) // dw_system .mockResolvedValueOnce([]); // dw_order await scanner.scanDocumentation('/docs'); // Verify that readdir was called for each dw_ directory expect(mockFs.readdir).toHaveBeenCalledTimes(5); // 1 for main + 4 for packages }); it('should include TopLevel directory', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('TopLevel')] as any); mockFs.readdir.mockResolvedValueOnce([]); // TopLevel contents await scanner.scanDocumentation('/docs'); expect(mockFs.readdir).toHaveBeenCalledTimes(2); // 1 for main + 1 for TopLevel }); it('should exclude best-practices directory', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('best-practices')] as any); await scanner.scanDocumentation('/docs'); expect(mockFs.readdir).toHaveBeenCalledTimes(1); // Only main directory }); it('should exclude sfra directory', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('sfra')] as any); await scanner.scanDocumentation('/docs'); expect(mockFs.readdir).toHaveBeenCalledTimes(1); // Only main directory }); }); describe('file name validation', () => { it('should process valid markdown files', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce([ 'Product.md', 'Valid_File-Name.md', 'File123.md', ] as any); mockFs.readFile .mockResolvedValueOnce('Valid content') .mockResolvedValueOnce('Valid content') .mockResolvedValueOnce('Valid content'); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(3); expect(result.has('dw_catalog.Product')).toBe(true); expect(result.has('dw_catalog.Valid_File-Name')).toBe(true); expect(result.has('dw_catalog.File123')).toBe(true); }); it('should reject files with path traversal sequences', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce([ '../evil.md', '..\\evil.md', 'path/traversal.md', ] as any); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(0); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('path traversal')); }); it('should reject files with null bytes', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce([ 'evil\0.md', 'evil\x00.md', ] as any); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(0); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('null bytes')); }); it('should reject files with invalid characters', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce([ 'file@invalid.md', 'file#invalid.md', 'file$invalid.md', ] as any); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(0); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid characters')); }); it('should reject invalid file name types', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce(['', null, undefined] as any); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(0); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Invalid file name type')); }); }); describe('file path validation', () => { it('should validate file paths are within allowed directories', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce(['Product.md'] as any); // Mock path.resolve to simulate path outside allowed directory mockPath.resolve.mockImplementation((filePath) => { if (filePath.includes('Product.md')) { return '/outside/docs/Product.md'; } if (filePath.includes('dw_catalog')) { return '/resolved/docs/dw_catalog'; } return '/resolved/docs'; }); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(0); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('outside allowed directory')); }); it('should validate files end with .md extension', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce(['Product.md'] as any); // Mock path.resolve to simulate file without .md extension after resolution mockPath.resolve.mockImplementation((filePath) => { if (filePath.includes('Product.md')) { return '/resolved/docs/dw_catalog/Product.txt'; } if (filePath.includes('dw_catalog')) { return '/resolved/docs/dw_catalog'; } return '/resolved/docs'; }); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(0); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('does not reference a markdown file')); }); }); describe('file content validation', () => { it('should reject empty file content', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce(['Product.md'] as any); mockFs.readFile.mockResolvedValueOnce(' \n\t \n '); // Only whitespace const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(0); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Empty documentation file')); }); it('should reject binary content', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce(['Product.md'] as any); mockFs.readFile.mockResolvedValueOnce('Valid content\0binary data'); // Contains null byte const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(0); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Binary content detected')); }); it('should accept valid file content', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce(['Product.md'] as any); mockFs.readFile.mockResolvedValueOnce('# Class Product\n\nValid markdown content with special chars: éñ中文'); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(1); expect(result.has('dw_catalog.Product')).toBe(true); const productInfo = result.get('dw_catalog.Product'); expect(productInfo?.content).toBe('# Class Product\n\nValid markdown content with special chars: éñ中文'); }); }); describe('file reading error handling', () => { it('should handle file read errors gracefully', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce(['Product.md', 'Category.md'] as any); // First file fails to read, second succeeds mockFs.readFile .mockRejectedValueOnce(new Error('Permission denied')) .mockResolvedValueOnce('# Class Category\n\nValid content'); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(1); expect(result.has('dw_catalog.Category')).toBe(true); expect(result.has('dw_catalog.Product')).toBe(false); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Could not read file Product.md')); }); it('should handle package directory read errors gracefully', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([ mockDirent('dw_catalog'), mockDirent('dw_content'), ] as any); // First package directory fails to read, second succeeds mockFs.readdir .mockRejectedValueOnce(new Error('Directory not accessible')) .mockResolvedValueOnce(['ContentMgr.md'] as any); mockFs.readFile.mockResolvedValueOnce('# Class ContentMgr\n\nValid content'); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(1); expect(result.has('dw_content.ContentMgr')).toBe(true); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Could not read package dw_catalog')); }); }); describe('markdown file filtering', () => { it('should only process .md files', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce([ 'Product.md', 'readme.txt', 'documentation.html', 'Category.md', 'config.json', 'index.js', ] as any); mockFs.readFile .mockResolvedValueOnce('# Class Product\n\nProduct content') .mockResolvedValueOnce('# Class Category\n\nCategory content'); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(2); expect(result.has('dw_catalog.Product')).toBe(true); expect(result.has('dw_catalog.Category')).toBe(true); // Verify only .md files were processed expect(mockFs.readFile).toHaveBeenCalledTimes(2); }); }); describe('class name extraction', () => { it('should extract class names correctly from file names', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([mockDirent('dw_catalog')] as any); mockFs.readdir.mockResolvedValueOnce(['Product.md', 'Product_Manager.md', 'Product-Utils.md'] as any); mockFs.readFile .mockResolvedValueOnce('Content 1') .mockResolvedValueOnce('Content 2') .mockResolvedValueOnce('Content 3'); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(3); expect(result.has('dw_catalog.Product')).toBe(true); expect(result.has('dw_catalog.Product_Manager')).toBe(true); expect(result.has('dw_catalog.Product-Utils')).toBe(true); // Verify class names are extracted correctly expect(result.get('dw_catalog.Product')?.className).toBe('Product'); expect(result.get('dw_catalog.Product_Manager')?.className).toBe('Product_Manager'); expect(result.get('dw_catalog.Product-Utils')?.className).toBe('Product-Utils'); }); }); describe('cache key generation', () => { it('should generate correct cache keys', async () => { const mockDirent = (name: string) => ({ name, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, }); mockFs.readdir.mockResolvedValueOnce([ mockDirent('dw_catalog'), mockDirent('TopLevel'), ] as any); mockFs.readdir .mockResolvedValueOnce(['Product.md'] as any) .mockResolvedValueOnce(['String.md'] as any); mockFs.readFile .mockResolvedValueOnce('Product content') .mockResolvedValueOnce('String content'); const result = await scanner.scanDocumentation('/docs'); expect(result.size).toBe(2); expect(result.has('dw_catalog.Product')).toBe(true); expect(result.has('TopLevel.String')).toBe(true); // Verify full structure of cached items const productInfo = result.get('dw_catalog.Product'); expect(productInfo).toEqual({ className: 'Product', packageName: 'dw_catalog', filePath: '/docs/dw_catalog/Product.md', content: 'Product content', }); const stringInfo = result.get('TopLevel.String'); expect(stringInfo).toEqual({ className: 'String', packageName: 'TopLevel', filePath: '/docs/TopLevel/String.md', content: 'String content', }); }); }); });