import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { EventEmitter } from 'events';
import {
RequestBatcher,
getBatcher,
resetBatcher,
BatchRequest,
BatchResult,
BatchMetrics,
BatchPriority,
BatchConfig,
ToolCompatibilityGroup
} from '../../src/core/request-batcher.js';
import { ToolRegistry } from '../../src/core/tool-registry.js';
// Mock dependencies
vi.mock('../../src/logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn()
}
}));
vi.mock('../../src/core/performance-monitor.js', () => ({
getPerformanceMonitor: vi.fn(() => ({
recordQueueMetrics: vi.fn()
}))
}));
vi.mock('../../src/core/tool-registry.js', () => ({
ToolRegistry: {
getInstance: vi.fn(() => ({
getHandler: vi.fn()
}))
}
}));
vi.mock('../../src/core/execution-context.js', () => ({
ExecutionContextImpl: class {
recordToolExecution = vi.fn();
cleanup = vi.fn();
}
}));
// Mock crypto for deterministic IDs in tests
vi.mock('crypto', () => ({
randomUUID: vi.fn(() => 'test-uuid-' + Math.random().toString(36).substr(2, 9))
}));
describe('RequestBatcher', () => {
let batcher: RequestBatcher;
let mockToolRegistry: any;
beforeEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
resetBatcher();
// Setup mock tool registry
mockToolRegistry = {
getHandler: vi.fn()
};
vi.mocked(ToolRegistry.getInstance).mockReturnValue(mockToolRegistry);
batcher = new RequestBatcher({
batchTimeout: 50, // Shorter timeout for tests
maxBatchSize: 5,
maxConcurrentBatches: 2
});
});
afterEach(async () => {
if (batcher) {
batcher.clearQueue();
batcher.cleanup();
}
vi.clearAllTimers();
vi.clearAllMocks();
// Allow any pending promises to resolve/reject
await new Promise(resolve => setTimeout(resolve, 0));
});
describe('Initialization', () => {
it('should initialize with default configuration', () => {
expect(batcher).toBeInstanceOf(RequestBatcher);
expect(batcher).toBeInstanceOf(EventEmitter);
});
it('should accept custom configuration', () => {
const customConfig: Partial<BatchConfig> = {
maxBatchSize: 20,
batchTimeout: 200,
maxConcurrentBatches: 5,
enableParallelExecution: false,
priorityWeighting: false
};
const customBatcher = new RequestBatcher(customConfig);
expect(customBatcher).toBeInstanceOf(RequestBatcher);
customBatcher.cleanup();
});
it('should create singleton instance with getBatcher', () => {
const batcher1 = getBatcher();
const batcher2 = getBatcher();
expect(batcher1).toBe(batcher2);
resetBatcher();
});
});
describe('Request Addition', () => {
it('should add request to queue', async () => {
const mockHandler = {
execute: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'result' }] }),
validatePermissions: vi.fn().mockResolvedValue(undefined)
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
const promise = batcher.addRequest('test-tool', { arg: 'value' });
const status = batcher.getQueueStatus();
expect(status.queueLength).toBe(1);
// Cleanup to prevent unhandled promise
batcher.clearQueue();
await expect(promise).rejects.toThrow('Queue cleared');
});
it('should handle request with custom priority', async () => {
const mockHandler = {
execute: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'result' }] })
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
const promise = batcher.addRequest('test-tool', { arg: 'value' }, {
priority: BatchPriority.HIGH
});
const status = batcher.getQueueStatus();
expect(status.priorityDistribution[BatchPriority.HIGH]).toBe(1);
batcher.clearQueue();
await expect(promise).rejects.toThrow('Queue cleared');
});
it('should handle request timeout', async () => {
vi.useFakeTimers();
const promise = batcher.addRequest('test-tool', { arg: 'value' }, {
timeout: 100
});
vi.advanceTimersByTime(150);
await expect(promise).rejects.toThrow('Request timeout after 100ms');
vi.useRealTimers();
});
it('should sort queue by priority when enabled', async () => {
const mockHandler = {
execute: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'result' }] })
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
// Add requests with different priorities
const promises = [
batcher.addRequest('tool1', {}, { priority: BatchPriority.LOW }),
batcher.addRequest('tool2', {}, { priority: BatchPriority.CRITICAL }),
batcher.addRequest('tool3', {}, { priority: BatchPriority.NORMAL }),
batcher.addRequest('tool4', {}, { priority: BatchPriority.HIGH })
];
// Queue should be sorted by priority
const status = batcher.getQueueStatus();
expect(status.queueLength).toBe(4);
batcher.clearQueue();
await Promise.all(promises.map(p => expect(p).rejects.toThrow('Queue cleared')));
});
});
describe('Batch Processing', () => {
it('should process batch after timeout', async () => {
vi.useFakeTimers();
const mockHandler = {
execute: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'result' }],
isError: false
})
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
const promise = batcher.addRequest('test-tool', { arg: 'value' });
// Advance time to trigger batch processing
vi.advanceTimersByTime(100); // Default batch timeout
// Wait for the promise to resolve
const result = await promise;
expect(result.success).toBe(true);
expect(mockHandler.execute).toHaveBeenCalledWith({ arg: 'value' }, expect.any(Object));
vi.useRealTimers();
});
it('should respect max batch size', async () => {
const mockHandler = {
execute: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'result' }],
isError: false
})
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
// Add more requests than the max batch size (5)
const promises = [];
for (let i = 0; i < 8; i++) {
promises.push(batcher.addRequest('test-tool', { index: i }));
}
// Wait for all requests to complete
await Promise.allSettled(promises);
// Should have been called 8 times total (all requests processed)
expect(mockHandler.execute).toHaveBeenCalledTimes(8);
// Verify the requests were processed in batches (not all at once)
const results = await Promise.allSettled(promises);
expect(results.every(r => r.status === 'fulfilled')).toBe(true);
});
it('should handle batch execution failure', async () => {
vi.useFakeTimers();
const mockHandler = {
execute: vi.fn().mockRejectedValue(new Error('Tool execution failed'))
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
const promise = batcher.addRequest('failing-tool', {});
// Advance timer to trigger batch processing
vi.advanceTimersByTime(100);
const result = await promise;
expect(result.success).toBe(false);
expect(result.error?.message).toBe('Tool execution failed');
vi.useRealTimers();
});
it('should emit batchCompleted event', async () => {
vi.useFakeTimers();
const mockHandler = {
execute: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'result' }],
isError: false
})
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
const batchCompletedHandler = vi.fn();
batcher.on('batchCompleted', batchCompletedHandler);
const promise = batcher.addRequest('test-tool', {});
vi.advanceTimersByTime(100);
// Wait for the request to complete
await promise;
expect(batchCompletedHandler).toHaveBeenCalledWith(
expect.objectContaining({
requestCount: 1,
successCount: 1,
failureCount: 0
})
);
vi.useRealTimers();
});
});
describe('Parallel Execution', () => {
it('should group compatible tools for parallel execution', async () => {
vi.useFakeTimers();
const mockHandler = {
execute: vi.fn().mockImplementation((args) =>
Promise.resolve({
content: [{ type: 'text', text: `result-${args.tool}` }],
isError: false
})
)
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
// Add compatible screenshot tools (can run in parallel)
const promises = [
batcher.addRequest('take_screenshot', { tool: 'screenshot1' }),
batcher.addRequest('list_screenshots', { tool: 'screenshot2' }),
batcher.addRequest('view_screenshot', { tool: 'screenshot3' })
];
// Advance timer to trigger batch processing
vi.advanceTimersByTime(100);
const results = await Promise.allSettled(promises);
// All should succeed
expect(results.every(r => r.status === 'fulfilled' && r.value.success)).toBe(true);
expect(mockHandler.execute).toHaveBeenCalledTimes(3);
vi.useRealTimers();
});
it('should execute incompatible tools sequentially', async () => {
vi.useFakeTimers();
let executionOrder: string[] = [];
const mockHandler = {
execute: vi.fn().mockImplementation((args) => {
executionOrder.push(args.tool);
return Promise.resolve({
content: [{ type: 'text', text: `result-${args.tool}` }],
isError: false
});
})
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
// Add automation tools (cannot run in parallel)
const promises = [
batcher.addRequest('click', { tool: 'click' }),
batcher.addRequest('type_text', { tool: 'type' }),
batcher.addRequest('key_press', { tool: 'key' })
];
vi.advanceTimersByTime(100);
await Promise.allSettled(promises);
// Should execute in order (sequential since they're in same batch)
expect(executionOrder).toEqual(['click', 'type', 'key']);
vi.useRealTimers();
});
it('should handle mixed compatible and incompatible tools', async () => {
vi.useFakeTimers();
const mockHandler = {
execute: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'result' }],
isError: false
})
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
// Mix of compatible and incompatible tools
const promises = [
batcher.addRequest('take_screenshot', {}), // Group 1 (screenshot)
batcher.addRequest('list_screenshots', {}), // Group 1 (screenshot)
batcher.addRequest('click', {}), // Group 2 (automation)
batcher.addRequest('list_windows', {}), // Group 3 (window query)
batcher.addRequest('type_text', {}) // Group 2 (automation)
];
vi.advanceTimersByTime(100);
const results = await Promise.allSettled(promises);
expect(results.every(r => r.status === 'fulfilled' && r.value.success)).toBe(true);
vi.useRealTimers();
});
it('should disable parallel execution when configured', async () => {
const sequentialBatcher = new RequestBatcher({
enableParallelExecution: false
});
vi.useFakeTimers();
const mockHandler = {
execute: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'result' }],
isError: false
})
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
// Add compatible tools that would normally run in parallel
const promises = [
sequentialBatcher.addRequest('take_screenshot', {}),
sequentialBatcher.addRequest('list_screenshots', {})
];
vi.advanceTimersByTime(100);
await Promise.allSettled(promises);
// Should still execute successfully but sequentially
expect(mockHandler.execute).toHaveBeenCalledTimes(2);
sequentialBatcher.cleanup();
vi.useRealTimers();
});
});
describe('Performance Tracking', () => {
it('should track tool performance metrics', async () => {
vi.useFakeTimers();
const mockHandler = {
execute: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'result' }],
isError: false
})
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
const promise = batcher.addRequest('perf-tool', {});
vi.advanceTimersByTime(100);
await promise;
const metrics = batcher.getToolPerformanceMetrics('perf-tool');
expect(metrics).toBeTruthy();
expect(metrics?.count).toBe(1);
expect(metrics?.averageTime).toBeGreaterThanOrEqual(0);
vi.useRealTimers();
});
it('should return null for unknown tool metrics', () => {
const metrics = batcher.getToolPerformanceMetrics('unknown-tool');
expect(metrics).toBeNull();
});
it('should track batch metrics', async () => {
vi.useFakeTimers();
const mockHandler = {
execute: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'result' }],
isError: false
})
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
const promise = batcher.addRequest('test-tool', {});
vi.advanceTimersByTime(100);
await promise;
const batchMetrics = batcher.getBatchMetrics();
expect(batchMetrics).toHaveLength(1);
expect(batchMetrics[0]).toMatchObject({
requestCount: 1,
successCount: 1,
failureCount: 0
});
vi.useRealTimers();
});
it('should limit performance history size', async () => {
const mockHandler = {
execute: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'result' }],
isError: false
})
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
// Add 105 requests for same tool
const promises = [];
for (let i = 0; i < 105; i++) {
promises.push(batcher.addRequest('overflow-tool', { index: i }));
}
// Wait for all to complete
await Promise.allSettled(promises);
const metrics = batcher.getToolPerformanceMetrics('overflow-tool');
expect(metrics?.count).toBeLessThanOrEqual(100);
});
});
describe('Queue Management', () => {
it('should provide queue status', () => {
const initialStatus = batcher.getQueueStatus();
expect(initialStatus).toMatchObject({
queueLength: 0,
activeBatches: 0,
priorityDistribution: {
[BatchPriority.LOW]: 0,
[BatchPriority.NORMAL]: 0,
[BatchPriority.HIGH]: 0,
[BatchPriority.CRITICAL]: 0
},
oldestRequestAge: 0
});
});
it('should clear queue and reject pending requests', async () => {
const promises = [
batcher.addRequest('tool1', {}),
batcher.addRequest('tool2', {}),
batcher.addRequest('tool3', {})
];
const statusBefore = batcher.getQueueStatus();
expect(statusBefore.queueLength).toBe(3);
batcher.clearQueue();
const statusAfter = batcher.getQueueStatus();
expect(statusAfter.queueLength).toBe(0);
// All promises should be rejected
await Promise.all(
promises.map(p => expect(p).rejects.toThrow('Queue cleared'))
);
});
it('should track oldest request age', async () => {
vi.useFakeTimers();
const startTime = Date.now();
vi.setSystemTime(startTime);
await batcher.addRequest('old-tool', {});
// Advance time
vi.advanceTimersByTime(5000);
await batcher.addRequest('new-tool', {});
const status = batcher.getQueueStatus();
expect(status.oldestRequestAge).toBeCloseTo(5000, -2);
batcher.clearQueue();
vi.useRealTimers();
});
});
describe('Configuration Updates', () => {
it('should update configuration dynamically', () => {
const newConfig: Partial<BatchConfig> = {
maxBatchSize: 25,
batchTimeout: 300
};
batcher.updateConfig(newConfig);
// Configuration updated internally - verify through behavior
expect(() => batcher.updateConfig(newConfig)).not.toThrow();
});
});
describe('Error Handling', () => {
it('should handle tool not found error', async () => {
vi.useFakeTimers();
mockToolRegistry.getHandler.mockReturnValue(null);
const promise = batcher.addRequest('nonexistent-tool', {});
vi.advanceTimersByTime(100);
await vi.runAllTimersAsync();
const result = await promise;
expect(result.success).toBe(false);
expect(result.error?.message).toContain('Tool not found');
vi.useRealTimers();
});
it('should handle permission validation failure', async () => {
vi.useFakeTimers();
const mockHandler = {
execute: vi.fn(),
validatePermissions: vi.fn().mockRejectedValue(new Error('Permission denied'))
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
const promise = batcher.addRequest('restricted-tool', {});
vi.advanceTimersByTime(100);
await vi.runAllTimersAsync();
const result = await promise;
expect(result.success).toBe(false);
expect(result.error?.message).toBe('Permission denied');
vi.useRealTimers();
});
it('should handle errors in parallel batch execution', async () => {
vi.useFakeTimers();
const mockHandler = {
execute: vi.fn()
.mockResolvedValueOnce({ content: [{ type: 'text', text: 'success' }], isError: false })
.mockRejectedValueOnce(new Error('Tool failed'))
.mockResolvedValueOnce({ content: [{ type: 'text', text: 'success' }], isError: false })
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
const promises = [
batcher.addRequest('take_screenshot', {}),
batcher.addRequest('list_screenshots', {}),
batcher.addRequest('view_screenshot', {})
];
vi.advanceTimersByTime(100);
await vi.runAllTimersAsync();
const results = await Promise.all(promises);
expect(results[0].success).toBe(true);
expect(results[1].success).toBe(false);
expect(results[1].error?.message).toBe('Tool failed');
expect(results[2].success).toBe(true);
vi.useRealTimers();
});
});
describe('Cleanup', () => {
it('should cleanup all resources', () => {
// Add some data
batcher.addRequest('tool1', {});
batcher.addRequest('tool2', {});
const cleanupSpy = vi.spyOn(batcher, 'removeAllListeners');
batcher.cleanup();
expect(cleanupSpy).toHaveBeenCalled();
expect(batcher.getQueueStatus().queueLength).toBe(0);
});
it('should stop performance reporting on cleanup', () => {
vi.useFakeTimers();
const clearIntervalSpy = vi.spyOn(global, 'clearInterval');
batcher.cleanup();
// Should clear the performance reporting interval
expect(clearIntervalSpy).toHaveBeenCalled();
vi.useRealTimers();
});
});
describe('Singleton Management', () => {
it('should reset singleton instance', () => {
const batcher1 = getBatcher();
resetBatcher();
const batcher2 = getBatcher();
expect(batcher1).not.toBe(batcher2);
resetBatcher();
});
it('should cleanup before resetting', () => {
const batcher = getBatcher();
const cleanupSpy = vi.spyOn(batcher, 'cleanup');
resetBatcher();
expect(cleanupSpy).toHaveBeenCalled();
});
});
describe('Concurrent Batch Handling', () => {
it('should respect max concurrent batches limit', async () => {
const batcherWithLimit = new RequestBatcher({
maxConcurrentBatches: 1, // Only one batch at a time
maxBatchSize: 2,
batchTimeout: 10
});
const mockHandler = {
execute: vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'result' }],
isError: false
})
};
mockToolRegistry.getHandler.mockReturnValue(mockHandler);
// Add 4 requests
const promises = [];
for (let i = 0; i < 4; i++) {
promises.push(batcherWithLimit.addRequest(`tool-${i}`, {}));
}
await Promise.allSettled(promises);
// All should complete successfully
expect(mockHandler.execute).toHaveBeenCalledTimes(4);
batcherWithLimit.cleanup();
});
});
});