175 lines
6.3 KiB
TypeScript
175 lines
6.3 KiB
TypeScript
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
|||
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||
|
|
import { tmpdir } from 'node:os';
|
||
|
|
import { join } from 'node:path';
|
||
|
|
import { tools, toolSetOf } from '../src/tools';
|
||
|
|
|
||
|
|
let dir: string;
|
||
|
|
let origCwd: string;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
origCwd = process.cwd();
|
||
|
|
dir = mkdtempSync(join(tmpdir(), 'shiro-extra-'));
|
||
|
|
process.chdir(dir);
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
process.chdir(origCwd);
|
||
|
|
rmSync(dir, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
const run = <T>(name: string, input: T) =>
|
||
|
|
Promise.resolve(
|
||
|
|
(tools as Record<string, { execute?: (i: T, o: unknown) => unknown }>)[name]!.execute!(input, {
|
||
|
|
toolCallId: 't1',
|
||
|
|
messages: [],
|
||
|
|
}),
|
||
|
|
) as Promise<string>;
|
||
|
|
|
||
|
|
const write = (path: string, text: string) => Bun.write(path, text);
|
||
|
|
|
||
|
|
test('all 20 extra tools are registered in the extra set', () => {
|
||
|
|
for (const n of [
|
||
|
|
'insert_lines', 'delete_lines', 'replace_lines', 'append_file', 'prepend_file', 'count_lines',
|
||
|
|
'tree', 'file_info', 'find_files', 'recent_files', 'changed_files',
|
||
|
|
'git_log_file', 'git_diff_commits', 'git_show_file', 'git_current_branch', 'git_changed_in_ref',
|
||
|
|
'outline', 'read_symbol', 'env_info', 'count_tokens',
|
||
|
|
]) {
|
||
|
|
expect(tools[n as keyof typeof tools], n).toBeDefined();
|
||
|
|
expect(toolSetOf(n), n).toBe('extra');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- edit ---
|
||
|
|
|
||
|
|
test('insert_lines adds a block at a position, pushing the rest down', async () => {
|
||
|
|
await write('a.txt', 'one\ntwo\nthree');
|
||
|
|
await run('insert_lines', { path: 'a.txt', line: 2, text: 'inserted' });
|
||
|
|
expect(await Bun.file('a.txt').text()).toBe('one\ninserted\ntwo\nthree');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('insert_lines refuses a position past the end', async () => {
|
||
|
|
await write('a.txt', 'one\ntwo');
|
||
|
|
await expect(run('insert_lines', { path: 'a.txt', line: 99, text: 'x' })).rejects.toThrow(/past the end/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('delete_lines removes an inclusive range', async () => {
|
||
|
|
await write('a.txt', 'one\ntwo\nthree\nfour');
|
||
|
|
await run('delete_lines', { path: 'a.txt', start: 2, end: 3 });
|
||
|
|
expect(await Bun.file('a.txt').text()).toBe('one\nfour');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('delete_lines refuses to delete the whole file', async () => {
|
||
|
|
await write('a.txt', 'one\ntwo');
|
||
|
|
await expect(run('delete_lines', { path: 'a.txt', start: 1, end: 2 })).rejects.toThrow(/delete_file/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('replace_lines swaps a range for new text', async () => {
|
||
|
|
await write('a.txt', 'one\ntwo\nthree');
|
||
|
|
await run('replace_lines', { path: 'a.txt', start: 2, end: 2, text: 'TWO\nTWO2' });
|
||
|
|
expect(await Bun.file('a.txt').text()).toBe('one\nTWO\nTWO2\nthree');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('append_file and prepend_file add at the ends', async () => {
|
||
|
|
await write('a.txt', 'middle\n');
|
||
|
|
await run('append_file', { path: 'a.txt', text: 'end' });
|
||
|
|
await run('prepend_file', { path: 'a.txt', text: 'start' });
|
||
|
|
expect(await Bun.file('a.txt').text()).toBe('start\nmiddle\nend\n');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('count_lines counts one file and a glob', async () => {
|
||
|
|
await write('a.ts', '1\n2\n3');
|
||
|
|
await Bun.write(join('sub', 'b.ts'), '1\n2');
|
||
|
|
const one = await run('count_lines', { path: 'a.ts' });
|
||
|
|
expect(one).toContain('3');
|
||
|
|
expect(one).toContain('a.ts');
|
||
|
|
const many = await run('count_lines', { pattern: '**/*.ts' });
|
||
|
|
expect(many).toContain('a.ts');
|
||
|
|
expect(many).toContain('b.ts');
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- inspect ---
|
||
|
|
|
||
|
|
test('tree shows an indented, directories-first shape', async () => {
|
||
|
|
await Bun.write(join('src', 'app', 'index.ts'), 'x');
|
||
|
|
await Bun.write(join('src', 'util.ts'), 'x');
|
||
|
|
const out = await run('tree', { path: 'src', depth: 3 });
|
||
|
|
expect(out).toContain('app/');
|
||
|
|
expect(out).toContain('index.ts');
|
||
|
|
expect(out).toContain('util.ts');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('file_info reports size, lines, kind, and mtime', async () => {
|
||
|
|
await write('a.txt', 'one\ntwo');
|
||
|
|
const out = await run('file_info', { path: 'a.txt' });
|
||
|
|
expect(out).toContain('2 lines');
|
||
|
|
expect(out).toContain('text');
|
||
|
|
expect(out).toContain('bytes');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('find_files matches a filename substring', async () => {
|
||
|
|
await Bun.write(join('src', 'auth.ts'), 'x');
|
||
|
|
await Bun.write(join('src', 'auth.test.ts'), 'x');
|
||
|
|
await Bun.write(join('src', 'other.ts'), 'x');
|
||
|
|
const out = await run('find_files', { name: 'auth' });
|
||
|
|
expect(out).toContain('auth.ts');
|
||
|
|
expect(out).toContain('auth.test.ts');
|
||
|
|
expect(out).not.toContain('other.ts');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('recent_files lists newest first', async () => {
|
||
|
|
await write('old.txt', 'x');
|
||
|
|
await new Promise((r) => setTimeout(r, 20));
|
||
|
|
await write('new.txt', 'x');
|
||
|
|
const out = await run('recent_files', { limit: 2 });
|
||
|
|
expect(out.indexOf('new.txt')).toBeLessThan(out.indexOf('old.txt'));
|
||
|
|
});
|
||
|
|
|
||
|
|
test('changed_files reports the working-tree delta in a repo, or refuses cleanly outside one', async () => {
|
||
|
|
try {
|
||
|
|
const out = await run('changed_files', {});
|
||
|
|
expect(typeof out).toBe('string');
|
||
|
|
} catch (e) {
|
||
|
|
// A temp dir is not a repository, so the tool must refuse with a clear message.
|
||
|
|
expect(String(e)).toMatch(/not a git repository|not installed/i);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- code ---
|
||
|
|
|
||
|
|
test('outline lists top-level declarations', async () => {
|
||
|
|
await write('m.ts', 'import x from "y";\nexport function build() {}\nclass Thing {}\nconst helper = () => {};\n');
|
||
|
|
const out = await run('outline', { path: 'm.ts' });
|
||
|
|
expect(out).toContain('build');
|
||
|
|
expect(out).toContain('Thing');
|
||
|
|
expect(out).toContain('helper');
|
||
|
|
expect(out).not.toContain('import x');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('read_symbol extracts one definition body', async () => {
|
||
|
|
await write('m.ts', 'function alpha() {\n return 1;\n}\n\nfunction beta() {\n return 2;\n}\n');
|
||
|
|
const out = await run('read_symbol', { path: 'm.ts', name: 'alpha' });
|
||
|
|
expect(out).toContain('function alpha');
|
||
|
|
expect(out).toContain('return 1;');
|
||
|
|
expect(out).not.toContain('function beta');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('read_symbol errors plainly on a miss', async () => {
|
||
|
|
await write('m.ts', 'const x = 1;\n');
|
||
|
|
await expect(run('read_symbol', { path: 'm.ts', name: 'nope' })).rejects.toThrow(/No definition/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('env_info reports the platform and cwd', async () => {
|
||
|
|
const out = await run('env_info', {});
|
||
|
|
expect(out).toContain('platform:');
|
||
|
|
expect(out).toContain('cwd:');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('count_tokens estimates a file and a string', async () => {
|
||
|
|
await write('a.txt', 'x'.repeat(400));
|
||
|
|
const fileOut = await run('count_tokens', { path: 'a.txt' });
|
||
|
|
expect(fileOut).toContain('~100 tokens');
|
||
|
|
const strOut = await run('count_tokens', { text: 'abcd'.repeat(25) });
|
||
|
|
expect(strOut).toContain('~25 tokens');
|
||
|
|
});
|