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}`); }