0d3cb498a3
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
468 lines
14 KiB
TypeScript
468 lines
14 KiB
TypeScript
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('');
|
|
});
|
|
});
|
|
});
|