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,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