Skip to main content
Glama
local_fetch_content.test.ts20.6 kB
/** * Tests for local_fetch_content tool */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ERROR_CODES } from '../../src/errors/errorCodes.js'; import { fetchContent } from '../../src/tools/local_fetch_content.js'; import * as pathValidator from '../../src/security/pathValidator.js'; import * as fs from 'fs/promises'; // Mock fs/promises vi.mock('fs/promises', () => ({ readFile: vi.fn(), stat: vi.fn(), })); // Mock pathValidator vi.mock('../../src/security/pathValidator.js', () => ({ pathValidator: { validate: vi.fn(), }, })); describe('local_fetch_content', () => { const mockReadFile = vi.mocked(fs.readFile); const mockStat = vi.mocked(fs.stat); const mockValidate = vi.mocked(pathValidator.pathValidator.validate); beforeEach(() => { vi.clearAllMocks(); mockValidate.mockReturnValue({ isValid: true }); // Default: small file size (< 100KB) mockStat.mockResolvedValue({ size: 1024 } as unknown as Awaited<ReturnType<typeof fs.stat>>); }); describe('Full content fetch', () => { it('should fetch full file content', async () => { const testContent = 'line 1\nline 2\nline 3'; mockReadFile.mockResolvedValue(testContent); const result = await fetchContent({ path: 'test.txt', fullContent: true, }); expect(result.status).toBe('hasResults'); expect(result.content).toBe(testContent); expect(result.isPartial).toBe(false); expect(result.totalLines).toBe(3); }); it('should apply minification when requested', async () => { const testContent = 'function test() {\n return true;\n}'; mockReadFile.mockResolvedValue(testContent); const result = await fetchContent({ path: 'test.js', fullContent: true, minified: true, }); expect(result.status).toBe('hasResults'); // Note: minified field removed - minification still happens but not tracked }); }); describe('Match string fetch', () => { it('should fetch lines matching pattern with context', async () => { const testContent = 'line 1\nline 2\nMATCH\nline 4\nline 5'; mockReadFile.mockResolvedValue(testContent); const result = await fetchContent({ path: 'test.txt', matchString: 'MATCH', matchStringContextLines: 1, }); expect(result.status).toBe('hasResults'); expect(result.content).toContain('line 2'); expect(result.content).toContain('MATCH'); expect(result.content).toContain('line 4'); expect(result.isPartial).toBe(true); }); it('should return empty when pattern not found', async () => { const testContent = 'line 1\nline 2\nline 3'; mockReadFile.mockResolvedValue(testContent); const result = await fetchContent({ path: 'test.txt', matchString: 'NOTFOUND', }); expect(result.status).toBe('empty'); expect(result.errorCode).toBe(ERROR_CODES.NO_MATCHES); }); }); describe('Error handling', () => { it('should handle invalid paths', async () => { mockValidate.mockReturnValue({ isValid: false, error: 'Invalid path', }); const result = await fetchContent({ path: '/invalid/path', }); expect(result.status).toBe('error'); expect(result.errorCode).toBe(ERROR_CODES.PATH_VALIDATION_FAILED); }); it('should handle file read errors', async () => { mockReadFile.mockRejectedValue(new Error('File not found')); const result = await fetchContent({ path: 'nonexistent.txt', }); expect(result.status).toBe('error'); expect(result.errorCode).toBe(ERROR_CODES.FILE_READ_FAILED); }); }); describe('Empty content handling', () => { it('should handle empty files', async () => { mockReadFile.mockResolvedValue(''); const result = await fetchContent({ path: 'empty.txt', fullContent: true, }); expect(result.status).toBe('empty'); }); }); describe('Large file handling', () => { it('should warn about large file without pagination options', async () => { // Mock large file (150KB) mockStat.mockResolvedValue({ size: 150 * 1024 } as unknown as Awaited<ReturnType<typeof fs.stat>>); const result = await fetchContent({ path: 'large-file.txt', // No charLength or matchString }); expect(result.status).toBe('error'); expect(result.errorCode).toBe(ERROR_CODES.FILE_TOO_LARGE); expect(result.hints).toBeDefined(); expect(result.hints!.length).toBeGreaterThan(0); }); it('should allow large file with charLength pagination', async () => { mockStat.mockResolvedValue({ size: 150 * 1024 } as unknown as Awaited<ReturnType<typeof fs.stat>>); mockReadFile.mockResolvedValue('test content for large file'); const result = await fetchContent({ path: 'large-file.txt', charLength: 10000, }); expect(result.status).toBe('hasResults'); }); it('should allow large file with matchString extraction', async () => { mockStat.mockResolvedValue({ size: 150 * 1024 } as unknown as Awaited<ReturnType<typeof fs.stat>>); mockReadFile.mockResolvedValue('line 1\nMATCH\nline 3'); const result = await fetchContent({ path: 'large-file.txt', matchString: 'MATCH', }); expect(result.status).toBe('hasResults'); }); it('should allow large file with fullContent flag and charLength', async () => { mockStat.mockResolvedValue({ size: 150 * 1024 } as unknown as Awaited<ReturnType<typeof fs.stat>>); mockReadFile.mockResolvedValue('full content of large file'); const result = await fetchContent({ path: 'large-file.txt', fullContent: true, charLength: 10000, }); expect(result.status).toBe('hasResults'); }); it('should not warn for files under 100KB', async () => { mockStat.mockResolvedValue({ size: 50 * 1024 } as unknown as Awaited<ReturnType<typeof fs.stat>>); mockReadFile.mockResolvedValue('content of small file'); const result = await fetchContent({ path: 'small-file.txt', // No pagination options }); expect(result.status).toBe('hasResults'); }); }); describe('Research context', () => { it('should preserve research goal and reasoning', async () => { const testContent = 'test content'; mockReadFile.mockResolvedValue(testContent); const result = await fetchContent({ path: 'test.txt', fullContent: true, researchGoal: 'Find implementation', reasoning: 'Testing feature X', }); expect(result.researchGoal).toBe('Find implementation'); expect(result.reasoning).toBe('Testing feature X'); }); }); describe('Character-based pagination (charOffset + charLength)', () => { it('should fetch content with charOffset and charLength', async () => { const largeContent = 'x'.repeat(20000); mockStat.mockResolvedValue({ size: 20000 } as unknown as Awaited<ReturnType<typeof fs.stat>>); mockReadFile.mockResolvedValue(largeContent); const result = await fetchContent({ path: 'large.txt', charOffset: 0, charLength: 5000, }); expect(result.status).toBe('hasResults'); expect(result.content?.length).toBeLessThanOrEqual(5000); expect(result.pagination?.hasMore).toBe(true); }); it('should return first chunk when charOffset = 0', async () => { const content = 'abcdefghijklmnopqrstuvwxyz'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 0, charLength: 10, }); expect(result.status).toBe('hasResults'); expect(result.content).toBe('abcdefghij'); expect(result.pagination?.charOffset).toBe(0); }); it('should return second chunk with charOffset', async () => { const content = 'abcdefghijklmnopqrstuvwxyz'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 10, charLength: 10, }); expect(result.status).toBe('hasResults'); expect(result.content).toBe('klmnopqrst'); expect(result.pagination?.charOffset).toBe(10); }); it('should return last chunk correctly', async () => { const content = 'x'.repeat(1000); mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 900, charLength: 200, }); expect(result.status).toBe('hasResults'); expect(result.content?.length).toBe(100); // Only 100 chars left expect(result.pagination?.hasMore).toBe(false); }); it('should handle charOffset = 0 explicitly', async () => { const content = 'test content'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 0, charLength: 100, }); expect(result.status).toBe('hasResults'); expect(result.pagination?.charOffset).toBe(0); }); it('should handle charOffset at exact file length', async () => { const content = 'x'.repeat(100); mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 100, charLength: 50, }); // When charOffset is at or beyond content, we still get hasResults with empty content expect(result.status).toBe('hasResults'); }); it('should handle charOffset beyond file length', async () => { const content = 'short text'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 1000, charLength: 100, }); // When charOffset is beyond content, we still get hasResults with empty content expect(result.status).toBe('hasResults'); }); it('should handle charLength = 1 (single char)', async () => { const content = 'abcdefghij'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charLength: 1, }); expect(result.status).toBe('hasResults'); expect(result.content).toBe('a'); expect(result.pagination?.hasMore).toBe(true); }); it('should handle charLength = 10000 (max)', async () => { const content = 'x'.repeat(20000); mockStat.mockResolvedValue({ size: 20000 } as unknown as Awaited<ReturnType<typeof fs.stat>>); mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charLength: 10000, }); expect(result.status).toBe('hasResults'); expect(result.content?.length).toBe(10000); expect(result.pagination?.hasMore).toBe(true); }); it('should handle charLength > remaining content', async () => { const content = 'short text'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charLength: 10000, }); expect(result.status).toBe('hasResults'); expect(result.content).toBe('short text'); expect(result.pagination?.hasMore).toBe(false); }); it('should handle empty file with pagination params', async () => { mockReadFile.mockResolvedValue(''); const result = await fetchContent({ path: 'empty.txt', charOffset: 0, charLength: 100, }); expect(result.status).toBe('empty'); }); }); describe('UTF-8 multi-byte character handling', () => { it('should handle ASCII content pagination', async () => { const content = 'Hello World!\nThis is ASCII text.'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 0, charLength: 10, }); expect(result.status).toBe('hasResults'); expect(result.content).toBe('Hello Worl'); }); it('should handle 2-byte UTF-8 chars (accented letters)', async () => { const content = 'Café résumé piñata'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 0, charLength: 10, }); expect(result.status).toBe('hasResults'); expect(result.content).toBeDefined(); // Should not have replacement character expect(result.content).not.toMatch(/\uFFFD/); }); it('should handle 3-byte UTF-8 chars (CJK characters)', async () => { const content = '你好世界 Hello 中文'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 0, charLength: 10, }); expect(result.status).toBe('hasResults'); expect(result.content).toBeDefined(); // Should not split UTF-8 characters expect(result.content).not.toMatch(/\uFFFD/); }); it('should handle 4-byte UTF-8 chars (emoji)', async () => { const content = '😀 Hello 🎉 World 👍'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 0, charLength: 10, }); expect(result.status).toBe('hasResults'); expect(result.content).toBeDefined(); // Should not split emoji expect(result.content).not.toMatch(/\uFFFD/); }); it('should handle mixed multi-byte content', async () => { const content = 'Hello 世界 café 😀 test'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 0, charLength: 15, }); expect(result.status).toBe('hasResults'); expect(result.content).toBeDefined(); expect(result.content).not.toMatch(/\uFFFD/); }); it('should not split multi-byte chars at charOffset boundary', async () => { // Position boundary right before a multi-byte char const content = 'aaaa' + 'é' + 'bbbb'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 4, charLength: 5, }); expect(result.status).toBe('hasResults'); expect(result.content).toBeDefined(); // Should include the 'é' without splitting expect(result.content).not.toMatch(/\uFFFD/); }); it('should not split multi-byte chars at charLength boundary', async () => { // Create content where charLength boundary falls in middle of UTF-8 char const content = 'a'.repeat(95) + 'café'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 0, charLength: 98, // Might cut through the 'é' }); expect(result.status).toBe('hasResults'); expect(result.content).toBeDefined(); // Should not have replacement character indicating split expect(result.content).not.toMatch(/\uFFFD/); }); it('should calculate byte offsets correctly for UTF-8', async () => { // In UTF-8, byte offset != character offset for multi-byte chars const content = '中文test'; // 中文 = 6 bytes, test = 4 bytes mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', charOffset: 2, // After "中文" charLength: 4, }); expect(result.status).toBe('hasResults'); expect(result.content).toBe('test'); }); }); describe('Integration with matchString', () => { it('should combine matchString with charOffset/charLength', async () => { const content = 'line1\nMATCH1\nline2\nMATCH2\nline3\nMATCH3\nline4'; mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', matchString: 'MATCH', charOffset: 0, charLength: 100, }); expect(result.status).toBe('hasResults'); expect(result.content).toContain('MATCH'); }); it('should paginate matched sections', async () => { const content = 'MATCH\n' + 'x'.repeat(10000) + '\nMATCH'; mockStat.mockResolvedValue({ size: 10020 } as unknown as Awaited<ReturnType<typeof fs.stat>>); mockReadFile.mockResolvedValue(content); const result = await fetchContent({ path: 'test.txt', matchString: 'MATCH', matchStringContextLines: 5, charLength: 5000, }); expect(result.status).toBe('hasResults'); }); it('should error with patternTooBroad when matches are too large without pagination', async () => { const manyLines = Array.from({ length: 2000 }, () => 'MATCH').join('\n'); mockStat.mockResolvedValue({ size: manyLines.length } as unknown as Awaited<ReturnType<typeof fs.stat>>); mockReadFile.mockResolvedValue(manyLines); const result = await fetchContent({ path: 'huge.txt', matchString: 'MATCH', // No charLength specified -> should be too broad }); expect(result.status).toBe('error'); expect(result.errorCode).toBe(ERROR_CODES.PATTERN_TOO_BROAD); }); }); describe('Pagination hints', () => { it('should show pagination hints when content is partial', async () => { const largeContent = 'x'.repeat(20000); mockStat.mockResolvedValue({ size: 20000 } as unknown as Awaited<ReturnType<typeof fs.stat>>); mockReadFile.mockResolvedValue(largeContent); const result = await fetchContent({ path: 'large.txt', charLength: 5000, }); expect(result.status).toBe('hasResults'); expect(result.pagination?.hasMore).toBe(true); expect(result.hints).toBeDefined(); }); it('should show charOffset for next chunk in hints', async () => { const largeContent = 'x'.repeat(20000); mockStat.mockResolvedValue({ size: 20000 } as unknown as Awaited<ReturnType<typeof fs.stat>>); mockReadFile.mockResolvedValue(largeContent); const result = await fetchContent({ path: 'large.txt', charOffset: 0, charLength: 5000, }); expect(result.status).toBe('hasResults'); if (result.pagination?.hasMore) { expect(result.hints).toBeDefined(); const hasCharOffsetHint = result.hints?.some(h => h.includes('charOffset') && h.includes('5000') ); expect(hasCharOffsetHint).toBe(true); } }); it('should show total chars in hints', async () => { const largeContent = 'x'.repeat(20000); mockStat.mockResolvedValue({ size: 20000 } as unknown as Awaited<ReturnType<typeof fs.stat>>); mockReadFile.mockResolvedValue(largeContent); const result = await fetchContent({ path: 'large.txt', charLength: 5000, }); expect(result.status).toBe('hasResults'); if (result.hints) { const hasTotalCharsHint = result.hints.some(h => h.includes('total') || h.includes('20000') ); expect(hasTotalCharsHint).toBe(true); } }); it('should not show pagination hints when content fits', async () => { const smallContent = 'Small file content'; mockReadFile.mockResolvedValue(smallContent); const result = await fetchContent({ path: 'small.txt', charLength: 10000, }); expect(result.status).toBe('hasResults'); expect(result.pagination?.hasMore).toBe(false); }); it('should show helpful hints for navigating pages', async () => { const largeContent = 'x'.repeat(20000); mockStat.mockResolvedValue({ size: 20000 } as unknown as Awaited<ReturnType<typeof fs.stat>>); mockReadFile.mockResolvedValue(largeContent); const result = await fetchContent({ path: 'large.txt', charLength: 5000, charOffset: 5000, }); expect(result.status).toBe('hasResults'); if (result.pagination?.hasMore) { expect(result.hints).toBeDefined(); // Should mention how to get next page const hasNavigationHint = result.hints?.some(h => h.toLowerCase().includes('next') || h.includes('charOffset=10000') ); expect(hasNavigationHint).toBe(true); } }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/bgauryy/local-explorer-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server