144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
|
|
import * as fs from 'fs';
|
||
|
|
import * as os from 'os';
|
||
|
|
import * as path from 'path';
|
||
|
|
import sharp = require('sharp');
|
||
|
|
import { listDirTool, readFileTool, writeFileTool } from '../src/modules/netaclaw/tools/builtin/file.js';
|
||
|
|
|
||
|
|
describe('file tools', () => {
|
||
|
|
const tempDirs: string[] = [];
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
tempDirs.splice(0).forEach(dir => fs.rmSync(dir, { recursive: true, force: true }));
|
||
|
|
});
|
||
|
|
|
||
|
|
const createTempDir = () => {
|
||
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'neta-file-tool-'));
|
||
|
|
tempDirs.push(root);
|
||
|
|
return root;
|
||
|
|
};
|
||
|
|
|
||
|
|
it('reads a whole small file by default', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
const filePath = path.join(root, 'note.txt');
|
||
|
|
fs.writeFileSync(filePath, 'alpha\nbeta', 'utf8');
|
||
|
|
|
||
|
|
const result = await readFileTool.execute('read-1', { path: filePath });
|
||
|
|
|
||
|
|
expect((result as any).text).toBe('alpha\nbeta');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reads paths with Pi-compatible Unicode space normalization', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
fs.writeFileSync(path.join(root, 'my file.txt'), 'normalized', 'utf8');
|
||
|
|
|
||
|
|
const result = await readFileTool.execute('read-1', {
|
||
|
|
path: 'my\u00A0file.txt',
|
||
|
|
_netaRuntime: { sessionCwd: root },
|
||
|
|
} as any);
|
||
|
|
|
||
|
|
expect((result as any).text).toBe('normalized');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns structured image results for image files', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
const filePath = path.join(root, 'pixel.png');
|
||
|
|
fs.writeFileSync(
|
||
|
|
filePath,
|
||
|
|
Buffer.from(
|
||
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wn0lVQAAAAASUVORK5CYII=',
|
||
|
|
'base64'
|
||
|
|
)
|
||
|
|
);
|
||
|
|
|
||
|
|
const result = await readFileTool.execute('read-image', { path: filePath });
|
||
|
|
|
||
|
|
expect(result).toEqual(expect.objectContaining({
|
||
|
|
type: 'image',
|
||
|
|
mimeType: 'image/png',
|
||
|
|
width: 1,
|
||
|
|
height: 1,
|
||
|
|
}));
|
||
|
|
expect((result as any).url).toContain('data:image/png;base64,');
|
||
|
|
expect((result as any).text).toContain('Dimensions: 1x1');
|
||
|
|
expect((result as any).text).toContain('Size:');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('resizes oversized image files before returning structured image data', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
const filePath = path.join(root, 'large.png');
|
||
|
|
const largeImage = await sharp({
|
||
|
|
create: {
|
||
|
|
width: 2400,
|
||
|
|
height: 1200,
|
||
|
|
channels: 3,
|
||
|
|
background: { r: 12, g: 34, b: 56 },
|
||
|
|
},
|
||
|
|
}).png().toBuffer();
|
||
|
|
fs.writeFileSync(filePath, largeImage);
|
||
|
|
|
||
|
|
const result = await readFileTool.execute('read-large-image', { path: filePath });
|
||
|
|
|
||
|
|
expect(result).toEqual(expect.objectContaining({
|
||
|
|
type: 'image',
|
||
|
|
resized: true,
|
||
|
|
originalWidth: 2400,
|
||
|
|
originalHeight: 1200,
|
||
|
|
}));
|
||
|
|
expect((result as any).width).toBeLessThanOrEqual(2000);
|
||
|
|
expect((result as any).height).toBeLessThanOrEqual(2000);
|
||
|
|
expect((result as any).url).toMatch(/^data:image\/(png|jpeg);base64,/);
|
||
|
|
expect((result as any).text).toContain('original 2400x1200');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reads a line window with offset and limit', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
const filePath = path.join(root, 'note.txt');
|
||
|
|
fs.writeFileSync(filePath, 'line 1\nline 2\nline 3\nline 4', 'utf8');
|
||
|
|
|
||
|
|
const result = await readFileTool.execute('read-2', {
|
||
|
|
path: filePath,
|
||
|
|
offset: 2,
|
||
|
|
limit: 2,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect((result as any).text).toContain('line 2\nline 3');
|
||
|
|
expect((result as any).text).toContain('offset=4');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('truncates large reads with a continuation hint', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
const filePath = path.join(root, 'large.txt');
|
||
|
|
const content = Array.from({ length: 2001 }, (_, index) => `line ${index + 1}`).join('\n');
|
||
|
|
fs.writeFileSync(filePath, content, 'utf8');
|
||
|
|
|
||
|
|
const result = await readFileTool.execute('read-3', { path: filePath });
|
||
|
|
const text = (result as any).text as string;
|
||
|
|
|
||
|
|
expect(text).toContain('line 1');
|
||
|
|
expect(text).toContain('line 2000');
|
||
|
|
expect(text).not.toContain('line 2001');
|
||
|
|
expect(text).toContain('已达到 2000 行限制');
|
||
|
|
expect(text).toContain('offset=2001');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('serializes write_file mutations through the file mutation queue', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
const filePath = path.join(root, 'nested', 'note.txt');
|
||
|
|
|
||
|
|
await writeFileTool.execute('write-1', { path: filePath, content: 'created' });
|
||
|
|
|
||
|
|
expect(fs.readFileSync(filePath, 'utf8')).toBe('created');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('lists runtime session cwd when path is omitted', async () => {
|
||
|
|
const root = createTempDir();
|
||
|
|
fs.writeFileSync(path.join(root, 'note.txt'), 'created', 'utf8');
|
||
|
|
|
||
|
|
const result = await listDirTool.execute('list-1', {
|
||
|
|
_netaRuntime: { sessionCwd: root },
|
||
|
|
} as any);
|
||
|
|
|
||
|
|
expect((result as any).text).toContain('note.txt');
|
||
|
|
});
|
||
|
|
});
|