mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
864 lines
27 KiB
TypeScript
864 lines
27 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
vi,
|
|
describe,
|
|
it,
|
|
expect,
|
|
beforeEach,
|
|
afterEach,
|
|
type Mock,
|
|
} from 'vitest';
|
|
|
|
const mockShellExecutionService = vi.hoisted(() => vi.fn());
|
|
vi.mock('../services/shellExecutionService.js', () => ({
|
|
ShellExecutionService: { execute: mockShellExecutionService },
|
|
}));
|
|
vi.mock('fs');
|
|
vi.mock('os');
|
|
vi.mock('crypto');
|
|
vi.mock('../utils/summarizer.js');
|
|
|
|
import { isCommandAllowed } from '../utils/shell-utils.js';
|
|
import { ShellTool } from './shell.js';
|
|
import { type Config } from '../config/config.js';
|
|
import {
|
|
type ShellExecutionResult,
|
|
type ShellOutputEvent,
|
|
} from '../services/shellExecutionService.js';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import { EOL } from 'node:os';
|
|
import * as path from 'node:path';
|
|
import * as crypto from 'node:crypto';
|
|
import * as summarizer from '../utils/summarizer.js';
|
|
import { ToolErrorType } from './tool-error.js';
|
|
import { ToolConfirmationOutcome } from './tools.js';
|
|
import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js';
|
|
import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js';
|
|
|
|
describe('ShellTool', () => {
|
|
let shellTool: ShellTool;
|
|
let mockConfig: Config;
|
|
let mockShellOutputCallback: (event: ShellOutputEvent) => void;
|
|
let resolveExecutionPromise: (result: ShellExecutionResult) => void;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
|
|
mockConfig = {
|
|
getCoreTools: vi.fn().mockReturnValue([]),
|
|
getExcludeTools: vi.fn().mockReturnValue([]),
|
|
getDebugMode: vi.fn().mockReturnValue(false),
|
|
getTargetDir: vi.fn().mockReturnValue('/test/dir'),
|
|
getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined),
|
|
getWorkspaceContext: vi
|
|
.fn()
|
|
.mockReturnValue(createMockWorkspaceContext('/test/dir')),
|
|
getGeminiClient: vi.fn(),
|
|
getGitCoAuthor: vi.fn().mockReturnValue({
|
|
enabled: true,
|
|
name: 'Qwen-Coder',
|
|
email: 'qwen-coder@alibabacloud.com',
|
|
}),
|
|
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
|
|
} as unknown as Config;
|
|
|
|
shellTool = new ShellTool(mockConfig);
|
|
|
|
vi.mocked(os.platform).mockReturnValue('linux');
|
|
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
|
|
(vi.mocked(crypto.randomBytes) as Mock).mockReturnValue(
|
|
Buffer.from('abcdef', 'hex'),
|
|
);
|
|
|
|
// Capture the output callback to simulate streaming events from the service
|
|
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
|
|
mockShellOutputCallback = callback;
|
|
return {
|
|
pid: 12345,
|
|
result: new Promise((resolve) => {
|
|
resolveExecutionPromise = resolve;
|
|
}),
|
|
};
|
|
});
|
|
});
|
|
|
|
describe('isCommandAllowed', () => {
|
|
it('should allow a command if no restrictions are provided', () => {
|
|
(mockConfig.getCoreTools as Mock).mockReturnValue(undefined);
|
|
(mockConfig.getExcludeTools as Mock).mockReturnValue(undefined);
|
|
expect(isCommandAllowed('ls -l', mockConfig).allowed).toBe(true);
|
|
});
|
|
|
|
it('should block a command with command substitution using $()', () => {
|
|
expect(isCommandAllowed('echo $(rm -rf /)', mockConfig).allowed).toBe(
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('build', () => {
|
|
it('should return an invocation for a valid command', () => {
|
|
const invocation = shellTool.build({
|
|
command: 'ls -l',
|
|
is_background: false,
|
|
});
|
|
expect(invocation).toBeDefined();
|
|
});
|
|
|
|
it('should throw an error for an empty command', () => {
|
|
expect(() =>
|
|
shellTool.build({ command: ' ', is_background: false }),
|
|
).toThrow('Command cannot be empty.');
|
|
});
|
|
|
|
it('should throw an error for a relative directory path', () => {
|
|
expect(() =>
|
|
shellTool.build({
|
|
command: 'ls',
|
|
directory: 'rel/path',
|
|
is_background: false,
|
|
}),
|
|
).toThrow('Directory must be an absolute path.');
|
|
});
|
|
|
|
it('should throw an error for a directory outside the workspace', () => {
|
|
(mockConfig.getWorkspaceContext as Mock).mockReturnValue(
|
|
createMockWorkspaceContext('/test/dir', ['/another/workspace']),
|
|
);
|
|
expect(() =>
|
|
shellTool.build({
|
|
command: 'ls',
|
|
directory: '/not/in/workspace',
|
|
is_background: false,
|
|
}),
|
|
).toThrow(
|
|
"Directory '/not/in/workspace' is not within any of the registered workspace directories.",
|
|
);
|
|
});
|
|
|
|
it('should return an invocation for a valid absolute directory path', () => {
|
|
(mockConfig.getWorkspaceContext as Mock).mockReturnValue(
|
|
createMockWorkspaceContext('/test/dir', ['/another/workspace']),
|
|
);
|
|
const invocation = shellTool.build({
|
|
command: 'ls',
|
|
directory: '/test/dir/subdir',
|
|
is_background: false,
|
|
});
|
|
expect(invocation).toBeDefined();
|
|
});
|
|
|
|
it('should include background indicator in description when is_background is true', () => {
|
|
const invocation = shellTool.build({
|
|
command: 'npm start',
|
|
is_background: true,
|
|
});
|
|
expect(invocation.getDescription()).toContain('[background]');
|
|
});
|
|
|
|
it('should not include background indicator in description when is_background is false', () => {
|
|
const invocation = shellTool.build({
|
|
command: 'npm test',
|
|
is_background: false,
|
|
});
|
|
expect(invocation.getDescription()).not.toContain('[background]');
|
|
});
|
|
});
|
|
|
|
describe('execute', () => {
|
|
const mockAbortSignal = new AbortController().signal;
|
|
|
|
const resolveShellExecution = (
|
|
result: Partial<ShellExecutionResult> = {},
|
|
) => {
|
|
const fullResult: ShellExecutionResult = {
|
|
rawOutput: Buffer.from(result.output || ''),
|
|
output: 'Success',
|
|
exitCode: 0,
|
|
signal: null,
|
|
error: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
...result,
|
|
};
|
|
resolveExecutionPromise(fullResult);
|
|
};
|
|
|
|
it('should wrap command on linux and parse pgrep output', async () => {
|
|
const invocation = shellTool.build({
|
|
command: 'my-command &',
|
|
is_background: false,
|
|
});
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
resolveShellExecution({ pid: 54321 });
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(`54321${EOL}54322${EOL}`); // Service PID and background PID
|
|
|
|
const result = await promise;
|
|
|
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
|
const wrappedCommand = `{ my-command & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
wrappedCommand,
|
|
'/test/dir',
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
expect(result.llmContent).toContain('Background PIDs: 54322');
|
|
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
|
});
|
|
|
|
it('should add ampersand to command when is_background is true and command does not end with &', async () => {
|
|
const invocation = shellTool.build({
|
|
command: 'npm start',
|
|
is_background: true,
|
|
});
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
resolveShellExecution({ pid: 54321 });
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
|
|
|
|
await promise;
|
|
|
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
|
const wrappedCommand = `{ npm start & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
wrappedCommand,
|
|
expect.any(String),
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should not add extra ampersand when is_background is true and command already ends with &', async () => {
|
|
const invocation = shellTool.build({
|
|
command: 'npm start &',
|
|
is_background: true,
|
|
});
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
resolveShellExecution({ pid: 54321 });
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
|
|
|
|
await promise;
|
|
|
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
|
const wrappedCommand = `{ npm start & }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
wrappedCommand,
|
|
expect.any(String),
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should not add ampersand when is_background is false', async () => {
|
|
const invocation = shellTool.build({
|
|
command: 'npm test',
|
|
is_background: false,
|
|
});
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
resolveShellExecution({ pid: 54321 });
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue('54321\n54322\n');
|
|
|
|
await promise;
|
|
|
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
|
const wrappedCommand = `{ npm test; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
wrappedCommand,
|
|
expect.any(String),
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should use the provided directory as cwd', async () => {
|
|
(mockConfig.getWorkspaceContext as Mock).mockReturnValue(
|
|
createMockWorkspaceContext('/test/dir'),
|
|
);
|
|
const invocation = shellTool.build({
|
|
command: 'ls',
|
|
directory: '/test/dir/subdir',
|
|
is_background: false,
|
|
});
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
resolveShellExecution();
|
|
await promise;
|
|
|
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
|
const wrappedCommand = `{ ls; }; __code=$?; pgrep -g 0 >${tmpFile} 2>&1; exit $__code;`;
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
wrappedCommand,
|
|
'/test/dir/subdir',
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should not wrap command on windows', async () => {
|
|
vi.mocked(os.platform).mockReturnValue('win32');
|
|
const invocation = shellTool.build({
|
|
command: 'dir',
|
|
is_background: false,
|
|
});
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
resolveShellExecution({
|
|
rawOutput: Buffer.from(''),
|
|
output: '',
|
|
exitCode: 0,
|
|
signal: null,
|
|
error: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
});
|
|
await promise;
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
'dir',
|
|
'/test/dir',
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should format error messages correctly', async () => {
|
|
const error = new Error('wrapped command failed');
|
|
const invocation = shellTool.build({
|
|
command: 'user-command',
|
|
is_background: false,
|
|
});
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
resolveShellExecution({
|
|
error,
|
|
exitCode: 1,
|
|
output: 'err',
|
|
rawOutput: Buffer.from('err'),
|
|
signal: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
});
|
|
|
|
const result = await promise;
|
|
expect(result.llmContent).toContain('Error: wrapped command failed');
|
|
expect(result.llmContent).not.toContain('pgrep');
|
|
});
|
|
|
|
it('should return a SHELL_EXECUTE_ERROR for a command failure', async () => {
|
|
const error = new Error('command failed');
|
|
const invocation = shellTool.build({
|
|
command: 'user-command',
|
|
is_background: false,
|
|
});
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
resolveShellExecution({
|
|
error,
|
|
exitCode: 1,
|
|
});
|
|
|
|
const result = await promise;
|
|
|
|
expect(result.error).toBeDefined();
|
|
expect(result.error?.type).toBe(ToolErrorType.SHELL_EXECUTE_ERROR);
|
|
expect(result.error?.message).toBe('command failed');
|
|
});
|
|
|
|
it('should throw an error for invalid parameters', () => {
|
|
expect(() =>
|
|
shellTool.build({ command: '', is_background: false }),
|
|
).toThrow('Command cannot be empty.');
|
|
});
|
|
|
|
it('should throw an error for invalid directory', () => {
|
|
expect(() =>
|
|
shellTool.build({
|
|
command: 'ls',
|
|
directory: 'nonexistent',
|
|
is_background: false,
|
|
}),
|
|
).toThrow('Directory must be an absolute path.');
|
|
});
|
|
|
|
it('should summarize output when configured', async () => {
|
|
(mockConfig.getSummarizeToolOutputConfig as Mock).mockReturnValue({
|
|
[shellTool.name]: { tokenBudget: 1000 },
|
|
});
|
|
vi.mocked(summarizer.summarizeToolOutput).mockResolvedValue(
|
|
'summarized output',
|
|
);
|
|
|
|
const invocation = shellTool.build({
|
|
command: 'ls',
|
|
is_background: false,
|
|
});
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
resolveExecutionPromise({
|
|
output: 'long output',
|
|
rawOutput: Buffer.from('long output'),
|
|
exitCode: 0,
|
|
signal: null,
|
|
error: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
});
|
|
|
|
const result = await promise;
|
|
|
|
expect(summarizer.summarizeToolOutput).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
mockConfig.getGeminiClient(),
|
|
expect.any(AbortSignal),
|
|
1000,
|
|
);
|
|
expect(result.llmContent).toBe('summarized output');
|
|
expect(result.returnDisplay).toBe('long output');
|
|
});
|
|
|
|
it('should clean up the temp file on synchronous execution error', async () => {
|
|
const error = new Error('sync spawn error');
|
|
mockShellExecutionService.mockImplementation(() => {
|
|
throw error;
|
|
});
|
|
vi.mocked(fs.existsSync).mockReturnValue(true); // Pretend the file exists
|
|
|
|
const invocation = shellTool.build({
|
|
command: 'a-command',
|
|
is_background: false,
|
|
});
|
|
await expect(invocation.execute(mockAbortSignal)).rejects.toThrow(error);
|
|
|
|
const tmpFile = path.join(os.tmpdir(), 'shell_pgrep_abcdef.tmp');
|
|
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
|
});
|
|
|
|
describe('Streaming to `updateOutput`', () => {
|
|
let updateOutputMock: Mock;
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
updateOutputMock = vi.fn();
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('should immediately show binary detection message and throttle progress', async () => {
|
|
const invocation = shellTool.build({
|
|
command: 'cat img',
|
|
is_background: false,
|
|
});
|
|
const promise = invocation.execute(mockAbortSignal, updateOutputMock);
|
|
|
|
mockShellOutputCallback({ type: 'binary_detected' });
|
|
expect(updateOutputMock).toHaveBeenCalledOnce();
|
|
expect(updateOutputMock).toHaveBeenCalledWith(
|
|
'[Binary output detected. Halting stream...]',
|
|
);
|
|
|
|
mockShellOutputCallback({
|
|
type: 'binary_progress',
|
|
bytesReceived: 1024,
|
|
});
|
|
expect(updateOutputMock).toHaveBeenCalledOnce();
|
|
|
|
// Advance time past the throttle interval.
|
|
await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1);
|
|
|
|
// Send a SECOND progress event. This one will trigger the flush.
|
|
mockShellOutputCallback({
|
|
type: 'binary_progress',
|
|
bytesReceived: 2048,
|
|
});
|
|
|
|
// Now it should be called a second time with the latest progress.
|
|
expect(updateOutputMock).toHaveBeenCalledTimes(2);
|
|
expect(updateOutputMock).toHaveBeenLastCalledWith(
|
|
'[Receiving binary output... 2.0 KB received]',
|
|
);
|
|
|
|
resolveExecutionPromise({
|
|
rawOutput: Buffer.from(''),
|
|
output: '',
|
|
exitCode: 0,
|
|
signal: null,
|
|
error: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
});
|
|
await promise;
|
|
});
|
|
});
|
|
|
|
describe('addCoAuthorToGitCommit', () => {
|
|
it('should add co-author to git commit with double quotes', async () => {
|
|
const command = 'git commit -m "Initial commit"';
|
|
const invocation = shellTool.build({ command, is_background: false });
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
|
|
// Mock the shell execution to return success
|
|
resolveExecutionPromise({
|
|
rawOutput: Buffer.from(''),
|
|
output: '',
|
|
exitCode: 0,
|
|
signal: null,
|
|
error: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
});
|
|
|
|
await promise;
|
|
|
|
// Verify that the command was executed with co-author added
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
|
),
|
|
expect.any(String),
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should add co-author to git commit with single quotes', async () => {
|
|
const command = "git commit -m 'Fix bug'";
|
|
const invocation = shellTool.build({ command, is_background: false });
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
|
|
resolveExecutionPromise({
|
|
rawOutput: Buffer.from(''),
|
|
output: '',
|
|
exitCode: 0,
|
|
signal: null,
|
|
error: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
});
|
|
|
|
await promise;
|
|
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
|
),
|
|
expect.any(String),
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should handle git commit with additional flags', async () => {
|
|
const command = 'git commit -a -m "Add feature"';
|
|
const invocation = shellTool.build({ command, is_background: false });
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
|
|
resolveExecutionPromise({
|
|
rawOutput: Buffer.from(''),
|
|
output: '',
|
|
exitCode: 0,
|
|
signal: null,
|
|
error: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
});
|
|
|
|
await promise;
|
|
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
|
),
|
|
expect.any(String),
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should not modify non-git commands', async () => {
|
|
const command = 'npm install';
|
|
const invocation = shellTool.build({ command, is_background: false });
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
|
|
resolveExecutionPromise({
|
|
rawOutput: Buffer.from(''),
|
|
output: '',
|
|
exitCode: 0,
|
|
signal: null,
|
|
error: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
});
|
|
|
|
await promise;
|
|
|
|
// On Linux, commands are wrapped with pgrep functionality
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
expect.stringContaining('npm install'),
|
|
expect.any(String),
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should not modify git commands without -m flag', async () => {
|
|
const command = 'git commit';
|
|
const invocation = shellTool.build({ command, is_background: false });
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
|
|
resolveExecutionPromise({
|
|
rawOutput: Buffer.from(''),
|
|
output: '',
|
|
exitCode: 0,
|
|
signal: null,
|
|
error: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
});
|
|
|
|
await promise;
|
|
|
|
// On Linux, commands are wrapped with pgrep functionality
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
expect.stringContaining('git commit'),
|
|
expect.any(String),
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should handle git commit with escaped quotes in message', async () => {
|
|
const command = 'git commit -m "Fix \\"quoted\\" text"';
|
|
const invocation = shellTool.build({ command, is_background: false });
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
|
|
resolveExecutionPromise({
|
|
rawOutput: Buffer.from(''),
|
|
output: '',
|
|
exitCode: 0,
|
|
signal: null,
|
|
error: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
});
|
|
|
|
await promise;
|
|
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>',
|
|
),
|
|
expect.any(String),
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should not add co-author when disabled in config', async () => {
|
|
// Mock config with disabled co-author
|
|
(mockConfig.getGitCoAuthor as Mock).mockReturnValue({
|
|
enabled: false,
|
|
name: 'Qwen-Coder',
|
|
email: 'qwen-coder@alibabacloud.com',
|
|
});
|
|
|
|
const command = 'git commit -m "Initial commit"';
|
|
const invocation = shellTool.build({ command, is_background: false });
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
|
|
resolveExecutionPromise({
|
|
rawOutput: Buffer.from(''),
|
|
output: '',
|
|
exitCode: 0,
|
|
signal: null,
|
|
error: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
});
|
|
|
|
await promise;
|
|
|
|
// On Linux, commands are wrapped with pgrep functionality
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
expect.stringContaining('git commit -m "Initial commit"'),
|
|
expect.any(String),
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
|
|
it('should use custom name and email from config', async () => {
|
|
// Mock config with custom co-author details
|
|
(mockConfig.getGitCoAuthor as Mock).mockReturnValue({
|
|
enabled: true,
|
|
name: 'Custom Bot',
|
|
email: 'custom@example.com',
|
|
});
|
|
|
|
const command = 'git commit -m "Test commit"';
|
|
const invocation = shellTool.build({ command, is_background: false });
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
|
|
resolveExecutionPromise({
|
|
rawOutput: Buffer.from(''),
|
|
output: '',
|
|
exitCode: 0,
|
|
signal: null,
|
|
error: null,
|
|
aborted: false,
|
|
pid: 12345,
|
|
executionMethod: 'child_process',
|
|
});
|
|
|
|
await promise;
|
|
|
|
expect(mockShellExecutionService).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'Co-authored-by: Custom Bot <custom@example.com>',
|
|
),
|
|
expect.any(String),
|
|
expect.any(Function),
|
|
expect.any(AbortSignal),
|
|
false,
|
|
{},
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('shouldConfirmExecute', () => {
|
|
it('should not request confirmation for read-only commands', async () => {
|
|
const invocation = shellTool.build({
|
|
command: 'ls -la',
|
|
is_background: false,
|
|
});
|
|
|
|
const confirmation = await invocation.shouldConfirmExecute(
|
|
new AbortController().signal,
|
|
);
|
|
|
|
expect(confirmation).toBe(false);
|
|
});
|
|
|
|
it('should request confirmation for a new command and whitelist it on "Always"', async () => {
|
|
const params = { command: 'npm install', is_background: false };
|
|
const invocation = shellTool.build(params);
|
|
const confirmation = await invocation.shouldConfirmExecute(
|
|
new AbortController().signal,
|
|
);
|
|
|
|
expect(confirmation).not.toBe(false);
|
|
expect(confirmation && confirmation.type).toBe('exec');
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
await (confirmation as any).onConfirm(
|
|
ToolConfirmationOutcome.ProceedAlways,
|
|
);
|
|
|
|
// Should now be whitelisted
|
|
const secondInvocation = shellTool.build({
|
|
command: 'npm test',
|
|
is_background: false,
|
|
});
|
|
const secondConfirmation = await secondInvocation.shouldConfirmExecute(
|
|
new AbortController().signal,
|
|
);
|
|
expect(secondConfirmation).toBe(false);
|
|
});
|
|
|
|
it('should throw an error if validation fails', () => {
|
|
expect(() =>
|
|
shellTool.build({ command: '', is_background: false }),
|
|
).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('getDescription', () => {
|
|
it('should return the windows description when on windows', () => {
|
|
vi.mocked(os.platform).mockReturnValue('win32');
|
|
const shellTool = new ShellTool(mockConfig);
|
|
expect(shellTool.description).toMatchSnapshot();
|
|
});
|
|
|
|
it('should return the non-windows description when not on windows', () => {
|
|
vi.mocked(os.platform).mockReturnValue('linux');
|
|
const shellTool = new ShellTool(mockConfig);
|
|
expect(shellTool.description).toMatchSnapshot();
|
|
});
|
|
});
|
|
|
|
describe('Windows background execution', () => {
|
|
it('should detect immediate failure in Windows background task', async () => {
|
|
vi.mocked(os.platform).mockReturnValue('win32');
|
|
const mockAbortSignal = new AbortController().signal;
|
|
|
|
const invocation = shellTool.build({
|
|
command: 'invalid_command',
|
|
is_background: true,
|
|
});
|
|
|
|
const promise = invocation.execute(mockAbortSignal);
|
|
|
|
// Wait a tick to ensure mockShellOutputCallback is assigned
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
if (mockShellOutputCallback) {
|
|
mockShellOutputCallback({
|
|
type: 'data',
|
|
chunk:
|
|
"'invalid_command' is not recognized as an internal or external command,\r\noperable program or batch file.\r\n",
|
|
});
|
|
}
|
|
|
|
const result = await promise;
|
|
expect(result.error).toBeDefined();
|
|
expect(result.llmContent).toContain('Command failed to start');
|
|
});
|
|
});
|
|
});
|