mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
532 lines
16 KiB
TypeScript
532 lines
16 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { vi, type Mock, type MockInstance } from 'vitest';
|
|
import type { Config } from '@qwen-code/qwen-code-core';
|
|
import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core';
|
|
import {
|
|
getErrorMessage,
|
|
handleError,
|
|
handleToolError,
|
|
handleCancellationError,
|
|
handleMaxTurnsExceededError,
|
|
} from './errors.js';
|
|
|
|
// Mock the core modules
|
|
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|
const original =
|
|
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
|
|
|
return {
|
|
...original,
|
|
parseAndFormatApiError: vi.fn((error: unknown) => {
|
|
if (error instanceof Error) {
|
|
return `API Error: ${error.message}`;
|
|
}
|
|
return `API Error: ${String(error)}`;
|
|
}),
|
|
JsonFormatter: vi.fn().mockImplementation(() => ({
|
|
formatError: vi.fn((error: Error, code?: string | number) =>
|
|
JSON.stringify(
|
|
{
|
|
error: {
|
|
type: error.constructor.name,
|
|
message: error.message,
|
|
...(code && { code }),
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
),
|
|
})),
|
|
FatalToolExecutionError: class extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'FatalToolExecutionError';
|
|
this.exitCode = 54;
|
|
}
|
|
exitCode: number;
|
|
},
|
|
FatalCancellationError: class extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'FatalCancellationError';
|
|
this.exitCode = 130;
|
|
}
|
|
exitCode: number;
|
|
},
|
|
};
|
|
});
|
|
|
|
describe('errors', () => {
|
|
let mockConfig: Config;
|
|
let processExitSpy: MockInstance;
|
|
let consoleErrorSpy: MockInstance;
|
|
|
|
beforeEach(() => {
|
|
// Reset mocks
|
|
vi.clearAllMocks();
|
|
|
|
// Mock console.error
|
|
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
// Mock process.exit to throw instead of actually exiting
|
|
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
throw new Error(`process.exit called with code: ${code}`);
|
|
});
|
|
|
|
// Create mock config
|
|
mockConfig = {
|
|
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
|
|
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }),
|
|
getDebugMode: vi.fn().mockReturnValue(true),
|
|
} as unknown as Config;
|
|
});
|
|
|
|
afterEach(() => {
|
|
consoleErrorSpy.mockRestore();
|
|
processExitSpy.mockRestore();
|
|
});
|
|
|
|
describe('getErrorMessage', () => {
|
|
it('should return error message for Error instances', () => {
|
|
const error = new Error('Test error message');
|
|
expect(getErrorMessage(error)).toBe('Test error message');
|
|
});
|
|
|
|
it('should convert non-Error values to strings', () => {
|
|
expect(getErrorMessage('string error')).toBe('string error');
|
|
expect(getErrorMessage(123)).toBe('123');
|
|
expect(getErrorMessage(null)).toBe('null');
|
|
expect(getErrorMessage(undefined)).toBe('undefined');
|
|
});
|
|
|
|
it('should handle objects', () => {
|
|
const obj = { message: 'test' };
|
|
expect(getErrorMessage(obj)).toBe('[object Object]');
|
|
});
|
|
});
|
|
|
|
describe('handleError', () => {
|
|
describe('in text mode', () => {
|
|
beforeEach(() => {
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.TEXT);
|
|
});
|
|
|
|
it('should log error message and re-throw', () => {
|
|
const testError = new Error('Test error');
|
|
|
|
expect(() => {
|
|
handleError(testError, mockConfig);
|
|
}).toThrow(testError);
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith('API Error: Test error');
|
|
});
|
|
|
|
it('should handle non-Error objects', () => {
|
|
const testError = 'String error';
|
|
|
|
expect(() => {
|
|
handleError(testError, mockConfig);
|
|
}).toThrow(testError);
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith('API Error: String error');
|
|
});
|
|
});
|
|
|
|
describe('in JSON mode', () => {
|
|
beforeEach(() => {
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.JSON);
|
|
});
|
|
|
|
it('should format error as JSON and exit with default code', () => {
|
|
const testError = new Error('Test error');
|
|
|
|
expect(() => {
|
|
handleError(testError, mockConfig);
|
|
}).toThrow('process.exit called with code: 1');
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
JSON.stringify(
|
|
{
|
|
error: {
|
|
type: 'Error',
|
|
message: 'Test error',
|
|
code: 1,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
});
|
|
|
|
it('should use custom error code when provided', () => {
|
|
const testError = new Error('Test error');
|
|
|
|
expect(() => {
|
|
handleError(testError, mockConfig, 42);
|
|
}).toThrow('process.exit called with code: 42');
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
JSON.stringify(
|
|
{
|
|
error: {
|
|
type: 'Error',
|
|
message: 'Test error',
|
|
code: 42,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
});
|
|
|
|
it('should extract exitCode from FatalError instances', () => {
|
|
const fatalError = new FatalInputError('Fatal error');
|
|
|
|
expect(() => {
|
|
handleError(fatalError, mockConfig);
|
|
}).toThrow('process.exit called with code: 42');
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
JSON.stringify(
|
|
{
|
|
error: {
|
|
type: 'FatalInputError',
|
|
message: 'Fatal error',
|
|
code: 42,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
});
|
|
|
|
it('should handle error with code property', () => {
|
|
const errorWithCode = new Error('Error with code') as Error & {
|
|
code: number;
|
|
};
|
|
errorWithCode.code = 404;
|
|
|
|
expect(() => {
|
|
handleError(errorWithCode, mockConfig);
|
|
}).toThrow('process.exit called with code: 404');
|
|
});
|
|
|
|
it('should handle error with status property', () => {
|
|
const errorWithStatus = new Error('Error with status') as Error & {
|
|
status: string;
|
|
};
|
|
errorWithStatus.status = 'TIMEOUT';
|
|
|
|
expect(() => {
|
|
handleError(errorWithStatus, mockConfig);
|
|
}).toThrow('process.exit called with code: 1'); // string codes become 1
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
JSON.stringify(
|
|
{
|
|
error: {
|
|
type: 'Error',
|
|
message: 'Error with status',
|
|
code: 'TIMEOUT',
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('handleToolError', () => {
|
|
const toolName = 'test-tool';
|
|
const toolError = new Error('Tool failed');
|
|
|
|
describe('when debug mode is enabled', () => {
|
|
beforeEach(() => {
|
|
(mockConfig.getDebugMode as Mock).mockReturnValue(true);
|
|
});
|
|
|
|
describe('in text mode', () => {
|
|
beforeEach(() => {
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.TEXT);
|
|
});
|
|
|
|
it('should log error message to stderr and not exit', () => {
|
|
handleToolError(toolName, toolError, mockConfig);
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'Error executing tool test-tool: Tool failed',
|
|
);
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should use resultDisplay when provided and not exit', () => {
|
|
handleToolError(
|
|
toolName,
|
|
toolError,
|
|
mockConfig,
|
|
'CUSTOM_ERROR',
|
|
'Custom display message',
|
|
);
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'Error executing tool test-tool: Custom display message',
|
|
);
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('in JSON mode', () => {
|
|
beforeEach(() => {
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.JSON);
|
|
});
|
|
|
|
it('should log error message to stderr and not exit', () => {
|
|
handleToolError(toolName, toolError, mockConfig);
|
|
|
|
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'Error executing tool test-tool: Tool failed',
|
|
);
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should log error with custom error code and not exit', () => {
|
|
handleToolError(toolName, toolError, mockConfig, 'CUSTOM_TOOL_ERROR');
|
|
|
|
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'Error executing tool test-tool: Tool failed',
|
|
);
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should log error with numeric error code and not exit', () => {
|
|
handleToolError(toolName, toolError, mockConfig, 500);
|
|
|
|
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'Error executing tool test-tool: Tool failed',
|
|
);
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should prefer resultDisplay over error message and not exit', () => {
|
|
handleToolError(
|
|
toolName,
|
|
toolError,
|
|
mockConfig,
|
|
'DISPLAY_ERROR',
|
|
'Display message',
|
|
);
|
|
|
|
// In JSON mode, should not exit (just log to stderr when debug mode is on)
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'Error executing tool test-tool: Display message',
|
|
);
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('in STREAM_JSON mode', () => {
|
|
beforeEach(() => {
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.STREAM_JSON);
|
|
});
|
|
|
|
it('should log error message to stderr and not exit', () => {
|
|
handleToolError(toolName, toolError, mockConfig);
|
|
|
|
// Should not exit in STREAM_JSON mode (just log to stderr when debug mode is on)
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'Error executing tool test-tool: Tool failed',
|
|
);
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('when debug mode is disabled', () => {
|
|
beforeEach(() => {
|
|
(mockConfig.getDebugMode as Mock).mockReturnValue(false);
|
|
});
|
|
|
|
it('should not log and not exit in text mode', () => {
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.TEXT);
|
|
|
|
handleToolError(toolName, toolError, mockConfig);
|
|
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not log and not exit in JSON mode', () => {
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.JSON);
|
|
|
|
handleToolError(toolName, toolError, mockConfig);
|
|
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not log and not exit in STREAM_JSON mode', () => {
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.STREAM_JSON);
|
|
|
|
handleToolError(toolName, toolError, mockConfig);
|
|
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('process exit behavior', () => {
|
|
beforeEach(() => {
|
|
(mockConfig.getDebugMode as Mock).mockReturnValue(true);
|
|
});
|
|
|
|
it('should never exit regardless of output format', () => {
|
|
// Test in TEXT mode
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.TEXT);
|
|
handleToolError(toolName, toolError, mockConfig);
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
|
|
// Test in JSON mode
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.JSON);
|
|
handleToolError(toolName, toolError, mockConfig);
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
|
|
// Test in STREAM_JSON mode
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.STREAM_JSON);
|
|
handleToolError(toolName, toolError, mockConfig);
|
|
expect(processExitSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('handleCancellationError', () => {
|
|
describe('in text mode', () => {
|
|
beforeEach(() => {
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.TEXT);
|
|
});
|
|
|
|
it('should log cancellation message and exit with 130', () => {
|
|
expect(() => {
|
|
handleCancellationError(mockConfig);
|
|
}).toThrow('process.exit called with code: 130');
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith('Operation cancelled.');
|
|
});
|
|
});
|
|
|
|
describe('in JSON mode', () => {
|
|
beforeEach(() => {
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.JSON);
|
|
});
|
|
|
|
it('should format cancellation as JSON and exit with 130', () => {
|
|
expect(() => {
|
|
handleCancellationError(mockConfig);
|
|
}).toThrow('process.exit called with code: 130');
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
JSON.stringify(
|
|
{
|
|
error: {
|
|
type: 'FatalCancellationError',
|
|
message: 'Operation cancelled.',
|
|
code: 130,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('handleMaxTurnsExceededError', () => {
|
|
describe('in text mode', () => {
|
|
beforeEach(() => {
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.TEXT);
|
|
});
|
|
|
|
it('should log max turns message and exit with 53', () => {
|
|
expect(() => {
|
|
handleMaxTurnsExceededError(mockConfig);
|
|
}).toThrow('process.exit called with code: 53');
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('in JSON mode', () => {
|
|
beforeEach(() => {
|
|
(
|
|
mockConfig.getOutputFormat as ReturnType<typeof vi.fn>
|
|
).mockReturnValue(OutputFormat.JSON);
|
|
});
|
|
|
|
it('should format max turns error as JSON and exit with 53', () => {
|
|
expect(() => {
|
|
handleMaxTurnsExceededError(mockConfig);
|
|
}).toThrow('process.exit called with code: 53');
|
|
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
JSON.stringify(
|
|
{
|
|
error: {
|
|
type: 'FatalTurnLimitedError',
|
|
message:
|
|
'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.',
|
|
code: 53,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
});
|