mirror of https://github.com/buster-so/buster.git
428 lines
11 KiB
TypeScript
428 lines
11 KiB
TypeScript
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||
|
import { tmpdir } from 'node:os';
|
||
|
import { join } from 'node:path';
|
||
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||
|
import { grepTool } from '../../../src/tools/file-tools/grep-tool';
|
||
|
import { validateArrayAccess } from '../../../src/utils/validation-helpers';
|
||
|
|
||
|
describe('Grep Tool Unit Tests', () => {
|
||
|
let tempDir: string;
|
||
|
let sourceDir: string;
|
||
|
|
||
|
beforeEach(() => {
|
||
|
// Create temporary directory structure for tests
|
||
|
tempDir = join(tmpdir(), `grep-test-${Date.now()}`);
|
||
|
sourceDir = join(tempDir, 'src');
|
||
|
|
||
|
mkdirSync(tempDir, { recursive: true });
|
||
|
mkdirSync(sourceDir, { recursive: true });
|
||
|
mkdirSync(join(sourceDir, 'components'), { recursive: true });
|
||
|
mkdirSync(join(tempDir, 'tests'), { recursive: true });
|
||
|
|
||
|
// Create test files with various content
|
||
|
writeFileSync(
|
||
|
join(sourceDir, 'app.ts'),
|
||
|
`
|
||
|
import express from 'express';
|
||
|
import { router } from './router';
|
||
|
|
||
|
const app = express();
|
||
|
app.use('/api', router);
|
||
|
|
||
|
export default app;
|
||
|
`
|
||
|
);
|
||
|
|
||
|
writeFileSync(
|
||
|
join(sourceDir, 'router.ts'),
|
||
|
`
|
||
|
import { Router } from 'express';
|
||
|
|
||
|
const router = Router();
|
||
|
|
||
|
router.get('/users', (req, res) => {
|
||
|
res.json({ users: [] });
|
||
|
});
|
||
|
|
||
|
router.post('/users', (req, res) => {
|
||
|
res.json({ success: true });
|
||
|
});
|
||
|
|
||
|
export { router };
|
||
|
`
|
||
|
);
|
||
|
|
||
|
writeFileSync(
|
||
|
join(sourceDir, 'components', 'Button.tsx'),
|
||
|
`
|
||
|
import React from 'react';
|
||
|
|
||
|
interface ButtonProps {
|
||
|
onClick: () => void;
|
||
|
children: React.ReactNode;
|
||
|
}
|
||
|
|
||
|
const Button: React.FC<ButtonProps> = ({ onClick, children }) => {
|
||
|
return (
|
||
|
<button onClick={onClick} className="btn">
|
||
|
{children}
|
||
|
</button>
|
||
|
);
|
||
|
};
|
||
|
|
||
|
export default Button;
|
||
|
`
|
||
|
);
|
||
|
|
||
|
writeFileSync(
|
||
|
join(tempDir, 'package.json'),
|
||
|
JSON.stringify(
|
||
|
{
|
||
|
name: 'test-app',
|
||
|
version: '1.0.0',
|
||
|
dependencies: {
|
||
|
express: '^4.18.0',
|
||
|
react: '^18.0.0',
|
||
|
},
|
||
|
},
|
||
|
null,
|
||
|
2
|
||
|
)
|
||
|
);
|
||
|
|
||
|
writeFileSync(
|
||
|
join(tempDir, 'README.md'),
|
||
|
`
|
||
|
# Test Application
|
||
|
|
||
|
This is a test application for grep functionality.
|
||
|
|
||
|
## Features
|
||
|
- Express server
|
||
|
- React components
|
||
|
- TypeScript support
|
||
|
|
||
|
## Usage
|
||
|
\`\`\`bash
|
||
|
npm start
|
||
|
\`\`\`
|
||
|
`
|
||
|
);
|
||
|
|
||
|
writeFileSync(
|
||
|
join(tempDir, 'tests', 'app.test.ts'),
|
||
|
`
|
||
|
import app from '../src/app';
|
||
|
import request from 'supertest';
|
||
|
|
||
|
describe('App', () => {
|
||
|
test('should respond to health check', async () => {
|
||
|
const response = await request(app).get('/health');
|
||
|
expect(response.status).toBe(200);
|
||
|
});
|
||
|
});
|
||
|
`
|
||
|
);
|
||
|
});
|
||
|
|
||
|
afterEach(() => {
|
||
|
// Clean up temporary directory
|
||
|
try {
|
||
|
rmSync(tempDir, { recursive: true, force: true });
|
||
|
} catch {
|
||
|
// Ignore cleanup errors
|
||
|
}
|
||
|
});
|
||
|
|
||
|
test('should have correct configuration', () => {
|
||
|
expect(grepTool.id).toBe('grep-search');
|
||
|
expect(grepTool.description).toBe(
|
||
|
'Search file contents using regular expressions with ripgrep'
|
||
|
);
|
||
|
expect(grepTool.inputSchema).toBeDefined();
|
||
|
expect(grepTool.outputSchema).toBeDefined();
|
||
|
expect(grepTool.execute).toBeDefined();
|
||
|
});
|
||
|
|
||
|
test('should validate input schema', () => {
|
||
|
const validInput = {
|
||
|
pattern: 'express',
|
||
|
path: '/absolute/path',
|
||
|
};
|
||
|
const result = grepTool.inputSchema.safeParse(validInput);
|
||
|
expect(result.success).toBe(true);
|
||
|
});
|
||
|
|
||
|
test('should validate output schema structure', () => {
|
||
|
const validOutput = {
|
||
|
pattern: 'express',
|
||
|
total_matches: 2,
|
||
|
files_searched: 5,
|
||
|
files_with_matches: 2,
|
||
|
matches: [
|
||
|
{
|
||
|
file: 'src/app.ts',
|
||
|
line_number: 1,
|
||
|
line_content: 'import express from "express";',
|
||
|
match_content: 'express',
|
||
|
context_before: [],
|
||
|
context_after: [],
|
||
|
},
|
||
|
],
|
||
|
};
|
||
|
|
||
|
const result = grepTool.outputSchema.safeParse(validOutput);
|
||
|
expect(result.success).toBe(true);
|
||
|
});
|
||
|
|
||
|
test('should find simple string matches', async () => {
|
||
|
const result = await grepTool.execute({
|
||
|
pattern: 'express',
|
||
|
path: tempDir,
|
||
|
case_sensitive: true,
|
||
|
});
|
||
|
|
||
|
expect(result.pattern).toBe('express');
|
||
|
expect(result.total_matches).toBeGreaterThan(0);
|
||
|
expect(result.files_with_matches).toBeGreaterThan(0);
|
||
|
|
||
|
// Should find matches in app.ts and router.ts
|
||
|
const appMatches = result.matches.filter((m: { file: string }) => m.file.includes('app.ts'));
|
||
|
const routerMatches = result.matches.filter((m: { file: string }) =>
|
||
|
m.file.includes('router.ts')
|
||
|
);
|
||
|
|
||
|
expect(appMatches.length).toBeGreaterThan(0);
|
||
|
expect(routerMatches.length).toBeGreaterThan(0);
|
||
|
});
|
||
|
|
||
|
test('should handle case insensitive search', async () => {
|
||
|
const result = await grepTool.execute({
|
||
|
pattern: 'EXPRESS',
|
||
|
path: tempDir,
|
||
|
case_sensitive: false,
|
||
|
});
|
||
|
|
||
|
expect(result.total_matches).toBeGreaterThan(0);
|
||
|
|
||
|
// Should find lowercase 'express' matches
|
||
|
const hasMatches = result.matches.some((m: { line_content: string }) =>
|
||
|
m.line_content.toLowerCase().includes('express')
|
||
|
);
|
||
|
expect(hasMatches).toBe(true);
|
||
|
});
|
||
|
|
||
|
test('should find regex patterns', async () => {
|
||
|
const result = await grepTool.execute({
|
||
|
pattern: 'router\\.(get|post)',
|
||
|
path: tempDir,
|
||
|
regex: true,
|
||
|
});
|
||
|
|
||
|
expect(result.total_matches).toBeGreaterThan(0);
|
||
|
|
||
|
// Should find both router.get and router.post
|
||
|
const getMatches = result.matches.filter((m) => m.line_content.includes('router.get'));
|
||
|
const postMatches = result.matches.filter((m) => m.line_content.includes('router.post'));
|
||
|
|
||
|
expect(getMatches.length).toBeGreaterThan(0);
|
||
|
expect(postMatches.length).toBeGreaterThan(0);
|
||
|
});
|
||
|
|
||
|
test('should handle whole word matching', async () => {
|
||
|
const result = await grepTool.execute({
|
||
|
pattern: 'app',
|
||
|
path: tempDir,
|
||
|
whole_word: true,
|
||
|
});
|
||
|
|
||
|
// Should find 'app' as a whole word, not within other words
|
||
|
const matches = result.matches;
|
||
|
expect(matches.length).toBeGreaterThan(0);
|
||
|
|
||
|
// Verify it's matching whole words
|
||
|
for (const match of matches) {
|
||
|
const wordBoundaryRegex = /\bapp\b/;
|
||
|
expect(wordBoundaryRegex.test(match.line_content)).toBe(true);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
test('should include only specified file patterns', async () => {
|
||
|
const result = await grepTool.execute({
|
||
|
pattern: 'React',
|
||
|
path: tempDir,
|
||
|
include: ['**/*.tsx', '**/*.ts'],
|
||
|
});
|
||
|
|
||
|
// Should only search TypeScript and TSX files
|
||
|
for (const match of result.matches) {
|
||
|
expect(match.file.endsWith('.ts') || match.file.endsWith('.tsx')).toBe(true);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
test('should exclude specified file patterns', async () => {
|
||
|
const result = await grepTool.execute({
|
||
|
pattern: 'test',
|
||
|
path: tempDir,
|
||
|
exclude: ['**/tests/**', '**/*.test.*'],
|
||
|
});
|
||
|
|
||
|
// Should not find matches in test files
|
||
|
const testFileMatches = result.matches.filter(
|
||
|
(m) => m.file.includes('test') || m.file.includes('Test')
|
||
|
);
|
||
|
expect(testFileMatches.length).toBe(0);
|
||
|
});
|
||
|
|
||
|
test('should limit matches per file when max_count is specified', async () => {
|
||
|
const result = await grepTool.execute({
|
||
|
pattern: 'router',
|
||
|
path: tempDir,
|
||
|
max_count: 1,
|
||
|
});
|
||
|
|
||
|
// Group matches by file
|
||
|
const matchesByFile = new Map<string, number>();
|
||
|
for (const match of result.matches) {
|
||
|
matchesByFile.set(match.file, (matchesByFile.get(match.file) || 0) + 1);
|
||
|
}
|
||
|
|
||
|
// Each file should have at most 1 match
|
||
|
for (const count of matchesByFile.values()) {
|
||
|
expect(count).toBeLessThanOrEqual(1);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
test('should provide context lines when requested', async () => {
|
||
|
const result = await grepTool.execute({
|
||
|
pattern: 'router.get',
|
||
|
path: tempDir,
|
||
|
context_lines: 2,
|
||
|
});
|
||
|
|
||
|
expect(result.matches.length).toBeGreaterThan(0);
|
||
|
|
||
|
const matchWithContext = validateArrayAccess(result.matches, 0, 'matches');
|
||
|
expect(matchWithContext?.context_before?.length).toBeGreaterThanOrEqual(0);
|
||
|
expect(matchWithContext?.context_after?.length).toBeGreaterThanOrEqual(0);
|
||
|
|
||
|
// Context should not exceed requested number of lines
|
||
|
expect(matchWithContext?.context_before?.length).toBeLessThanOrEqual(2);
|
||
|
expect(matchWithContext?.context_after?.length).toBeLessThanOrEqual(2);
|
||
|
});
|
||
|
|
||
|
test('should handle empty pattern error', async () => {
|
||
|
await expect(
|
||
|
grepTool.execute({
|
||
|
pattern: '',
|
||
|
path: tempDir,
|
||
|
})
|
||
|
).rejects.toThrow('Pattern cannot be empty');
|
||
|
});
|
||
|
|
||
|
test('should handle non-absolute paths', async () => {
|
||
|
await expect(
|
||
|
grepTool.execute({
|
||
|
pattern: 'test',
|
||
|
path: 'relative/path',
|
||
|
})
|
||
|
).rejects.toThrow('Path must be absolute');
|
||
|
});
|
||
|
|
||
|
test('should handle path traversal attempts', async () => {
|
||
|
await expect(
|
||
|
grepTool.execute({
|
||
|
context: {
|
||
|
pattern: 'test',
|
||
|
path: '/tmp/../etc',
|
||
|
},
|
||
|
})
|
||
|
).rejects.toThrow('Path traversal not allowed');
|
||
|
});
|
||
|
|
||
|
test('should handle access to sensitive directories', async () => {
|
||
|
await expect(
|
||
|
grepTool.execute({
|
||
|
context: {
|
||
|
pattern: 'root',
|
||
|
path: '/etc',
|
||
|
},
|
||
|
})
|
||
|
).rejects.toThrow('Access denied to path');
|
||
|
});
|
||
|
|
||
|
test('should handle multiline patterns when supported', async () => {
|
||
|
// Create a file with multiline content
|
||
|
writeFileSync(
|
||
|
join(tempDir, 'multiline.txt'),
|
||
|
`
|
||
|
function example() {
|
||
|
return {
|
||
|
name: 'test',
|
||
|
value: 42
|
||
|
};
|
||
|
}
|
||
|
`
|
||
|
);
|
||
|
|
||
|
const result = await grepTool.execute({
|
||
|
pattern: 'return \\{[^}]+\\}',
|
||
|
path: tempDir,
|
||
|
regex: true,
|
||
|
multiline: true,
|
||
|
});
|
||
|
|
||
|
// This test may not work with fallback implementation
|
||
|
// but should work with ripgrep if available
|
||
|
expect(result.total_matches).toBeGreaterThanOrEqual(0);
|
||
|
});
|
||
|
|
||
|
test('should return accurate file statistics', async () => {
|
||
|
const result = await grepTool.execute({
|
||
|
pattern: 'import',
|
||
|
path: tempDir,
|
||
|
});
|
||
|
|
||
|
expect(result.files_searched).toBeGreaterThan(0);
|
||
|
expect(result.files_with_matches).toBeGreaterThan(0);
|
||
|
expect(result.files_with_matches).toBeLessThanOrEqual(result.files_searched);
|
||
|
expect(result.total_matches).toBeGreaterThanOrEqual(result.files_with_matches);
|
||
|
});
|
||
|
|
||
|
test('should handle special regex characters in literal search', async () => {
|
||
|
// Create a file with special characters
|
||
|
writeFileSync(
|
||
|
join(tempDir, 'special.txt'),
|
||
|
`
|
||
|
Price: $10.99 (excluding tax)
|
||
|
Email: user@example.com
|
||
|
Pattern: /^[a-z]+$/
|
||
|
`
|
||
|
);
|
||
|
|
||
|
const result = await grepTool.execute({
|
||
|
pattern: '$10.99',
|
||
|
path: tempDir,
|
||
|
regex: false, // Literal search
|
||
|
});
|
||
|
|
||
|
expect(result.total_matches).toBe(1);
|
||
|
expect(result.matches[0].line_content).toContain('$10.99');
|
||
|
});
|
||
|
|
||
|
test('should handle binary files gracefully', async () => {
|
||
|
// Create a fake binary file
|
||
|
const binaryContent = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd]);
|
||
|
writeFileSync(join(tempDir, 'binary.bin'), binaryContent);
|
||
|
|
||
|
const result = await grepTool.execute({
|
||
|
pattern: 'test',
|
||
|
path: tempDir,
|
||
|
});
|
||
|
|
||
|
// Should not crash, may or may not find matches in binary files
|
||
|
expect(result).toBeDefined();
|
||
|
expect(result.total_matches).toBeGreaterThanOrEqual(0);
|
||
|
});
|
||
|
});
|