138 lines
4.3 KiB
Bash
138 lines
4.3 KiB
Bash
|
|
import * as fs from 'fs';
|
||
|
|
import * as fsp from 'fs/promises';
|
||
|
|
import iconv = require('iconv-lite');
|
||
|
|
import { bashTool, createBashTool, type BashOperations } from '../src/modules/netaclaw/tools/builtin/bash.js';
|
||
|
|
|
||
|
|
describe('bash tool', () => {
|
||
|
|
const tempLogs: string[] = [];
|
||
|
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||
|
|
const originalEnv = process.env;
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
tempLogs.splice(0).forEach(file => fs.rmSync(file, { force: true }));
|
||
|
|
process.env = originalEnv;
|
||
|
|
if (originalPlatform) {
|
||
|
|
Object.defineProperty(process, 'platform', originalPlatform);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns normal command output unchanged', async () => {
|
||
|
|
const tool = createBashTool({
|
||
|
|
operations: createMockOperations(({ onStdout }) => {
|
||
|
|
onStdout(Buffer.from('hello'));
|
||
|
|
return { exitCode: 0 };
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await tool.execute('bash-1', {
|
||
|
|
command: 'node -e "process.stdout.write(\'hello\')"',
|
||
|
|
timeout: 5000,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect((result as any).text).toBe('hello');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('keeps the tail of long command output and writes a full log', async () => {
|
||
|
|
const tool = createBashTool({
|
||
|
|
operations: createMockOperations(({ onStdout }) => {
|
||
|
|
const output = Array.from({ length: 2001 }, (_, index) => `line ${index + 1}`).join('\n');
|
||
|
|
onStdout(Buffer.from(output));
|
||
|
|
return { exitCode: 0 };
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await tool.execute('bash-2', {
|
||
|
|
command: 'long-output',
|
||
|
|
timeout: 5000,
|
||
|
|
});
|
||
|
|
const text = (result as any).text as string;
|
||
|
|
const fullOutputPath = extractFullOutputPath(text);
|
||
|
|
tempLogs.push(fullOutputPath);
|
||
|
|
await waitForFile(fullOutputPath);
|
||
|
|
|
||
|
|
expect(text).not.toContain('line 1\n');
|
||
|
|
expect(text).toContain('line 2001');
|
||
|
|
expect(text).toContain('完整输出:');
|
||
|
|
expect(fs.readFileSync(fullOutputPath, 'utf8')).toContain('line 1');
|
||
|
|
expect(fs.readFileSync(fullOutputPath, 'utf8')).toContain('line 2001');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('includes stderr output for failed commands', async () => {
|
||
|
|
const tool = createBashTool({
|
||
|
|
operations: createMockOperations(({ onStderr }) => {
|
||
|
|
onStderr(Buffer.from('bad\n'));
|
||
|
|
throw { message: '命令退出码 2', exitCode: 2 };
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await tool.execute('bash-3', {
|
||
|
|
command: 'failing-command',
|
||
|
|
timeout: 5000,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect((result as any).text).toContain('命令执行失败:');
|
||
|
|
expect((result as any).text).toContain('[stderr]: bad');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('decodes localized Windows shell stderr instead of showing mojibake', async () => {
|
||
|
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||
|
|
const tool = createBashTool({
|
||
|
|
operations: createMockOperations(({ onStderr }) => {
|
||
|
|
onStderr(iconv.encode("'pwd' 不是内部或外部命令,也不是可运行的程序或批处理文件。\r\n", 'gb18030'));
|
||
|
|
return { exitCode: 1 };
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
const result = await tool.execute('bash-encoding', {
|
||
|
|
command: 'pwd',
|
||
|
|
timeout: 5000,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect((result as any).text).toContain("'pwd' 不是内部或外部命令");
|
||
|
|
expect((result as any).text).not.toContain('ڲ');
|
||
|
|
} finally {
|
||
|
|
if (originalPlatform) {
|
||
|
|
Object.defineProperty(process, 'platform', originalPlatform);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('exports the default bash tool instance', () => {
|
||
|
|
expect(bashTool.name).toBe('bash');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
function createMockOperations(
|
||
|
|
runner: (options: {
|
||
|
|
timeout?: number;
|
||
|
|
onStdout: (chunk: Buffer) => void;
|
||
|
|
onStderr: (chunk: Buffer) => void;
|
||
|
|
}) => { exitCode: number | null } | Promise<{ exitCode: number | null }>,
|
||
|
|
): BashOperations {
|
||
|
|
return {
|
||
|
|
async exec(_command, _cwd, options) {
|
||
|
|
return await runner(options);
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function extractFullOutputPath(text: string): string {
|
||
|
|
const match = text.match(/完整输出: (.+?)\]/);
|
||
|
|
if (!match) throw new Error(`full output path not found in: ${text}`);
|
||
|
|
return match[1];
|
||
|
|
}
|
||
|
|
|
||
|
|
async function waitForFile(filePath: string) {
|
||
|
|
for (let i = 0; i < 20; i++) {
|
||
|
|
try {
|
||
|
|
await fsp.access(filePath);
|
||
|
|
return;
|
||
|
|
} catch {
|
||
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
throw new Error(`file not ready: ${filePath}`);
|
||
|
|
}
|