This commit is contained in:
202
packages/agent/tests/system-tools.test.ts
Normal file
202
packages/agent/tests/system-tools.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createSystemTools } from '../src/system-tools.js';
|
||||
import type { ToolDefinition } from '../src/tools.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
describe('createSystemTools', () => {
|
||||
let workspace: string;
|
||||
let tools: ToolDefinition[];
|
||||
|
||||
beforeEach(() => {
|
||||
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-test-'));
|
||||
tools = createSystemTools(workspace);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// On Windows, killed child processes may briefly hold locks on cwd
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function getTool(name: string): ToolDefinition {
|
||||
const tool = tools.find((t) => t.name === name);
|
||||
if (!tool) throw new Error(`Tool "${name}" not found`);
|
||||
return tool;
|
||||
}
|
||||
|
||||
it('creates all system tools', () => {
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain('bash');
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('write_file');
|
||||
expect(names).toContain('edit_file');
|
||||
expect(names).toContain('search_files');
|
||||
expect(names).toContain('search_content');
|
||||
expect(names).toContain('web_search');
|
||||
expect(names).toContain('web_fetch');
|
||||
expect(names).toContain('multi_edit');
|
||||
expect(names).toContain('get_task_output');
|
||||
expect(names).toContain('kill_task');
|
||||
expect(names).toContain('run_code');
|
||||
expect(tools).toHaveLength(12);
|
||||
});
|
||||
|
||||
describe('bash', () => {
|
||||
it('executes a simple command', async () => {
|
||||
const bash = getTool('bash');
|
||||
const result = await bash.execute({ command: 'echo hello world' });
|
||||
expect(result.trim()).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns stderr on failure', async () => {
|
||||
const bash = getTool('bash');
|
||||
// Use a command that reliably writes to stderr cross-platform
|
||||
const result = await bash.execute({ command: 'node -e "process.stderr.write(String.fromCharCode(101,114,114)); process.exit(1)"' });
|
||||
expect(result).toContain('err');
|
||||
});
|
||||
|
||||
it('respects timeout', async () => {
|
||||
const bash = getTool('bash');
|
||||
// Use ping which reliably blocks on both Windows and Unix
|
||||
const isWindows = process.platform === 'win32';
|
||||
const sleepCmd = isWindows ? 'ping -n 30 127.0.0.1' : 'sleep 30';
|
||||
const result = await bash.execute({ command: sleepCmd, timeout: 1000 });
|
||||
expect(result.toLowerCase()).toContain('timeout');
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe('read_file', () => {
|
||||
it('reads a file within workspace', async () => {
|
||||
const filePath = path.join(workspace, 'test.txt');
|
||||
fs.writeFileSync(filePath, 'file contents here');
|
||||
|
||||
const readFile = getTool('read_file');
|
||||
const result = await readFile.execute({ path: 'test.txt' });
|
||||
expect(result).toBe('file contents here');
|
||||
});
|
||||
|
||||
it('rejects paths outside workspace', async () => {
|
||||
const readFile = getTool('read_file');
|
||||
const result = await readFile.execute({ path: '../../etc/passwd' });
|
||||
expect(result.toLowerCase()).toContain('outside');
|
||||
});
|
||||
});
|
||||
|
||||
describe('write_file', () => {
|
||||
it('creates a new file', async () => {
|
||||
const writeFile = getTool('write_file');
|
||||
await writeFile.execute({ path: 'new-file.txt', content: 'new content' });
|
||||
|
||||
const written = fs.readFileSync(path.join(workspace, 'new-file.txt'), 'utf-8');
|
||||
expect(written).toBe('new content');
|
||||
});
|
||||
|
||||
it('creates intermediate directories', async () => {
|
||||
const writeFile = getTool('write_file');
|
||||
await writeFile.execute({ path: 'a/b/c/deep.txt', content: 'deep content' });
|
||||
|
||||
const written = fs.readFileSync(path.join(workspace, 'a', 'b', 'c', 'deep.txt'), 'utf-8');
|
||||
expect(written).toBe('deep content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit_file', () => {
|
||||
it('replaces exact string', async () => {
|
||||
const filePath = path.join(workspace, 'edit-me.txt');
|
||||
fs.writeFileSync(filePath, 'hello world, hello universe');
|
||||
|
||||
const editFile = getTool('edit_file');
|
||||
const result = await editFile.execute({
|
||||
path: 'edit-me.txt',
|
||||
old_string: 'world',
|
||||
new_string: 'planet',
|
||||
});
|
||||
|
||||
const edited = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(edited).toBe('hello planet, hello universe');
|
||||
expect(result.toLowerCase()).toContain('success');
|
||||
});
|
||||
|
||||
it('fails if old_string not found', async () => {
|
||||
const filePath = path.join(workspace, 'edit-me.txt');
|
||||
fs.writeFileSync(filePath, 'hello world');
|
||||
|
||||
const editFile = getTool('edit_file');
|
||||
const result = await editFile.execute({
|
||||
path: 'edit-me.txt',
|
||||
old_string: 'nonexistent',
|
||||
new_string: 'replacement',
|
||||
});
|
||||
expect(result.toLowerCase()).toContain('not found');
|
||||
});
|
||||
|
||||
it('fails if old_string appears more than once', async () => {
|
||||
const filePath = path.join(workspace, 'edit-me.txt');
|
||||
fs.writeFileSync(filePath, 'hello hello');
|
||||
|
||||
const editFile = getTool('edit_file');
|
||||
const result = await editFile.execute({
|
||||
path: 'edit-me.txt',
|
||||
old_string: 'hello',
|
||||
new_string: 'hi',
|
||||
});
|
||||
expect(result.toLowerCase()).toContain('multiple');
|
||||
});
|
||||
});
|
||||
|
||||
describe('search_files', () => {
|
||||
it('finds files by glob pattern', async () => {
|
||||
fs.writeFileSync(path.join(workspace, 'foo.ts'), '');
|
||||
fs.writeFileSync(path.join(workspace, 'bar.ts'), '');
|
||||
fs.writeFileSync(path.join(workspace, 'baz.js'), '');
|
||||
|
||||
const searchFiles = getTool('search_files');
|
||||
const result = await searchFiles.execute({ pattern: '**/*.ts' });
|
||||
expect(result).toContain('foo.ts');
|
||||
expect(result).toContain('bar.ts');
|
||||
expect(result).not.toContain('baz.js');
|
||||
});
|
||||
|
||||
it('ignores node_modules', async () => {
|
||||
const nmDir = path.join(workspace, 'node_modules', 'pkg');
|
||||
fs.mkdirSync(nmDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nmDir, 'index.ts'), '');
|
||||
fs.writeFileSync(path.join(workspace, 'app.ts'), '');
|
||||
|
||||
const searchFiles = getTool('search_files');
|
||||
const result = await searchFiles.execute({ pattern: '**/*.ts' });
|
||||
expect(result).toContain('app.ts');
|
||||
expect(result).not.toContain('node_modules');
|
||||
});
|
||||
});
|
||||
|
||||
describe('search_content', () => {
|
||||
it('finds content in files', async () => {
|
||||
fs.writeFileSync(path.join(workspace, 'a.txt'), 'line1\nfind me here\nline3');
|
||||
fs.writeFileSync(path.join(workspace, 'b.txt'), 'nothing relevant');
|
||||
|
||||
const searchContent = getTool('search_content');
|
||||
const result = await searchContent.execute({ pattern: 'find me' });
|
||||
expect(result).toContain('a.txt');
|
||||
expect(result).toContain('find me here');
|
||||
expect(result).not.toContain('b.txt');
|
||||
});
|
||||
|
||||
it('supports regex patterns', async () => {
|
||||
fs.writeFileSync(path.join(workspace, 'code.ts'), 'const foo = 123;\nconst bar = 456;');
|
||||
|
||||
const searchContent = getTool('search_content');
|
||||
const result = await searchContent.execute({ pattern: 'const \\w+ = \\d+' });
|
||||
expect(result).toContain('code.ts');
|
||||
expect(result).toContain('const foo = 123');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user