chore: import upstream snapshot with attribution
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mcpCommand } from '../../../src/commands/mcp/index';
|
||||
import { startStdioMcpServer } from '../../../src/commands/mcp/server';
|
||||
import logger from '../../../src/logger';
|
||||
|
||||
vi.mock('../../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/telemetry', () => ({
|
||||
default: {
|
||||
record: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/server', () => ({
|
||||
startHttpMcpServer: vi.fn(),
|
||||
startStdioMcpServer: vi.fn(),
|
||||
createMcpServer: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('mcp command', () => {
|
||||
let program: Command;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.exitCode = undefined;
|
||||
program = new Command();
|
||||
mcpCommand(program);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should set exitCode=1 for invalid transport type', async () => {
|
||||
const mcpCmd = program.commands.find((cmd) => cmd.name() === 'mcp');
|
||||
expect(mcpCmd).toBeDefined();
|
||||
await mcpCmd!.parseAsync(['node', 'test', '--transport', 'invalid']);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Invalid transport type: invalid. Must be "http" or "stdio".',
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should set exitCode=1 for invalid port number', async () => {
|
||||
const mcpCmd = program.commands.find((cmd) => cmd.name() === 'mcp');
|
||||
expect(mcpCmd).toBeDefined();
|
||||
await mcpCmd!.parseAsync(['node', 'test', '--port', 'not-a-number']);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith('Invalid port number: not-a-number');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startup failures', () => {
|
||||
it('logs dependency errors and sets exitCode=1', async () => {
|
||||
vi.mocked(startStdioMcpServer).mockRejectedValueOnce(
|
||||
new Error('The @modelcontextprotocol/sdk package is required for MCP server support.'),
|
||||
);
|
||||
|
||||
const mcpCmd = program.commands.find((cmd) => cmd.name() === 'mcp');
|
||||
expect(mcpCmd).toBeDefined();
|
||||
await mcpCmd!.parseAsync(['node', 'test', '--transport', 'stdio']);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Failed to start MCP server: The @modelcontextprotocol/sdk package is required for MCP server support.',
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AuthenticationError,
|
||||
ConfigurationError,
|
||||
FileOperationError,
|
||||
isMcpError,
|
||||
NotFoundError,
|
||||
ProviderError,
|
||||
RateLimitError,
|
||||
ServiceUnavailableError,
|
||||
SharingError,
|
||||
TimeoutError,
|
||||
toMcpError,
|
||||
ValidationError,
|
||||
} from '../../../../src/commands/mcp/lib/errors';
|
||||
|
||||
describe('MCP Errors', () => {
|
||||
describe('ValidationError', () => {
|
||||
it('should create validation error with message', () => {
|
||||
const error = new ValidationError('Invalid input');
|
||||
expect(error.message).toBe('Invalid input');
|
||||
expect(error.code).toBe('VALIDATION_ERROR');
|
||||
expect(error.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should include details when provided', () => {
|
||||
const details = { field: 'email', value: 'invalid' };
|
||||
const error = new ValidationError('Invalid email', details);
|
||||
expect(error.details).toEqual(details);
|
||||
});
|
||||
|
||||
it('should serialize to JSON correctly', () => {
|
||||
const error = new ValidationError('Test error', { foo: 'bar' });
|
||||
expect(error.toJSON()).toEqual({
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Test error',
|
||||
details: { foo: 'bar' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotFoundError', () => {
|
||||
it('should create error with resource and id', () => {
|
||||
const error = new NotFoundError('Evaluation', 'eval_123');
|
||||
expect(error.message).toBe('Evaluation with ID "eval_123" not found');
|
||||
expect(error.details).toEqual({ resource: 'Evaluation', id: 'eval_123' });
|
||||
});
|
||||
|
||||
it('should create error with resource only', () => {
|
||||
const error = new NotFoundError('Configuration');
|
||||
expect(error.message).toBe('Configuration not found');
|
||||
expect(error.details).toEqual({ resource: 'Configuration', id: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfigurationError', () => {
|
||||
it('should create configuration error', () => {
|
||||
const error = new ConfigurationError('Invalid config', '/path/to/config.yaml');
|
||||
expect(error.message).toBe('Invalid config');
|
||||
expect(error.details).toEqual({ configPath: '/path/to/config.yaml' });
|
||||
expect(error.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProviderError', () => {
|
||||
it('should create provider error with details', () => {
|
||||
const error = new ProviderError('openai:gpt-4', 'API key invalid', { endpoint: '/v1/chat' });
|
||||
expect(error.message).toBe('Provider "openai:gpt-4" error: API key invalid');
|
||||
expect(error.details).toEqual({ providerId: 'openai:gpt-4', endpoint: '/v1/chat' });
|
||||
expect(error.statusCode).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimeoutError', () => {
|
||||
it('should create timeout error', () => {
|
||||
const error = new TimeoutError('API call', 30000);
|
||||
expect(error.message).toBe('Operation "API call" timed out after 30000ms');
|
||||
expect(error.details).toEqual({ operation: 'API call', timeoutMs: 30000 });
|
||||
expect(error.statusCode).toBe(408);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServiceUnavailableError', () => {
|
||||
it('should create service unavailable error', () => {
|
||||
const error = new ServiceUnavailableError('Database', { reason: 'Connection failed' });
|
||||
expect(error.message).toBe('Service "Database" is unavailable');
|
||||
expect(error.details).toEqual({ service: 'Database', reason: 'Connection failed' });
|
||||
expect(error.statusCode).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthenticationError', () => {
|
||||
it('should create authentication error with default message', () => {
|
||||
const error = new AuthenticationError();
|
||||
expect(error.message).toBe('Authentication failed');
|
||||
expect(error.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should create authentication error with custom message', () => {
|
||||
const error = new AuthenticationError('Invalid API key');
|
||||
expect(error.message).toBe('Invalid API key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SharingError', () => {
|
||||
it('should create sharing error', () => {
|
||||
const error = new SharingError('Sharing service down', { evalId: 'eval_123' });
|
||||
expect(error.message).toBe('Sharing service down');
|
||||
expect(error.details).toEqual({ evalId: 'eval_123' });
|
||||
expect(error.statusCode).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RateLimitError', () => {
|
||||
it('should create rate limit error without retry after', () => {
|
||||
const error = new RateLimitError('OpenAI API');
|
||||
expect(error.message).toBe('Rate limit exceeded for OpenAI API');
|
||||
expect(error.statusCode).toBe(429);
|
||||
});
|
||||
|
||||
it('should create rate limit error with retry after', () => {
|
||||
const error = new RateLimitError('OpenAI API', 60, { requests: 100 });
|
||||
expect(error.message).toBe('Rate limit exceeded for OpenAI API. Retry after 60 seconds.');
|
||||
expect(error.details).toEqual({ service: 'OpenAI API', retryAfter: 60, requests: 100 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileOperationError', () => {
|
||||
it('should create file operation error', () => {
|
||||
const originalError = new Error('Permission denied');
|
||||
const error = new FileOperationError('write', '/path/to/file.txt', originalError);
|
||||
expect(error.message).toBe('Failed to write file: /path/to/file.txt');
|
||||
expect(error.details).toEqual({
|
||||
operation: 'write',
|
||||
filePath: '/path/to/file.txt',
|
||||
originalError: 'Permission denied',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toMcpError', () => {
|
||||
it('should return McpError as-is', () => {
|
||||
const error = new ValidationError('Test');
|
||||
expect(toMcpError(error)).toBe(error);
|
||||
});
|
||||
|
||||
it('should convert rate limit errors', () => {
|
||||
const error = new Error('Rate limit exceeded');
|
||||
const mcpError = toMcpError(error);
|
||||
expect(mcpError).toBeInstanceOf(RateLimitError);
|
||||
});
|
||||
|
||||
it('should convert not found errors', () => {
|
||||
const error = new Error('File not found');
|
||||
const mcpError = toMcpError(error);
|
||||
expect(mcpError).toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
it('should convert ENOENT errors', () => {
|
||||
const error = new Error('ENOENT: no such file or directory');
|
||||
const mcpError = toMcpError(error);
|
||||
expect(mcpError).toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
it('should convert timeout errors', () => {
|
||||
const error = new Error('Request timed out');
|
||||
const mcpError = toMcpError(error);
|
||||
expect(mcpError).toBeInstanceOf(TimeoutError);
|
||||
});
|
||||
|
||||
it('should convert authentication errors', () => {
|
||||
const error = new Error('Unauthorized access');
|
||||
const mcpError = toMcpError(error);
|
||||
expect(mcpError).toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
it('should convert configuration errors', () => {
|
||||
const error = new Error('Invalid configuration');
|
||||
const mcpError = toMcpError(error);
|
||||
expect(mcpError).toBeInstanceOf(ConfigurationError);
|
||||
});
|
||||
|
||||
it('should convert generic errors to ValidationError', () => {
|
||||
const error = new Error('Some other error');
|
||||
const mcpError = toMcpError(error);
|
||||
expect(mcpError).toBeInstanceOf(ValidationError);
|
||||
expect(mcpError.message).toBe('Some other error');
|
||||
});
|
||||
|
||||
it('should handle non-Error objects', () => {
|
||||
const mcpError = toMcpError('string error');
|
||||
expect(mcpError).toBeInstanceOf(ValidationError);
|
||||
expect(mcpError.message).toBe('Unknown error occurred');
|
||||
expect(mcpError.details).toEqual({ originalError: 'string error' });
|
||||
});
|
||||
|
||||
it('should use custom default message', () => {
|
||||
const mcpError = toMcpError(null, 'Custom error message');
|
||||
expect(mcpError.message).toBe('Custom error message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMcpError', () => {
|
||||
it('should return true for McpError instances', () => {
|
||||
expect(isMcpError(new ValidationError('Test'))).toBe(true);
|
||||
expect(isMcpError(new NotFoundError('Resource'))).toBe(true);
|
||||
expect(isMcpError(new RateLimitError('Service'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-McpError instances', () => {
|
||||
expect(isMcpError(new Error('Test'))).toBe(false);
|
||||
expect(isMcpError('string')).toBe(false);
|
||||
expect(isMcpError(null)).toBe(false);
|
||||
expect(isMcpError(undefined)).toBe(false);
|
||||
expect(isMcpError({})).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BatchProcessor,
|
||||
EvaluationCache,
|
||||
paginate,
|
||||
streamProcess,
|
||||
} from '../../../../src/commands/mcp/lib/performance';
|
||||
|
||||
import type { EvalSummary } from '../../../../src/types';
|
||||
|
||||
describe('MCP Performance', () => {
|
||||
describe('EvaluationCache', () => {
|
||||
const createMockEvalSummary = (id: string): EvalSummary => ({
|
||||
evalId: id,
|
||||
datasetId: null,
|
||||
createdAt: Date.now(),
|
||||
description: `Test evaluation ${id}`,
|
||||
numTests: 10,
|
||||
isRedteam: false,
|
||||
passRate: 0.9,
|
||||
label: `Eval ${id}`,
|
||||
providers: [{ id: 'provider1', label: 'Provider 1' }],
|
||||
});
|
||||
|
||||
it('should store and retrieve values', () => {
|
||||
const cache = new EvaluationCache();
|
||||
const mockEvalSummaries = [createMockEvalSummary('eval1')];
|
||||
cache.set('key1', mockEvalSummaries);
|
||||
expect(cache.get('key1')).toEqual(mockEvalSummaries);
|
||||
});
|
||||
|
||||
it('should return undefined for missing keys', () => {
|
||||
const cache = new EvaluationCache();
|
||||
expect(cache.get('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should check if key exists', () => {
|
||||
const cache = new EvaluationCache();
|
||||
const mockEvalSummaries = [createMockEvalSummary('eval1')];
|
||||
cache.set('exists', mockEvalSummaries);
|
||||
expect(cache.has('exists')).toBe(true);
|
||||
expect(cache.has('missing')).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear all entries', () => {
|
||||
const cache = new EvaluationCache();
|
||||
const mockEvalSummaries1 = [createMockEvalSummary('eval1')];
|
||||
const mockEvalSummaries2 = [createMockEvalSummary('eval2')];
|
||||
cache.set('key1', mockEvalSummaries1);
|
||||
cache.set('key2', mockEvalSummaries2);
|
||||
cache.clear();
|
||||
expect(cache.has('key1')).toBe(false);
|
||||
expect(cache.has('key2')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return stats', () => {
|
||||
const cache = new EvaluationCache();
|
||||
const mockEvalSummaries = [createMockEvalSummary('eval1')];
|
||||
cache.set('key1', mockEvalSummaries);
|
||||
const stats = cache.getStats();
|
||||
expect(stats.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paginate', () => {
|
||||
const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
|
||||
it('should return first page by default', () => {
|
||||
const result = paginate(items);
|
||||
expect(result.data).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
expect(result.pagination.page).toBe(1);
|
||||
expect(result.pagination.totalItems).toBe(10);
|
||||
});
|
||||
|
||||
it('should paginate with custom page size', () => {
|
||||
const result = paginate(items, { pageSize: 3 });
|
||||
expect(result.data).toEqual([1, 2, 3]);
|
||||
expect(result.pagination.totalPages).toBe(4);
|
||||
expect(result.pagination.hasNextPage).toBe(true);
|
||||
expect(result.pagination.hasPreviousPage).toBe(false);
|
||||
});
|
||||
|
||||
it('should return correct page', () => {
|
||||
const result = paginate(items, { page: 2, pageSize: 3 });
|
||||
expect(result.data).toEqual([4, 5, 6]);
|
||||
expect(result.pagination.hasPreviousPage).toBe(true);
|
||||
expect(result.pagination.hasNextPage).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle last page', () => {
|
||||
const result = paginate(items, { page: 4, pageSize: 3 });
|
||||
expect(result.data).toEqual([10]);
|
||||
expect(result.pagination.hasNextPage).toBe(false);
|
||||
expect(result.pagination.hasPreviousPage).toBe(true);
|
||||
});
|
||||
|
||||
it('should constrain page size to max', () => {
|
||||
const result = paginate(items, { pageSize: 200, maxPageSize: 5 });
|
||||
expect(result.pagination.pageSize).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle empty arrays', () => {
|
||||
const result = paginate([]);
|
||||
expect(result.data).toEqual([]);
|
||||
expect(result.pagination.totalItems).toBe(0);
|
||||
expect(result.pagination.totalPages).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamProcess', () => {
|
||||
it('should process all items', async () => {
|
||||
const items = [1, 2, 3, 4, 5];
|
||||
const processor = async (x: number) => x * 2;
|
||||
|
||||
const results: number[] = [];
|
||||
for await (const result of streamProcess(items, processor, 2)) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
expect(results.sort((a, b) => a - b)).toEqual([2, 4, 6, 8, 10]);
|
||||
});
|
||||
|
||||
it('should respect concurrency limit', async () => {
|
||||
let concurrent = 0;
|
||||
let maxConcurrent = 0;
|
||||
// Hold each in-flight call until the SUT has filled the concurrency
|
||||
// window, then release a wave together. This makes maxConcurrent
|
||||
// observation independent of wall-clock pacing.
|
||||
const limit = 2;
|
||||
let releaseWave: (() => void) | undefined;
|
||||
let waveBarrier = new Promise<void>((resolve) => {
|
||||
releaseWave = resolve;
|
||||
});
|
||||
|
||||
const items = [1, 2, 3, 4, 5, 6];
|
||||
const processor = async (x: number) => {
|
||||
concurrent++;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
||||
if (concurrent >= limit) {
|
||||
releaseWave?.();
|
||||
}
|
||||
let resolveFallback!: () => void;
|
||||
const fallback = new Promise<void>((r) => {
|
||||
resolveFallback = r;
|
||||
});
|
||||
const fallbackHandle = setTimeout(() => resolveFallback(), 100);
|
||||
try {
|
||||
await Promise.race([waveBarrier, fallback]);
|
||||
} finally {
|
||||
clearTimeout(fallbackHandle);
|
||||
}
|
||||
concurrent--;
|
||||
if (concurrent === 0) {
|
||||
waveBarrier = new Promise<void>((resolve) => {
|
||||
releaseWave = resolve;
|
||||
});
|
||||
}
|
||||
return x;
|
||||
};
|
||||
|
||||
const results: number[] = [];
|
||||
for await (const result of streamProcess(items, processor, 2)) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(2);
|
||||
expect(results.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should yield results as they complete', async () => {
|
||||
const items = [1, 2, 3];
|
||||
const processor = async (x: number) => {
|
||||
await Promise.resolve();
|
||||
return x;
|
||||
};
|
||||
|
||||
const results: number[] = [];
|
||||
for await (const result of streamProcess(items, processor, 3)) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// All items should be processed
|
||||
expect(results.sort((a, b) => a - b)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('should handle empty input', async () => {
|
||||
const results: number[] = [];
|
||||
for await (const result of streamProcess([], async (x: number) => x)) {
|
||||
results.push(result);
|
||||
}
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle single item', async () => {
|
||||
const results: number[] = [];
|
||||
for await (const result of streamProcess([42], async (x) => x * 2)) {
|
||||
results.push(result);
|
||||
}
|
||||
expect(results).toEqual([84]);
|
||||
});
|
||||
|
||||
it('should properly track which promise completed', async () => {
|
||||
// Verifies the fix for a bug where the wrong promise was removed when
|
||||
// items completed in reverse arrival order. Use microtask-staggered
|
||||
// resolution so deterministic completion order still differs from
|
||||
// arrival order, without wall-clock pacing.
|
||||
const items = [1, 2, 3, 4, 5];
|
||||
|
||||
const processor = async (x: number) => {
|
||||
// x=5 yields 1 microtask, x=1 yields 5 microtasks → reverse order.
|
||||
for (let i = 0; i < 6 - x; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
return x;
|
||||
};
|
||||
|
||||
const results: number[] = [];
|
||||
for await (const result of streamProcess(items, processor, 3)) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
// All items should be yielded exactly once
|
||||
expect(results.length).toBe(5);
|
||||
expect(results.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BatchProcessor', () => {
|
||||
it('should process items in batches', async () => {
|
||||
const processedBatches: number[][] = [];
|
||||
const processor = async (batch: number[]) => {
|
||||
processedBatches.push([...batch]);
|
||||
return batch.map((x) => x * 2);
|
||||
};
|
||||
|
||||
const batchProcessor = new BatchProcessor(processor, 2, 10);
|
||||
|
||||
const results = await Promise.all([
|
||||
batchProcessor.add(1),
|
||||
batchProcessor.add(2),
|
||||
batchProcessor.add(3),
|
||||
]);
|
||||
|
||||
// Results should be correct regardless of batching
|
||||
expect(results.sort((a, b) => a - b)).toEqual([2, 4, 6]);
|
||||
});
|
||||
|
||||
it('should handle single item', async () => {
|
||||
const processor = async (batch: number[]) => batch.map((x) => x * 2);
|
||||
const batchProcessor = new BatchProcessor(processor, 10, 5);
|
||||
|
||||
const result = await batchProcessor.add(5);
|
||||
expect(result).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ConfigurationError } from '../../../../src/commands/mcp/lib/errors';
|
||||
import { validateFilePath, validateProviderId } from '../../../../src/commands/mcp/lib/security';
|
||||
import { escapeRegExp } from '../../../../src/util/text';
|
||||
|
||||
describe('MCP Security', () => {
|
||||
describe('validateFilePath', () => {
|
||||
it('should allow simple relative paths', () => {
|
||||
expect(() => validateFilePath('output.yaml')).not.toThrow();
|
||||
expect(() => validateFilePath('data/results.json')).not.toThrow();
|
||||
expect(() => validateFilePath('my-file.txt')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject paths containing ".."', () => {
|
||||
expect(() => validateFilePath('../etc/passwd')).toThrow(ConfigurationError);
|
||||
expect(() => validateFilePath('foo/../bar')).toThrow(ConfigurationError);
|
||||
expect(() => validateFilePath('/tmp/../etc/passwd')).toThrow(ConfigurationError);
|
||||
});
|
||||
|
||||
it('should reject paths containing "~"', () => {
|
||||
expect(() => validateFilePath('~/.ssh/id_rsa')).toThrow(ConfigurationError);
|
||||
expect(() => validateFilePath('~/Documents/file.txt')).toThrow(ConfigurationError);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')('should reject paths to system directories', () => {
|
||||
// Unix paths are only recognized as absolute on Unix systems
|
||||
expect(() => validateFilePath('/etc/passwd')).toThrow(ConfigurationError);
|
||||
expect(() => validateFilePath('/sys/kernel')).toThrow(ConfigurationError);
|
||||
expect(() => validateFilePath('/proc/self/environ')).toThrow(ConfigurationError);
|
||||
expect(() => validateFilePath('/dev/null')).toThrow(ConfigurationError);
|
||||
expect(() => validateFilePath('/var/run/docker.sock')).toThrow(ConfigurationError);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('should reject Windows system directories', () => {
|
||||
// Windows paths are only recognized as absolute on Windows
|
||||
expect(() => validateFilePath('C:\\Windows\\System32\\config')).toThrow(ConfigurationError);
|
||||
expect(() => validateFilePath('C:\\Program Files\\app')).toThrow(ConfigurationError);
|
||||
expect(() => validateFilePath('C:\\ProgramData\\secret')).toThrow(ConfigurationError);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'should allow absolute paths to non-system directories',
|
||||
() => {
|
||||
// Unix paths are only recognized as absolute on Unix systems
|
||||
expect(() => validateFilePath('/tmp/output.yaml')).not.toThrow();
|
||||
expect(() => validateFilePath('/home/user/data.json')).not.toThrow();
|
||||
expect(() => validateFilePath('/Users/test/file.txt')).not.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
describe('with basePath', () => {
|
||||
it('should allow paths within the base directory', () => {
|
||||
expect(() => validateFilePath('output.yaml', '/tmp/workdir')).not.toThrow();
|
||||
expect(() => validateFilePath('subdir/file.txt', '/tmp/workdir')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject paths that escape the base directory', () => {
|
||||
// Note: ".." is caught by the pre-normalization check
|
||||
expect(() => validateFilePath('../escape.txt', '/tmp/workdir')).toThrow(ConfigurationError);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include the original path in the error details', () => {
|
||||
try {
|
||||
validateFilePath('../etc/passwd');
|
||||
expect.fail('Expected error to be thrown');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ConfigurationError);
|
||||
expect((error as ConfigurationError).details).toEqual({ configPath: '../etc/passwd' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeRegExp', () => {
|
||||
it('should escape special regex characters', () => {
|
||||
expect(escapeRegExp('hello.world')).toBe('hello\\.world');
|
||||
expect(escapeRegExp('foo*bar')).toBe('foo\\*bar');
|
||||
expect(escapeRegExp('a+b')).toBe('a\\+b');
|
||||
expect(escapeRegExp('test?')).toBe('test\\?');
|
||||
expect(escapeRegExp('^start')).toBe('\\^start');
|
||||
expect(escapeRegExp('end$')).toBe('end\\$');
|
||||
});
|
||||
|
||||
it('should escape brackets and braces', () => {
|
||||
expect(escapeRegExp('[abc]')).toBe('\\[abc\\]');
|
||||
expect(escapeRegExp('{1,3}')).toBe('\\{1,3\\}');
|
||||
expect(escapeRegExp('(group)')).toBe('\\(group\\)');
|
||||
});
|
||||
|
||||
it('should escape pipe and backslash', () => {
|
||||
expect(escapeRegExp('a|b')).toBe('a\\|b');
|
||||
expect(escapeRegExp('path\\to\\file')).toBe('path\\\\to\\\\file');
|
||||
});
|
||||
|
||||
it('should return strings without special chars unchanged', () => {
|
||||
expect(escapeRegExp('hello')).toBe('hello');
|
||||
expect(escapeRegExp('openai:gpt-4')).toBe('openai:gpt-4');
|
||||
expect(escapeRegExp('simple_test')).toBe('simple_test');
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(escapeRegExp('')).toBe('');
|
||||
});
|
||||
|
||||
it('should produce strings safe for regex construction', () => {
|
||||
const userInput = 'openai:gpt-4.0';
|
||||
const escaped = escapeRegExp(userInput);
|
||||
const regex = new RegExp(escaped);
|
||||
expect(regex.test('openai:gpt-4.0')).toBe(true);
|
||||
expect(regex.test('openai:gpt-4X0')).toBe(false); // "." should not match any char
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateProviderId', () => {
|
||||
it('should accept valid provider:model format', () => {
|
||||
expect(() => validateProviderId('openai:gpt-4')).not.toThrow();
|
||||
expect(() => validateProviderId('anthropic:claude-3')).not.toThrow();
|
||||
expect(() => validateProviderId('azure:gpt-4o')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept file path providers', () => {
|
||||
expect(() => validateProviderId('providers/custom.js')).not.toThrow();
|
||||
expect(() => validateProviderId('my-provider.ts')).not.toThrow();
|
||||
expect(() => validateProviderId('script.py')).not.toThrow();
|
||||
expect(() => validateProviderId('module.mjs')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept HTTP providers', () => {
|
||||
expect(() => validateProviderId('http://localhost:8080/api')).not.toThrow();
|
||||
expect(() => validateProviderId('https://api.example.com/v1')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid formats', () => {
|
||||
expect(() => validateProviderId('invalid')).toThrow(ConfigurationError);
|
||||
expect(() => validateProviderId('')).toThrow(ConfigurationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
initializeToolRegistry,
|
||||
TOOL_DEFINITIONS,
|
||||
toolRegistry,
|
||||
} from '../../../../src/commands/mcp/lib/toolRegistry';
|
||||
|
||||
describe('ToolRegistry', () => {
|
||||
beforeEach(() => {
|
||||
// Re-initialize the registry for each test
|
||||
initializeToolRegistry();
|
||||
});
|
||||
|
||||
describe('TOOL_DEFINITIONS', () => {
|
||||
it('should define all 14 MCP tools', () => {
|
||||
expect(TOOL_DEFINITIONS.length).toBe(14);
|
||||
});
|
||||
|
||||
it('should have all tools with required metadata', () => {
|
||||
for (const tool of TOOL_DEFINITIONS) {
|
||||
expect(tool.name).toBeDefined();
|
||||
expect(tool.name.length).toBeGreaterThan(0);
|
||||
expect(tool.description).toBeDefined();
|
||||
expect(tool.description.length).toBeGreaterThan(0);
|
||||
expect(tool.parameters).toBeDefined();
|
||||
expect(tool.annotations).toBeDefined();
|
||||
expect(tool.category).toMatch(/^(evaluation|generation|redteam|configuration|debugging)$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have correct tool names', () => {
|
||||
const expectedToolNames = [
|
||||
'list_evaluations',
|
||||
'get_evaluation_details',
|
||||
'run_evaluation',
|
||||
'share_evaluation',
|
||||
'validate_promptfoo_config',
|
||||
'test_provider',
|
||||
'run_assertion',
|
||||
'generate_dataset',
|
||||
'generate_test_cases',
|
||||
'compare_providers',
|
||||
'redteam_generate',
|
||||
'redteam_run',
|
||||
'list_logs',
|
||||
'read_logs',
|
||||
];
|
||||
|
||||
const actualToolNames = TOOL_DEFINITIONS.map((t) => t.name);
|
||||
expect(actualToolNames).toEqual(expect.arrayContaining(expectedToolNames));
|
||||
expect(actualToolNames.length).toBe(expectedToolNames.length);
|
||||
});
|
||||
|
||||
it('should have read-only hints for read-only tools', () => {
|
||||
const readOnlyTools = [
|
||||
'list_evaluations',
|
||||
'get_evaluation_details',
|
||||
'validate_promptfoo_config',
|
||||
'test_provider',
|
||||
'run_assertion',
|
||||
'compare_providers',
|
||||
'list_logs',
|
||||
'read_logs',
|
||||
];
|
||||
|
||||
for (const toolName of readOnlyTools) {
|
||||
const tool = TOOL_DEFINITIONS.find((t) => t.name === toolName);
|
||||
expect(tool?.annotations.readOnlyHint).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have long-running hints for long-running tools', () => {
|
||||
const longRunningTools = [
|
||||
'run_evaluation',
|
||||
'generate_dataset',
|
||||
'generate_test_cases',
|
||||
'compare_providers',
|
||||
'redteam_generate',
|
||||
'redteam_run',
|
||||
];
|
||||
|
||||
for (const toolName of longRunningTools) {
|
||||
const tool = TOOL_DEFINITIONS.find((t) => t.name === toolName);
|
||||
expect(tool?.annotations.longRunningHint).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('registry operations', () => {
|
||||
it('should retrieve all registered tools', () => {
|
||||
const tools = toolRegistry.getAll();
|
||||
expect(tools.length).toBe(14);
|
||||
});
|
||||
|
||||
it('should retrieve a tool by name', () => {
|
||||
const tool = toolRegistry.get('list_evaluations');
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool?.name).toBe('list_evaluations');
|
||||
expect(tool?.category).toBe('evaluation');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent tool', () => {
|
||||
const tool = toolRegistry.get('non_existent_tool');
|
||||
expect(tool).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should get tools by category', () => {
|
||||
const evaluationTools = toolRegistry.getByCategory('evaluation');
|
||||
expect(evaluationTools.length).toBe(4);
|
||||
expect(evaluationTools.every((t) => t.category === 'evaluation')).toBe(true);
|
||||
|
||||
const generationTools = toolRegistry.getByCategory('generation');
|
||||
expect(generationTools.length).toBe(3);
|
||||
|
||||
const redteamTools = toolRegistry.getByCategory('redteam');
|
||||
expect(redteamTools.length).toBe(2);
|
||||
|
||||
const configTools = toolRegistry.getByCategory('configuration');
|
||||
expect(configTools.length).toBe(3);
|
||||
|
||||
const debuggingTools = toolRegistry.getByCategory('debugging');
|
||||
expect(debuggingTools.length).toBe(2);
|
||||
expect(debuggingTools.every((t) => t.category === 'debugging')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDocs', () => {
|
||||
it('should generate documentation object', () => {
|
||||
const docs = toolRegistry.generateDocs();
|
||||
|
||||
expect(docs.totalTools).toBe(14);
|
||||
expect(docs.version).toBe('1.0.0');
|
||||
expect(docs.lastUpdated).toBeDefined();
|
||||
expect(docs.tools.length).toBe(14);
|
||||
});
|
||||
|
||||
it('should include all tool fields in docs', () => {
|
||||
const docs = toolRegistry.generateDocs();
|
||||
|
||||
for (const tool of docs.tools) {
|
||||
expect(tool.name).toBeDefined();
|
||||
expect(tool.description).toBeDefined();
|
||||
expect(tool.parameters).toBeDefined();
|
||||
expect(tool.category).toBeDefined();
|
||||
expect(tool.annotations).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
assertNotNull,
|
||||
createToolResponse,
|
||||
debounce,
|
||||
filterNonNull,
|
||||
formatDuration,
|
||||
getProperty,
|
||||
hasKey,
|
||||
retry,
|
||||
safeStringify,
|
||||
truncateText,
|
||||
withTimeout,
|
||||
} from '../../../../src/commands/mcp/lib/utils';
|
||||
|
||||
describe('MCP Utility Functions', () => {
|
||||
describe('createToolResponse', () => {
|
||||
it('should create successful tool response with data', () => {
|
||||
const response = createToolResponse('test_tool', true, { result: 'success' });
|
||||
|
||||
expect(response.isError).toBe(false);
|
||||
expect(response.content).toHaveLength(1);
|
||||
expect(response.content[0].type).toBe('text');
|
||||
|
||||
const parsedContent = JSON.parse(response.content[0].text);
|
||||
expect(parsedContent.tool).toBe('test_tool');
|
||||
expect(parsedContent.success).toBe(true);
|
||||
expect(parsedContent.data).toEqual({ result: 'success' });
|
||||
expect(parsedContent.timestamp).toBeDefined();
|
||||
expect(parsedContent.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create error tool response with error message', () => {
|
||||
const response = createToolResponse('test_tool', false, undefined, 'Something went wrong');
|
||||
|
||||
expect(response.isError).toBe(true);
|
||||
expect(response.content).toHaveLength(1);
|
||||
|
||||
const parsedContent = JSON.parse(response.content[0].text);
|
||||
expect(parsedContent.tool).toBe('test_tool');
|
||||
expect(parsedContent.success).toBe(false);
|
||||
expect(parsedContent.error).toBe('Something went wrong');
|
||||
expect(parsedContent.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create response without data or error', () => {
|
||||
const response = createToolResponse('test_tool', true);
|
||||
|
||||
const parsedContent = JSON.parse(response.content[0].text);
|
||||
expect(parsedContent.tool).toBe('test_tool');
|
||||
expect(parsedContent.success).toBe(true);
|
||||
expect(parsedContent.data).toBeUndefined();
|
||||
expect(parsedContent.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include timestamp in ISO format', () => {
|
||||
const response = createToolResponse('test_tool', true);
|
||||
const parsedContent = JSON.parse(response.content[0].text);
|
||||
|
||||
expect(parsedContent.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withTimeout', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should resolve when promise resolves before timeout', async () => {
|
||||
const promise = Promise.resolve('success');
|
||||
const result = await withTimeout(promise, 1000, 'Timed out');
|
||||
|
||||
expect(result).toBe('success');
|
||||
});
|
||||
|
||||
it('should reject when promise times out', async () => {
|
||||
const promise = new Promise((resolve) => setTimeout(() => resolve('late'), 100));
|
||||
const result = withTimeout(promise, 50, 'Operation timed out');
|
||||
const expectation = expect(result).rejects.toThrow('Operation timed out');
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
await expectation;
|
||||
});
|
||||
|
||||
it('should reject with original error if promise rejects', async () => {
|
||||
const promise = Promise.reject(new Error('Original error'));
|
||||
|
||||
await expect(withTimeout(promise, 1000, 'Timed out')).rejects.toThrow('Original error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeStringify', () => {
|
||||
it('should stringify normal objects', () => {
|
||||
const obj = { name: 'test', value: 123 };
|
||||
const result = safeStringify(obj);
|
||||
|
||||
expect(result).toBe('{"name":"test","value":123}');
|
||||
});
|
||||
|
||||
it('should handle BigInt values', () => {
|
||||
const obj = { bigNumber: BigInt(123456789012345) };
|
||||
const result = safeStringify(obj);
|
||||
|
||||
expect(result).toBe('{"bigNumber":"123456789012345"}');
|
||||
});
|
||||
|
||||
it('should handle function values', () => {
|
||||
const obj = { func: () => 'test' };
|
||||
const result = safeStringify(obj);
|
||||
|
||||
expect(result).toBe('{"func":"[Function]"}');
|
||||
});
|
||||
|
||||
it('should handle undefined values', () => {
|
||||
const obj = { undef: undefined };
|
||||
const result = safeStringify(obj);
|
||||
|
||||
expect(result).toBe('{"undef":"[Undefined]"}');
|
||||
});
|
||||
|
||||
it('should use custom replacer when provided', () => {
|
||||
const obj = { secret: 'password', public: 'data' };
|
||||
const result = safeStringify(obj, 2, (key, value) =>
|
||||
key === 'secret' ? '[REDACTED]' : value,
|
||||
);
|
||||
|
||||
expect(result).toContain('[REDACTED]');
|
||||
expect(result).toContain('data');
|
||||
});
|
||||
|
||||
it('should format with spacing when provided', () => {
|
||||
const obj = { a: 1, b: 2 };
|
||||
const result = safeStringify(obj, 2);
|
||||
|
||||
expect(result).toContain('\n');
|
||||
expect(result).toContain(' ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasKey', () => {
|
||||
it('should return true when key exists', () => {
|
||||
const obj = { name: 'test', value: 123 };
|
||||
const result = hasKey(obj, 'name');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when key does not exist', () => {
|
||||
const obj = { name: 'test' };
|
||||
const result = hasKey(obj, 'missing');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should work with inherited properties', () => {
|
||||
const obj = Object.create({ inherited: 'value' });
|
||||
obj.own = 'value';
|
||||
|
||||
expect(hasKey(obj, 'own')).toBe(true);
|
||||
expect(hasKey(obj, 'inherited')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProperty', () => {
|
||||
it('should return property value when it exists', () => {
|
||||
const obj = { name: 'test', value: 123 };
|
||||
const result = getProperty(obj, 'name', 'default');
|
||||
|
||||
expect(result).toBe('test');
|
||||
});
|
||||
|
||||
it('should return default value when property is undefined', () => {
|
||||
const obj = { name: 'test' };
|
||||
const result = getProperty(obj, 'missing', 'default');
|
||||
|
||||
expect(result).toBe('default');
|
||||
});
|
||||
|
||||
it('should return property value when it is null', () => {
|
||||
const obj = { value: null };
|
||||
const result = getProperty(obj, 'value', 'default');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return property value when it is falsy but not undefined', () => {
|
||||
const obj = { value: 0, empty: '', bool: false };
|
||||
|
||||
expect(getProperty(obj, 'value', 'default')).toBe(0);
|
||||
expect(getProperty(obj, 'empty', 'default')).toBe('');
|
||||
expect(getProperty(obj, 'bool', 'default')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertNotNull', () => {
|
||||
it('should return value when it is not null or undefined', () => {
|
||||
expect(assertNotNull('test')).toBe('test');
|
||||
expect(assertNotNull(0)).toBe(0);
|
||||
expect(assertNotNull(false)).toBe(false);
|
||||
expect(assertNotNull('')).toBe('');
|
||||
});
|
||||
|
||||
it('should throw error when value is null', () => {
|
||||
expect(() => assertNotNull(null)).toThrow('Value cannot be null or undefined');
|
||||
});
|
||||
|
||||
it('should throw error when value is undefined', () => {
|
||||
expect(() => assertNotNull(undefined)).toThrow('Value cannot be null or undefined');
|
||||
});
|
||||
|
||||
it('should throw custom error message', () => {
|
||||
expect(() => assertNotNull(null, 'Custom error message')).toThrow('Custom error message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterNonNull', () => {
|
||||
it('should filter out null and undefined values', () => {
|
||||
const array = ['a', null, 'b', undefined, 'c', null];
|
||||
const result = filterNonNull(array);
|
||||
|
||||
expect(result).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('should preserve falsy but non-null values', () => {
|
||||
const array = ['', 0, false, null, undefined, 'test'];
|
||||
const result = filterNonNull(array);
|
||||
|
||||
expect(result).toEqual(['', 0, false, 'test']);
|
||||
});
|
||||
|
||||
it('should return empty array for all null/undefined', () => {
|
||||
const array = [null, undefined, null];
|
||||
const result = filterNonNull(array);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return same array if no null/undefined values', () => {
|
||||
const array = ['a', 'b', 'c'];
|
||||
const result = filterNonNull(array);
|
||||
|
||||
expect(result).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('debounce', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should debounce function calls', () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = debounce(mockFn, 100);
|
||||
|
||||
debouncedFn('arg1');
|
||||
debouncedFn('arg2');
|
||||
debouncedFn('arg3');
|
||||
|
||||
expect(mockFn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith('arg3');
|
||||
});
|
||||
|
||||
it('should reset timer on subsequent calls', () => {
|
||||
const mockFn = vi.fn();
|
||||
const debouncedFn = debounce(mockFn, 100);
|
||||
|
||||
debouncedFn('arg1');
|
||||
vi.advanceTimersByTime(50);
|
||||
|
||||
debouncedFn('arg2');
|
||||
vi.advanceTimersByTime(50);
|
||||
|
||||
expect(mockFn).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(50);
|
||||
|
||||
expect(mockFn).toHaveBeenCalledTimes(1);
|
||||
expect(mockFn).toHaveBeenCalledWith('arg2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry', () => {
|
||||
it('should succeed on first attempt', async () => {
|
||||
const operation = vi.fn().mockResolvedValue('success');
|
||||
const result = await retry(operation);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should retry on failure and eventually succeed', async () => {
|
||||
const operation = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('fail1'))
|
||||
.mockRejectedValueOnce(new Error('fail2'))
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const result = await retry(operation, { maxAttempts: 3, baseDelay: 10 });
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(operation).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should throw last error after max attempts', async () => {
|
||||
const operation = vi.fn().mockRejectedValue(new Error('persistent failure'));
|
||||
|
||||
await expect(retry(operation, { maxAttempts: 2, baseDelay: 10 })).rejects.toThrow(
|
||||
'persistent failure',
|
||||
);
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should use exponential backoff', async () => {
|
||||
const operation = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('fail1'))
|
||||
.mockRejectedValueOnce(new Error('fail2'))
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Mock Date.now to control timing
|
||||
const mockDateNow = vi.spyOn(Date, 'now');
|
||||
let timeOffset = 0;
|
||||
mockDateNow.mockImplementation(function () {
|
||||
return startTime + timeOffset;
|
||||
});
|
||||
|
||||
// Mock setTimeout to simulate delays
|
||||
const mockSetTimeout = vi.spyOn(global, 'setTimeout');
|
||||
mockSetTimeout.mockImplementation(function (callback, delay) {
|
||||
timeOffset += delay ?? 0;
|
||||
callback();
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
await retry(operation, { maxAttempts: 3, baseDelay: 100, backoffFactor: 2 });
|
||||
|
||||
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 100);
|
||||
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 200);
|
||||
|
||||
mockDateNow.mockRestore();
|
||||
mockSetTimeout.mockRestore();
|
||||
});
|
||||
|
||||
it('should respect max delay', async () => {
|
||||
const operation = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('fail1'))
|
||||
.mockResolvedValue('success');
|
||||
|
||||
const mockSetTimeout = vi.spyOn(global, 'setTimeout');
|
||||
mockSetTimeout.mockImplementation(function (callback) {
|
||||
callback();
|
||||
return {} as any;
|
||||
});
|
||||
|
||||
await retry(operation, {
|
||||
maxAttempts: 2,
|
||||
baseDelay: 1000,
|
||||
backoffFactor: 10,
|
||||
maxDelay: 500,
|
||||
});
|
||||
|
||||
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 500);
|
||||
|
||||
mockSetTimeout.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('should format milliseconds', () => {
|
||||
expect(formatDuration(500)).toBe('500ms');
|
||||
expect(formatDuration(999)).toBe('999ms');
|
||||
});
|
||||
|
||||
it('should format seconds', () => {
|
||||
expect(formatDuration(1000)).toBe('1s');
|
||||
expect(formatDuration(30000)).toBe('30s');
|
||||
expect(formatDuration(59000)).toBe('59s');
|
||||
});
|
||||
|
||||
it('should format minutes and seconds', () => {
|
||||
expect(formatDuration(60000)).toBe('1m');
|
||||
expect(formatDuration(90000)).toBe('1m 30s');
|
||||
expect(formatDuration(3599000)).toBe('59m 59s');
|
||||
});
|
||||
|
||||
it('should format hours and minutes', () => {
|
||||
expect(formatDuration(3600000)).toBe('1h');
|
||||
expect(formatDuration(3660000)).toBe('1h 1m');
|
||||
expect(formatDuration(7200000)).toBe('2h');
|
||||
});
|
||||
|
||||
it('should handle zero duration', () => {
|
||||
expect(formatDuration(0)).toBe('0ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateText', () => {
|
||||
it('should return original text if shorter than max length', () => {
|
||||
const text = 'Short text';
|
||||
const result = truncateText(text, 20);
|
||||
|
||||
expect(result).toBe('Short text');
|
||||
});
|
||||
|
||||
it('should return original text if equal to max length', () => {
|
||||
const text = 'Exact length';
|
||||
const result = truncateText(text, 12);
|
||||
|
||||
expect(result).toBe('Exact length');
|
||||
});
|
||||
|
||||
it('should truncate and add ellipsis if longer than max length', () => {
|
||||
const text = 'This is a very long text that should be truncated';
|
||||
const result = truncateText(text, 20);
|
||||
|
||||
expect(result).toBe('This is a very lo...');
|
||||
expect(result).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('should truncate without ellipsis when max length is 3 or less', () => {
|
||||
expect(truncateText('Hello', 3)).toBe('Hel');
|
||||
expect(truncateText('Hello', 2)).toBe('He');
|
||||
expect(truncateText('Hello', 1)).toBe('H');
|
||||
});
|
||||
|
||||
it('should use ellipsis starting at max length 4', () => {
|
||||
const result = truncateText('Hello World', 4);
|
||||
expect(result).toBe('H...');
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should return empty string when max length is zero', () => {
|
||||
const result = truncateText('Hello', 0);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string when max length is negative', () => {
|
||||
const result = truncateText('Hello', -1);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const result = truncateText('', 10);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('MCP server optional dependency', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('@modelcontextprotocol/sdk/server/mcp.js');
|
||||
vi.doUnmock('@modelcontextprotocol/sdk/server/stdio.js');
|
||||
vi.doUnmock('../../../src/util/packageImportErrors');
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('explains how to install the MCP SDK when server mode is requested', async () => {
|
||||
vi.doMock('@modelcontextprotocol/sdk/server/mcp.js', () => {
|
||||
throw new Error('Cannot find package @modelcontextprotocol/sdk');
|
||||
});
|
||||
vi.doMock('../../../src/util/packageImportErrors', () => ({
|
||||
isMissingPackageImportError: () => true,
|
||||
}));
|
||||
|
||||
const { createMcpServer } = await import('../../../src/commands/mcp/server');
|
||||
const createServerPromise = createMcpServer();
|
||||
|
||||
await expect(createServerPromise).rejects.toThrow(
|
||||
'The @modelcontextprotocol/sdk package is required for MCP server support.',
|
||||
);
|
||||
await expect(createServerPromise).rejects.toThrow('npm install @modelcontextprotocol/sdk');
|
||||
});
|
||||
|
||||
it('surfaces stdio dependency errors before console logging is muted', async () => {
|
||||
vi.doMock('@modelcontextprotocol/sdk/server/mcp.js', () => {
|
||||
throw new Error('Cannot find package @modelcontextprotocol/sdk');
|
||||
});
|
||||
vi.doMock('../../../src/util/packageImportErrors', () => ({
|
||||
isMissingPackageImportError: () => true,
|
||||
}));
|
||||
|
||||
const loggerModule = await import('../../../src/logger');
|
||||
const originalTransports = [...loggerModule.default.transports];
|
||||
const consoleTransport = { constructor: { name: 'Console' }, silent: false };
|
||||
loggerModule.default.transports.splice(
|
||||
0,
|
||||
loggerModule.default.transports.length,
|
||||
consoleTransport as any,
|
||||
);
|
||||
|
||||
try {
|
||||
const { startStdioMcpServer } = await import('../../../src/commands/mcp/server');
|
||||
|
||||
await expect(startStdioMcpServer()).rejects.toThrow(
|
||||
'The @modelcontextprotocol/sdk package is required for MCP server support.',
|
||||
);
|
||||
expect(consoleTransport.silent).toBe(false);
|
||||
} finally {
|
||||
loggerModule.default.transports.splice(
|
||||
0,
|
||||
loggerModule.default.transports.length,
|
||||
...originalTransports,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('restores stdio console logging when server creation fails after muting', async () => {
|
||||
vi.doMock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
|
||||
McpServer: class {
|
||||
constructor() {
|
||||
throw new Error('server create failed');
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.doMock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
|
||||
StdioServerTransport: class {},
|
||||
}));
|
||||
|
||||
const loggerModule = await import('../../../src/logger');
|
||||
const originalTransports = [...loggerModule.default.transports];
|
||||
const consoleTransport = { constructor: { name: 'Console' }, silent: false };
|
||||
loggerModule.default.transports.splice(
|
||||
0,
|
||||
loggerModule.default.transports.length,
|
||||
consoleTransport as any,
|
||||
);
|
||||
|
||||
try {
|
||||
const { startStdioMcpServer } = await import('../../../src/commands/mcp/server');
|
||||
|
||||
await expect(startStdioMcpServer()).rejects.toThrow('server create failed');
|
||||
expect(consoleTransport.silent).toBe(false);
|
||||
} finally {
|
||||
loggerModule.default.transports.splice(
|
||||
0,
|
||||
loggerModule.default.transports.length,
|
||||
...originalTransports,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mockProcessEnv } from '../../util/utils';
|
||||
|
||||
const { mockRandomUUID } = vi.hoisted(() => ({
|
||||
mockRandomUUID: vi.fn(() => 'secure-mcp-session-id'),
|
||||
}));
|
||||
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomUUID: mockRandomUUID,
|
||||
}));
|
||||
|
||||
const expressMocks = vi.hoisted(() => {
|
||||
const close = vi.fn((callback: (error?: Error) => void) => callback());
|
||||
const listen = vi.fn((_port: number, callback: () => void) => {
|
||||
callback();
|
||||
return { close };
|
||||
});
|
||||
const app = {
|
||||
use: vi.fn(),
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
listen,
|
||||
};
|
||||
const json = vi.fn();
|
||||
const express = Object.assign(
|
||||
vi.fn(() => app),
|
||||
{ json },
|
||||
);
|
||||
return { app, close, express, json, listen };
|
||||
});
|
||||
|
||||
vi.mock('express', () => ({
|
||||
default: expressMocks.express,
|
||||
}));
|
||||
|
||||
// Mock dependencies before importing the module
|
||||
vi.mock('../../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
transports: [],
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/telemetry', () => ({
|
||||
default: {
|
||||
record: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock all tool registrations
|
||||
vi.mock('../../../src/commands/mcp/tools/listEvaluations', () => ({
|
||||
registerListEvaluationsTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/tools/getEvaluationDetails', () => ({
|
||||
registerGetEvaluationDetailsTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/tools/validatePromptfooConfig', () => ({
|
||||
registerValidatePromptfooConfigTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/tools/testProvider', () => ({
|
||||
registerTestProviderTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/tools/runAssertion', () => ({
|
||||
registerRunAssertionTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/tools/runEvaluation', () => ({
|
||||
registerRunEvaluationTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/tools/shareEvaluation', () => ({
|
||||
registerShareEvaluationTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/tools/generateDataset', () => ({
|
||||
registerGenerateDatasetTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/tools/generateTestCases', () => ({
|
||||
registerGenerateTestCasesTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/tools/compareProviders', () => ({
|
||||
registerCompareProvidersTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/tools/redteamRun', () => ({
|
||||
registerRedteamRunTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/tools/redteamGenerate', () => ({
|
||||
registerRedteamGenerateTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/commands/mcp/resources', () => ({
|
||||
registerResources: vi.fn(),
|
||||
}));
|
||||
|
||||
// Store the McpServer constructor calls using vi.hoisted
|
||||
const mcpServerMocks = vi.hoisted(() => {
|
||||
const mcpServerCalls: Array<{ name: string; version: string; description?: string }> = [];
|
||||
|
||||
// Create a mock class that can be instantiated with `new`
|
||||
const mockMcpServerImplementation = function MockMcpServer(
|
||||
this: {
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
tool: ReturnType<typeof vi.fn>;
|
||||
resource: ReturnType<typeof vi.fn>;
|
||||
},
|
||||
config: { name: string; version: string; description?: string },
|
||||
) {
|
||||
mcpServerCalls.push(config);
|
||||
this.connect = vi.fn();
|
||||
this.close = vi.fn().mockResolvedValue(undefined);
|
||||
this.tool = vi.fn();
|
||||
this.resource = vi.fn();
|
||||
};
|
||||
const MockMcpServer = vi.fn(mockMcpServerImplementation);
|
||||
|
||||
return { mcpServerCalls, MockMcpServer, mockMcpServerImplementation };
|
||||
});
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
|
||||
McpServer: mcpServerMocks.MockMcpServer,
|
||||
}));
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
|
||||
StdioServerTransport: vi.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
const streamableHttpMocks = vi.hoisted(() => ({
|
||||
constructor: vi.fn().mockImplementation(function MockStreamableHTTPServerTransport() {
|
||||
return {
|
||||
handleRequest: vi.fn(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
|
||||
StreamableHTTPServerTransport: streamableHttpMocks.constructor,
|
||||
}));
|
||||
|
||||
const { mcpServerCalls } = mcpServerMocks;
|
||||
|
||||
describe('MCP Server', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mcpServerMocks.MockMcpServer.mockReset().mockImplementation(
|
||||
mcpServerMocks.mockMcpServerImplementation,
|
||||
);
|
||||
streamableHttpMocks.constructor.mockClear();
|
||||
mockRandomUUID.mockClear();
|
||||
mcpServerCalls.length = 0;
|
||||
});
|
||||
|
||||
describe('createMcpServer', () => {
|
||||
it('should create server with correct name and version', async () => {
|
||||
const { createMcpServer } = await import('../../../src/commands/mcp/server');
|
||||
await createMcpServer();
|
||||
|
||||
expect(mcpServerCalls.length).toBe(1);
|
||||
expect(mcpServerCalls[0].name).toBe('Promptfoo MCP');
|
||||
expect(mcpServerCalls[0].version).toBe('1.0.0');
|
||||
});
|
||||
|
||||
it('should create server with a description field', async () => {
|
||||
const { createMcpServer } = await import('../../../src/commands/mcp/server');
|
||||
await createMcpServer();
|
||||
|
||||
expect(mcpServerCalls.length).toBe(1);
|
||||
expect(mcpServerCalls[0]).toHaveProperty('description');
|
||||
expect(typeof mcpServerCalls[0].description).toBe('string');
|
||||
});
|
||||
|
||||
it('should have a meaningful description that describes server capabilities', async () => {
|
||||
const { createMcpServer } = await import('../../../src/commands/mcp/server');
|
||||
await createMcpServer();
|
||||
|
||||
expect(mcpServerCalls.length).toBe(1);
|
||||
const description = mcpServerCalls[0].description;
|
||||
|
||||
// Verify the description contains key information about the server
|
||||
expect(description).toBeDefined();
|
||||
expect(description!.length).toBeGreaterThan(50); // Should be descriptive
|
||||
expect(description).toContain('MCP server');
|
||||
expect(description).toContain('evaluation');
|
||||
});
|
||||
|
||||
it('should include security testing capabilities in description', async () => {
|
||||
const { createMcpServer } = await import('../../../src/commands/mcp/server');
|
||||
await createMcpServer();
|
||||
|
||||
expect(mcpServerCalls.length).toBe(1);
|
||||
const description = mcpServerCalls[0].description;
|
||||
|
||||
// The description should mention security/red teaming capabilities
|
||||
expect(description).toMatch(/red team|security/i);
|
||||
});
|
||||
|
||||
it('should register all expected tools', async () => {
|
||||
const { createMcpServer } = await import('../../../src/commands/mcp/server');
|
||||
const { registerListEvaluationsTool } = await import(
|
||||
'../../../src/commands/mcp/tools/listEvaluations'
|
||||
);
|
||||
const { registerGetEvaluationDetailsTool } = await import(
|
||||
'../../../src/commands/mcp/tools/getEvaluationDetails'
|
||||
);
|
||||
const { registerValidatePromptfooConfigTool } = await import(
|
||||
'../../../src/commands/mcp/tools/validatePromptfooConfig'
|
||||
);
|
||||
const { registerTestProviderTool } = await import(
|
||||
'../../../src/commands/mcp/tools/testProvider'
|
||||
);
|
||||
const { registerRunAssertionTool } = await import(
|
||||
'../../../src/commands/mcp/tools/runAssertion'
|
||||
);
|
||||
const { registerRunEvaluationTool } = await import(
|
||||
'../../../src/commands/mcp/tools/runEvaluation'
|
||||
);
|
||||
const { registerShareEvaluationTool } = await import(
|
||||
'../../../src/commands/mcp/tools/shareEvaluation'
|
||||
);
|
||||
const { registerGenerateDatasetTool } = await import(
|
||||
'../../../src/commands/mcp/tools/generateDataset'
|
||||
);
|
||||
const { registerGenerateTestCasesTool } = await import(
|
||||
'../../../src/commands/mcp/tools/generateTestCases'
|
||||
);
|
||||
const { registerCompareProvidersTool } = await import(
|
||||
'../../../src/commands/mcp/tools/compareProviders'
|
||||
);
|
||||
const { registerRedteamRunTool } = await import('../../../src/commands/mcp/tools/redteamRun');
|
||||
const { registerRedteamGenerateTool } = await import(
|
||||
'../../../src/commands/mcp/tools/redteamGenerate'
|
||||
);
|
||||
const { registerResources } = await import('../../../src/commands/mcp/resources');
|
||||
|
||||
await createMcpServer();
|
||||
|
||||
// Core evaluation tools
|
||||
expect(registerListEvaluationsTool).toHaveBeenCalled();
|
||||
expect(registerGetEvaluationDetailsTool).toHaveBeenCalled();
|
||||
expect(registerValidatePromptfooConfigTool).toHaveBeenCalled();
|
||||
expect(registerTestProviderTool).toHaveBeenCalled();
|
||||
expect(registerRunAssertionTool).toHaveBeenCalled();
|
||||
expect(registerRunEvaluationTool).toHaveBeenCalled();
|
||||
expect(registerShareEvaluationTool).toHaveBeenCalled();
|
||||
|
||||
// Generation tools
|
||||
expect(registerGenerateDatasetTool).toHaveBeenCalled();
|
||||
expect(registerGenerateTestCasesTool).toHaveBeenCalled();
|
||||
expect(registerCompareProvidersTool).toHaveBeenCalled();
|
||||
|
||||
// Redteam tools
|
||||
expect(registerRedteamRunTool).toHaveBeenCalled();
|
||||
expect(registerRedteamGenerateTool).toHaveBeenCalled();
|
||||
|
||||
// Resources
|
||||
expect(registerResources).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should track telemetry for MCP server creation', async () => {
|
||||
const { createMcpServer } = await import('../../../src/commands/mcp/server');
|
||||
const telemetry = await import('../../../src/telemetry');
|
||||
|
||||
await createMcpServer();
|
||||
|
||||
expect(telemetry.default.record).toHaveBeenCalledWith('feature_used', {
|
||||
feature: 'mcp_server',
|
||||
transport: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('startHttpMcpServer', () => {
|
||||
it('creates cryptographically random session identifiers', async () => {
|
||||
const restoreEnv = mockProcessEnv({ MCP_TRANSPORT: undefined });
|
||||
let shutdown: (() => void) | undefined;
|
||||
vi.spyOn(process, 'once').mockImplementation(((event: string, listener: () => void) => {
|
||||
if (event === 'SIGINT') {
|
||||
shutdown = listener;
|
||||
}
|
||||
return process;
|
||||
}) as typeof process.once);
|
||||
|
||||
let serverPromise: Promise<void> | undefined;
|
||||
try {
|
||||
const { startHttpMcpServer } = await import('../../../src/commands/mcp/server');
|
||||
serverPromise = startHttpMcpServer(3100);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(streamableHttpMocks.constructor).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const transportOptions = streamableHttpMocks.constructor.mock.calls[0][0] as {
|
||||
sessionIdGenerator: () => string;
|
||||
};
|
||||
expect(transportOptions.sessionIdGenerator()).toBe('secure-mcp-session-id');
|
||||
expect(mockRandomUUID).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
if (shutdown) {
|
||||
shutdown();
|
||||
await serverPromise;
|
||||
}
|
||||
restoreEnv();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Schema from getEvaluationDetails.ts
|
||||
const evalIdSchema = z
|
||||
.string()
|
||||
.min(1, 'Eval ID cannot be empty')
|
||||
.regex(/^[a-zA-Z0-9_:-]+$/, 'Invalid eval ID format');
|
||||
|
||||
describe('getEvaluationDetails eval ID validation', () => {
|
||||
describe('valid eval IDs', () => {
|
||||
it('should accept new format eval IDs with random sequence', () => {
|
||||
const validIds = [
|
||||
'eval-8h1-2025-11-15T14:17:18',
|
||||
'eval-abc-2024-01-01T00:00:00',
|
||||
'eval-XyZ-2025-12-31T23:59:59',
|
||||
'eval-123-2025-06-15T12:30:45',
|
||||
];
|
||||
|
||||
validIds.forEach((id) => {
|
||||
const result = evalIdSchema.safeParse(id);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept old format eval IDs without random sequence', () => {
|
||||
const validIds = [
|
||||
'eval-2024-10-01T18:24:51',
|
||||
'eval-2025-01-01T00:00:00',
|
||||
'eval-2023-12-31T23:59:59',
|
||||
];
|
||||
|
||||
validIds.forEach((id) => {
|
||||
const result = evalIdSchema.safeParse(id);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept eval IDs with underscores', () => {
|
||||
// Even though current format uses hyphens, be permissive for legacy
|
||||
const validIds = ['eval_abc123', 'eval_test_123'];
|
||||
|
||||
validIds.forEach((id) => {
|
||||
const result = evalIdSchema.safeParse(id);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept simple alphanumeric IDs', () => {
|
||||
const validIds = ['eval123', 'evalABC', 'eval-test-123'];
|
||||
|
||||
validIds.forEach((id) => {
|
||||
const result = evalIdSchema.safeParse(id);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid eval IDs', () => {
|
||||
it('should reject empty strings', () => {
|
||||
const result = evalIdSchema.safeParse('');
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Eval ID cannot be empty');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject IDs with spaces', () => {
|
||||
const invalidIds = [
|
||||
'eval 123',
|
||||
'eval-abc 123',
|
||||
'eval-2024-10-01T18:24:51 ',
|
||||
' eval-2024-10-01T18:24:51',
|
||||
];
|
||||
|
||||
invalidIds.forEach((id) => {
|
||||
const result = evalIdSchema.safeParse(id);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Invalid eval ID format');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject IDs with special characters', () => {
|
||||
const invalidIds = [
|
||||
'eval@123',
|
||||
'eval#abc',
|
||||
'eval$test',
|
||||
'eval%123',
|
||||
'eval&test',
|
||||
'eval*123',
|
||||
'eval(test)',
|
||||
'eval+123',
|
||||
'eval=test',
|
||||
'eval[123]',
|
||||
'eval{test}',
|
||||
'eval/123',
|
||||
'eval\\test',
|
||||
'eval|123',
|
||||
'eval;test',
|
||||
'eval,123',
|
||||
'eval.test',
|
||||
'eval?123',
|
||||
'eval!test',
|
||||
];
|
||||
|
||||
invalidIds.forEach((id) => {
|
||||
const result = evalIdSchema.safeParse(id);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('Invalid eval ID format');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject IDs with newlines or tabs', () => {
|
||||
const invalidIds = ['eval\n123', 'eval\t123', 'eval\r123'];
|
||||
|
||||
invalidIds.forEach((id) => {
|
||||
const result = evalIdSchema.safeParse(id);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject non-string values', () => {
|
||||
const invalidValues = [null, undefined, 123, {}, [], true];
|
||||
|
||||
invalidValues.forEach((value) => {
|
||||
const result = evalIdSchema.safeParse(value);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should accept very long eval IDs', () => {
|
||||
const longId = 'eval-' + 'a'.repeat(100) + '-2025-11-15T14:17:18';
|
||||
const result = evalIdSchema.safeParse(longId);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept IDs with multiple colons', () => {
|
||||
const id = 'eval:test:2025-11-15T14:17:18';
|
||||
const result = evalIdSchema.safeParse(id);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept IDs with multiple hyphens', () => {
|
||||
const id = 'eval---test---123';
|
||||
const result = evalIdSchema.safeParse(id);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regression test for #6222', () => {
|
||||
it('should accept eval IDs returned by list_evaluations', () => {
|
||||
// This is the exact format that caused the bug report
|
||||
const bugReportId = 'eval-8h1-2025-11-15T14:17:18';
|
||||
const result = evalIdSchema.safeParse(bugReportId);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(bugReportId);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept all valid ISO timestamp formats in eval IDs', () => {
|
||||
// Test various times to ensure colons in time component work
|
||||
const idsWithTimes = [
|
||||
'eval-abc-2025-11-15T00:00:00',
|
||||
'eval-abc-2025-11-15T12:30:45',
|
||||
'eval-abc-2025-11-15T23:59:59',
|
||||
'eval-abc-2025-11-15T14:17:18', // From bug report
|
||||
];
|
||||
|
||||
idsWithTimes.forEach((id) => {
|
||||
const result = evalIdSchema.safeParse(id);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { registerGetEvaluationDetailsTool } from '../../../../src/commands/mcp/tools/getEvaluationDetails';
|
||||
import { readResult } from '../../../../src/util/database';
|
||||
|
||||
vi.mock('../../../../src/util/database', () => ({
|
||||
readResult: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/commands/mcp/lib/utils', () => ({
|
||||
createToolResponse: vi.fn((name, success, data) => ({ name, success, data })),
|
||||
}));
|
||||
|
||||
describe('get_evaluation_details filtering', () => {
|
||||
const getMockEvalData = () => ({
|
||||
result: {
|
||||
results: [
|
||||
{ success: true, error: null, metadata: {} },
|
||||
{ success: false, error: 'Failed assertion', metadata: {} },
|
||||
{ success: false, error: 'Runtime error', metadata: {} },
|
||||
{ success: true, error: null, metadata: { highlighted: true } },
|
||||
],
|
||||
table: {
|
||||
head: {
|
||||
providers: [],
|
||||
prompts: [{}, {}],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockMcpServer = {
|
||||
tool: vi.fn(),
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should filter failures correctly', async () => {
|
||||
registerGetEvaluationDetailsTool(mockMcpServer);
|
||||
const toolHandler = mockMcpServer.tool.mock.calls[0][2];
|
||||
vi.mocked(readResult).mockResolvedValue(getMockEvalData() as any);
|
||||
|
||||
const response = await toolHandler({ id: 'test-eval', filter: 'failures' });
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const filteredResults = response.data.evaluation.results;
|
||||
expect(filteredResults).toHaveLength(2);
|
||||
expect(filteredResults[0].success).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter passes correctly', async () => {
|
||||
registerGetEvaluationDetailsTool(mockMcpServer);
|
||||
const toolHandler = mockMcpServer.tool.mock.calls[0][2];
|
||||
vi.mocked(readResult).mockResolvedValue(getMockEvalData() as any);
|
||||
|
||||
const response = await toolHandler({ id: 'test-eval', filter: 'passes' });
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const filteredResults = response.data.evaluation.results;
|
||||
expect(filteredResults).toHaveLength(2);
|
||||
filteredResults.forEach((r: any) => {
|
||||
expect(r.success).toBe(true);
|
||||
expect(r.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter errors correctly', async () => {
|
||||
registerGetEvaluationDetailsTool(mockMcpServer);
|
||||
const toolHandler = mockMcpServer.tool.mock.calls[0][2];
|
||||
vi.mocked(readResult).mockResolvedValue(getMockEvalData() as any);
|
||||
|
||||
const response = await toolHandler({ id: 'test-eval', filter: 'errors' });
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const filteredResults = response.data.evaluation.results;
|
||||
expect(filteredResults).toHaveLength(2);
|
||||
expect(filteredResults[1].error).toBe('Runtime error');
|
||||
});
|
||||
|
||||
it('should filter highlights correctly', async () => {
|
||||
registerGetEvaluationDetailsTool(mockMcpServer);
|
||||
const toolHandler = mockMcpServer.tool.mock.calls[0][2];
|
||||
vi.mocked(readResult).mockResolvedValue(getMockEvalData() as any);
|
||||
|
||||
const response = await toolHandler({ id: 'test-eval', filter: 'highlights' });
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const filteredResults = response.data.evaluation.results;
|
||||
expect(filteredResults).toHaveLength(1);
|
||||
expect(filteredResults[0].metadata.highlighted).toBe(true);
|
||||
});
|
||||
|
||||
it('should return all results when filter is "all"', async () => {
|
||||
registerGetEvaluationDetailsTool(mockMcpServer);
|
||||
const toolHandler = mockMcpServer.tool.mock.calls[0][2];
|
||||
vi.mocked(readResult).mockResolvedValue(getMockEvalData() as any);
|
||||
|
||||
const response = await toolHandler({ id: 'test-eval', filter: 'all' });
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data.evaluation.results).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('redacts Azure Blob SAS tokens from returned evaluation config', async () => {
|
||||
const sasUri = 'az://account/container/tests.yaml?sp=r&sig=azure-secret';
|
||||
const storedEvaluation = getMockEvalData() as any;
|
||||
storedEvaluation.result.config = { tests: sasUri };
|
||||
registerGetEvaluationDetailsTool(mockMcpServer);
|
||||
const toolHandler = mockMcpServer.tool.mock.calls[0][2];
|
||||
vi.mocked(readResult).mockResolvedValue(storedEvaluation);
|
||||
|
||||
const response = await toolHandler({ id: 'test-eval', filter: 'all' });
|
||||
|
||||
expect(response.data.evaluation.config.tests).toBe(
|
||||
'az://account/container/tests.yaml?sp=r&sig=%5BREDACTED%5D',
|
||||
);
|
||||
expect(storedEvaluation.result.config.tests).toBe(sasUri);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies before importing
|
||||
vi.mock('../../../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockReadLastLines = vi.fn();
|
||||
const mockReadFirstLines = vi.fn();
|
||||
|
||||
vi.mock('../../../../src/util/logs', () => ({
|
||||
getLogDirectory: vi.fn().mockReturnValue('/mock/logs'),
|
||||
getLogFiles: vi.fn(),
|
||||
formatFileSize: vi.fn((bytes: number) => {
|
||||
if (bytes === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
|
||||
}),
|
||||
readLastLines: (...args: unknown[]) => mockReadLastLines(...args),
|
||||
readFirstLines: (...args: unknown[]) => mockReadFirstLines(...args),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: {
|
||||
stat: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import mocked modules
|
||||
import fs from 'fs/promises';
|
||||
|
||||
import { getLogFiles } from '../../../../src/util/logs';
|
||||
|
||||
describe('logs MCP tools', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('list_logs tool', () => {
|
||||
it('should list log files with pagination', async () => {
|
||||
const mockLogFiles = [
|
||||
{
|
||||
name: 'promptfoo-debug-2024-01-15_10-30-00.log',
|
||||
path: '/mock/logs/promptfoo-debug-2024-01-15_10-30-00.log',
|
||||
type: 'debug' as const,
|
||||
size: 1024,
|
||||
mtime: new Date('2024-01-15T10:30:00Z'),
|
||||
},
|
||||
{
|
||||
name: 'promptfoo-error-2024-01-15_10-30-00.log',
|
||||
path: '/mock/logs/promptfoo-error-2024-01-15_10-30-00.log',
|
||||
type: 'error' as const,
|
||||
size: 512,
|
||||
mtime: new Date('2024-01-15T10:30:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(getLogFiles).mockResolvedValue(mockLogFiles);
|
||||
|
||||
// Import tool after mocks are set up
|
||||
const { registerListLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
|
||||
|
||||
// Create a mock server to capture the registered tool
|
||||
let registeredHandler: ((args: any) => Promise<any>) | null = null;
|
||||
const mockServer = {
|
||||
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
|
||||
registeredHandler = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
registerListLogsTool(mockServer as any);
|
||||
|
||||
expect(mockServer.tool).toHaveBeenCalledWith(
|
||||
'list_logs',
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
// Execute the handler
|
||||
const result = await registeredHandler!({ type: 'all', page: 1, pageSize: 20 });
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
const response = JSON.parse(result.content[0].text);
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data.logs).toHaveLength(2);
|
||||
expect(response.data.summary.totalFiles).toBe(2);
|
||||
expect(response.data.summary.debugFiles).toBe(1);
|
||||
expect(response.data.summary.errorFiles).toBe(1);
|
||||
});
|
||||
|
||||
it('should return empty result when no log files exist', async () => {
|
||||
vi.mocked(getLogFiles).mockResolvedValue([]);
|
||||
|
||||
const { registerListLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
|
||||
|
||||
let registeredHandler: ((args: any) => Promise<any>) | null = null;
|
||||
const mockServer = {
|
||||
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
|
||||
registeredHandler = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
registerListLogsTool(mockServer as any);
|
||||
|
||||
const result = await registeredHandler!({ type: 'all' });
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
const response = JSON.parse(result.content[0].text);
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data.logs).toHaveLength(0);
|
||||
expect(response.data.summary.message).toContain('No log files found');
|
||||
});
|
||||
|
||||
it('should filter by log type', async () => {
|
||||
const mockLogFiles = [
|
||||
{
|
||||
name: 'promptfoo-debug-2024-01-15_10-30-00.log',
|
||||
path: '/mock/logs/promptfoo-debug-2024-01-15_10-30-00.log',
|
||||
type: 'debug' as const,
|
||||
size: 1024,
|
||||
mtime: new Date('2024-01-15T10:30:00Z'),
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(getLogFiles).mockResolvedValue(mockLogFiles);
|
||||
|
||||
const { registerListLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
|
||||
|
||||
let registeredHandler: ((args: any) => Promise<any>) | null = null;
|
||||
const mockServer = {
|
||||
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
|
||||
registeredHandler = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
registerListLogsTool(mockServer as any);
|
||||
|
||||
const result = await registeredHandler!({ type: 'debug' });
|
||||
|
||||
expect(getLogFiles).toHaveBeenCalledWith('debug');
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
vi.mocked(getLogFiles).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const { registerListLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
|
||||
|
||||
let registeredHandler: ((args: any) => Promise<any>) | null = null;
|
||||
const mockServer = {
|
||||
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
|
||||
registeredHandler = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
registerListLogsTool(mockServer as any);
|
||||
|
||||
const result = await registeredHandler!({ type: 'all' });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const response = JSON.parse(result.content[0].text);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toContain('Permission denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('read_logs tool', () => {
|
||||
const mockLogFile = {
|
||||
name: 'promptfoo-debug-2024-01-15_10-30-00.log',
|
||||
path: '/mock/logs/promptfoo-debug-2024-01-15_10-30-00.log',
|
||||
type: 'debug' as const,
|
||||
size: 1024,
|
||||
mtime: new Date('2024-01-15T10:30:00Z'),
|
||||
};
|
||||
|
||||
it('should read latest log file by default', async () => {
|
||||
vi.mocked(getLogFiles).mockResolvedValue([mockLogFile]);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as any);
|
||||
|
||||
// Mock readLastLines to return log lines
|
||||
const mockLines = ['2024-01-15 [INFO] Test log line 1', '2024-01-15 [ERROR] Test error'];
|
||||
mockReadLastLines.mockResolvedValue(mockLines);
|
||||
|
||||
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
|
||||
|
||||
let registeredHandler: ((args: any) => Promise<any>) | null = null;
|
||||
const mockServer = {
|
||||
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
|
||||
registeredHandler = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
registerReadLogsTool(mockServer as any);
|
||||
|
||||
const result = await registeredHandler!({});
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
const response = JSON.parse(result.content[0].text);
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data.file.name).toBe(mockLogFile.name);
|
||||
expect(response.data.content).toContain('Test log line 1');
|
||||
expect(response.data.metadata.readMode).toBe('tail');
|
||||
});
|
||||
|
||||
it('should find log file by partial name', async () => {
|
||||
vi.mocked(getLogFiles).mockResolvedValue([mockLogFile]);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as any);
|
||||
|
||||
mockReadLastLines.mockResolvedValue(['Test line']);
|
||||
|
||||
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
|
||||
|
||||
let registeredHandler: ((args: any) => Promise<any>) | null = null;
|
||||
const mockServer = {
|
||||
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
|
||||
registeredHandler = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
registerReadLogsTool(mockServer as any);
|
||||
|
||||
const result = await registeredHandler!({ file: 'debug-2024-01-15' });
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
const response = JSON.parse(result.content[0].text);
|
||||
expect(response.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should return error when file not found', async () => {
|
||||
vi.mocked(getLogFiles).mockResolvedValue([mockLogFile]);
|
||||
|
||||
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
|
||||
|
||||
let registeredHandler: ((args: any) => Promise<any>) | null = null;
|
||||
const mockServer = {
|
||||
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
|
||||
registeredHandler = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
registerReadLogsTool(mockServer as any);
|
||||
|
||||
const result = await registeredHandler!({ file: 'nonexistent-file.log' });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const response = JSON.parse(result.content[0].text);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('should return error when no logs exist for type', async () => {
|
||||
vi.mocked(getLogFiles).mockResolvedValue([]);
|
||||
|
||||
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
|
||||
|
||||
let registeredHandler: ((args: any) => Promise<any>) | null = null;
|
||||
const mockServer = {
|
||||
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
|
||||
registeredHandler = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
registerReadLogsTool(mockServer as any);
|
||||
|
||||
const result = await registeredHandler!({ type: 'error' });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const response = JSON.parse(result.content[0].text);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toContain('No error log files found');
|
||||
});
|
||||
|
||||
it('should read from head when specified', async () => {
|
||||
vi.mocked(getLogFiles).mockResolvedValue([mockLogFile]);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as any);
|
||||
|
||||
mockReadFirstLines.mockResolvedValue(['First line', 'Second line']);
|
||||
|
||||
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
|
||||
|
||||
let registeredHandler: ((args: any) => Promise<any>) | null = null;
|
||||
const mockServer = {
|
||||
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
|
||||
registeredHandler = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
registerReadLogsTool(mockServer as any);
|
||||
|
||||
const result = await registeredHandler!({ head: true, lines: 2 });
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
const response = JSON.parse(result.content[0].text);
|
||||
expect(response.data.metadata.readMode).toBe('head');
|
||||
});
|
||||
|
||||
it('should filter content with grep pattern', async () => {
|
||||
vi.mocked(getLogFiles).mockResolvedValue([mockLogFile]);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as any);
|
||||
|
||||
const mockLines = [
|
||||
'2024-01-15 [INFO] Normal message',
|
||||
'2024-01-15 [ERROR] Error occurred',
|
||||
'2024-01-15 [INFO] Another normal message',
|
||||
'2024-01-15 [ERROR] Another error',
|
||||
];
|
||||
mockReadLastLines.mockResolvedValue(mockLines);
|
||||
|
||||
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
|
||||
|
||||
let registeredHandler: ((args: any) => Promise<any>) | null = null;
|
||||
const mockServer = {
|
||||
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
|
||||
registeredHandler = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
registerReadLogsTool(mockServer as any);
|
||||
|
||||
const result = await registeredHandler!({ grep: 'ERROR' });
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
const response = JSON.parse(result.content[0].text);
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data.content).toContain('ERROR');
|
||||
expect(response.data.content).not.toContain('Normal message');
|
||||
expect(response.data.metadata.grepPattern).toBe('ERROR');
|
||||
});
|
||||
|
||||
it('should handle invalid grep pattern gracefully', async () => {
|
||||
vi.mocked(getLogFiles).mockResolvedValue([mockLogFile]);
|
||||
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as any);
|
||||
|
||||
const mockLines = ['Test [ERROR] line', 'Test [INFO] line'];
|
||||
mockReadLastLines.mockResolvedValue(mockLines);
|
||||
|
||||
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
|
||||
|
||||
let registeredHandler: ((args: any) => Promise<any>) | null = null;
|
||||
const mockServer = {
|
||||
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
|
||||
registeredHandler = handler;
|
||||
}),
|
||||
};
|
||||
|
||||
registerReadLogsTool(mockServer as any);
|
||||
|
||||
// Invalid regex should fall back to substring match
|
||||
const result = await registeredHandler!({ grep: '[ERROR' }); // Invalid regex
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
const response = JSON.parse(result.content[0].text);
|
||||
expect(response.success).toBe(true);
|
||||
// Should still match using substring
|
||||
expect(response.data.content).toContain('[ERROR]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerLogTools', () => {
|
||||
it('should register both list_logs and read_logs tools', async () => {
|
||||
const { registerLogTools } = await import('../../../../src/commands/mcp/tools/logs');
|
||||
|
||||
const registeredTools: string[] = [];
|
||||
const mockServer = {
|
||||
tool: vi.fn((name: string) => {
|
||||
registeredTools.push(name);
|
||||
}),
|
||||
};
|
||||
|
||||
registerLogTools(mockServer as any);
|
||||
|
||||
expect(registeredTools).toContain('list_logs');
|
||||
expect(registeredTools).toContain('read_logs');
|
||||
expect(mockServer.tool).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies before importing the module
|
||||
vi.mock('../../../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/util/config/default', () => ({
|
||||
loadDefaultConfig: vi.fn().mockResolvedValue({
|
||||
defaultConfig: {},
|
||||
defaultConfigPath: 'promptfooconfig.yaml',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/util/config/load', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../../src/util/config/load')>()),
|
||||
resolveConfigs: vi.fn().mockResolvedValue({
|
||||
config: {},
|
||||
testSuite: {
|
||||
prompts: [{ label: 'test-prompt', raw: 'What is 2+2?' }],
|
||||
providers: [{ id: 'test-provider' }],
|
||||
tests: [{ vars: { input: 'test' } }],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/node/doEval', () => ({
|
||||
doEval: vi.fn().mockResolvedValue({
|
||||
id: 'test-eval-123',
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 3,
|
||||
stats: { successes: 1, failures: 0, errors: 0 },
|
||||
results: [
|
||||
{
|
||||
testCase: { description: 'test case 1', assert: [] },
|
||||
vars: { input: 'test' },
|
||||
prompt: { label: 'test-prompt', raw: 'What is 2+2?' },
|
||||
provider: { id: 'test-provider', label: 'Test Provider' },
|
||||
response: { output: 'The answer is 4' },
|
||||
success: true,
|
||||
score: 1,
|
||||
namedScores: {},
|
||||
tokenUsage: { total: 10 },
|
||||
cost: 0.001,
|
||||
latencyMs: 100,
|
||||
},
|
||||
],
|
||||
prompts: [{ label: 'test-prompt', provider: 'test-provider', metrics: {} }],
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('runEvaluation tool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('result formatting', () => {
|
||||
it('should use shared formatter for pagination', async () => {
|
||||
const { formatEvaluationResults } = await import(
|
||||
'../../../../src/commands/mcp/lib/resultFormatter'
|
||||
);
|
||||
|
||||
const mockSummary = {
|
||||
version: 3,
|
||||
stats: { successes: 5, failures: 2, errors: 0 },
|
||||
results: Array.from({ length: 10 }, (_, i) => ({
|
||||
testCase: { description: `test case ${i}`, assert: [] },
|
||||
vars: { index: i },
|
||||
prompt: { label: 'test', raw: 'prompt text' },
|
||||
provider: { id: 'provider', label: 'Provider' },
|
||||
response: { output: `response ${i}` },
|
||||
success: i < 5,
|
||||
score: i < 5 ? 1 : 0,
|
||||
namedScores: {},
|
||||
cost: 0.001,
|
||||
latencyMs: 100,
|
||||
})),
|
||||
prompts: [],
|
||||
};
|
||||
|
||||
// Test default pagination
|
||||
const result = formatEvaluationResults(mockSummary as any);
|
||||
expect(result.pagination.totalResults).toBe(10);
|
||||
expect(result.pagination.limit).toBe(20);
|
||||
expect(result.pagination.offset).toBe(0);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
expect(result.results.length).toBe(10);
|
||||
});
|
||||
|
||||
it('should respect custom pagination options', async () => {
|
||||
const { formatEvaluationResults } = await import(
|
||||
'../../../../src/commands/mcp/lib/resultFormatter'
|
||||
);
|
||||
|
||||
const mockSummary = {
|
||||
version: 3,
|
||||
stats: { successes: 100, failures: 0, errors: 0 },
|
||||
results: Array.from({ length: 100 }, (_, i) => ({
|
||||
testCase: { description: `test case ${i}`, assert: [] },
|
||||
vars: { index: i },
|
||||
prompt: { label: 'test', raw: 'prompt text' },
|
||||
provider: { id: 'provider', label: 'Provider' },
|
||||
response: { output: `response ${i}` },
|
||||
success: true,
|
||||
score: 1,
|
||||
namedScores: {},
|
||||
cost: 0.001,
|
||||
latencyMs: 100,
|
||||
})),
|
||||
prompts: [],
|
||||
};
|
||||
|
||||
// Test custom limit and offset
|
||||
const result = formatEvaluationResults(mockSummary as any, {
|
||||
resultLimit: 10,
|
||||
resultOffset: 20,
|
||||
});
|
||||
expect(result.pagination.totalResults).toBe(100);
|
||||
expect(result.pagination.limit).toBe(10);
|
||||
expect(result.pagination.offset).toBe(20);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.returnedCount).toBe(10);
|
||||
expect(result.results.length).toBe(10);
|
||||
expect(result.results[0].index).toBe(20);
|
||||
});
|
||||
|
||||
it('should limit results to max 100', async () => {
|
||||
const { formatEvaluationResults } = await import(
|
||||
'../../../../src/commands/mcp/lib/resultFormatter'
|
||||
);
|
||||
|
||||
const mockSummary = {
|
||||
version: 3,
|
||||
stats: { successes: 200, failures: 0, errors: 0 },
|
||||
results: Array.from({ length: 200 }, (_, i) => ({
|
||||
testCase: { description: `test case ${i}`, assert: [] },
|
||||
vars: { index: i },
|
||||
prompt: { label: 'test', raw: 'prompt text' },
|
||||
provider: { id: 'provider', label: 'Provider' },
|
||||
response: { output: `response ${i}` },
|
||||
success: true,
|
||||
score: 1,
|
||||
namedScores: {},
|
||||
cost: 0.001,
|
||||
latencyMs: 100,
|
||||
})),
|
||||
prompts: [],
|
||||
};
|
||||
|
||||
// Even if requesting 200, should cap at 100
|
||||
const result = formatEvaluationResults(mockSummary as any, {
|
||||
resultLimit: 200,
|
||||
});
|
||||
expect(result.pagination.limit).toBe(100);
|
||||
expect(result.results.length).toBe(100);
|
||||
});
|
||||
|
||||
it('should truncate long text fields', async () => {
|
||||
const { formatEvaluationResults } = await import(
|
||||
'../../../../src/commands/mcp/lib/resultFormatter'
|
||||
);
|
||||
|
||||
const longText = 'x'.repeat(500);
|
||||
const mockSummary = {
|
||||
version: 3,
|
||||
stats: { successes: 1, failures: 0, errors: 0 },
|
||||
results: [
|
||||
{
|
||||
testCase: { description: 'test', assert: [] },
|
||||
vars: {},
|
||||
prompt: { label: 'test', raw: longText },
|
||||
provider: { id: 'provider', label: 'Provider' },
|
||||
response: { output: longText },
|
||||
success: true,
|
||||
score: 1,
|
||||
namedScores: {},
|
||||
},
|
||||
],
|
||||
prompts: [],
|
||||
};
|
||||
|
||||
const result = formatEvaluationResults(mockSummary as any);
|
||||
expect(result.results[0].prompt.raw.length).toBeLessThan(200);
|
||||
expect(result.results[0].prompt.raw).toContain('...');
|
||||
expect(result.results[0].response.output!.length).toBeLessThan(300);
|
||||
expect(result.results[0].response.output).toContain('...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompts summary formatting', () => {
|
||||
it('should format prompts summary for version 3', async () => {
|
||||
const { formatPromptsSummary } = await import(
|
||||
'../../../../src/commands/mcp/lib/resultFormatter'
|
||||
);
|
||||
|
||||
const mockSummary = {
|
||||
version: 3,
|
||||
stats: { successes: 1, failures: 0, errors: 0 },
|
||||
results: [],
|
||||
prompts: [
|
||||
{ label: 'prompt1', provider: 'openai', metrics: { avgScore: 0.95 } },
|
||||
{ label: 'prompt2', provider: 'anthropic', metrics: { avgScore: 0.88 } },
|
||||
],
|
||||
};
|
||||
|
||||
const result = formatPromptsSummary(mockSummary as any);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].label).toBe('prompt1');
|
||||
expect(result[0].provider).toBe('openai');
|
||||
expect(result[0].metrics).toEqual({ avgScore: 0.95 });
|
||||
});
|
||||
|
||||
it('should return empty array for non-version 3 summaries', async () => {
|
||||
const { formatPromptsSummary } = await import(
|
||||
'../../../../src/commands/mcp/lib/resultFormatter'
|
||||
);
|
||||
|
||||
const mockSummary = {
|
||||
version: 2,
|
||||
stats: { successes: 1, failures: 0, errors: 0 },
|
||||
results: [],
|
||||
};
|
||||
|
||||
const result = formatPromptsSummary(mockSummary as any);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptFilter validation', () => {
|
||||
it('should error on mixed numeric and non-numeric filters', async () => {
|
||||
const { registerRunEvaluationTool } = await import(
|
||||
'../../../../src/commands/mcp/tools/runEvaluation'
|
||||
);
|
||||
|
||||
let toolHandler: any;
|
||||
const mockServer = {
|
||||
tool: vi.fn((_name, _schema, handler) => {
|
||||
toolHandler = handler;
|
||||
}),
|
||||
} as any;
|
||||
|
||||
registerRunEvaluationTool(mockServer);
|
||||
|
||||
const result = await toolHandler({
|
||||
configPath: 'test.yaml',
|
||||
promptFilter: ['0', 'morning.*'],
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('Cannot mix numeric indices and regex patterns');
|
||||
});
|
||||
|
||||
it('should return error when all prompts are filtered out', async () => {
|
||||
const { resolveConfigs } = await import('../../../../src/util/config/load');
|
||||
|
||||
vi.mocked(resolveConfigs).mockResolvedValueOnce({
|
||||
config: {},
|
||||
testSuite: {
|
||||
prompts: [{ label: 'test-prompt', raw: 'test' }],
|
||||
providers: [{ id: 'test-provider' }],
|
||||
tests: [{ vars: { input: 'test' } }],
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { registerRunEvaluationTool } = await import(
|
||||
'../../../../src/commands/mcp/tools/runEvaluation'
|
||||
);
|
||||
|
||||
let toolHandler: any;
|
||||
registerRunEvaluationTool({
|
||||
tool: vi.fn((_name, _schema, handler) => {
|
||||
toolHandler = handler;
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await toolHandler({
|
||||
configPath: 'test.yaml',
|
||||
promptFilter: 'nonexistent.*',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('No prompts found after applying filter');
|
||||
});
|
||||
|
||||
it('should return error when all providers are filtered out', async () => {
|
||||
const { resolveConfigs } = await import('../../../../src/util/config/load');
|
||||
|
||||
vi.mocked(resolveConfigs).mockResolvedValueOnce({
|
||||
config: {},
|
||||
testSuite: {
|
||||
prompts: [{ label: 'test-prompt', raw: 'test' }],
|
||||
providers: [
|
||||
{ id: () => 'openai:gpt-4', callApi: async () => ({ output: 'test' }) },
|
||||
] as any,
|
||||
tests: [{ vars: { input: 'test' } }],
|
||||
},
|
||||
} as any);
|
||||
|
||||
const { registerRunEvaluationTool } = await import(
|
||||
'../../../../src/commands/mcp/tools/runEvaluation'
|
||||
);
|
||||
|
||||
let toolHandler: any;
|
||||
registerRunEvaluationTool({
|
||||
tool: vi.fn((_name, _schema, handler) => {
|
||||
toolHandler = handler;
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await toolHandler({
|
||||
configPath: 'test.yaml',
|
||||
providerFilter: 'nonexistent',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('No providers matched filter');
|
||||
});
|
||||
|
||||
it('should return an error when config resolution fails instead of terminating the host', async () => {
|
||||
const { ConfigResolutionError, resolveConfigs } = await import(
|
||||
'../../../../src/util/config/load'
|
||||
);
|
||||
|
||||
vi.mocked(resolveConfigs).mockRejectedValueOnce(
|
||||
new ConfigResolutionError('You must provide at least 1 prompt'),
|
||||
);
|
||||
|
||||
const { registerRunEvaluationTool } = await import(
|
||||
'../../../../src/commands/mcp/tools/runEvaluation'
|
||||
);
|
||||
|
||||
let toolHandler: any;
|
||||
registerRunEvaluationTool({
|
||||
tool: vi.fn((_name, _schema, handler) => {
|
||||
toolHandler = handler;
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await toolHandler({
|
||||
configPath: 'test.yaml',
|
||||
promptFilter: 'anything',
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const logger = (await import('../../../../src/logger')).default;
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Evaluation execution failed: You must provide at least 1 prompt',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user