138 lines
4.3 KiB
Bash
Raw Normal View History

2026-05-20 21:39:12 +08:00
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}`);
}