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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
+810
View File
@@ -0,0 +1,810 @@
import search from '@inquirer/search';
import { Command } from 'commander';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { authCommand } from '../../src/commands/auth';
import { isNonInteractive } from '../../src/envars';
import { getUserEmail, setUserEmail } from '../../src/globalConfig/accounts';
import { cloudConfig } from '../../src/globalConfig/cloud';
import logger from '../../src/logger';
import { getDefaultTeam, getUserTeams } from '../../src/util/cloud';
import { fetchWithProxy } from '../../src/util/fetch/index';
import { openAuthBrowser } from '../../src/util/server';
import { createMockResponse, mockGlobal, stripAnsi } from '../util/utils';
vi.mock('@inquirer/search');
const mockCloudUser = {
id: '1',
email: 'test@example.com',
name: 'Test User',
createdAt: new Date(),
updatedAt: new Date(),
};
const mockOrganization = {
id: '1',
name: 'Test Org',
createdAt: new Date(),
updatedAt: new Date(),
};
const mockApp = {
id: '1',
name: 'Test App',
createdAt: new Date(),
updatedAt: new Date(),
url: 'https://app.example.com',
};
vi.mock('../../src/envars');
vi.mock('../../src/globalConfig/accounts');
vi.mock('../../src/globalConfig/cloud');
vi.mock('../../src/logger');
vi.mock('../../src/util/cloud');
vi.mock('../../src/util/fetch/index.ts');
vi.mock('../../src/util/server');
const mockFetch = vi.fn();
const restoreFetch = mockGlobal('fetch', mockFetch);
afterAll(() => {
restoreFetch();
});
describe('auth command', () => {
let program: Command;
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
program = new Command();
process.exitCode = undefined;
authCommand(program);
// Set up a basic mock that just returns the expected data
vi.mocked(cloudConfig.validateApiToken).mockResolvedValue({
user: mockCloudUser,
organization: mockOrganization,
app: mockApp,
hasActiveLicense: false,
});
});
describe('login', () => {
it('should set email in config after successful login with API key', async () => {
mockFetch.mockResolvedValueOnce(
createMockResponse({
ok: true,
body: { user: mockCloudUser },
}),
);
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
expect(setUserEmail).toHaveBeenCalledWith('test@example.com');
expect(cloudConfig.validateApiToken).toHaveBeenCalledWith('test-key', undefined);
expect(cloudConfig.saveValidatedApiToken).toHaveBeenCalledWith(
'test-key',
undefined,
mockCloudUser,
mockApp,
false,
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Successfully logged in'));
});
it('should prompt for browser opening when no API key is provided in interactive environment', async () => {
// Mock interactive environment
vi.mocked(isNonInteractive).mockReturnValue(false);
vi.mocked(cloudConfig.getAppUrl).mockReturnValue('https://www.promptfoo.app');
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test']);
expect(openAuthBrowser).toHaveBeenCalledWith(
'https://www.promptfoo.app/',
'https://www.promptfoo.app/welcome',
0, // BrowserBehavior.ASK
);
});
it('should exit with error when no API key is provided in non-interactive environment', async () => {
// Mock non-interactive environment (CI, cron, SSH without TTY, etc.)
vi.mocked(isNonInteractive).mockReturnValue(true);
vi.mocked(cloudConfig.getAppUrl).mockReturnValue('https://www.promptfoo.app');
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test']);
expect(logger.error).toHaveBeenCalledWith(
'Authentication required. Please set PROMPTFOO_API_KEY environment variable or run `promptfoo auth login` in an interactive environment.',
);
// Check that both info calls were made
const infoCalls = vi.mocked(logger.info).mock.calls;
const infoMessages = infoCalls.map((call) => stripAnsi(String(call[0])));
expect(infoCalls.length).toBeGreaterThanOrEqual(2);
expect(
infoMessages.some((message) =>
message.includes('Manual login URL: https://www.promptfoo.app/'),
),
).toBe(true);
expect(
infoMessages.some((message) =>
message.includes('After login, get your API token at: https://www.promptfoo.app/welcome'),
),
).toBe(true);
expect(process.exitCode).toBe(1);
expect(openAuthBrowser).not.toHaveBeenCalled();
});
it('should use custom host for browser opening when provided in interactive environment', async () => {
// Mock interactive environment
vi.mocked(isNonInteractive).mockReturnValue(false);
const customHost = 'https://custom.promptfoo.com';
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--host', customHost]);
expect(openAuthBrowser).toHaveBeenCalledWith(
'https://custom.promptfoo.com/',
'https://custom.promptfoo.com/welcome',
0, // BrowserBehavior.ASK
);
});
it('should use custom host when provided', async () => {
const customHost = 'https://custom-api.example.com';
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key', '--host', customHost]);
expect(cloudConfig.validateApiToken).toHaveBeenCalledWith('test-key', customHost);
expect(cloudConfig.saveValidatedApiToken).toHaveBeenCalledWith(
'test-key',
customHost,
mockCloudUser,
mockApp,
false,
);
});
it('should handle login request failure', async () => {
vi.mocked(cloudConfig.validateApiToken).mockRejectedValueOnce(new Error('Bad Request'));
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Authentication failed: Bad Request'),
);
expect(process.exitCode).toBe(1);
});
it('should overwrite existing email in config after successful login', async () => {
const newCloudUser = { ...mockCloudUser, email: 'new@example.com' };
vi.mocked(getUserEmail).mockReturnValue('old@example.com');
vi.mocked(cloudConfig.validateApiToken).mockResolvedValueOnce({
user: newCloudUser,
organization: mockOrganization,
app: mockApp,
hasActiveLicense: false,
});
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
expect(setUserEmail).toHaveBeenCalledWith('new@example.com');
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Updating local email configuration'),
);
});
it('should handle non-Error objects in the catch block', async () => {
vi.mocked(cloudConfig.validateApiToken).mockImplementationOnce(function () {
// Intentionally throw a non-Error value to exercise defensive error-message handling.
throw 'String error message';
});
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Authentication failed: String error message'),
);
expect(process.exitCode).toBe(1);
// Reset exitCode
process.exitCode = 0;
});
it('should use --team flag to set specific team', async () => {
const mockTeams = [
{
id: 'team-1',
name: 'Default',
slug: 'default',
organizationId: '1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
{
id: 'team-2',
name: 'Security Team',
slug: 'security',
organizationId: '1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
];
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key', '--team', 'security']);
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', '1');
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Security Team'));
});
it('should resolve --team across organizations when --org is omitted', async () => {
const mockTeams = [
{
id: 'team-1',
name: 'Default',
slug: 'default',
organizationId: '1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
{
id: 'team-2',
name: 'Security Team',
slug: 'security',
organizationId: 'org-2',
createdAt: '2024-01-02',
updatedAt: '2024-01-02',
},
];
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key', '--team', 'security']);
expect(cloudConfig.setCurrentOrganization).toHaveBeenCalledWith('org-2');
expect(cloudConfig.cacheTeams).toHaveBeenCalledWith([mockTeams[1]], 'org-2');
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', 'org-2');
expect(logger.warn).not.toHaveBeenCalled();
});
it('should use the custom host when resolving --team before saving API key login', async () => {
const customHost = 'https://api.promptfoo.example';
const mockTeams = [
{
id: 'team-1',
name: 'Default',
slug: 'default',
organizationId: '1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
{
id: 'team-2',
name: 'Security Team',
slug: 'security',
organizationId: 'org-2',
createdAt: '2024-01-02',
updatedAt: '2024-01-02',
},
];
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync([
'node',
'test',
'--api-key',
'test-key',
'--host',
customHost,
'--team',
'security',
]);
expect(getUserTeams).toHaveBeenCalledWith(customHost, 'test-key');
expect(cloudConfig.setCurrentOrganization).toHaveBeenCalledWith('org-2');
expect(cloudConfig.cacheTeams).toHaveBeenCalledWith([mockTeams[1]], 'org-2');
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', 'org-2');
});
it('should scope team selection and current organization to --org', async () => {
const mockTeams = [
{
id: 'team-1',
name: 'Default',
slug: 'default',
organizationId: '1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
{
id: 'team-2',
name: 'Security Team',
slug: 'security',
organizationId: 'org-2',
createdAt: '2024-01-02',
updatedAt: '2024-01-02',
},
];
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key', '--org', 'org-2']);
expect(cloudConfig.setCurrentOrganization).toHaveBeenCalledWith('org-2');
expect(cloudConfig.cacheTeams).toHaveBeenCalledWith([mockTeams[1]], 'org-2');
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', 'org-2');
});
it('should prefer an exact team name over a slug match when --org and --team are provided', async () => {
const mockTeams = [
{
id: 'team-1',
name: 'Slug Match',
slug: 'shared',
organizationId: 'org-2',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
{
id: 'team-2',
name: 'Shared',
slug: 'shared-name',
organizationId: 'org-2',
createdAt: '2024-01-02',
updatedAt: '2024-01-02',
},
];
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync([
'node',
'test',
'--api-key',
'test-key',
'--org',
'org-2',
'--team',
'shared',
]);
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', 'org-2');
});
it('should log and persist the resolved organization when the default org has no teams', async () => {
const mockTeams = [
{
id: 'team-2',
name: 'Security Team',
slug: 'security',
organizationId: 'org-2',
createdAt: '2024-01-02',
updatedAt: '2024-01-02',
},
];
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
expect(cloudConfig.setCurrentOrganization).toHaveBeenCalledWith('org-2');
expect(cloudConfig.cacheTeams).toHaveBeenCalledWith([mockTeams[0]], 'org-2');
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', 'org-2');
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Organization:'));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('org-2'));
});
it('should fail login when --org does not match any accessible team organization', async () => {
vi.mocked(getUserTeams).mockResolvedValue([
{
id: 'team-1',
name: 'Default',
slug: 'default',
organizationId: '1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
]);
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key', '--org', 'missing-org']);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
"Authentication failed: Organization 'missing-org' not found in your accessible teams.",
),
);
expect(cloudConfig.saveValidatedApiToken).not.toHaveBeenCalled();
expect(setUserEmail).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it('should reject unknown --org when no teams are returned', async () => {
vi.mocked(getUserTeams).mockResolvedValue([]);
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key', '--org', 'missing-org']);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
"Authentication failed: Organization 'missing-org' not found in your accessible teams. Available organizations: 1",
),
);
expect(cloudConfig.saveValidatedApiToken).not.toHaveBeenCalled();
expect(setUserEmail).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it('should auto-select single team without prompting', async () => {
const mockTeams = [
{
id: 'team-1',
name: 'Only Team',
slug: 'only',
organizationId: '1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
];
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
expect(search).not.toHaveBeenCalled();
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-1', '1');
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Only Team'));
});
it('should prompt for team selection when multiple teams exist in interactive mode', async () => {
const mockTeams = [
{
id: 'team-1',
name: 'Default',
slug: 'default',
organizationId: '1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
{
id: 'team-2',
name: 'Security Team',
slug: 'security-alpha',
organizationId: '1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
];
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
vi.mocked(isNonInteractive).mockReturnValue(false);
vi.mocked(search).mockResolvedValue('team-2');
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
expect(search).toHaveBeenCalledWith({
message: 'Select a team to use:',
source: expect.any(Function),
});
const source = vi.mocked(search).mock.calls[0]?.[0].source;
const signal = new AbortController().signal;
await expect(source?.(undefined, { signal })).resolves.toEqual([
expect.objectContaining({ name: 'Default', value: 'team-1', description: 'default' }),
expect.objectContaining({
name: 'Security Team',
value: 'team-2',
description: 'security-alpha',
}),
]);
await expect(source?.('sec', { signal })).resolves.toEqual([
expect.objectContaining({
name: 'Security Team',
value: 'team-2',
description: 'security-alpha',
}),
]);
await expect(source?.('alpha', { signal })).resolves.toEqual([
expect.objectContaining({
name: 'Security Team',
value: 'team-2',
description: 'security-alpha',
}),
]);
await expect(source?.('DEFAULT', { signal })).resolves.toEqual([
expect.objectContaining({ name: 'Default', value: 'team-1', description: 'default' }),
]);
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', '1');
});
it('should use default team with warning in non-interactive mode when multiple teams exist', async () => {
const mockTeams = [
{
id: 'team-1',
name: 'Default',
slug: 'default',
organizationId: '1',
createdAt: '2024-01-02',
updatedAt: '2024-01-01',
},
{
id: 'team-2',
name: 'Security Team',
slug: 'security',
organizationId: '1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
];
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
vi.mocked(isNonInteractive).mockReturnValue(true);
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
expect(search).not.toHaveBeenCalled();
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', '1');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('You have access to 2 teams'),
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('--team flag'));
});
it('should fall back to default team when user cancels interactive selection', async () => {
const mockTeams = [
{
id: 'team-1',
name: 'Default',
slug: 'default',
organizationId: '1',
createdAt: '2024-01-02',
updatedAt: '2024-01-01',
},
{
id: 'team-2',
name: 'Security Team',
slug: 'security',
organizationId: '1',
createdAt: '2024-01-01',
updatedAt: '2024-01-01',
},
];
vi.mocked(getUserTeams).mockResolvedValue(mockTeams);
vi.mocked(isNonInteractive).mockReturnValue(false);
vi.mocked(search).mockRejectedValue(new Error('User cancelled'));
const loginCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'login');
await loginCmd?.parseAsync(['node', 'test', '--api-key', 'test-key']);
expect(cloudConfig.setCurrentTeamId).toHaveBeenCalledWith('team-2', '1');
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('(default)'));
});
});
describe('logout', () => {
it('should unset email and delete cloud config after logout', async () => {
vi.mocked(getUserEmail).mockReturnValue('test@example.com');
vi.mocked(cloudConfig.getApiKey).mockReturnValue('api-key');
const logoutCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'logout');
await logoutCmd?.parseAsync(['node', 'test']);
expect(cloudConfig.delete).toHaveBeenCalledWith();
expect(setUserEmail).toHaveBeenCalledWith('');
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Successfully logged out'));
});
it('should show "already logged out" message when no session exists', async () => {
vi.mocked(getUserEmail).mockReturnValue(null);
vi.mocked(cloudConfig.getApiKey).mockReturnValue(undefined);
const logoutCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'logout');
await logoutCmd?.parseAsync(['node', 'test']);
expect(cloudConfig.delete).not.toHaveBeenCalled();
expect(setUserEmail).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("You're already logged out"),
);
});
});
describe('whoami', () => {
it('should show user info when logged in', async () => {
vi.mocked(getUserEmail).mockReturnValue('test@example.com');
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-api-key');
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://api.example.com');
vi.mocked(cloudConfig.getAppUrl).mockReturnValue('https://app.example.com');
vi.mocked(getDefaultTeam).mockResolvedValueOnce({
id: 'team-1',
name: 'Default Team',
organizationId: 'org-1',
createdAt: '2023-01-01T00:00:00Z',
});
vi.mocked(fetchWithProxy).mockResolvedValueOnce(
createMockResponse({
ok: true,
body: {
user: mockCloudUser,
organization: mockOrganization,
},
}),
);
const whoamiCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'whoami');
await whoamiCmd?.parseAsync(['node', 'test']);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Currently logged in as:'));
});
it('should handle not logged in state', async () => {
// Reset logger mock before test
vi.mocked(logger.info).mockClear();
vi.mocked(getUserEmail).mockReturnValue(null);
vi.mocked(cloudConfig.getApiKey).mockReturnValue(undefined);
const whoamiCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'whoami');
await whoamiCmd?.parseAsync(['node', 'test']);
// Get the actual logged message
const infoMessages = vi.mocked(logger.info).mock.calls.map((call) => call[0]);
// Verify it contains our expected text
expect(infoMessages).toHaveLength(1);
expect(infoMessages[0]).toContain('Not logged in');
expect(infoMessages[0]).toContain('promptfoo auth login');
// No telemetry is recorded in this case (as per implementation)
});
it('should handle API error', async () => {
vi.mocked(getUserEmail).mockReturnValue('test@example.com');
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-api-key');
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://api.example.com');
vi.mocked(fetchWithProxy).mockResolvedValueOnce(
createMockResponse({
ok: false,
statusText: 'Internal Server Error',
}),
);
const whoamiCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'whoami');
await whoamiCmd?.parseAsync(['node', 'test']);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
'Failed to get user info: Failed to fetch user info: Internal Server Error',
),
);
expect(process.exitCode).toBe(1);
process.exitCode = 0;
});
it('should handle failed API response with empty body', async () => {
vi.mocked(getUserEmail).mockReturnValue('test@example.com');
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-api-key');
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://api.example.com');
// Mock response with an empty body to exercise error-body fallback handling.
vi.mocked(fetchWithProxy).mockResolvedValueOnce(
createMockResponse({
ok: false,
statusText: 'Internal Server Error',
// Providing no body or an empty body
}),
);
const whoamiCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'whoami');
await whoamiCmd?.parseAsync(['node', 'test']);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
'Failed to get user info: Failed to fetch user info: Internal Server Error',
),
);
expect(process.exitCode).toBe(1);
// Reset exitCode
process.exitCode = 0;
});
it('should handle non-Error object in the catch block', async () => {
vi.mocked(getUserEmail).mockReturnValue('test@example.com');
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-api-key');
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://api.example.com');
// Intentionally throw a non-Error value to exercise defensive error-message handling.
vi.mocked(fetchWithProxy).mockImplementationOnce(function () {
throw 'String error from fetch';
});
const whoamiCmd = program.commands
.find((cmd) => cmd.name() === 'auth')
?.commands.find((cmd) => cmd.name() === 'whoami');
await whoamiCmd?.parseAsync(['node', 'test']);
expect(logger.error).toHaveBeenCalledWith('Failed to get user info: String error from fetch');
expect(process.exitCode).toBe(1);
// Reset exitCode
process.exitCode = 0;
});
});
});
+285
View File
@@ -0,0 +1,285 @@
import confirm from '@inquirer/confirm';
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { configCommand } from '../../src/commands/config';
import { getUserEmail, setUserEmail } from '../../src/globalConfig/accounts';
import { cloudConfig } from '../../src/globalConfig/cloud';
import logger from '../../src/logger';
vi.mock('../../src/globalConfig/accounts');
vi.mock('../../src/globalConfig/cloud');
vi.mock('../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('@inquirer/confirm');
describe('config command', () => {
let program: Command;
beforeEach(() => {
vi.clearAllMocks();
program = new Command();
configCommand(program);
});
describe('set email', () => {
it('should not allow setting email when user is logged in', async () => {
// Mock logged in state
vi.mocked(cloudConfig.getApiKey).mockImplementation(function () {
return 'test-api-key';
});
// Execute set email command
const setEmailCmd = program.commands
.find((cmd) => cmd.name() === 'config')
?.commands.find((cmd) => cmd.name() === 'set')
?.commands.find((cmd) => cmd.name() === 'email');
await setEmailCmd?.parseAsync(['node', 'test', 'new@example.com']);
// Verify email was not set and error was shown
expect(setUserEmail).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
"Cannot update email while logged in. Email is managed through 'promptfoo auth login'",
),
);
expect(process.exitCode).toBe(1);
});
it('should allow setting email when user is not logged in', async () => {
// Mock logged out state
vi.mocked(cloudConfig.getApiKey).mockImplementation(function () {
return undefined;
});
// Execute set email command
const setEmailCmd = program.commands
.find((cmd) => cmd.name() === 'config')
?.commands.find((cmd) => cmd.name() === 'set')
?.commands.find((cmd) => cmd.name() === 'email');
await setEmailCmd?.parseAsync(['node', 'test', 'test@example.com']);
// Verify email was set
expect(setUserEmail).toHaveBeenCalledWith('test@example.com');
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Email set to test@example.com'),
);
});
it('should validate email format even when not logged in', async () => {
// Mock logged out state
vi.mocked(cloudConfig.getApiKey).mockImplementation(function () {
return undefined;
});
// Execute set email command with invalid email
const setEmailCmd = program.commands
.find((cmd) => cmd.name() === 'config')
?.commands.find((cmd) => cmd.name() === 'set')
?.commands.find((cmd) => cmd.name() === 'email');
await setEmailCmd?.parseAsync(['node', 'test', 'invalid-email']);
// Verify email was not set and error was shown
expect(setUserEmail).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Invalid email address'));
expect(process.exitCode).toBe(1);
});
});
describe('unset email', () => {
it('should not allow unsetting email when user is logged in', async () => {
// Mock logged in state
vi.mocked(cloudConfig.getApiKey).mockImplementation(function () {
return 'test-api-key';
});
vi.mocked(getUserEmail).mockImplementation(function () {
return 'test@example.com';
});
// Execute unset email command
const unsetEmailCmd = program.commands
.find((cmd) => cmd.name() === 'config')
?.commands.find((cmd) => cmd.name() === 'unset')
?.commands.find((cmd) => cmd.name() === 'email');
await unsetEmailCmd?.parseAsync(['node', 'test']);
// Verify email was not unset and error was shown
expect(setUserEmail).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
"Cannot update email while logged in. Email is managed through 'promptfoo auth login'",
),
);
expect(process.exitCode).toBe(1);
});
it('should allow unsetting email when user is not logged in with force flag', async () => {
// Mock logged out state
vi.mocked(cloudConfig.getApiKey).mockImplementation(function () {
return undefined;
});
vi.mocked(getUserEmail).mockImplementation(function () {
return 'test@example.com';
});
// Execute unset email command with force flag
const unsetEmailCmd = program.commands
.find((cmd) => cmd.name() === 'config')
?.commands.find((cmd) => cmd.name() === 'unset')
?.commands.find((cmd) => cmd.name() === 'email');
await unsetEmailCmd?.parseAsync(['node', 'test', '--force']);
// Verify email was unset
expect(setUserEmail).toHaveBeenCalledWith('');
expect(logger.info).toHaveBeenCalledWith('Email has been unset.');
});
it('should handle user confirmation for unsetting email', async () => {
// Mock logged out state and user confirmation
vi.mocked(cloudConfig.getApiKey).mockImplementation(function () {
return undefined;
});
vi.mocked(getUserEmail).mockImplementation(function () {
return 'test@example.com';
});
vi.mocked(confirm).mockResolvedValueOnce(true);
// Execute unset email command without force flag
const unsetEmailCmd = program.commands
.find((cmd) => cmd.name() === 'config')
?.commands.find((cmd) => cmd.name() === 'unset')
?.commands.find((cmd) => cmd.name() === 'email');
await unsetEmailCmd?.parseAsync(['node', 'test']);
// Verify email was unset after confirmation
expect(confirm).toHaveBeenCalledWith({
message: 'Are you sure you want to unset the email "test@example.com"?',
default: false,
});
expect(setUserEmail).toHaveBeenCalledWith('');
expect(logger.info).toHaveBeenCalledWith('Email has been unset.');
});
it('should handle user cancellation for unsetting email', async () => {
// Mock logged out state and user cancellation
vi.mocked(cloudConfig.getApiKey).mockImplementation(function () {
return undefined;
});
vi.mocked(getUserEmail).mockImplementation(function () {
return 'test@example.com';
});
vi.mocked(confirm).mockResolvedValueOnce(false);
// Execute unset email command without force flag
const unsetEmailCmd = program.commands
.find((cmd) => cmd.name() === 'config')
?.commands.find((cmd) => cmd.name() === 'unset')
?.commands.find((cmd) => cmd.name() === 'email');
await unsetEmailCmd?.parseAsync(['node', 'test']);
// Verify operation was cancelled
expect(setUserEmail).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Operation cancelled.');
});
it('should handle case when no email is set', async () => {
// Mock logged out state and no existing email
vi.mocked(cloudConfig.getApiKey).mockImplementation(function () {
return undefined;
});
vi.mocked(getUserEmail).mockImplementation(function () {
return null;
});
// Execute unset email command
const unsetEmailCmd = program.commands
.find((cmd) => cmd.name() === 'config')
?.commands.find((cmd) => cmd.name() === 'unset')
?.commands.find((cmd) => cmd.name() === 'email');
await unsetEmailCmd?.parseAsync(['node', 'test']);
// Verify appropriate message was shown
expect(logger.info).toHaveBeenCalledWith('No email is currently set.');
expect(setUserEmail).not.toHaveBeenCalled();
});
});
describe('get email', () => {
it('should show email when it exists', async () => {
// Mock existing email
vi.mocked(getUserEmail).mockImplementation(function () {
return 'test@example.com';
});
vi.mocked(cloudConfig.getApiKey).mockImplementation(function () {
return undefined;
});
// Execute get email command
const getEmailCmd = program.commands
.find((cmd) => cmd.name() === 'config')
?.commands.find((cmd) => cmd.name() === 'get')
?.commands.find((cmd) => cmd.name() === 'email');
await getEmailCmd?.parseAsync(['node', 'test']);
// Verify email was shown
expect(logger.info).toHaveBeenCalledWith('test@example.com');
});
it('should show message when no email is set', async () => {
// Mock no existing email
vi.mocked(getUserEmail).mockImplementation(function () {
return null;
});
vi.mocked(cloudConfig.getApiKey).mockImplementation(function () {
return undefined;
});
// Execute get email command
const getEmailCmd = program.commands
.find((cmd) => cmd.name() === 'config')
?.commands.find((cmd) => cmd.name() === 'get')
?.commands.find((cmd) => cmd.name() === 'email');
await getEmailCmd?.parseAsync(['node', 'test']);
// Verify message was shown
expect(logger.info).toHaveBeenCalledWith(
'No email set. Use "promptfoo config set email <email>" to set one.',
);
});
it('should show auth guidance when API key exists and no local email is set', async () => {
vi.mocked(getUserEmail).mockImplementation(function () {
return null;
});
vi.mocked(cloudConfig.getApiKey).mockImplementation(function () {
return 'test-api-key';
});
const getEmailCmd = program.commands
.find((cmd) => cmd.name() === 'config')
?.commands.find((cmd) => cmd.name() === 'get')
?.commands.find((cmd) => cmd.name() === 'email');
await getEmailCmd?.parseAsync(['node', 'test']);
expect(logger.info).toHaveBeenCalledWith(
"Email is managed through 'promptfoo auth login'. Run 'promptfoo auth whoami' to view the current account.",
);
});
});
});
+229
View File
@@ -0,0 +1,229 @@
import confirm from '@inquirer/confirm';
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { deleteCommand, handleEvalDelete, handleEvalDeleteAll } from '../../src/commands/delete';
import logger from '../../src/logger';
import Eval from '../../src/models/eval';
import * as database from '../../src/util/database';
import type { EvalWithMetadata } from '../../src/types/index';
vi.mock('@inquirer/confirm');
vi.mock('../../src/util/database');
vi.mock('../../src/logger');
vi.mock('../../src/models/eval');
describe('delete command', () => {
let program: Command;
beforeEach(() => {
program = new Command();
vi.resetAllMocks();
process.exitCode = undefined;
});
describe('handleEvalDelete', () => {
it('should successfully delete evaluation', async () => {
await handleEvalDelete('test-id');
expect(database.deleteEval).toHaveBeenCalledWith('test-id');
expect(logger.info).toHaveBeenCalledWith(
'Evaluation with ID test-id has been successfully deleted.',
);
});
it('should handle error when deleting evaluation', async () => {
const error = new Error('Delete failed');
vi.mocked(database.deleteEval).mockRejectedValueOnce(error);
await handleEvalDelete('test-id');
expect(logger.error).toHaveBeenCalledWith(
'Could not delete evaluation with ID test-id:\nError: Delete failed',
);
expect(process.exitCode).toBe(1);
});
});
describe('handleEvalDeleteAll', () => {
it('should delete all evaluations when confirmed', async () => {
vi.mocked(confirm).mockResolvedValueOnce(true);
await handleEvalDeleteAll();
expect(database.deleteAllEvals).toHaveBeenCalledWith();
expect(logger.info).toHaveBeenCalledWith('All evaluations have been deleted.');
});
it('should not delete evaluations when not confirmed', async () => {
vi.mocked(confirm).mockResolvedValueOnce(false);
await handleEvalDeleteAll();
expect(database.deleteAllEvals).not.toHaveBeenCalled();
});
});
describe('delete command', () => {
it('should handle eval deletion when resource exists', async () => {
const mockEval = {
id: 'test-id',
date: new Date(),
config: {},
version: 3,
timestamp: new Date().toISOString(),
results: {
version: 3,
timestamp: new Date().toISOString(),
results: [],
prompts: [],
stats: {
successes: 0,
failures: 0,
errors: 0,
duration: 0,
tokenUsage: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
},
assertions: {} as any,
},
},
},
prompts: [],
stats: {
successes: 0,
failures: 0,
errors: 0,
duration: 0,
tokenUsage: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
},
assertions: {} as any,
},
},
} as EvalWithMetadata;
vi.mocked(database.getEvalFromId).mockResolvedValueOnce(mockEval);
deleteCommand(program);
await program.parseAsync(['node', 'test', 'delete', 'test-id']);
expect(database.getEvalFromId).toHaveBeenCalledWith('test-id');
expect(database.deleteEval).toHaveBeenCalledWith('test-id');
});
it('should handle when resource does not exist', async () => {
vi.mocked(database.getEvalFromId).mockResolvedValueOnce(undefined);
deleteCommand(program);
await program.parseAsync(['node', 'test', 'delete', 'test-id']);
expect(logger.error).toHaveBeenCalledWith('No resource found with ID test-id');
expect(process.exitCode).toBe(1);
});
describe('eval subcommand', () => {
it('should handle latest eval deletion', async () => {
const mockLatestEval = {
createdAt: new Date(),
config: {},
results: [],
prompts: [],
assertions: [],
vars: {},
providers: [],
tests: [],
outputs: [],
sharing: false,
description: '',
nunjucksTemplates: {},
version: 3,
grading: {},
metrics: {},
stats: {
successes: 0,
failures: 0,
errors: 0,
duration: 0,
tokenUsage: {
total: 0,
prompt: 0,
completion: 0,
cached: 0,
numRequests: 0,
completionDetails: {
reasoning: 0,
acceptedPrediction: 0,
rejectedPrediction: 0,
},
assertions: {} as any,
},
},
duration: 0,
summary: '',
status: 'completed',
error: null,
testSuites: [],
maxConcurrency: 1,
repeat: 1,
table: [],
views: [],
isUnfinished: false,
isShared: false,
latestGrade: null,
latestScore: null,
id: 'latest-id',
} as any;
vi.mocked(Eval.latest).mockResolvedValueOnce(mockLatestEval);
deleteCommand(program);
await program.parseAsync(['node', 'test', 'delete', 'eval', 'latest']);
expect(database.deleteEval).toHaveBeenCalledWith('latest-id');
});
it('should handle when no latest eval exists', async () => {
vi.mocked(Eval.latest).mockResolvedValueOnce(undefined);
deleteCommand(program);
await program.parseAsync(['node', 'test', 'delete', 'eval', 'latest']);
expect(logger.error).toHaveBeenCalledWith('No eval found.');
expect(process.exitCode).toBe(1);
});
it('should handle all evals deletion', async () => {
vi.mocked(confirm).mockResolvedValueOnce(true);
deleteCommand(program);
await program.parseAsync(['node', 'test', 'delete', 'eval', 'all']);
expect(database.deleteAllEvals).toHaveBeenCalledWith();
});
it('should handle specific eval deletion', async () => {
deleteCommand(program);
await program.parseAsync(['node', 'test', 'delete', 'eval', 'specific-id']);
expect(database.deleteEval).toHaveBeenCalledWith('specific-id');
});
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { evalSetupCommand } from '../../src/commands/evalSetup';
import { getDefaultPort } from '../../src/constants';
import { startServer } from '../../src/server/server';
import telemetry from '../../src/telemetry';
import { setupEnv } from '../../src/util';
import { setConfigDirectoryPath } from '../../src/util/config/manage';
import { BrowserBehavior, checkServerRunning, openBrowser } from '../../src/util/server';
vi.mock('../../src/server/server');
vi.mock('../../src/telemetry');
vi.mock('../../src/util/server');
vi.mock('../../src/util', async (importOriginal) => {
return {
...(await importOriginal()),
setupEnv: vi.fn(),
};
});
vi.mock('../../src/util/config/manage', async (importOriginal) => {
return {
...(await importOriginal()),
setConfigDirectoryPath: vi.fn(),
};
});
describe('evalSetupCommand', () => {
let program: Command;
beforeEach(() => {
program = new Command();
vi.clearAllMocks();
});
it('should register the setup command with correct options', () => {
evalSetupCommand(program);
const setupCmd = program.commands.find((cmd) => cmd.name() === 'setup');
expect(setupCmd).toBeDefined();
expect(setupCmd?.description()).toBe('Start browser UI and open to eval setup');
expect(setupCmd?.opts().port).toBe(getDefaultPort().toString());
});
it('should handle setup command without directory', async () => {
vi.mocked(checkServerRunning).mockResolvedValue(false);
evalSetupCommand(program);
await program.parseAsync(['node', 'test', 'setup', '--port', '3000']);
expect(setupEnv).toHaveBeenCalledWith(undefined);
expect(telemetry.record).toHaveBeenCalledWith('eval setup', {});
expect(startServer).toHaveBeenCalledWith('3000', BrowserBehavior.OPEN_TO_EVAL_SETUP);
});
it('should handle setup command with directory', async () => {
vi.mocked(checkServerRunning).mockResolvedValue(false);
evalSetupCommand(program);
await program.parseAsync(['node', 'test', 'setup', 'test-dir', '--port', '3000']);
expect(setConfigDirectoryPath).toHaveBeenCalledWith('test-dir');
expect(startServer).toHaveBeenCalledWith('3000', BrowserBehavior.OPEN_TO_EVAL_SETUP);
});
it('should open browser if server is already running', async () => {
vi.mocked(checkServerRunning).mockResolvedValue(true);
evalSetupCommand(program);
await program.parseAsync(['node', 'test', 'setup']);
expect(openBrowser).toHaveBeenCalledWith(BrowserBehavior.OPEN_TO_EVAL_SETUP);
expect(startServer).not.toHaveBeenCalled();
});
it('should handle setup command with env file path', async () => {
vi.mocked(checkServerRunning).mockResolvedValue(false);
evalSetupCommand(program);
await program.parseAsync(['node', 'test', 'setup', '--env-file', '.env.test']);
expect(setupEnv).toHaveBeenCalledWith('.env.test');
});
});
+352
View File
@@ -0,0 +1,352 @@
import { EventEmitter } from 'events';
import fs from 'fs';
import fsPromises from 'fs/promises';
import zlib from 'zlib';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, Mocked, vi } from 'vitest';
import { exportCommand } from '../../src/commands/export';
import logger from '../../src/logger';
import Eval from '../../src/models/eval';
import { getLogDirectory, getLogFiles } from '../../src/util/logs';
import { writeOutput } from '../../src/util/output';
vi.mock('../../src/telemetry', () => ({
default: {
record: vi.fn(),
},
}));
vi.mock('../../src/util/output', async (importOriginal) => {
return {
...(await importOriginal()),
writeOutput: vi.fn(),
createOutputData: vi.fn().mockImplementation(async (evalRecord, shareableUrl) => ({
evalId: evalRecord.id,
results: await evalRecord.toEvaluateSummary(),
config: evalRecord.config,
shareableUrl,
metadata: {
promptfooVersion: '1.0.0',
nodeVersion: 'v20.0.0',
platform: 'linux',
arch: 'x64',
exportedAt: '2025-07-01T00:00:00.000Z',
evaluationCreatedAt: '2025-07-01T00:00:00.000Z',
author: 'test-author',
},
})),
};
});
vi.mock('../../src/logger', () => ({
default: {
info: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../../src/util/config/manage', () => ({
getConfigDirectoryPath: vi.fn().mockReturnValue('/tmp/test-config'),
maybeReadConfig: vi.fn(),
readConfigs: vi.fn(),
writeMultipleOutputs: vi.fn(),
}));
vi.mock('../../src/util/logs', () => ({
getLogDirectory: vi.fn().mockReturnValue('/tmp/test-config/logs'),
getLogFiles: vi.fn().mockResolvedValue([]),
}));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
default: {
...actual,
createWriteStream: vi.fn(),
},
createWriteStream: vi.fn(),
};
});
vi.mock('fs/promises', () => ({
default: {
access: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn(),
stat: vi.fn(),
},
access: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn(),
stat: vi.fn(),
}));
vi.mock('zlib', async (importOriginal) => {
const actual = await importOriginal<typeof import('zlib')>();
return {
...actual,
default: {
...actual,
createGzip: vi.fn(),
},
createGzip: vi.fn(),
gzip: vi.fn(),
};
});
vi.mock('../../src/database', async (importOriginal) => {
return {
...(await importOriginal()),
getDbInstance: vi.fn(),
};
});
describe('exportCommand', () => {
let program: Command;
let mockEval: any;
const mockFsPromises = fsPromises as Mocked<typeof fsPromises>;
beforeEach(() => {
program = new Command();
process.exitCode = 0;
mockEval = {
id: 'test-id',
createdAt: '2025-07-01T00:00:00.000Z',
author: 'test-author',
config: { test: 'config' },
toEvaluateSummary: vi.fn().mockResolvedValue({ test: 'summary' }),
};
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-07-01T00:00:00.000Z'));
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
process.exitCode = 0;
});
it('should export latest eval record', async () => {
vi.spyOn(Eval, 'latest').mockResolvedValue(mockEval);
exportCommand(program);
await program.parseAsync(['node', 'test', 'export', 'eval', 'latest', '--output', 'test.json']);
expect(Eval.latest).toHaveBeenCalledWith();
expect(writeOutput).toHaveBeenCalledWith('test.json', mockEval, null, {
includeMedia: false,
});
expect(process.exitCode).toBe(0);
});
it('should export eval record by id', async () => {
vi.spyOn(Eval, 'findById').mockResolvedValue(mockEval);
exportCommand(program);
await program.parseAsync([
'node',
'test',
'export',
'eval',
'test-id',
'--output',
'test.json',
]);
expect(Eval.findById).toHaveBeenCalledWith('test-id');
expect(writeOutput).toHaveBeenCalledWith('test.json', mockEval, null, {
includeMedia: false,
});
expect(process.exitCode).toBe(0);
});
it('should pass embedded media opt-in to file exports', async () => {
vi.spyOn(Eval, 'findById').mockResolvedValue(mockEval);
exportCommand(program);
await program.parseAsync([
'node',
'test',
'export',
'eval',
'test-id',
'--include-media',
'--output',
'test.json',
]);
expect(writeOutput).toHaveBeenCalledWith('test.json', mockEval, null, {
includeMedia: true,
});
});
it('should log JSON data when no output specified', async () => {
vi.spyOn(Eval, 'findById').mockResolvedValue(mockEval);
exportCommand(program);
await program.parseAsync(['node', 'test', 'export', 'eval', 'test-id']);
const expectedJson = {
evalId: 'test-id',
results: { test: 'summary' },
config: { test: 'config' },
shareableUrl: null,
metadata: {
promptfooVersion: '1.0.0',
nodeVersion: 'v20.0.0',
platform: 'linux',
arch: 'x64',
exportedAt: '2025-07-01T00:00:00.000Z',
evaluationCreatedAt: '2025-07-01T00:00:00.000Z',
author: 'test-author',
},
};
expect(logger.info).toHaveBeenCalledWith(JSON.stringify(expectedJson, null, 2));
});
it('should exit with error when eval not found', async () => {
vi.spyOn(Eval, 'findById').mockResolvedValue(undefined);
exportCommand(program);
await program.parseAsync(['node', 'test', 'export', 'eval', 'non-existent-id']);
expect(process.exitCode).toBe(1);
});
it('should handle export errors', async () => {
vi.spyOn(Eval, 'findById').mockRejectedValue(new Error('Export failed'));
exportCommand(program);
await program.parseAsync(['node', 'test', 'export', 'eval', 'test-id']);
expect(process.exitCode).toBe(1);
});
describe('logs export', () => {
const mockLogDir = '/test/config/logs';
const mockGetLogDirectory = vi.mocked(getLogDirectory);
const mockGetLogFiles = vi.mocked(getLogFiles);
beforeEach(() => {
mockGetLogDirectory.mockReturnValue(mockLogDir);
mockGetLogFiles.mockResolvedValue([]);
mockFsPromises.access.mockResolvedValue(undefined);
// Reset all mocks for clean state
vi.clearAllMocks();
});
it('should handle missing log directory', async () => {
mockFsPromises.access.mockRejectedValue(new Error('ENOENT'));
exportCommand(program);
await program.parseAsync(['node', 'test', 'export', 'logs']);
expect(logger.error).toHaveBeenCalledWith(
`No log directory found. Logs are created when running commands like "promptfoo eval".\nLog directory: ${mockLogDir}`,
);
expect(process.exitCode).toBe(1);
});
it('should handle no log files found', async () => {
mockGetLogFiles.mockResolvedValue([]);
exportCommand(program);
await program.parseAsync(['node', 'test', 'export', 'logs']);
expect(logger.error).toHaveBeenCalledWith(
`No log files found in the logs directory. Logs are created when running commands like "promptfoo eval".\nLog directory: ${mockLogDir}`,
);
expect(process.exitCode).toBe(1);
});
it('should handle invalid count parameter', async () => {
mockGetLogFiles.mockResolvedValue([
{
name: 'promptfoo-debug-2025-01-01.log',
path: '/test/config/logs/promptfoo-debug-2025-01-01.log',
mtime: new Date(),
type: 'debug',
size: 1024,
},
]);
exportCommand(program);
await program.parseAsync(['node', 'test', 'export', 'logs', '--count', 'invalid']);
expect(logger.error).toHaveBeenCalledWith('Count must be a positive number');
expect(process.exitCode).toBe(1);
});
it('should handle zero count parameter', async () => {
mockGetLogFiles.mockResolvedValue([
{
name: 'promptfoo-debug-2025-01-01.log',
path: '/test/config/logs/promptfoo-debug-2025-01-01.log',
mtime: new Date(),
type: 'debug',
size: 1024,
},
]);
exportCommand(program);
await program.parseAsync(['node', 'test', 'export', 'logs', '--count', '0']);
expect(logger.error).toHaveBeenCalledWith('Count must be a positive number');
expect(process.exitCode).toBe(1);
});
it('should archive log files using async fs helpers', async () => {
const logPath = '/test/config/logs/promptfoo-debug-2025-01-01.log';
const output = new EventEmitter() as EventEmitter & {
on: typeof EventEmitter.prototype.on;
};
const gzip = {
end: vi.fn(() => output.emit('close')),
pipe: vi.fn(),
write: vi.fn(),
on: vi.fn(),
};
vi.mocked(fs.createWriteStream).mockReturnValue(output as unknown as fs.WriteStream);
vi.mocked(zlib.createGzip).mockReturnValue(gzip as unknown as zlib.Gzip);
mockGetLogFiles.mockResolvedValue([
{
name: 'promptfoo-debug-2025-01-01.log',
path: logPath,
mtime: new Date('2025-01-01T00:00:00.000Z'),
type: 'debug',
size: 12,
},
]);
mockFsPromises.readFile.mockResolvedValue(Buffer.from('hello logs'));
mockFsPromises.stat.mockImplementation(async (filePath) => {
if (filePath === 'logs.gz') {
return { size: 123 } as fs.Stats;
}
return {
size: 10,
mtime: new Date('2025-01-01T00:00:00.000Z'),
} as fs.Stats;
});
exportCommand(program);
await program.parseAsync(['node', 'test', 'export', 'logs', '--output', 'logs.gz']);
expect(mockFsPromises.readFile).toHaveBeenCalledWith(logPath);
expect(mockFsPromises.stat).toHaveBeenCalledWith(logPath);
expect(logger.info).toHaveBeenCalledWith('Log files have been collected in: logs.gz');
});
});
});
+233
View File
@@ -0,0 +1,233 @@
import fs from 'fs';
import * as yaml from 'js-yaml';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { synthesizeFromTestSuite } from '../../../src/assertions/synthesis';
import { disableCache } from '../../../src/cache';
import { doGenerateAssertions } from '../../../src/commands/generate/assertions';
import telemetry from '../../../src/telemetry';
import { resolveConfigs } from '../../../src/util/config/load';
import { loadYaml } from '../../../src/util/yamlLoad';
import type { Assertion, TestSuite } from '../../../src/types/index';
const fsMocks = vi.hoisted(() => ({
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
existsSync: vi.fn(),
}));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
default: {
...actual,
...fsMocks,
},
...fsMocks,
};
});
vi.mock('fs/promises', () => ({
default: {
readFile: fsMocks.readFileSync,
writeFile: fsMocks.writeFileSync,
},
readFile: fsMocks.readFileSync,
writeFile: fsMocks.writeFileSync,
}));
vi.mock('js-yaml');
vi.mock('../../../src/util/yamlLoad');
vi.mock('../../../src/assertions/synthesis');
vi.mock('../../../src/util/config/load', () => ({
resolveConfigs: vi.fn(),
}));
vi.mock('../../../src/cache', () => ({
disableCache: vi.fn(),
}));
vi.mock('../../../src/logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
child: vi.fn().mockReturnValue({}),
},
}));
vi.mock('../../../src/telemetry', () => ({
default: {
record: vi.fn(),
send: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('../../../src/util', () => ({
printBorder: vi.fn(),
setupEnv: vi.fn(),
}));
vi.mock('../../../src/util/promptfooCommand', () => ({
promptfooCommand: vi.fn().mockReturnValue('promptfoo eval'),
detectInstaller: vi.fn().mockReturnValue('unknown'),
isRunningUnderNpx: vi.fn().mockReturnValue(false),
}));
describe('assertion generation', () => {
beforeAll(() => {
vi.useFakeTimers();
});
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
afterAll(() => {
vi.useRealTimers();
});
describe('doGenerateAssertions', () => {
const mockTestSuite: TestSuite = {
prompts: [{ raw: 'test prompt', label: 'test' }],
tests: [
{
assert: [
{
type: 'llm-rubric',
value: 'test question',
},
],
},
],
providers: [
{
id: () => 'test-provider',
callApi: vi.fn() as any,
},
],
};
const mockResults: Assertion[] = [
{ type: 'pi', value: 'additional assertion' },
{ type: 'pi', value: 'additional assertion 2' },
];
beforeEach(() => {
vi.mocked(synthesizeFromTestSuite).mockResolvedValue(mockResults);
vi.mocked(yaml.dump).mockReturnValue('yaml content');
vi.mocked(loadYaml).mockReturnValue(mockTestSuite);
vi.mocked(fs.readFileSync).mockReturnValue('mock config content');
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
vi.mocked(disableCache).mockImplementation(() => undefined);
vi.mocked(telemetry.record).mockImplementation(() => undefined);
vi.mocked(resolveConfigs).mockResolvedValue({
testSuite: mockTestSuite,
config: {},
basePath: '',
});
});
it('should write YAML output', async () => {
const configPath = 'config.yaml';
await doGenerateAssertions({
cache: true,
config: configPath,
numAssertions: '2',
type: 'pi',
output: 'output.yaml',
write: false,
defaultConfig: {},
defaultConfigPath: configPath,
});
expect(fs.writeFileSync).toHaveBeenCalledWith('output.yaml', 'yaml content');
});
it('should throw error for unsupported file type', async () => {
const configPath = 'config.yaml';
await expect(
doGenerateAssertions({
cache: true,
config: configPath,
numAssertions: '2',
type: 'pi',
output: 'output.txt',
write: false,
defaultConfig: {},
defaultConfigPath: configPath,
}),
).rejects.toThrow('Unsupported output file type: output.txt');
});
it('should write to config file when write option is true', async () => {
const configPath = 'config.yaml';
await doGenerateAssertions({
cache: true,
config: configPath,
numAssertions: '2',
type: 'pi',
write: true,
defaultConfig: {},
defaultConfigPath: configPath,
});
expect(fs.writeFileSync).toHaveBeenCalledWith(configPath, expect.any(String));
});
it('should throw error when no config file found', async () => {
await expect(
doGenerateAssertions({
cache: true,
numAssertions: '2',
type: 'pi',
write: false,
defaultConfig: {},
defaultConfigPath: undefined,
}),
).rejects.toThrow('Could not find a config file');
});
it('should handle output without file extension', async () => {
const configPath = 'config.yaml';
await expect(
doGenerateAssertions({
cache: true,
config: configPath,
numAssertions: '2',
type: 'pi',
output: 'output',
write: false,
defaultConfig: {},
defaultConfigPath: configPath,
}),
).rejects.toThrow('Unsupported output file type: output');
});
it('should handle synthesis errors', async () => {
vi.mocked(synthesizeFromTestSuite).mockRejectedValue(new Error('Synthesis failed'));
const configPath = 'config.yaml';
await expect(
doGenerateAssertions({
cache: true,
config: configPath,
numAssertions: '2',
type: 'pi',
output: 'output.yaml',
write: false,
defaultConfig: {},
defaultConfigPath: configPath,
}),
).rejects.toThrow('Synthesis failed');
});
});
});
+239
View File
@@ -0,0 +1,239 @@
import fs from 'fs';
import * as yaml from 'js-yaml';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { disableCache } from '../../../src/cache';
import { doGenerateDataset } from '../../../src/commands/generate/dataset';
import telemetry from '../../../src/telemetry';
import { synthesizeFromTestSuite } from '../../../src/testCase/synthesis';
import { resolveConfigs } from '../../../src/util/config/load';
import { loadYaml } from '../../../src/util/yamlLoad';
import type { TestSuite, VarMapping } from '../../../src/types/index';
const fsMocks = vi.hoisted(() => ({
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
existsSync: vi.fn(),
}));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
default: {
...actual,
...fsMocks,
},
...fsMocks,
};
});
vi.mock('fs/promises', () => ({
default: {
readFile: fsMocks.readFileSync,
writeFile: fsMocks.writeFileSync,
},
readFile: fsMocks.readFileSync,
writeFile: fsMocks.writeFileSync,
}));
vi.mock('js-yaml');
vi.mock('../../../src/util/yamlLoad');
vi.mock('../../../src/testCase/synthesis');
vi.mock('../../../src/util/config/load', () => ({
resolveConfigs: vi.fn(),
}));
vi.mock('../../../src/cache', () => ({
disableCache: vi.fn(),
}));
vi.mock('../../../src/logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
child: vi.fn().mockReturnValue({}),
},
}));
vi.mock('../../../src/telemetry', () => ({
default: {
record: vi.fn(),
send: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock('../../../src/util', () => ({
printBorder: vi.fn(),
setupEnv: vi.fn(),
}));
vi.mock('../../../src/util/promptfooCommand', () => ({
promptfooCommand: vi.fn().mockReturnValue('promptfoo eval'),
detectInstaller: vi.fn().mockReturnValue('unknown'),
isRunningUnderNpx: vi.fn().mockReturnValue(false),
}));
describe('dataset generation', () => {
beforeAll(() => {
vi.useFakeTimers();
});
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
afterAll(() => {
vi.useRealTimers();
});
describe('doGenerateDataset', () => {
const mockTestSuite: TestSuite = {
prompts: [{ raw: 'test prompt', label: 'test' }],
tests: [],
providers: [
{
id: () => 'test-provider',
callApi: vi.fn() as any,
},
],
};
const mockResults: VarMapping[] = [{ var1: 'value1' }, { var1: 'value2' }];
beforeEach(() => {
vi.mocked(synthesizeFromTestSuite).mockResolvedValue(mockResults);
vi.mocked(yaml.dump).mockReturnValue('yaml content');
vi.mocked(loadYaml).mockReturnValue(mockTestSuite);
vi.mocked(fs.readFileSync).mockReturnValue('mock config content');
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
vi.mocked(disableCache).mockImplementation(() => undefined);
vi.mocked(telemetry.record).mockResolvedValue(undefined as never);
vi.mocked(resolveConfigs).mockResolvedValue({
testSuite: mockTestSuite,
config: {},
basePath: '',
});
});
it('should write YAML output', async () => {
const configPath = 'config.yaml';
await doGenerateDataset({
cache: true,
config: configPath,
numPersonas: '5',
numTestCasesPerPersona: '3',
output: 'output.yaml',
write: false,
defaultConfig: {},
defaultConfigPath: configPath,
});
expect(fs.writeFileSync).toHaveBeenCalledWith('output.yaml', 'yaml content');
});
it('should write CSV output', async () => {
const configPath = 'config.yaml';
await doGenerateDataset({
cache: true,
config: configPath,
numPersonas: '5',
numTestCasesPerPersona: '3',
output: 'output.csv',
write: false,
defaultConfig: {},
defaultConfigPath: configPath,
});
const expectedCsv = 'var1\n"value1"\n"value2"\n';
expect(fs.writeFileSync).toHaveBeenCalledWith('output.csv', expectedCsv);
});
it('should throw error for unsupported file type', async () => {
const configPath = 'config.yaml';
await expect(
doGenerateDataset({
cache: true,
config: configPath,
numPersonas: '5',
numTestCasesPerPersona: '3',
output: 'output.txt',
write: false,
defaultConfig: {},
defaultConfigPath: configPath,
}),
).rejects.toThrow('Unsupported output file type: output.txt');
});
it('should write to config file when write option is true', async () => {
const configPath = 'config.yaml';
await doGenerateDataset({
cache: true,
config: configPath,
numPersonas: '5',
numTestCasesPerPersona: '3',
write: true,
defaultConfig: {},
defaultConfigPath: configPath,
});
expect(fs.writeFileSync).toHaveBeenCalledWith(configPath, expect.any(String));
});
it('should throw error when no config file found', async () => {
await expect(
doGenerateDataset({
cache: true,
numPersonas: '5',
numTestCasesPerPersona: '3',
write: false,
defaultConfig: {},
defaultConfigPath: undefined,
}),
).rejects.toThrow('Could not find a config file');
});
it('should handle output without file extension', async () => {
const configPath = 'config.yaml';
await expect(
doGenerateDataset({
cache: true,
config: configPath,
numPersonas: '5',
numTestCasesPerPersona: '3',
output: 'output',
write: false,
defaultConfig: {},
defaultConfigPath: configPath,
}),
).rejects.toThrow('Unsupported output file type: output');
});
it('should handle synthesis errors', async () => {
vi.mocked(synthesizeFromTestSuite).mockRejectedValue(new Error('Synthesis failed'));
const configPath = 'config.yaml';
await expect(
doGenerateDataset({
cache: true,
config: configPath,
numPersonas: '5',
numTestCasesPerPersona: '3',
output: 'output.yaml',
write: false,
defaultConfig: {},
defaultConfigPath: configPath,
}),
).rejects.toThrow('Synthesis failed');
});
});
});
File diff suppressed because it is too large Load Diff
+581
View File
@@ -0,0 +1,581 @@
import fs from 'fs/promises';
import confirm from '@inquirer/confirm';
import select from '@inquirer/select';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as init from '../../src/commands/init';
import logger from '../../src/logger';
import { fetchWithProxy } from '../../src/util/fetch/index';
import { createMockResponse } from '../util/utils';
vi.mock('../../src/redteam/commands/init', async (importOriginal) => {
return {
...(await importOriginal()),
redteamInit: vi.fn(),
};
});
vi.mock('../../src/server/server', async (importOriginal) => {
return {
...(await importOriginal()),
startServer: vi.fn(),
BrowserBehavior: {
ASK: 0,
OPEN: 1,
SKIP: 2,
OPEN_TO_REPORT: 3,
OPEN_TO_REDTEAM_CREATE: 4,
},
};
});
vi.mock('../../src/util/fetch/index', async (importOriginal) => {
return {
...(await importOriginal()),
fetchWithProxy: vi.fn(),
};
});
vi.mock('fs/promises');
vi.mock('../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('path', async () => ({
...(await vi.importActual('path')),
resolve: vi.fn(),
}));
vi.mock('../../src/constants');
vi.mock('../../src/onboarding');
vi.mock('../../src/telemetry');
vi.mock('@inquirer/confirm');
vi.mock('@inquirer/input');
vi.mock('@inquirer/select');
const mockFetchWithProxy = vi.mocked(fetchWithProxy);
describe('init command', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchWithProxy.mockReset();
vi.mocked(confirm).mockReset();
vi.mocked(select).mockReset();
vi.mocked(fs.access).mockReset();
vi.mocked(fs.mkdir).mockReset();
vi.mocked(fs.readdir).mockReset();
vi.mocked(fs.rm).mockReset();
vi.mocked(fs.writeFile).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('downloadFile', () => {
it('should download a file successfully', async () => {
const mockResponse = createMockResponse({
ok: true,
status: 200,
text: () => Promise.resolve('file content'),
});
mockFetchWithProxy.mockResolvedValue(mockResponse);
await init.downloadFile('https://example.com/file.txt', '/path/to/file.txt');
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://example.com/file.txt');
expect(fs.writeFile).toHaveBeenCalledWith('/path/to/file.txt', 'file content');
});
it('should throw an error if download fails', async () => {
const mockResponse = createMockResponse({
ok: false,
status: 404,
statusText: 'Not Found',
});
mockFetchWithProxy.mockResolvedValue(mockResponse);
await expect(
init.downloadFile('https://example.com/file.txt', '/path/to/file.txt'),
).rejects.toThrow('Failed to download file: Not Found');
});
it('should handle network errors', async () => {
mockFetchWithProxy.mockRejectedValue(new Error('Network error'));
await expect(
init.downloadFile('https://example.com/file.txt', '/path/to/file.txt'),
).rejects.toThrow('Network error');
});
});
describe('downloadDirectory', () => {
it('should throw an error if fetching directory contents fails on both VERSION and main', async () => {
const mockResponse = createMockResponse({
ok: false,
statusText: 'Not Found',
});
mockFetchWithProxy.mockResolvedValueOnce(mockResponse).mockResolvedValueOnce(mockResponse);
await expect(init.downloadDirectory('example', '/path/to/target')).rejects.toThrow(
'Failed to fetch directory contents for refs:',
);
expect(mockFetchWithProxy).toHaveBeenCalledTimes(2);
expect(mockFetchWithProxy.mock.calls[0][0]).toContain('?ref=');
expect(mockFetchWithProxy.mock.calls[1][0]).toContain('?ref=main');
});
it('should succeed if VERSION fails but main succeeds', async () => {
const mockFailedResponse = createMockResponse({
ok: false,
statusText: 'Not Found',
});
const mockSuccessResponse = createMockResponse({
ok: true,
json: () => Promise.resolve([]),
});
mockFetchWithProxy
.mockResolvedValueOnce(mockFailedResponse)
.mockResolvedValueOnce(mockSuccessResponse);
await init.downloadDirectory('example', '/path/to/target');
expect(mockFetchWithProxy).toHaveBeenCalledTimes(2);
expect(mockFetchWithProxy.mock.calls[0][0]).toContain('?ref=');
expect(mockFetchWithProxy.mock.calls[1][0]).toContain('?ref=main');
});
it('should handle network errors', async () => {
mockFetchWithProxy.mockRejectedValue(new Error('Network error'));
await expect(init.downloadDirectory('example', '/path/to/target')).rejects.toThrow(
'Network error',
);
});
});
describe('downloadExample', () => {
it('should throw an error if directory creation fails', async () => {
vi.spyOn(fs, 'mkdir').mockRejectedValue(new Error('Permission denied'));
await expect(init.downloadExample('example', '/path/to/target')).rejects.toThrow(
'Failed to download example: Permission denied',
);
});
it('should throw an error if downloadDirectory fails', async () => {
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined);
// Mock fetch to simulate downloadDirectory failure
mockFetchWithProxy.mockRejectedValue(new Error('Network error'));
await expect(init.downloadExample('example', '/path/to/target')).rejects.toThrow(
'Failed to download example: Network error',
);
});
});
describe('getExamplesList', () => {
it('should return a list of examples', async () => {
const mockResponse = createMockResponse({
ok: true,
status: 200,
json: () =>
Promise.resolve({
tree: [
{ path: 'examples/provider-http/basic/promptfooconfig.yaml', type: 'blob' },
{ path: 'examples/provider-http/README.md', type: 'blob' },
{ path: 'examples/eval-json-output/promptfooconfig.yaml', type: 'blob' },
{ path: 'examples/provider-http/basic/server.js', type: 'blob' },
],
}),
});
mockFetchWithProxy.mockResolvedValue(mockResponse);
const examples = await init.getExamplesList();
expect(examples).toEqual(['eval-json-output', 'provider-http/basic']);
});
it('should fall back to main when VERSION tree request fails', async () => {
const mockVersionFailure = createMockResponse({
ok: false,
status: 404,
statusText: 'Not Found',
});
const mockMainSuccess = createMockResponse({
ok: true,
status: 200,
json: () =>
Promise.resolve({
tree: [{ path: 'examples/config-js/promptfooconfig.js', type: 'blob' }],
}),
});
mockFetchWithProxy
.mockResolvedValueOnce(mockVersionFailure)
.mockResolvedValueOnce(mockMainSuccess);
const examples = await init.getExamplesList();
expect(examples).toEqual(['config-js']);
expect(mockFetchWithProxy).toHaveBeenCalledTimes(2);
expect(mockFetchWithProxy.mock.calls[1][0]).toContain('/git/trees/main?recursive=1');
});
it('should return an empty array if fetching fails', async () => {
const mockVersionFailure = createMockResponse({
ok: false,
status: 404,
statusText: 'Not Found',
});
const mockMainFailure = createMockResponse({
ok: false,
status: 404,
statusText: 'Not Found',
});
mockFetchWithProxy
.mockResolvedValueOnce(mockVersionFailure)
.mockResolvedValueOnce(mockMainFailure);
const examples = await init.getExamplesList();
expect(examples).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Not Found'));
});
it('should handle network errors', async () => {
mockFetchWithProxy.mockRejectedValue(new Error('Network error'));
const examples = await init.getExamplesList();
expect(examples).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Network error'));
});
});
describe('handleExampleDownload', () => {
describe('alias resolution', () => {
it('should resolve old example name to new name via EXAMPLE_ALIASES', async () => {
// Download will fail, but we're testing alias resolution, not download
mockFetchWithProxy.mockRejectedValue(new Error('404 Not Found'));
vi.mocked(confirm).mockResolvedValue(false);
await init.handleExampleDownload('.', 'custom-provider');
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("'custom-provider' has been renamed to 'provider-custom/basic'"),
);
});
it('should pass through unknown example names without logging rename message', async () => {
mockFetchWithProxy.mockRejectedValue(new Error('404 Not Found'));
vi.mocked(confirm).mockResolvedValue(false);
await init.handleExampleDownload('.', 'some-unknown-example');
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('has been renamed'));
});
it('should show replacement messaging for removed examples', async () => {
mockFetchWithProxy.mockRejectedValue(new Error('404 Not Found'));
vi.mocked(confirm).mockResolvedValue(false);
await init.handleExampleDownload('.', 'dbrx-benchmark');
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('dbrx-benchmark was removed because DBRX is no longer available'),
);
});
it('should download using the resolved alias name', async () => {
mockFetchWithProxy.mockRejectedValue(new Error('404 Not Found'));
vi.mocked(confirm).mockResolvedValue(false);
const result = await init.handleExampleDownload('.', 'custom-provider');
// The resolved name should be used, not the alias
expect(result).toEqual('provider-custom/basic');
});
it('should map legacy root slugs to runnable subdirectory examples', async () => {
mockFetchWithProxy.mockRejectedValue(new Error('404 Not Found'));
vi.mocked(confirm).mockResolvedValue(false);
const amazonBedrockResult = await init.handleExampleDownload('.', 'amazon-bedrock');
const xaiResult = await init.handleExampleDownload('.', 'xai');
const openSourceResult = await init.handleExampleDownload('.', 'open-source-comparison');
const opencodeResult = await init.handleExampleDownload('.', 'opencode-sdk');
expect(amazonBedrockResult).toEqual('amazon-bedrock/models');
expect(xaiResult).toEqual('xai/chat');
expect(openSourceResult).toEqual('compare-open-source-models');
expect(opencodeResult).toEqual('provider-opencode-sdk/basic');
});
it('should preserve legacy GPT model comparison example names', async () => {
mockFetchWithProxy.mockRejectedValue(new Error('404 Not Found'));
vi.mocked(confirm).mockResolvedValue(false);
const legacyFolderResult = await init.handleExampleDownload(
'.',
'compare-gpt-4o-vs-4o-mini',
);
const legacyAliasResult = await init.handleExampleDownload('.', 'gpt-4o-vs-4o-mini');
const legacyMmluResult = await init.handleExampleDownload(
'.',
'compare-gpt-5-vs-gpt-5-mini-mmlu',
);
const agnosticMmluAliasResult = await init.handleExampleDownload(
'.',
'compare-gpt-mmlu-pro',
);
const shortMmluAliasResult = await init.handleExampleDownload('.', 'gpt-mmlu-pro');
const modelTiersMmluAliasResult = await init.handleExampleDownload(
'.',
'gpt-model-tiers-mmlu-pro',
);
expect(legacyFolderResult).toEqual('compare-gpt-model-tiers');
expect(legacyAliasResult).toEqual('compare-gpt-model-tiers');
expect(legacyMmluResult).toEqual('compare-gpt-model-tiers-mmlu-pro');
expect(agnosticMmluAliasResult).toEqual('compare-gpt-model-tiers-mmlu-pro');
expect(shortMmluAliasResult).toEqual('compare-gpt-model-tiers-mmlu-pro');
expect(modelTiersMmluAliasResult).toEqual('compare-gpt-model-tiers-mmlu-pro');
});
it('should use legacy ref for removed examples', async () => {
const mockFailure = createMockResponse({
ok: false,
status: 404,
statusText: 'Not Found',
});
mockFetchWithProxy.mockResolvedValue(mockFailure);
vi.mocked(confirm).mockResolvedValue(false);
const result = await init.handleExampleDownload('.', 'assistant-cli');
expect(result).toEqual('assistant-cli');
expect(mockFetchWithProxy.mock.calls[0][0]).toContain(
'/repos/promptfoo/promptfoo/contents/examples/assistant-cli?ref=0.120.26',
);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('assistant-cli was removed'),
);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining("legacy 'assistant-cli' example from promptfoo@0.120.26"),
);
});
it('should reset to default refs when retrying after legacy example failure', async () => {
const mockLegacyFailure = createMockResponse({
ok: false,
status: 404,
statusText: 'Not Found',
});
const mockTreeResponse = createMockResponse({
ok: true,
status: 200,
json: () =>
Promise.resolve({
tree: [{ path: 'examples/provider-http/basic/promptfooconfig.yaml', type: 'blob' }],
}),
});
const mockDefaultRefFailure = createMockResponse({
ok: false,
status: 404,
statusText: 'Not Found',
});
const mockMainRefSuccess = createMockResponse({
ok: true,
json: () => Promise.resolve([]),
});
mockFetchWithProxy
.mockResolvedValueOnce(mockLegacyFailure)
.mockResolvedValueOnce(mockTreeResponse)
.mockResolvedValueOnce(mockDefaultRefFailure)
.mockResolvedValueOnce(mockMainRefSuccess);
vi.mocked(confirm).mockResolvedValue(true);
vi.mocked(select).mockResolvedValue('provider-http/basic');
vi.spyOn(fs, 'readdir').mockResolvedValue([]);
await init.handleExampleDownload('.', 'assistant-cli');
expect(mockFetchWithProxy.mock.calls[2][0]).toContain(
'/contents/examples/provider-http/basic?ref=',
);
expect(mockFetchWithProxy.mock.calls[3][0]).toContain(
'/contents/examples/provider-http/basic?ref=main',
);
});
it('should provide docs URL when selected subdirectory example has no local readme', async () => {
const mockTreeResponse = createMockResponse({
ok: true,
status: 200,
json: () =>
Promise.resolve({
tree: [
{ path: 'examples/provider-opencode-sdk/basic/promptfooconfig.yaml', type: 'blob' },
],
}),
});
const mockDirectoryResponse = createMockResponse({
ok: true,
json: () =>
Promise.resolve([
{
download_url: 'https://example.com/promptfooconfig.yaml',
name: 'promptfooconfig.yaml',
type: 'file',
},
]),
});
const mockFileResponse = createMockResponse({
ok: true,
text: () => Promise.resolve('description: test'),
});
mockFetchWithProxy.mockImplementation(async (url) => {
const requestUrl = url.toString();
if (requestUrl.includes('/git/trees/')) {
return mockTreeResponse;
}
if (requestUrl.includes('/contents/examples/provider-opencode-sdk/basic')) {
return mockDirectoryResponse;
}
if (requestUrl === 'https://example.com/promptfooconfig.yaml') {
return mockFileResponse;
}
return createMockResponse({
ok: false,
status: 404,
statusText: 'Not Found',
});
});
vi.mocked(select).mockResolvedValue('provider-opencode-sdk/basic');
vi.spyOn(fs, 'readdir').mockResolvedValue(['promptfooconfig.yaml'] as unknown as Awaited<
ReturnType<typeof fs.readdir>
>);
vi.spyOn(fs, 'access').mockImplementation(async (targetPath) => {
if (targetPath.toString().endsWith('README.md')) {
throw new Error('ENOENT');
}
});
await init.handleExampleDownload('.', true);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Example docs:'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining(
'https://github.com/promptfoo/promptfoo/tree/main/examples/provider-opencode-sdk/basic',
),
);
});
});
describe('when download fails', () => {
it('should not show success message when user declines retry', async () => {
// Download failed
mockFetchWithProxy.mockRejectedValue(new Error('404 Not Found'));
// User selects not to download another example
vi.mocked(confirm).mockResolvedValue(false);
const loggerSpy = vi.spyOn(logger, 'info');
const result = await init.handleExampleDownload('.', 'nonexistent-example');
expect(result).toEqual('nonexistent-example');
expect(loggerSpy).not.toHaveBeenCalledWith(
expect.stringContaining('cd nonexistent-example && promptfoo eval'),
);
});
it('should show helpful message when user declines retry', async () => {
// Download failed
mockFetchWithProxy.mockRejectedValue(new Error('404 Not Found'));
// User selects not to download another example
vi.mocked(confirm).mockResolvedValue(false);
const loggerSpy = vi.spyOn(logger, 'info');
const result = await init.handleExampleDownload('.', 'nonexistent-example');
expect(result).toEqual('nonexistent-example');
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('No example downloaded'));
});
it('should not clean up directory when it existed before', async () => {
// Download failed
mockFetchWithProxy.mockRejectedValue(new Error('404 Not Found'));
// User selects not to download another example
vi.mocked(confirm).mockResolvedValue(false);
// Directory exists before download
vi.spyOn(fs, 'access').mockResolvedValue(undefined);
// Mock successful cleanup
const rmSpy = vi.spyOn(fs, 'rm').mockResolvedValue(undefined);
await init.handleExampleDownload('.', 'nonexistent-example');
// Should not clean up the directory
expect(rmSpy).not.toHaveBeenCalledWith('nonexistent-example', {
recursive: true,
force: true,
});
});
it('should clean up directory when it did not exist before', async () => {
// Download failed
mockFetchWithProxy.mockRejectedValue(new Error('404 Not Found'));
// User selects not to download another example
vi.mocked(confirm).mockResolvedValue(false);
// Directory doesn't exist before download (fs.access throws)
vi.spyOn(fs, 'access').mockRejectedValue(new Error('ENOENT: no such file or directory'));
// Mock successful cleanup
const rmSpy = vi.spyOn(fs, 'rm').mockResolvedValue(undefined);
await init.handleExampleDownload('.', 'nonexistent-example');
// Should clean up the directory
expect(rmSpy).toHaveBeenCalledWith('nonexistent-example', { recursive: true, force: true });
});
});
});
describe('initCommand', () => {
let program: Command;
beforeEach(() => {
program = new Command();
init.initCommand(program);
const initCmd = program.commands.find((cmd) => cmd.name() === 'init');
if (!initCmd) {
throw new Error('initCmd not found');
}
});
it('should set up the init command correctly', () => {
const initCmd = program.commands.find((cmd) => cmd.name() === 'init');
expect(initCmd).toBeDefined();
expect(initCmd?.description()).toBe(
'Set up a new promptfoo project with prompts, providers, and test cases',
);
expect(initCmd?.options).toHaveLength(2);
});
});
});
+385
View File
@@ -0,0 +1,385 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import { listCommand } from '../../src/commands/list';
import logger from '../../src/logger';
import Eval, { EvalQueries } from '../../src/models/eval';
import { wrapTable } from '../../src/table';
import { sha256 } from '../../src/util/createHash';
import { getPrompts, getTestCases } from '../../src/util/database';
import { printBorder, setupEnv } from '../../src/util/index';
import type { PromptWithMetadata, TestCasesWithMetadata } from '../../src/types/index';
vi.mock('../../src/logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('../../src/models/eval', () => ({
default: {
getMany: vi.fn(),
},
EvalQueries: {
getVarsFromEvals: vi.fn(),
},
}));
vi.mock('../../src/table', () => ({
wrapTable: vi.fn(),
}));
vi.mock('../../src/util/createHash', () => ({
sha256: vi.fn(),
}));
vi.mock('../../src/util/database', () => ({
getPrompts: vi.fn(),
getTestCases: vi.fn(),
}));
vi.mock('../../src/util/index', () => ({
printBorder: vi.fn(),
setupEnv: vi.fn(),
}));
describe('list command', () => {
let program: Command;
const evalModel = Eval as unknown as {
getMany: Mock;
};
const evalQueries = EvalQueries as unknown as {
getVarsFromEvals: Mock;
};
function getListSubcommand(name: 'evals' | 'prompts' | 'datasets') {
const listCmd = program.commands.find((cmd) => cmd.name() === 'list');
return listCmd?.commands.find((cmd) => cmd.name() === name);
}
function createEval({
id,
createdAt,
description,
providers,
prompts,
}: {
id: string;
createdAt: number;
description?: string;
providers: unknown;
prompts: Array<{
raw: string;
metrics?: {
score?: number;
testPassCount?: number;
testFailCount?: number;
testErrorCount?: number;
};
}>;
}) {
return {
id,
createdAt,
config: {
description,
providers,
},
getPrompts: vi.fn().mockReturnValue(prompts),
};
}
beforeEach(() => {
vi.resetAllMocks();
process.exitCode = 0;
program = new Command();
listCommand(program);
vi.mocked(wrapTable).mockReturnValue('mocked table');
vi.mocked(sha256).mockImplementation((input: string | Buffer) => `hash-${input}`);
vi.mocked(getPrompts).mockResolvedValue([]);
vi.mocked(getTestCases).mockResolvedValue([]);
evalModel.getMany.mockResolvedValue([]);
evalQueries.getVarsFromEvals.mockResolvedValue({});
});
afterEach(() => {
vi.resetAllMocks();
});
describe('registration', () => {
it('registers list command and subcommands', () => {
const listCmd = program.commands.find((cmd) => cmd.name() === 'list');
expect(listCmd).toBeDefined();
expect(listCmd?.description()).toBe('List various resources');
expect(listCmd?.commands.map((cmd) => cmd.name())).toEqual(
expect.arrayContaining(['evals', 'prompts', 'datasets']),
);
});
});
describe('evals', () => {
it('prints eval ids with --ids-only', async () => {
evalModel.getMany.mockResolvedValue([{ id: 'eval-1' }, { id: 'eval-2' }]);
const evalsCmd = getListSubcommand('evals');
await evalsCmd?.parseAsync(['node', 'test', '--ids-only', '-n', '2', '--env-path', '.env']);
expect(setupEnv).toHaveBeenCalledWith('.env');
expect(evalModel.getMany).toHaveBeenCalledWith(2);
expect(logger.info).toHaveBeenNthCalledWith(1, 'eval-1');
expect(logger.info).toHaveBeenNthCalledWith(2, 'eval-2');
expect(wrapTable).not.toHaveBeenCalled();
});
it('renders table output', async () => {
const olderEval = createEval({
id: 'eval-old',
createdAt: 1,
description: 'Older evaluation',
providers: ['openai:gpt-4'],
prompts: [{ raw: 'Older prompt' }],
});
const newerEval = createEval({
id: 'eval-new',
createdAt: 2,
description: 'Newer evaluation',
providers: ['openai:gpt-4'],
prompts: [{ raw: 'Newer prompt' }],
});
evalModel.getMany.mockResolvedValue([newerEval, olderEval]);
evalQueries.getVarsFromEvals.mockResolvedValue({
'eval-old': ['topic'],
'eval-new': ['name', 'city'],
});
const evalsCmd = getListSubcommand('evals');
await evalsCmd?.parseAsync(['node', 'test', '-n', '2']);
expect(evalModel.getMany).toHaveBeenCalledWith(2);
// Note: evals.sort() in list.ts mutates the array in place, so the mock
// reference reflects the sorted (ascending createdAt) order by assertion time.
expect(evalQueries.getVarsFromEvals).toHaveBeenCalledWith([olderEval, newerEval]);
expect(wrapTable).toHaveBeenCalledTimes(1);
const [tableRows] = vi.mocked(wrapTable).mock.calls[0];
expect(tableRows[0]).toMatchObject({
'eval id': 'eval-old',
prompts: 'hash-O',
vars: 'topic',
});
expect(tableRows[1]).toMatchObject({
'eval id': 'eval-new',
prompts: 'hash-N',
vars: 'name, city',
});
expect(printBorder).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('promptfoo show eval <id>'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('promptfoo show prompt <id>'),
);
});
});
describe('prompts', () => {
// Prompts are sorted by recentEvalId (ascending), so eval-a < eval-b
it('prints prompt ids with --ids-only in sorted order', async () => {
const prompts: PromptWithMetadata[] = [
{
id: 'prompt-b',
prompt: { raw: 'Prompt B', label: 'Prompt B' },
count: 1,
recentEvalDate: new Date('2025-01-01T00:00:00.000Z'),
recentEvalId: 'eval-b',
evals: [],
},
{
id: 'prompt-a',
prompt: { raw: 'Prompt A', label: 'Prompt A' },
count: 2,
recentEvalDate: new Date('2025-01-02T00:00:00.000Z'),
recentEvalId: 'eval-a',
evals: [],
},
];
vi.mocked(getPrompts).mockResolvedValue(prompts);
const promptsCmd = getListSubcommand('prompts');
await promptsCmd?.parseAsync(['node', 'test', '--ids-only', '--env-path', '.env.prompts']);
expect(setupEnv).toHaveBeenCalledWith('.env.prompts');
expect(logger.info).toHaveBeenNthCalledWith(1, 'prompt-a');
expect(logger.info).toHaveBeenNthCalledWith(2, 'prompt-b');
});
it('renders prompts table output', async () => {
const prompts: PromptWithMetadata[] = [
{
id: 'abcdef123456',
prompt: { raw: 'x'.repeat(110), label: 'x'.repeat(110) },
count: 3,
recentEvalDate: new Date('2025-01-03T00:00:00.000Z'),
recentEvalId: 'eval-123',
evals: [],
},
];
vi.mocked(getPrompts).mockResolvedValue(prompts);
const promptsCmd = getListSubcommand('prompts');
await promptsCmd?.parseAsync(['node', 'test', '-n', '1']);
expect(getPrompts).toHaveBeenCalledWith(1);
expect(wrapTable).toHaveBeenCalledTimes(1);
const [tableRows] = vi.mocked(wrapTable).mock.calls[0];
expect(tableRows[0]).toMatchObject({
'prompt id': 'abcdef',
evals: 3,
'recent eval': 'eval-123',
});
expect(tableRows[0].raw).toMatch(/\.\.\.$/);
expect(printBorder).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('promptfoo show prompt <id>'),
);
});
});
describe('datasets', () => {
// Datasets are sorted by recentEvalId (descending), so eval-999 > eval-001
it('prints dataset ids with --ids-only in sorted order', async () => {
const datasets: TestCasesWithMetadata[] = [
{
id: 'dataset-old',
testCases: [],
prompts: [],
count: 1,
recentEvalDate: new Date('2025-01-01T00:00:00.000Z'),
recentEvalId: 'eval-001',
},
{
id: 'dataset-new',
testCases: [],
prompts: [],
count: 2,
recentEvalDate: new Date('2025-01-02T00:00:00.000Z'),
recentEvalId: 'eval-999',
},
];
vi.mocked(getTestCases).mockResolvedValue(datasets);
const datasetsCmd = getListSubcommand('datasets');
await datasetsCmd?.parseAsync(['node', 'test', '--ids-only']);
expect(logger.info).toHaveBeenNthCalledWith(1, 'dataset-new');
expect(logger.info).toHaveBeenNthCalledWith(2, 'dataset-old');
expect(wrapTable).not.toHaveBeenCalled();
});
it('renders the best prompt from the highest-scoring dataset prompt', async () => {
const datasets: TestCasesWithMetadata[] = [
{
id: 'dataset-1',
testCases: [],
count: 3,
recentEvalDate: new Date('2025-01-10T00:00:00.000Z'),
recentEvalId: 'eval-10',
prompts: [
{
id: 'low-001',
evalId: 'eval-10',
prompt: {
raw: 'Low',
label: 'Low',
provider: 'echo',
metrics: {
score: 0.3,
testPassCount: 0,
testFailCount: 0,
testErrorCount: 0,
assertPassCount: 0,
assertFailCount: 0,
totalLatencyMs: 0,
tokenUsage: {},
namedScores: {},
namedScoresCount: {},
cost: 0,
},
},
},
{
id: 'high-001',
evalId: 'eval-10',
prompt: {
raw: 'High',
label: 'High',
provider: 'echo',
metrics: {
score: 0.9,
testPassCount: 0,
testFailCount: 0,
testErrorCount: 0,
assertPassCount: 0,
assertFailCount: 0,
totalLatencyMs: 0,
tokenUsage: {},
namedScores: {},
namedScoresCount: {},
cost: 0,
},
},
},
],
},
];
const datasetId = datasets[0].id;
const bestPromptId = datasets[0].prompts[1].id;
vi.mocked(getTestCases).mockResolvedValue(datasets);
const datasetsCmd = getListSubcommand('datasets');
await datasetsCmd?.parseAsync(['node', 'test']);
expect(wrapTable).toHaveBeenCalledTimes(1);
const [tableRows] = vi.mocked(wrapTable).mock.calls[0];
expect(tableRows[0]).toMatchObject({
'dataset id': datasetId.slice(0, 6),
'best prompt': bestPromptId.slice(0, 6),
evals: 3,
prompts: 2,
'recent eval': 'eval-10',
});
});
});
describe('error handling', () => {
it('propagates error when Eval.getMany rejects in evals --ids-only', async () => {
evalModel.getMany.mockRejectedValue(new Error('Database error'));
const evalsCmd = getListSubcommand('evals');
await expect(evalsCmd?.parseAsync(['node', 'test', '--ids-only'])).rejects.toThrow(
'Database error',
);
});
it('propagates error when getPrompts rejects', async () => {
vi.mocked(getPrompts).mockRejectedValue(new Error('Prompts fetch failed'));
const promptsCmd = getListSubcommand('prompts');
await expect(promptsCmd?.parseAsync(['node', 'test', '--ids-only'])).rejects.toThrow(
'Prompts fetch failed',
);
});
it('propagates error when getTestCases rejects', async () => {
vi.mocked(getTestCases).mockRejectedValue(new Error('Datasets fetch failed'));
const datasetsCmd = getListSubcommand('datasets');
await expect(datasetsCmd?.parseAsync(['node', 'test', '--ids-only'])).rejects.toThrow(
'Datasets fetch failed',
);
});
});
});
+436
View File
@@ -0,0 +1,436 @@
import fs from 'fs/promises';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import cliState from '../../src/cliState';
import { logsCommand } from '../../src/commands/logs';
import logger from '../../src/logger';
import * as logsUtil from '../../src/util/logs';
vi.mock('fs/promises');
vi.mock('fs');
vi.mock('../../src/logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('../../src/telemetry', () => ({
default: {
record: vi.fn(),
send: vi.fn(),
},
}));
vi.mock('../../src/cliState', () => ({
default: {
debugLogFile: undefined,
errorLogFile: undefined,
},
}));
vi.mock('../../src/util/logs', () => ({
getLogDirectory: vi.fn().mockReturnValue('/home/user/.promptfoo/logs'),
getLogFiles: vi.fn().mockResolvedValue([]),
getLogFilesSync: vi.fn().mockReturnValue([]),
findLogFile: vi.fn().mockReturnValue(null),
formatFileSize: vi.fn((bytes: number) => `${bytes} B`),
readLastLines: vi.fn().mockResolvedValue([]),
readFirstLines: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../src/util/index', () => ({
printBorder: vi.fn(),
}));
vi.mock('../../src/table', () => ({
wrapTable: vi.fn().mockReturnValue('mocked table'),
}));
describe('logs command', () => {
let program: Command;
const mockFs = vi.mocked(fs);
const mockLogsUtil = vi.mocked(logsUtil);
const mockCliState = vi.mocked(cliState);
beforeEach(() => {
vi.clearAllMocks();
program = new Command();
program.enablePositionalOptions(); // Required for passThroughOptions() in logs command
logsCommand(program);
process.exitCode = 0;
// Reset cliState
mockCliState.debugLogFile = undefined;
mockCliState.errorLogFile = undefined;
});
afterEach(() => {
vi.resetAllMocks();
process.exitCode = 0;
});
describe('command registration', () => {
it('should register logs command with correct options', () => {
const cmd = program.commands.find((c) => c.name() === 'logs');
expect(cmd).toBeDefined();
expect(cmd?.description()).toContain('View promptfoo log files');
const options = cmd?.options;
expect(options?.find((o) => o.long === '--type')).toBeDefined();
expect(options?.find((o) => o.long === '--lines')).toBeDefined();
expect(options?.find((o) => o.long === '--head')).toBeDefined();
expect(options?.find((o) => o.long === '--follow')).toBeDefined();
expect(options?.find((o) => o.long === '--list')).toBeDefined();
expect(options?.find((o) => o.long === '--grep')).toBeDefined();
expect(options?.find((o) => o.long === '--no-color')).toBeDefined();
});
it('should register list subcommand', () => {
const logsCmd = program.commands.find((c) => c.name() === 'logs');
const listCmd = logsCmd?.commands.find((c) => c.name() === 'list');
expect(listCmd).toBeDefined();
expect(listCmd?.description()).toContain('List available log files');
});
});
describe('--list option', () => {
it('should display message when no log files found', async () => {
mockLogsUtil.getLogFiles.mockResolvedValue([]);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', '--list']);
expect(mockLogsUtil.getLogFiles).toHaveBeenCalledWith('all');
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('No log files found'));
});
it('should display table when log files exist', async () => {
mockLogsUtil.getLogFiles.mockResolvedValue([
{
name: 'promptfoo-debug-2024-01-01_10-00-00.log',
path: '/home/user/.promptfoo/logs/promptfoo-debug-2024-01-01_10-00-00.log',
mtime: new Date('2024-01-01T10:00:00Z'),
type: 'debug',
size: 1024,
},
]);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', '--list']);
// wrapTable is called and result is logged
expect(logger.info).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('promptfoo logs <filename>'),
);
});
it('should filter by type when specified', async () => {
mockLogsUtil.getLogFiles.mockResolvedValue([]);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', '--list', '--type', 'error']);
expect(mockLogsUtil.getLogFiles).toHaveBeenCalledWith('error');
});
it('should default to all log types', async () => {
mockLogsUtil.getLogFiles.mockResolvedValue([]);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', '--list']);
expect(mockLogsUtil.getLogFiles).toHaveBeenCalledWith('all');
});
});
describe('viewing log files', () => {
const mockLogPath = '/home/user/.promptfoo/logs/promptfoo-debug-2024-01-01_10-00-00.log';
const mockLogContent = `2024-01-01T10:00:00.000Z [INFO]: Test message 1
2024-01-01T10:00:01.000Z [DEBUG]: Test debug message
2024-01-01T10:00:02.000Z [ERROR]: Test error message
2024-01-01T10:00:03.000Z [WARN]: Test warning message`;
beforeEach(() => {
mockLogsUtil.findLogFile.mockReturnValue(null);
mockLogsUtil.getLogFiles.mockResolvedValue([
{
name: 'promptfoo-debug-2024-01-01_10-00-00.log',
path: mockLogPath,
mtime: new Date('2024-01-01T10:00:00Z'),
type: 'debug',
size: mockLogContent.length,
},
]);
mockFs.access.mockResolvedValue(undefined);
mockFs.stat.mockResolvedValue({
size: mockLogContent.length,
mtime: new Date('2024-01-01T10:00:00Z'),
} as any);
mockFs.readFile.mockResolvedValue(mockLogContent);
});
it('should show error when no log files available', async () => {
mockLogsUtil.getLogFiles.mockResolvedValue([]);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test']);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('No log files found'));
expect(process.exitCode).toBe(1);
});
it('should show error when specified file not found', async () => {
mockLogsUtil.findLogFile.mockReturnValue(null);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', 'nonexistent.log']);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Log file not found'));
expect(process.exitCode).toBe(1);
});
it('should display most recent log file by default', async () => {
mockFs.access.mockResolvedValue(undefined);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test']);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('promptfoo-debug'));
expect(mockFs.readFile).toHaveBeenCalledWith(mockLogPath, 'utf-8');
});
it('should use current session log file when available', async () => {
const sessionLogPath = '/session/log/path.log';
mockCliState.debugLogFile = sessionLogPath;
mockFs.access.mockImplementation(async (p) => {
if (p !== sessionLogPath) {
throw new Error('Not found');
}
});
mockFs.stat.mockResolvedValue({
size: mockLogContent.length,
mtime: new Date(),
} as any);
mockFs.readFile.mockResolvedValue(mockLogContent);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test']);
expect(mockFs.readFile).toHaveBeenCalledWith(sessionLogPath, 'utf-8');
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('current CLI session'));
});
it('should display specific file when provided', async () => {
mockLogsUtil.findLogFile.mockReturnValue(mockLogPath);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', 'promptfoo-debug-2024-01-01_10-00-00.log']);
expect(mockLogsUtil.findLogFile).toHaveBeenCalledWith(
'promptfoo-debug-2024-01-01_10-00-00.log',
'all',
);
expect(mockFs.readFile).toHaveBeenCalledWith(mockLogPath, 'utf-8');
});
it('should limit output with --lines option', async () => {
mockLogsUtil.findLogFile.mockReturnValue(mockLogPath);
mockLogsUtil.readLastLines.mockResolvedValue(['line 1', 'line 2']);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', '-n', '2']);
expect(mockLogsUtil.readLastLines).toHaveBeenCalledWith(mockLogPath, 2);
});
it('should handle permission errors', async () => {
mockLogsUtil.getLogFiles.mockResolvedValue([
{
name: 'test.log',
path: mockLogPath,
mtime: new Date(),
type: 'debug',
size: 100,
},
]);
mockFs.access.mockRejectedValue(new Error('Permission denied'));
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test']);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Permission denied'));
expect(process.exitCode).toBe(1);
});
it('should handle empty log files', async () => {
mockLogsUtil.getLogFiles.mockResolvedValue([
{
name: 'empty.log',
path: mockLogPath,
mtime: new Date(),
type: 'debug',
size: 0,
},
]);
mockFs.stat.mockResolvedValue({
size: 0,
mtime: new Date(),
} as any);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test']);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Log file is empty'));
});
it('should warn about large files', async () => {
const largeSize = 2 * 1024 * 1024; // 2MB
mockLogsUtil.getLogFiles.mockResolvedValue([
{
name: 'large.log',
path: mockLogPath,
mtime: new Date(),
type: 'debug',
size: largeSize,
},
]);
mockFs.stat.mockResolvedValue({
size: largeSize,
mtime: new Date(),
} as any);
mockFs.readFile.mockResolvedValue('a'.repeat(largeSize));
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test']);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('large'));
});
it('should filter output with --grep option', async () => {
const logContentWithErrors = `2024-01-01T10:00:00.000Z [INFO]: Starting application
2024-01-01T10:00:01.000Z [ERROR]: Connection failed
2024-01-01T10:00:02.000Z [INFO]: Retrying connection
2024-01-01T10:00:03.000Z [ERROR]: Connection timeout`;
mockLogsUtil.getLogFiles.mockResolvedValue([
{
name: 'test.log',
path: mockLogPath,
mtime: new Date(),
type: 'debug',
size: logContentWithErrors.length,
},
]);
mockFs.stat.mockResolvedValue({
size: logContentWithErrors.length,
mtime: new Date(),
} as any);
mockFs.readFile.mockResolvedValue(logContentWithErrors);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', '--grep', 'ERROR']);
// Should have logged content containing ERROR lines
expect(logger.info).toHaveBeenCalled();
const calls = vi.mocked(logger.info).mock.calls;
const outputCall = calls.find((call) => String(call[0]).includes('Connection'));
expect(outputCall).toBeDefined();
});
it('should show message when grep finds no matches', async () => {
mockLogsUtil.getLogFiles.mockResolvedValue([
{
name: 'test.log',
path: mockLogPath,
mtime: new Date(),
type: 'debug',
size: mockLogContent.length,
},
]);
mockFs.stat.mockResolvedValue({
size: mockLogContent.length,
mtime: new Date(),
} as any);
mockFs.readFile.mockResolvedValue(mockLogContent);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', '--grep', 'NONEXISTENT_PATTERN']);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('No lines matching pattern found'),
);
});
it('should handle invalid regex patterns gracefully', async () => {
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', '--grep', '[invalid']);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Invalid grep pattern'));
expect(process.exitCode).toBe(1);
});
});
describe('list subcommand', () => {
it('should list all log types by default', async () => {
mockLogsUtil.getLogFiles.mockResolvedValue([]);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
const listCmd = logsCmd?.commands.find((c) => c.name() === 'list');
await listCmd?.parseAsync(['node', 'test']);
expect(mockLogsUtil.getLogFiles).toHaveBeenCalledWith('all');
});
it('should filter by type when specified', async () => {
mockLogsUtil.getLogFiles.mockResolvedValue([]);
const logsCmd = program.commands.find((c) => c.name() === 'logs');
const listCmd = logsCmd?.commands.find((c) => c.name() === 'list');
await listCmd?.parseAsync(['node', 'test', '--type', 'error']);
expect(mockLogsUtil.getLogFiles).toHaveBeenCalledWith('error');
});
});
describe('error handling', () => {
it('should handle general errors gracefully', async () => {
mockLogsUtil.getLogFiles.mockRejectedValue(new Error('Unexpected error'));
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test']);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to read logs'));
expect(process.exitCode).toBe(1);
});
it('should reject invalid --type values', async () => {
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', '--type', 'invalid']);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Invalid log type'));
expect(process.exitCode).toBe(1);
});
it('should reject invalid --lines values', async () => {
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', '--lines', '-5']);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('--lines must be a positive number'),
);
expect(process.exitCode).toBe(1);
});
it('should reject invalid --head values', async () => {
const logsCmd = program.commands.find((c) => c.name() === 'logs');
await logsCmd?.parseAsync(['node', 'test', '--head', 'abc']);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('--head must be a positive number'),
);
expect(process.exitCode).toBe(1);
});
});
});
+80
View File
@@ -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);
});
});
});
+218
View File
@@ -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);
});
});
});
+256
View File
@@ -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);
});
});
});
+138
View File
@@ -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);
});
});
});
+149
View File
@@ -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();
}
});
});
});
+467
View File
@@ -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,
);
}
});
});
+321
View File
@@ -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);
});
});
+389
View File
@@ -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',
);
});
});
});
+188
View File
@@ -0,0 +1,188 @@
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock fs module before importing the module under test
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
default: {
...actual,
readFileSync: vi.fn(),
unlinkSync: vi.fn(),
writeFileSync: vi.fn(),
},
readFileSync: vi.fn(),
unlinkSync: vi.fn(),
writeFileSync: vi.fn(),
};
});
vi.mock('../../src/logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('../../src/models/modelAudit', () => ({
__esModule: true,
default: {
create: vi.fn().mockResolvedValue({ id: 'scan-test-123' }),
findByRevision: vi.fn().mockResolvedValue(null),
},
}));
vi.mock('../../src/util/huggingfaceMetadata', () => ({
isHuggingFaceModel: vi.fn().mockReturnValue(false),
getHuggingFaceMetadata: vi.fn().mockResolvedValue(null),
parseHuggingFaceModel: vi.fn().mockReturnValue(null),
}));
vi.mock('../../src/globalConfig/accounts', () => ({
getAuthor: vi.fn().mockReturnValue('test-author'),
}));
import { createTempOutputPath, supportsCliUiWithOutput } from '../../src/commands/modelScan';
import logger from '../../src/logger';
describe('supportsCliUiWithOutput', () => {
it('should return false for null version', () => {
expect(supportsCliUiWithOutput(null)).toBe(false);
});
it('should return false for versions below 0.2.20', () => {
const oldVersions = ['0.1.0', '0.2.0', '0.2.19', '0.2.10', '0.2.1'];
for (const version of oldVersions) {
expect(supportsCliUiWithOutput(version)).toBe(false);
}
});
it('should return true for version 0.2.20', () => {
expect(supportsCliUiWithOutput('0.2.20')).toBe(true);
});
it('should return true for versions above 0.2.20', () => {
const newVersions = ['0.2.21', '0.2.30', '0.3.0', '1.0.0', '0.10.0'];
for (const version of newVersions) {
expect(supportsCliUiWithOutput(version)).toBe(true);
}
});
it('should return false for invalid version strings', () => {
// Note: semver.coerce is lenient - '1.2' becomes '1.2.0' which is valid
const invalidVersions = ['invalid', 'a.b.c', ''];
for (const version of invalidVersions) {
expect(supportsCliUiWithOutput(version)).toBe(false);
}
});
it('should handle partial version strings with semver.coerce', () => {
// semver.coerce('1.2') becomes '1.2.0' which is >= 0.2.20
expect(supportsCliUiWithOutput('1.2')).toBe(true);
// semver.coerce('0.2') becomes '0.2.0' which is < 0.2.20
expect(supportsCliUiWithOutput('0.2')).toBe(false);
});
it('should handle edge cases for version comparison', () => {
// Version 0.3.0 should be supported (minor > 2)
expect(supportsCliUiWithOutput('0.3.0')).toBe(true);
// Version 1.0.0 should be supported (major > 0)
expect(supportsCliUiWithOutput('1.0.0')).toBe(true);
// Version 0.2.19 should NOT be supported
expect(supportsCliUiWithOutput('0.2.19')).toBe(false);
});
});
describe('createTempOutputPath', () => {
it('should generate path in system temp directory', () => {
const tempPath = createTempOutputPath();
expect(tempPath).toContain(os.tmpdir());
});
it('should generate path with promptfoo-modelscan prefix', () => {
const tempPath = createTempOutputPath();
expect(tempPath).toContain('promptfoo-modelscan-');
});
it('should generate path with .json extension', () => {
const tempPath = createTempOutputPath();
expect(tempPath).toMatch(/\.json$/);
});
it('should generate UUID-based unique paths', () => {
const tempPath = createTempOutputPath();
expect(path.basename(tempPath)).toBe('results.json');
expect(path.basename(path.dirname(tempPath))).toMatch(
/^promptfoo-modelscan-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-[A-Za-z0-9]{6}$/,
);
});
it('should generate unique paths on each call', () => {
const paths = new Set<string>();
for (let i = 0; i < 10; i++) {
paths.add(createTempOutputPath());
}
// All paths should be unique
expect(paths.size).toBe(10);
});
});
/**
* These tests document the expected fs behavior patterns used in the temp file workflow.
* They verify that the cleanup logic handles various fs scenarios correctly.
* Note: These test the behavior patterns, not the internal functions directly
* (which are not exported).
*/
describe('temp file workflow - expected fs behavior patterns', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
it('documents: temp file paths use createTempOutputPath format', () => {
// The temp file workflow uses createTempOutputPath to generate unique paths
const tempFilePath = createTempOutputPath();
expect(tempFilePath).toContain('promptfoo-modelscan-');
expect(tempFilePath).toMatch(/\.json$/);
});
it('documents: cleanup logs debug on failure (not error)', () => {
// When cleanup fails, it should log at debug level, not error
// This is because cleanup failures are not critical
const cleanupError = new Error('EPERM: operation not permitted');
const tempFilePath = createTempOutputPath();
// Simulate the cleanup behavior pattern from processScanResultsFromFile
try {
throw cleanupError;
} catch (error) {
logger.debug(`Failed to cleanup temp file ${tempFilePath}: ${error}`);
}
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Failed to cleanup temp file'),
);
expect(logger.error).not.toHaveBeenCalled();
});
it('documents: JSON parsing throws on invalid content', () => {
// The workflow expects JSON.parse to throw on invalid content
const invalidJson = 'not valid json {{{';
expect(() => JSON.parse(invalidJson)).toThrow();
});
it('documents: empty string is falsy for output validation', () => {
// The workflow checks `if (!jsonOutput)` to detect empty output
const emptyOutput = '';
expect(!emptyOutput).toBe(true);
expect(!emptyOutput.trim()).toBe(true);
});
});
File diff suppressed because it is too large Load Diff
+274
View File
@@ -0,0 +1,274 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { doOptimize, optimizeCommand } from '../../src/commands/optimize';
import logger from '../../src/logger';
import { optimizePromptTestSuite } from '../../src/optimizer/promptOptimizer';
import telemetry from '../../src/telemetry';
import { resolveConfigs } from '../../src/util/config/load';
import { setupEnv } from '../../src/util/index';
vi.mock('../../src/optimizer/promptOptimizer', () => ({
optimizePromptTestSuite: vi.fn(),
}));
vi.mock('../../src/telemetry', () => ({
default: {
record: vi.fn(),
},
}));
vi.mock('../../src/util/config/load', () => ({
resolveConfigs: vi.fn(),
}));
vi.mock('../../src/util', () => ({
printBorder: vi.fn(),
setupEnv: vi.fn(),
}));
vi.mock('../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../../src/util/promptfooCommand', () => ({
promptfooCommand: vi.fn().mockReturnValue('promptfoo init'),
}));
describe('optimize command', () => {
beforeEach(() => {
vi.resetAllMocks();
process.exitCode = undefined;
vi.mocked(resolveConfigs).mockResolvedValue({
config: {},
testSuite: {
providers: [],
prompts: [{ raw: 'Prompt', label: 'Prompt' }],
tests: [{}],
},
basePath: '',
} as any);
vi.mocked(optimizePromptTestSuite).mockResolvedValue({
baselinePrompt: { label: 'Prompt', raw: 'Prompt', metrics: { score: 0.5 } },
bestPrompt: { label: 'Prompt [optimized 1]', raw: 'Better prompt', metrics: { score: 0.8 } },
improved: true,
candidates: [{ prompt: 'Better prompt', hypothesis: 'Improve clarity.' }],
} as any);
});
afterEach(() => {
vi.resetAllMocks();
process.exitCode = undefined;
});
it('uses the implicit default config path when -c is omitted', async () => {
await doOptimize({
defaultConfig: {},
defaultConfigPath: 'promptfooconfig.yaml',
});
expect(setupEnv).toHaveBeenCalledWith(undefined);
expect(resolveConfigs).toHaveBeenCalledWith({ config: ['promptfooconfig.yaml'] }, {});
expect(optimizePromptTestSuite).toHaveBeenCalledTimes(1);
expect(optimizePromptTestSuite).toHaveBeenCalledWith({}, expect.any(Object), {
promptIndex: 0,
providerIndex: 0,
validationSplit: undefined,
});
expect(telemetry.record).toHaveBeenCalledWith(
'command_used',
expect.objectContaining({ name: 'optimize - started' }),
);
});
it('loads config-defined env files when the CLI does not override them', async () => {
vi.mocked(resolveConfigs).mockResolvedValue({
config: {},
testSuite: {
providers: [],
prompts: [{ raw: 'Prompt', label: 'Prompt' }],
tests: [{}],
},
basePath: '',
commandLineOptions: {
envPath: '.env.optimize',
},
} as any);
await doOptimize({
defaultConfig: {},
defaultConfigPath: 'promptfooconfig.yaml',
});
expect(setupEnv).toHaveBeenNthCalledWith(1, undefined);
expect(setupEnv).toHaveBeenNthCalledWith(2, '.env.optimize');
});
it('keeps explicit CLI env files ahead of config-defined env files', async () => {
vi.mocked(resolveConfigs).mockResolvedValue({
config: {},
testSuite: {
providers: [],
prompts: [{ raw: 'Prompt', label: 'Prompt' }],
tests: [{}],
},
basePath: '',
commandLineOptions: {
envPath: '.env.config',
},
} as any);
await doOptimize({
defaultConfig: {},
defaultConfigPath: 'promptfooconfig.yaml',
envPath: '.env.cli',
});
expect(setupEnv).toHaveBeenCalledTimes(1);
expect(setupEnv).toHaveBeenCalledWith('.env.cli');
});
it('passes validation split through to the optimizer', async () => {
await doOptimize({
defaultConfig: {},
defaultConfigPath: 'promptfooconfig.yaml',
validationSplit: 0.2,
});
expect(optimizePromptTestSuite).toHaveBeenCalledWith({}, expect.any(Object), {
promptIndex: 0,
providerIndex: 0,
validationSplit: 0.2,
});
});
it('passes explicit prompt and provider indices through to the optimizer', async () => {
await doOptimize({
defaultConfig: {},
defaultConfigPath: 'promptfooconfig.yaml',
promptIndex: 2,
providerIndex: 1,
});
expect(optimizePromptTestSuite).toHaveBeenCalledWith({}, expect.any(Object), {
promptIndex: 2,
providerIndex: 1,
validationSplit: undefined,
});
});
it('requires a config path when no default config is available', async () => {
await expect(
doOptimize({
defaultConfig: {},
defaultConfigPath: undefined,
}),
).rejects.toThrow('Could not find a config file.');
});
it('logs validation scores and a non-improving result', async () => {
vi.mocked(optimizePromptTestSuite).mockResolvedValue({
baselinePrompt: { label: 'Prompt', raw: 'Prompt', metrics: { score: 0.5 } },
bestPrompt: { label: 'Prompt', raw: 'Prompt', metrics: { score: 0.5 } },
baselineValidationPrompt: {
label: 'Prompt',
raw: 'Prompt',
metrics: { score: 0.4 },
},
bestValidationPrompt: {
label: 'Prompt',
raw: 'Prompt',
metrics: { score: 0.4 },
},
improved: false,
candidates: [],
searchTestCount: 1,
validationSplit: 0.2,
validationTestCount: 1,
} as any);
await doOptimize({
defaultConfig: {},
defaultConfigPath: 'promptfooconfig.yaml',
validationSplit: 0.2,
});
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Baseline validation:'));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Best validation:'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Baseline remains strongest.'),
);
});
it('parses valid CLI optimizer selections and validation split', async () => {
const program = new Command();
optimizeCommand(program, {}, 'promptfooconfig.yaml');
await program.parseAsync([
'node',
'test',
'optimize',
'--prompt-index',
'2',
'--provider-index',
'1',
'--validation-split',
'0.3',
]);
expect(optimizePromptTestSuite).toHaveBeenCalledWith({}, expect.any(Object), {
promptIndex: 2,
providerIndex: 1,
validationSplit: 0.3,
});
});
it.each([
['--prompt-index', '-1', '--prompt-index must be a non-negative integer.'],
['--provider-index', '1.5', '--provider-index must be a non-negative integer.'],
[
'--validation-split',
'0.75',
'--validation-split must be greater than 0 and less than or equal to 0.5',
],
[
'--validation-split',
'0.2typo',
'--validation-split must be greater than 0 and less than or equal to 0.5',
],
])('rejects invalid %s values', async (flag, value, message) => {
const program = new Command();
program.exitOverride();
optimizeCommand(program, {}, 'promptfooconfig.yaml');
await expect(program.parseAsync(['node', 'test', 'optimize', flag, value])).rejects.toThrow(
message,
);
});
it('logs action handler failures and sets the exit code', async () => {
vi.mocked(resolveConfigs).mockRejectedValue(new Error('config exploded'));
const program = new Command();
optimizeCommand(program, {}, 'promptfooconfig.yaml');
await program.parseAsync(['node', 'test', 'optimize']);
expect(logger.error).toHaveBeenCalledWith('config exploded');
expect(process.exitCode).toBe(1);
});
it('registers a top-level optimize command', () => {
const program = new Command();
optimizeCommand(program, {}, 'promptfooconfig.yaml');
const command = program.commands[0];
expect(command.name()).toBe('optimize');
expect(command.description()).toBe('Optimize one prompt against one configured provider');
expect(command.options.some((option) => option.long === '--prompt-index')).toBe(true);
expect(command.options.some((option) => option.long === '--provider-index')).toBe(true);
expect(command.options.some((option) => option.long === '--validation-split')).toBe(true);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { AbortPromptError, ExitPromptError } from '@inquirer/core';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { initCommand } from '../../../src/commands/redteam/init';
import { getDefaultPort } from '../../../src/constants';
import logger from '../../../src/logger';
import { redteamInit } from '../../../src/redteam/commands/init';
import { startServer } from '../../../src/server/server';
import telemetry from '../../../src/telemetry';
import { setupEnv } from '../../../src/util/index';
import { BrowserBehavior, checkServerRunning, openBrowser } from '../../../src/util/server';
import { mockProcessEnv } from '../../util/utils';
vi.mock('../../../src/logger');
vi.mock('../../../src/redteam/commands/init');
vi.mock('../../../src/server/server');
vi.mock('../../../src/telemetry');
vi.mock('../../../src/util/index', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/util/index')>()),
setupEnv: vi.fn(),
}));
vi.mock('../../../src/util/server');
describe('redteam init command', () => {
let originalExitCode: number | string | null | undefined;
let program: Command;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(redteamInit).mockReset();
vi.mocked(startServer).mockReset();
vi.mocked(telemetry.record).mockReset();
vi.mocked(setupEnv).mockReset();
vi.mocked(checkServerRunning).mockReset();
vi.mocked(openBrowser).mockReset();
originalExitCode = process.exitCode;
process.exitCode = 0;
program = new Command();
initCommand(program);
});
afterEach(() => {
process.exitCode = originalExitCode;
vi.restoreAllMocks();
});
it('runs the reusable flow in non-GUI mode', async () => {
await program.parseAsync([
'node',
'test',
'init',
'project-dir',
'--no-gui',
'--env-file',
'.env.test',
]);
expect(setupEnv).toHaveBeenCalledWith('.env.test');
expect(redteamInit).toHaveBeenCalledWith('project-dir');
expect(checkServerRunning).not.toHaveBeenCalled();
});
it('opens the redteam create UI when a server is already running', async () => {
const restoreEnv = mockProcessEnv({ DISPLAY: ':99' });
vi.mocked(checkServerRunning).mockResolvedValue(true);
try {
await program.parseAsync(['node', 'test', 'init']);
} finally {
restoreEnv();
}
expect(openBrowser).toHaveBeenCalledWith(BrowserBehavior.OPEN_TO_REDTEAM_CREATE);
expect(startServer).not.toHaveBeenCalled();
expect(redteamInit).not.toHaveBeenCalled();
});
it('starts the redteam create UI when no server is running', async () => {
const restoreEnv = mockProcessEnv({ DISPLAY: ':99' });
vi.mocked(checkServerRunning).mockResolvedValue(false);
try {
await program.parseAsync(['node', 'test', 'init']);
} finally {
restoreEnv();
}
expect(startServer).toHaveBeenCalledWith(
getDefaultPort(),
BrowserBehavior.OPEN_TO_REDTEAM_CREATE,
);
expect(openBrowser).not.toHaveBeenCalled();
expect(redteamInit).not.toHaveBeenCalled();
});
it.each([
new ExitPromptError(),
new AbortPromptError(),
])('marks %s cancellation without hard-exiting', async (error) => {
process.exitCode = undefined;
vi.mocked(redteamInit).mockRejectedValueOnce(error);
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
await expect(program.parseAsync(['node', 'test', 'init', '--no-gui'])).resolves.toBe(program);
expect(telemetry.record).toHaveBeenCalledWith('funnel', {
type: 'redteam onboarding',
step: 'early exit',
});
expect(logger.info).toHaveBeenCalled();
expect(process.exitCode).toBe(130);
expect(exitSpy).not.toHaveBeenCalled();
});
it('rethrows unexpected errors', async () => {
vi.mocked(redteamInit).mockRejectedValueOnce(new Error('unexpected failure'));
await expect(program.parseAsync(['node', 'test', 'init', '--no-gui'])).rejects.toThrow(
'unexpected failure',
);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { redteamReportCommand } from '../../../src/commands/redteam/report';
import { startServer } from '../../../src/server/server';
import { setConfigDirectoryPath } from '../../../src/util/config/manage';
import { setupEnv } from '../../../src/util/index';
import { BrowserBehavior, checkServerRunning, openBrowser } from '../../../src/util/server';
vi.mock('../../../src/server/server');
vi.mock('../../../src/util');
vi.mock('../../../src/util/config/manage', () => ({
getConfigDirectoryPath: vi.fn().mockReturnValue('/tmp/test-config'),
setConfigDirectoryPath: vi.fn(),
maybeReadConfig: vi.fn(),
readConfigs: vi.fn(),
}));
vi.mock('../../../src/util/server');
describe('redteamReportCommand', () => {
let program: Command;
beforeEach(() => {
program = new Command();
vi.clearAllMocks();
});
it('should set up command with correct options', () => {
redteamReportCommand(program);
const cmd = program.commands[0];
expect(cmd.name()).toBe('report');
expect(cmd.description()).toBe('Start browser UI and open to report');
expect(cmd.opts()).toEqual({
port: expect.any(String),
filterDescription: undefined,
envPath: undefined,
});
});
it('should handle report command with directory', async () => {
redteamReportCommand(program);
const cmd = program.commands[0];
vi.mocked(checkServerRunning).mockResolvedValue(false);
await cmd.parseAsync(['node', 'test', 'testdir', '--port', '3000']);
expect(setupEnv).toHaveBeenCalledWith(undefined);
expect(setConfigDirectoryPath).toHaveBeenCalledWith('testdir');
expect(startServer).toHaveBeenCalledWith('3000', BrowserBehavior.OPEN_TO_REPORT);
});
it('should open browser if server is already running', async () => {
redteamReportCommand(program);
const cmd = program.commands[0];
vi.mocked(checkServerRunning).mockResolvedValue(true);
await cmd.parseAsync(['node', 'test', '--port', '3000']);
expect(openBrowser).toHaveBeenCalledWith(BrowserBehavior.OPEN_TO_REPORT);
expect(startServer).not.toHaveBeenCalled();
});
it('should ignore filter description option', async () => {
redteamReportCommand(program);
const cmd = program.commands[0];
vi.mocked(checkServerRunning).mockResolvedValue(false);
await cmd.parseAsync(['node', 'test', '--filter-description', 'test.*']);
expect(startServer).toHaveBeenCalledWith(expect.any(String), BrowserBehavior.OPEN_TO_REPORT);
});
it('should handle report command with env file path', async () => {
redteamReportCommand(program);
const cmd = program.commands[0];
await cmd.parseAsync(['node', 'test', '--env-file', '.env.test']);
expect(setupEnv).toHaveBeenCalledWith('.env.test');
});
});
+92
View File
@@ -0,0 +1,92 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { redteamSetupCommand } from '../../../src/commands/redteam/setup';
import { getDefaultPort } from '../../../src/constants';
import { startServer } from '../../../src/server/server';
import { setConfigDirectoryPath } from '../../../src/util/config/manage';
import { setupEnv } from '../../../src/util/index';
import { BrowserBehavior, checkServerRunning, openBrowser } from '../../../src/util/server';
vi.mock('../../../src/server/server');
vi.mock('../../../src/util/server');
vi.mock('../../../src/util', async (importOriginal) => {
return {
...(await importOriginal()),
setupEnv: vi.fn(),
};
});
vi.mock('../../../src/util/config/manage', async (importOriginal) => {
return {
...(await importOriginal()),
setConfigDirectoryPath: vi.fn(),
};
});
describe('redteamSetupCommand', () => {
let program: Command;
beforeEach(() => {
program = new Command();
vi.clearAllMocks();
});
it('should register the setup command with correct options', () => {
redteamSetupCommand(program);
const setupCmd = program.commands.find((cmd) => cmd.name() === 'setup');
expect(setupCmd).toBeDefined();
expect(setupCmd?.description()).toBe('Start browser UI and open to redteam setup');
expect(setupCmd?.opts().port).toBe(getDefaultPort().toString());
});
it('should handle setup command without directory', async () => {
vi.mocked(checkServerRunning).mockResolvedValue(false);
redteamSetupCommand(program);
await program.parseAsync(['node', 'test', 'setup', '--port', '3000']);
expect(setupEnv).toHaveBeenCalledWith(undefined);
expect(startServer).toHaveBeenCalledWith('3000', BrowserBehavior.OPEN_TO_REDTEAM_CREATE);
});
it('should handle setup command with directory', async () => {
vi.mocked(checkServerRunning).mockResolvedValue(false);
redteamSetupCommand(program);
await program.parseAsync(['node', 'test', 'setup', 'test-dir', '--port', '3000']);
expect(setConfigDirectoryPath).toHaveBeenCalledWith('test-dir');
expect(startServer).toHaveBeenCalledWith('3000', BrowserBehavior.OPEN_TO_REDTEAM_CREATE);
});
it('should open browser if server is already running', async () => {
vi.mocked(checkServerRunning).mockResolvedValue(true);
redteamSetupCommand(program);
await program.parseAsync(['node', 'test', 'setup']);
expect(openBrowser).toHaveBeenCalledWith(BrowserBehavior.OPEN_TO_REDTEAM_CREATE);
expect(startServer).not.toHaveBeenCalled();
});
it('should ignore filter description option', async () => {
vi.mocked(checkServerRunning).mockResolvedValue(false);
redteamSetupCommand(program);
await program.parseAsync(['node', 'test', 'setup', '--filter-description', 'test.*']);
expect(startServer).toHaveBeenCalledWith(
getDefaultPort().toString(),
BrowserBehavior.OPEN_TO_REDTEAM_CREATE,
);
});
it('should handle setup command with env file path', async () => {
vi.mocked(checkServerRunning).mockResolvedValue(false);
redteamSetupCommand(program);
await program.parseAsync(['node', 'test', 'setup', '--env-file', '.env.test']);
expect(setupEnv).toHaveBeenCalledWith('.env.test');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { Command } from 'commander';
import { describe, expect, it } from 'vitest';
import {
deleteErrorResults as commandDeleteErrorResults,
getErrorResultIds as commandGetErrorResultIds,
recalculatePromptMetrics as commandRecalculatePromptMetrics,
retryCommand as commandRetryCommand,
setupRetryCommand,
} from '../../src/commands/retry';
import {
deleteErrorResults,
getErrorResultIds,
recalculatePromptMetrics,
retryCommand,
} from '../../src/node/retry';
describe('setupRetryCommand', () => {
it('preserves the established command-module runtime exports', () => {
expect(commandDeleteErrorResults).toBe(deleteErrorResults);
expect(commandGetErrorResultIds).toBe(getErrorResultIds);
expect(commandRecalculatePromptMetrics).toBe(recalculatePromptMetrics);
expect(commandRetryCommand).toBe(retryCommand);
});
it('registers retry as a CLI command', () => {
const program = new Command();
setupRetryCommand(program);
const retry = program.commands.find((command) => command.name() === 'retry');
expect(retry).toBeDefined();
expect(retry?.description()).toBe('Retry all ERROR results from a given evaluation');
expect(retry?.options.map((option) => option.long)).toEqual([
'--config',
'--verbose',
'--max-concurrency',
'--delay',
'--share',
'--no-share',
]);
});
});
+581
View File
@@ -0,0 +1,581 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
createAndDisplayShareableModelAuditUrl,
createAndDisplayShareableUrl,
notCloudEnabledShareInstructions,
shareCommand,
} from '../../src/commands/share';
import * as envars from '../../src/envars';
import logger from '../../src/logger';
import Eval from '../../src/models/eval';
import ModelAudit from '../../src/models/modelAudit';
import {
createShareableModelAuditUrl,
createShareableUrl,
isModelAuditSharingEnabled,
isSharingEnabled,
} from '../../src/share';
import { loadDefaultConfig } from '../../src/util/config/default';
vi.mock('../../src/share');
vi.mock('../../src/logger');
vi.mock('../../src/telemetry', () => ({
default: {
record: vi.fn(),
send: vi.fn(),
},
}));
vi.mock('../../src/envars');
vi.mock('readline');
vi.mock('../../src/models/eval');
vi.mock('../../src/models/modelAudit');
vi.mock('../../src/util', async (importOriginal) => {
return {
...(await importOriginal()),
setupEnv: vi.fn(),
};
});
vi.mock('../../src/util/config/default');
describe('Share Command', () => {
beforeEach(() => {
vi.clearAllMocks();
process.exitCode = 0; // Reset exitCode before each test
});
describe('notCloudEnabledShareInstructions', () => {
it('should log instructions for cloud setup', () => {
notCloudEnabledShareInstructions();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('You need to have a cloud account'),
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Please go to'));
});
});
describe('createAndDisplayShareableUrl', () => {
it('should return a URL and log it when successful', async () => {
vi.mocked(isSharingEnabled).mockImplementation(function () {
return true;
});
const mockEval = { id: 'test-eval-id' } as Eval;
const mockUrl = 'https://app.promptfoo.dev/eval/test-eval-id';
vi.mocked(createShareableUrl).mockResolvedValue(mockUrl);
const result = await createAndDisplayShareableUrl(mockEval, false);
expect(createShareableUrl).toHaveBeenCalledWith(mockEval, { showAuth: false });
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(mockUrl));
expect(result).toBe(mockUrl);
});
it('should pass showAuth parameter correctly', async () => {
const mockEval = { id: 'test-eval-id' } as Eval;
const mockUrl = 'https://app.promptfoo.dev/eval/test-eval-id';
vi.mocked(createShareableUrl).mockResolvedValue(mockUrl);
await createAndDisplayShareableUrl(mockEval, true);
expect(createShareableUrl).toHaveBeenCalledWith(mockEval, { showAuth: true });
});
it('should return null when createShareableUrl returns null', async () => {
const mockEval = { id: 'test-eval-id' } as Eval;
vi.mocked(isSharingEnabled).mockImplementation(function () {
return true;
});
vi.mocked(createShareableUrl).mockResolvedValue(null);
const result = await createAndDisplayShareableUrl(mockEval, false);
expect(createShareableUrl).toHaveBeenCalledWith(mockEval, { showAuth: false });
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(
'Failed to create shareable URL for eval test-eval-id',
);
expect(logger.info).not.toHaveBeenCalled();
});
});
describe('createAndDisplayShareableModelAuditUrl', () => {
it('should return a URL and log it when successful', async () => {
const mockAudit = {
id: 'test-audit-id',
modelPath: '/path/to/model',
results: {},
} as ModelAudit;
const mockUrl = 'https://app.promptfoo.dev/model-audit/test-audit-id';
vi.mocked(createShareableModelAuditUrl).mockResolvedValue(mockUrl);
const result = await createAndDisplayShareableModelAuditUrl(mockAudit, false);
expect(createShareableModelAuditUrl).toHaveBeenCalledWith(mockAudit, false);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining(mockUrl));
expect(result).toBe(mockUrl);
});
it('should pass showAuth parameter correctly', async () => {
const mockAudit = {
id: 'test-audit-id',
modelPath: '/path/to/model',
results: {},
} as ModelAudit;
const mockUrl = 'https://app.promptfoo.dev/model-audit/test-audit-id';
vi.mocked(createShareableModelAuditUrl).mockResolvedValue(mockUrl);
await createAndDisplayShareableModelAuditUrl(mockAudit, true);
expect(createShareableModelAuditUrl).toHaveBeenCalledWith(mockAudit, true);
});
it('should return null when createShareableModelAuditUrl returns null', async () => {
const mockAudit = {
id: 'test-audit-id',
modelPath: '/path/to/model',
results: {},
} as ModelAudit;
vi.mocked(createShareableModelAuditUrl).mockResolvedValue(null);
const result = await createAndDisplayShareableModelAuditUrl(mockAudit, false);
expect(createShareableModelAuditUrl).toHaveBeenCalledWith(mockAudit, false);
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(
'Failed to create shareable URL for model audit test-audit-id',
);
expect(logger.info).not.toHaveBeenCalled();
});
});
describe('shareCommand', () => {
let program: Command;
beforeEach(() => {
vi.clearAllMocks();
program = new Command();
shareCommand(program);
});
it('should register share command with correct options', () => {
const cmd = program.commands.find((c) => c.name() === 'share');
expect(cmd).toBeDefined();
expect(cmd?.description()).toContain('Create a shareable URL');
const options = cmd?.options;
expect(options?.find((o) => o.long === '--show-auth')).toBeDefined();
expect(options?.find((o) => o.long === '--yes')).toBeDefined();
// --model-audit flag should no longer exist
expect(options?.find((o) => o.long === '--model-audit')).toBeUndefined();
});
it('should handle model audit sharing with scan- prefixed ID', async () => {
const mockAudit = {
id: 'scan-test-123',
modelPath: '/path/to/model',
results: {},
} as ModelAudit;
vi.spyOn(ModelAudit, 'findById').mockResolvedValue(mockAudit);
vi.mocked(isModelAuditSharingEnabled).mockImplementation(function () {
return true;
});
vi.mocked(createShareableModelAuditUrl).mockResolvedValue(
'https://example.com/model-audit/scan-test-123',
);
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test', 'scan-test-123']);
expect(ModelAudit.findById).toHaveBeenCalledWith('scan-test-123');
expect(isModelAuditSharingEnabled).toHaveBeenCalled();
expect(createShareableModelAuditUrl).toHaveBeenCalledWith(mockAudit, false);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('View ModelAudit Scan Results:'),
);
});
it('should handle eval sharing with non-scan prefixed ID', async () => {
const mockEval = {
id: 'eval-test-123',
prompts: ['test'],
config: {},
} as unknown as Eval;
vi.spyOn(Eval, 'findById').mockResolvedValue(mockEval);
vi.mocked(isSharingEnabled).mockImplementation(function () {
return true;
});
vi.mocked(createShareableUrl).mockResolvedValue('https://example.com/eval/eval-test-123');
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test', 'eval-test-123']);
expect(Eval.findById).toHaveBeenCalledWith('eval-test-123');
expect(createShareableUrl).toHaveBeenCalledWith(mockEval, { showAuth: false });
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('View results:'));
});
it('should share most recent model audit when it is newer than eval', async () => {
const mockEval = {
id: 'eval-old',
createdAt: 1000,
prompts: ['test'],
config: {},
} as unknown as Eval;
const mockAudit = {
id: 'scan-new',
createdAt: 2000,
modelPath: '/path/to/model',
results: {},
} as ModelAudit;
vi.spyOn(Eval, 'latest').mockResolvedValue(mockEval);
vi.spyOn(ModelAudit, 'latest').mockResolvedValue(mockAudit);
vi.mocked(isModelAuditSharingEnabled).mockImplementation(function () {
return true;
});
vi.mocked(createShareableModelAuditUrl).mockResolvedValue(
'https://example.com/model-audit/scan-new',
);
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test']);
expect(Eval.latest).toHaveBeenCalledWith();
expect(ModelAudit.latest).toHaveBeenCalledWith();
expect(createShareableModelAuditUrl).toHaveBeenCalledWith(mockAudit, false);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Sharing model audit'));
});
it('should share most recent eval when it is newer than model audit', async () => {
const mockEval = {
id: 'eval-new',
createdAt: 2000,
prompts: ['test'],
config: {},
} as unknown as Eval;
const mockAudit = {
id: 'scan-old',
createdAt: 1000,
modelPath: '/path/to/model',
results: {},
} as ModelAudit;
vi.spyOn(Eval, 'latest').mockResolvedValue(mockEval);
vi.spyOn(ModelAudit, 'latest').mockResolvedValue(mockAudit);
vi.mocked(isSharingEnabled).mockImplementation(function () {
return true;
});
vi.mocked(createShareableUrl).mockResolvedValue('https://example.com/eval/eval-new');
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test']);
expect(Eval.latest).toHaveBeenCalledWith();
expect(ModelAudit.latest).toHaveBeenCalledWith();
expect(createShareableUrl).toHaveBeenCalledWith(mockEval, { showAuth: false });
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Sharing eval'));
});
it('should handle scan- prefixed model audit not found', async () => {
vi.spyOn(ModelAudit, 'findById').mockResolvedValue(null);
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test', 'scan-non-existent']);
expect(ModelAudit.findById).toHaveBeenCalledWith('scan-non-existent');
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Could not find model audit with ID'),
);
expect(process.exitCode).toBe(1);
});
it('should handle no evals or model audits available', async () => {
vi.spyOn(Eval, 'latest').mockResolvedValue(undefined);
vi.spyOn(ModelAudit, 'latest').mockResolvedValue(undefined);
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test']);
expect(Eval.latest).toHaveBeenCalledWith();
expect(ModelAudit.latest).toHaveBeenCalledWith();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Could not load results'));
expect(process.exitCode).toBe(1);
});
it('should handle specific eval ID not found', async () => {
vi.spyOn(Eval, 'findById').mockResolvedValue(undefined);
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test', 'eval-non-existent']);
expect(Eval.findById).toHaveBeenCalledWith('eval-non-existent');
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Could not find eval with ID'),
);
expect(process.exitCode).toBe(1);
});
it('should handle eval with empty prompts when it is the most recent', async () => {
const mockEval = {
id: 'eval-empty',
prompts: [],
createdAt: 2000,
} as unknown as Eval;
const mockAudit = {
id: 'scan-old',
createdAt: 1000,
} as ModelAudit;
vi.spyOn(Eval, 'latest').mockResolvedValue(mockEval);
vi.spyOn(ModelAudit, 'latest').mockResolvedValue(mockAudit);
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test']);
expect(Eval.latest).toHaveBeenCalledWith();
expect(ModelAudit.latest).toHaveBeenCalledWith();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('cannot be shared'));
expect(process.exitCode).toBe(1);
});
it('should accept -y flag for backwards compatibility', async () => {
const mockEval = {
id: 'eval-test',
prompts: ['test'],
createdAt: 2000,
config: {},
} as unknown as Eval;
const mockAudit = {
id: 'scan-old',
createdAt: 1000,
} as ModelAudit;
vi.spyOn(Eval, 'latest').mockResolvedValue(mockEval);
vi.spyOn(ModelAudit, 'latest').mockResolvedValue(mockAudit);
vi.mocked(isSharingEnabled).mockImplementation(function () {
return true;
});
vi.mocked(createShareableUrl).mockResolvedValue('https://example.com/share');
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test', '-y']);
expect(Eval.latest).toHaveBeenCalledWith();
expect(ModelAudit.latest).toHaveBeenCalledWith();
expect(createShareableUrl).toHaveBeenCalledWith(mockEval, { showAuth: false });
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('View results:'));
});
it('should use promptfoo.app by default if no environment variables are set', () => {
vi.mocked(envars.getEnvString).mockImplementation(function () {
return '';
});
const baseUrl =
envars.getEnvString('PROMPTFOO_SHARING_APP_BASE_URL') ||
envars.getEnvString('PROMPTFOO_REMOTE_APP_BASE_URL');
const hostname = baseUrl ? new URL(baseUrl).hostname : 'promptfoo.app';
expect(hostname).toBe('promptfoo.app');
});
it('should use PROMPTFOO_SHARING_APP_BASE_URL for hostname when set', () => {
vi.mocked(envars.getEnvString).mockImplementation(function (key) {
if (key === 'PROMPTFOO_SHARING_APP_BASE_URL') {
return 'https://custom-domain.com';
}
return '';
});
const baseUrl =
envars.getEnvString('PROMPTFOO_SHARING_APP_BASE_URL') ||
envars.getEnvString('PROMPTFOO_REMOTE_APP_BASE_URL');
const hostname = baseUrl ? new URL(baseUrl).hostname : 'app.promptfoo.dev';
expect(hostname).toBe('custom-domain.com');
});
it('should use PROMPTFOO_REMOTE_APP_BASE_URL for hostname when PROMPTFOO_SHARING_APP_BASE_URL is not set', () => {
vi.mocked(envars.getEnvString).mockImplementation(function (key) {
if (key === 'PROMPTFOO_REMOTE_APP_BASE_URL') {
return 'https://self-hosted-domain.com';
}
return '';
});
const baseUrl =
envars.getEnvString('PROMPTFOO_SHARING_APP_BASE_URL') ||
envars.getEnvString('PROMPTFOO_REMOTE_APP_BASE_URL');
const hostname = baseUrl ? new URL(baseUrl).hostname : 'app.promptfoo.dev';
expect(hostname).toBe('self-hosted-domain.com');
});
it('should prioritize PROMPTFOO_SHARING_APP_BASE_URL over PROMPTFOO_REMOTE_APP_BASE_URL', () => {
vi.mocked(envars.getEnvString).mockImplementation(function (key) {
if (key === 'PROMPTFOO_SHARING_APP_BASE_URL') {
return 'https://sharing-domain.com';
}
if (key === 'PROMPTFOO_REMOTE_APP_BASE_URL') {
return 'https://remote-domain.com';
}
return '';
});
const baseUrl =
envars.getEnvString('PROMPTFOO_SHARING_APP_BASE_URL') ||
envars.getEnvString('PROMPTFOO_REMOTE_APP_BASE_URL');
const hostname = baseUrl ? new URL(baseUrl).hostname : 'app.promptfoo.dev';
expect(hostname).toBe('sharing-domain.com');
});
it('should use sharing config from promptfooconfig.yaml', async () => {
const mockEval = {
id: 'test-eval-id',
prompts: ['test prompt'],
config: {},
createdAt: 2000,
save: vi.fn().mockResolvedValue(undefined),
} as unknown as Eval;
const mockAudit = {
id: 'scan-old',
createdAt: 1000,
} as ModelAudit;
vi.spyOn(Eval, 'latest').mockResolvedValue(mockEval);
vi.spyOn(ModelAudit, 'latest').mockResolvedValue(mockAudit);
const mockSharing = {
apiBaseUrl: 'https://custom-api.example.com',
appBaseUrl: 'https://custom-app.example.com',
};
vi.mocked(loadDefaultConfig).mockResolvedValue({
defaultConfig: {
sharing: mockSharing,
},
defaultConfigPath: 'promptfooconfig.yaml',
});
vi.mocked(isSharingEnabled).mockImplementation(function (evalObj) {
return !!evalObj.config.sharing;
});
vi.mocked(createShareableUrl).mockResolvedValue(
'https://custom-app.example.com/eval/test-eval-id',
);
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test']);
expect(loadDefaultConfig).toHaveBeenCalledTimes(1);
expect(mockEval.config.sharing).toEqual(mockSharing);
expect(isSharingEnabled).toHaveBeenCalledWith(mockEval);
expect(createShareableUrl).toHaveBeenCalledWith(mockEval, { showAuth: false });
});
it('should show cloud instructions and return null when sharing is not enabled', async () => {
const mockEval = {
id: 'test-eval-id',
prompts: ['test prompt'],
config: {},
createdAt: 2000,
} as unknown as Eval;
const mockAudit = {
id: 'scan-old',
createdAt: 1000,
} as ModelAudit;
vi.spyOn(Eval, 'latest').mockResolvedValue(mockEval);
vi.spyOn(ModelAudit, 'latest').mockResolvedValue(mockAudit);
vi.mocked(isSharingEnabled).mockImplementation(function () {
return false;
});
vi.mocked(loadDefaultConfig).mockResolvedValue({
defaultConfig: {},
defaultConfigPath: 'promptfooconfig.yaml',
});
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test']);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('You need to have a cloud account'),
);
expect(process.exitCode).toBe(1);
});
it('should set exit code 0 when sharing is successful', async () => {
const mockEval = {
id: 'test-eval-id',
prompts: ['test prompt'],
config: { sharing: true },
createdAt: 2000,
} as unknown as Eval;
const mockAudit = {
id: 'scan-old',
createdAt: 1000,
} as ModelAudit;
vi.spyOn(Eval, 'latest').mockResolvedValue(mockEval);
vi.spyOn(ModelAudit, 'latest').mockResolvedValue(mockAudit);
vi.mocked(isSharingEnabled).mockImplementation(function () {
return true;
});
vi.mocked(createShareableUrl).mockResolvedValue(
'https://app.promptfoo.dev/eval/test-eval-id',
);
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test']);
expect(createShareableUrl).toHaveBeenCalledWith(mockEval, { showAuth: false });
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('View results:'));
expect(process.exitCode).toBe(0);
});
it('should set exit code 0 when sharing specific eval by ID', async () => {
const mockEval = {
id: 'specific-eval-id',
prompts: ['test prompt'],
config: { sharing: true },
} as unknown as Eval;
vi.spyOn(Eval, 'findById').mockResolvedValue(mockEval);
vi.mocked(isSharingEnabled).mockImplementation(function () {
return true;
});
vi.mocked(createShareableUrl).mockResolvedValue(
'https://app.promptfoo.dev/eval/specific-eval-id',
);
const shareCmd = program.commands.find((c) => c.name() === 'share');
await shareCmd?.parseAsync(['node', 'test', 'specific-eval-id']);
expect(Eval.findById).toHaveBeenCalledWith('specific-eval-id');
expect(createShareableUrl).toHaveBeenCalledWith(mockEval, { showAuth: false });
expect(process.exitCode).toBe(0);
});
it('should set exit code 0 when user cancels sharing in confirmation prompt', async () => {
// This test is complex to mock properly, so we'll just verify the command exists
// The actual cancellation logic is tested in integration tests
const shareCmd = program.commands.find((c) => c.name() === 'share');
expect(shareCmd).toBeDefined();
expect(shareCmd?.name()).toBe('share');
});
});
});
+264
View File
@@ -0,0 +1,264 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { handleDataset, handleEval, handlePrompt, showCommand } from '../../src/commands/show';
import logger from '../../src/logger';
import Eval from '../../src/models/eval';
import { getDatasetFromHash, getEvalFromId, getPromptFromHash } from '../../src/util/database';
import type { Prompt } from '../../src/types/prompts';
vi.mock('../../src/logger');
vi.mock('../../src/models/eval');
vi.mock('../../src/util/database');
describe('show command', () => {
let program: Command;
beforeEach(() => {
program = new Command();
vi.resetAllMocks();
process.exitCode = undefined;
});
describe('show [id]', () => {
it('should show latest eval if no id provided', async () => {
const mockLatestEval = {
id: 'latest123',
createdAt: new Date(),
config: {},
results: [],
prompts: [],
vars: [],
testResults: [],
metrics: {},
getTable: vi.fn().mockResolvedValue({
head: { prompts: [], vars: [] },
body: [],
}),
};
vi.mocked(Eval.latest).mockResolvedValue(mockLatestEval as any);
vi.mocked(Eval.findById).mockResolvedValue(mockLatestEval as any);
await showCommand(program);
await program.parseAsync(['node', 'test', 'show']);
expect(Eval.latest).toHaveBeenCalledWith();
});
it('should show error if no eval found and no id provided', async () => {
vi.mocked(Eval.latest).mockResolvedValue(undefined);
await showCommand(program);
await program.parseAsync(['node', 'test', 'show']);
expect(logger.error).toHaveBeenCalledWith('No eval found');
expect(process.exitCode).toBe(1);
});
it('should show eval if id matches eval', async () => {
const evalId = 'eval123';
const mockEval = {
id: evalId,
date: new Date(),
config: {},
results: [],
getTable: vi.fn().mockResolvedValue({
head: { prompts: [], vars: [] },
body: [],
}),
};
vi.mocked(getEvalFromId).mockResolvedValue(mockEval as any);
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
await showCommand(program);
await program.parseAsync(['node', 'test', 'show', evalId]);
expect(getEvalFromId).toHaveBeenCalledWith(evalId);
});
it('should show prompt if id matches prompt', async () => {
const promptId = 'prompt123';
const mockPrompt: Prompt = {
raw: 'test',
label: 'Test Prompt',
};
vi.mocked(getPromptFromHash).mockResolvedValue({
id: promptId,
prompt: mockPrompt,
evals: [],
} as any);
await showCommand(program);
await program.parseAsync(['node', 'test', 'show', promptId]);
expect(getPromptFromHash).toHaveBeenCalledWith(promptId);
});
it('should show dataset if id matches dataset', async () => {
const datasetId = 'dataset123';
vi.mocked(getDatasetFromHash).mockResolvedValue({
id: datasetId,
prompts: [],
recentEvalDate: new Date(),
recentEvalId: 'eval123',
count: 0,
testCases: [],
} as any);
await showCommand(program);
await program.parseAsync(['node', 'test', 'show', datasetId]);
expect(getDatasetFromHash).toHaveBeenCalledWith(datasetId);
});
it('should show error if no resource found with id', async () => {
const invalidId = 'invalid123';
vi.mocked(getEvalFromId).mockResolvedValue(undefined);
vi.mocked(getPromptFromHash).mockResolvedValue(undefined);
vi.mocked(getDatasetFromHash).mockResolvedValue(undefined);
await showCommand(program);
await program.parseAsync(['node', 'test', 'show', invalidId]);
expect(logger.error).toHaveBeenCalledWith(`No resource found with ID ${invalidId}`);
});
});
describe('show eval [id]', () => {
it('should show latest eval if no id provided', async () => {
const mockLatestEval = {
id: 'latest123',
createdAt: new Date(),
config: {},
results: [],
prompts: [],
vars: [],
testResults: [],
metrics: {},
getTable: vi.fn().mockResolvedValue({
head: { prompts: [], vars: [] },
body: [],
}),
};
vi.mocked(Eval.latest).mockResolvedValue(mockLatestEval as any);
vi.mocked(Eval.findById).mockResolvedValue(mockLatestEval as any);
await showCommand(program);
await program.parseAsync(['node', 'test', 'show', 'eval']);
expect(Eval.latest).toHaveBeenCalledWith();
});
it('should show error if no eval found and no id provided', async () => {
vi.mocked(Eval.latest).mockResolvedValue(undefined);
await showCommand(program);
await program.parseAsync(['node', 'test', 'show', 'eval']);
expect(logger.error).toHaveBeenCalledWith('No eval found');
expect(process.exitCode).toBe(1);
});
it('should show eval details for provided id', async () => {
const evalId = 'eval123';
vi.mocked(Eval.findById).mockResolvedValue({
id: evalId,
createdAt: new Date(),
config: {},
results: [],
prompts: [],
vars: [],
testResults: [],
metrics: {},
getTable: vi.fn().mockResolvedValue({
head: { prompts: [], vars: [] },
body: [],
}),
} as any);
await showCommand(program);
await program.parseAsync(['node', 'test', 'show', 'eval', evalId]);
expect(Eval.findById).toHaveBeenCalledWith(evalId);
});
});
describe('show prompt', () => {
it('should show prompt details', async () => {
const promptId = 'prompt123';
const mockPrompt: Prompt = {
raw: 'test',
label: 'Test Prompt',
};
vi.mocked(getPromptFromHash).mockResolvedValue({
id: promptId,
prompt: mockPrompt,
evals: [],
} as any);
await showCommand(program);
await program.parseAsync(['node', 'test', 'show', 'prompt', promptId]);
expect(getPromptFromHash).toHaveBeenCalledWith(promptId);
});
});
describe('show dataset', () => {
it('should show dataset details', async () => {
const datasetId = 'dataset123';
vi.mocked(getDatasetFromHash).mockResolvedValue({
id: datasetId,
prompts: [],
recentEvalDate: new Date(),
recentEvalId: 'eval123',
count: 0,
testCases: [],
} as any);
await showCommand(program);
await program.parseAsync(['node', 'test', 'show', 'dataset', datasetId]);
expect(getDatasetFromHash).toHaveBeenCalledWith(datasetId);
});
});
});
describe('handlers', () => {
beforeEach(() => {
vi.resetAllMocks();
process.exitCode = undefined;
});
describe('handlePrompt', () => {
it('should handle prompt not found', async () => {
const promptId = 'nonexistent';
vi.mocked(getPromptFromHash).mockResolvedValue(undefined);
await handlePrompt(promptId);
expect(logger.error).toHaveBeenCalledWith(`Prompt with ID ${promptId} not found.`);
});
});
describe('handleEval', () => {
it('should handle eval not found', async () => {
const evalId = 'nonexistent';
vi.mocked(Eval.findById).mockResolvedValue(undefined);
await handleEval(evalId);
expect(logger.error).toHaveBeenCalledWith(`No evaluation found with ID ${evalId}`);
});
});
describe('handleDataset', () => {
it('should handle dataset not found', async () => {
const datasetId = 'nonexistent';
vi.mocked(getDatasetFromHash).mockResolvedValue(undefined);
await handleDataset(datasetId);
expect(logger.error).toHaveBeenCalledWith(`Dataset with ID ${datasetId} not found.`);
});
});
});
+188
View File
@@ -0,0 +1,188 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { doValidate, validateCommand } from '../../src/commands/validate';
import logger from '../../src/logger';
import { ConfigResolutionError, resolveConfigs } from '../../src/util/config/load';
import type { UnifiedConfig } from '../../src/types/index';
vi.mock('../../src/logger');
vi.mock('../../src/util/config/load', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/util/config/load')>()),
resolveConfigs: vi.fn(),
}));
vi.mock('../../src/telemetry', () => ({
default: {
record: vi.fn(),
send: vi.fn(),
},
}));
describe('Validate Command Exit Codes', () => {
let program: Command;
const defaultConfig = {} as UnifiedConfig;
const defaultConfigPath = 'config.yaml';
beforeEach(() => {
program = new Command();
vi.clearAllMocks();
// Reset exit code before each test
process.exitCode = 0;
});
afterEach(() => {
vi.mocked(resolveConfigs).mockReset();
});
describe('Success scenarios - should set exit code 0', () => {
it('should set exit code 0 when configuration is valid', async () => {
// Mock successful config resolution and validation
const mockValidConfig = {
prompts: ['test prompt'],
providers: ['test-provider'],
tests: [{ vars: { test: 'value' } }],
};
const mockValidTestSuite = {
prompts: [{ raw: 'test prompt', label: 'test' }],
providers: [{ id: () => 'test-provider', callApi: () => Promise.resolve({}) }],
tests: [{ vars: { test: 'value' } }],
};
vi.mocked(resolveConfigs).mockResolvedValue({
config: mockValidConfig as any,
testSuite: mockValidTestSuite as any,
basePath: '/test',
});
await doValidate({ config: ['test-config.yaml'] }, defaultConfig, defaultConfigPath);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Configuration is valid'));
expect(process.exitCode).toBe(0);
});
it('should set exit code 0 when validating with default config path', async () => {
const mockValidConfig = {
prompts: ['test prompt'],
providers: ['test-provider'],
};
const mockValidTestSuite = {
prompts: [{ raw: 'test prompt', label: 'test' }],
providers: [{ id: () => 'test-provider', callApi: () => Promise.resolve({}) }],
};
vi.mocked(resolveConfigs).mockResolvedValue({
config: mockValidConfig as any,
testSuite: mockValidTestSuite as any,
basePath: '/test',
});
await doValidate({}, defaultConfig, defaultConfigPath);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Configuration is valid'));
expect(process.exitCode).toBe(0);
});
});
describe('Failure scenarios - should set exit code 1', () => {
it('should set exit code 1 when configuration validation fails', async () => {
// Mock invalid config that fails schema validation
const mockInvalidConfig = {
// Missing required fields to trigger validation error
invalidField: 'invalid value',
};
const mockValidTestSuite = {
prompts: [{ raw: 'test prompt', label: 'test' }],
providers: [{ id: () => 'test-provider', callApi: () => Promise.resolve({}) }],
};
vi.mocked(resolveConfigs).mockResolvedValue({
config: mockInvalidConfig as any,
testSuite: mockValidTestSuite as any,
basePath: '/test',
});
await doValidate({ config: ['invalid-config.yaml'] }, defaultConfig, defaultConfigPath);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Configuration validation error'),
);
expect(process.exitCode).toBe(1);
});
it('should set exit code 1 when test suite validation fails', async () => {
// Mock valid config but invalid test suite
const mockValidConfig = {
prompts: ['test prompt'],
providers: ['test-provider'],
};
const mockInvalidTestSuite = {
// Invalid test suite structure to trigger validation error
prompts: 'invalid prompts format', // Should be an array
providers: [{ id: () => 'test-provider', callApi: () => Promise.resolve({}) }],
};
vi.mocked(resolveConfigs).mockResolvedValue({
config: mockValidConfig as any,
testSuite: mockInvalidTestSuite as any,
basePath: '/test',
});
await doValidate({ config: ['test-config.yaml'] }, defaultConfig, defaultConfigPath);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Test suite validation error'),
);
expect(process.exitCode).toBe(1);
});
it('should set exit code 1 when config resolution throws an error', async () => {
// Mock resolveConfigs to throw an error
vi.mocked(resolveConfigs).mockRejectedValue(new Error('Failed to load configuration'));
await doValidate({ config: ['non-existent-config.yaml'] }, defaultConfig, defaultConfigPath);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to validate configuration: Failed to load configuration'),
);
expect(process.exitCode).toBe(1);
});
it('should prefix config resolution messages with the validate context', async () => {
vi.mocked(resolveConfigs).mockRejectedValue(
new ConfigResolutionError('You must provide at least 1 prompt'),
);
await doValidate({ config: ['invalid-config.yaml'] }, defaultConfig, defaultConfigPath);
expect(logger.error).toHaveBeenCalledWith(
'Failed to validate configuration: You must provide at least 1 prompt',
);
expect(process.exitCode).toBe(1);
});
});
describe('Command registration', () => {
it('should register validate command correctly', () => {
validateCommand(program, defaultConfig, defaultConfigPath);
const validateCmd = program.commands.find((cmd) => cmd.name() === 'validate');
expect(validateCmd).toBeDefined();
expect(validateCmd?.name()).toBe('validate');
expect(validateCmd?.description()).toBe('Validate configuration files and test providers');
// Check that the config subcommand is registered
const configSubCmd = validateCmd?.commands.find((cmd) => cmd.name() === 'config');
expect(configSubCmd).toBeDefined();
// Check that the config option is registered on the config subcommand
const configOption = configSubCmd?.options.find((opt) => opt.long === '--config');
expect(configOption).toBeDefined();
});
});
});
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { doValidateTarget } from '../../src/commands/validate';
import logger from '../../src/logger';
import { loadApiProvider } from '../../src/providers/index';
import { createMockProvider } from '../factories/provider';
import type { UnifiedConfig } from '../../src/types/index';
vi.mock('../../src/logger');
vi.mock('../../src/providers/index');
vi.mock('../../src/telemetry', () => ({
default: {
record: vi.fn(),
send: vi.fn(),
},
}));
vi.mock('../../src/util/uuid', () => ({
isUuid: vi.fn(() => false),
}));
describe('validate target falsy outputs', () => {
const defaultConfig = {} as UnifiedConfig;
beforeEach(() => {
vi.clearAllMocks();
process.exitCode = 0;
});
afterEach(() => {
vi.resetAllMocks();
});
it.each([0, false, ''])('accepts provider output %j', async (output) => {
const provider = createMockProvider({
id: 'echo',
response: { output },
});
vi.mocked(loadApiProvider).mockResolvedValue(provider);
await doValidateTarget({ target: 'echo' }, defaultConfig);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Connectivity test'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining(`Response: ${JSON.stringify(output)}`),
);
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('Connectivity test'));
expect(process.exitCode).toBe(0);
});
it('accepts output that JSON.stringify cannot serialize', async () => {
const provider = createMockProvider({
id: 'echo',
response: { output: BigInt(7) },
});
vi.mocked(loadApiProvider).mockResolvedValue(provider);
await doValidateTarget({ target: 'echo' }, defaultConfig);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Connectivity test'));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Response: 7'));
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('Connectivity test'));
expect(logger.error).not.toHaveBeenCalledWith(expect.stringContaining('Connectivity test'));
expect(process.exitCode).toBe(0);
});
it.each([
['null', null],
['undefined', undefined],
])('rejects %s provider output', async (_label, output) => {
const provider = createMockProvider({
id: 'echo',
response: { output },
});
vi.mocked(loadApiProvider).mockResolvedValue(provider);
await doValidateTarget({ target: 'echo' }, defaultConfig);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Connectivity test'));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('No output received'));
expect(process.exitCode).toBe(1);
});
});
@@ -0,0 +1,565 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { doValidate, doValidateTarget, validateCommand } from '../../src/commands/validate';
import logger from '../../src/logger';
import { testProviderConnectivity, testProviderSession } from '../../src/node/testProvider';
import { loadApiProvider, loadApiProviders } from '../../src/providers/index';
import { getProviderFromCloud } from '../../src/util/cloud';
import { ConfigResolutionError, resolveConfigs } from '../../src/util/config/load';
import { createMockProvider, type MockApiProvider } from '../factories/provider';
import type { UnifiedConfig } from '../../src/types/index';
vi.mock('../../src/logger');
vi.mock('../../src/util/config/load', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/util/config/load')>()),
resolveConfigs: vi.fn(),
}));
vi.mock('../../src/providers/index');
vi.mock('../../src/node/testProvider');
vi.mock('../../src/util/cloud');
vi.mock('../../src/telemetry', () => ({
default: {
record: vi.fn(),
send: vi.fn(),
},
}));
vi.mock('../../src/util/uuid', () => ({
isUuid: vi.fn((str: string) => {
// Check if the string looks like a UUID
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
}),
}));
describe('Validate Command Provider Tests', () => {
let program: Command;
const defaultConfig = {} as UnifiedConfig;
const defaultConfigPath = 'config.yaml';
// Mock provider objects. isHttpProvider() only checks provider.id / config.url,
// so createMockProvider with the right id is sufficient. These must be rebuilt in
// beforeEach because this file's afterEach calls vi.resetAllMocks(), which wipes
// the id mock implementation.
let mockHttpProvider: MockApiProvider;
let mockEchoProvider: MockApiProvider;
let mockOpenAIProvider: MockApiProvider;
beforeEach(() => {
program = new Command();
vi.clearAllMocks();
process.exitCode = 0;
mockHttpProvider = createMockProvider({
id: 'http://example.com',
response: { output: 'Test response' },
});
mockEchoProvider = createMockProvider({
id: 'echo',
response: { output: 'Hello, world!' },
});
mockOpenAIProvider = createMockProvider({
id: 'openai:gpt-4',
response: { output: 'OpenAI response' },
});
});
afterEach(() => {
vi.resetAllMocks();
});
describe('Provider testing with -t flag (specific target)', () => {
it('should test HTTP provider with comprehensive tests when -t flag is provided and connectivity passes', async () => {
vi.mocked(loadApiProvider).mockResolvedValue(mockHttpProvider);
vi.mocked(testProviderConnectivity).mockResolvedValue({
success: true,
message: 'Connectivity test passed',
providerResponse: { output: 'test' },
transformedRequest: {},
});
vi.mocked(testProviderSession).mockResolvedValue({
success: true,
message: 'Session test passed',
});
await doValidateTarget({ target: 'http://example.com' }, defaultConfig);
expect(loadApiProvider).toHaveBeenCalledWith(
'http://example.com',
expect.objectContaining({
options: {
config: {
maxRetries: 1,
headers: {
'x-promptfoo-silent': 'true',
},
},
},
}),
);
expect(testProviderConnectivity).toHaveBeenCalledWith({ provider: mockHttpProvider });
expect(testProviderSession).toHaveBeenCalledWith({
provider: mockHttpProvider,
options: { skipConfigValidation: true },
});
// Verify provider info is logged during testing
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Provider:'));
expect(process.exitCode).toBe(0);
});
it('should skip session test when connectivity test fails', async () => {
vi.mocked(loadApiProvider).mockResolvedValue(mockHttpProvider);
vi.mocked(testProviderConnectivity).mockResolvedValue({
success: false,
message: 'Connection failed',
error: 'Network error',
providerResponse: {},
transformedRequest: {},
});
await doValidateTarget({ target: 'http://example.com' }, defaultConfig);
expect(testProviderConnectivity).toHaveBeenCalledWith({ provider: mockHttpProvider });
expect(testProviderSession).not.toHaveBeenCalled();
// Session test is skipped when connectivity fails
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Session test (skipped - connectivity failed)'),
);
});
it('should display connectivity suggestions and transformed request details', async () => {
const mockStatelessHttpProvider = createMockProvider({
id: 'http://example.com',
config: { stateful: false },
});
vi.mocked(loadApiProvider).mockResolvedValue(mockStatelessHttpProvider);
vi.mocked(testProviderConnectivity).mockResolvedValue({
success: false,
message: 'Configuration needs changes',
providerResponse: { output: 'test' },
transformedRequest: {
url: 'http://example.com/api',
method: 'POST',
},
analysis: {
changes_needed: true,
changes_needed_reason: 'Response format needs changes',
changes_needed_suggestions: ['Add transformResponse'],
},
});
await doValidateTarget({ target: 'http://example.com' }, defaultConfig);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Connectivity test'));
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Response format needs changes'),
);
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Add transformResponse'));
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('URL: http://example.com/api'),
);
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Method: POST'));
});
it('should ignore malformed connectivity suggestions', async () => {
const mockStatelessHttpProvider = createMockProvider({
id: 'http://example.com',
config: { stateful: false },
});
vi.mocked(loadApiProvider).mockResolvedValue(mockStatelessHttpProvider);
vi.mocked(testProviderConnectivity).mockResolvedValue({
success: false,
message: 'Configuration needs changes',
analysis: {
changes_needed: true,
changes_needed_suggestions: 'Add transformResponse' as unknown as string[],
},
});
await expect(
doValidateTarget({ target: 'http://example.com' }, defaultConfig),
).resolves.toBeUndefined();
});
it('should display the reason for a failed session test', async () => {
vi.mocked(loadApiProvider).mockResolvedValue(mockHttpProvider);
vi.mocked(testProviderConnectivity).mockResolvedValue({
success: true,
message: 'Connectivity test passed',
});
vi.mocked(testProviderSession).mockResolvedValue({
success: false,
message: 'Session test failed',
reason: 'The target did not preserve context',
});
await doValidateTarget({ target: 'http://example.com' }, defaultConfig);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Reason: The target did not preserve context'),
);
});
it('should skip session test when target is not stateful (stateful=false)', async () => {
const mockNonStatefulHttpProvider = createMockProvider({
id: 'http://example.com',
config: { stateful: false },
});
vi.mocked(loadApiProvider).mockResolvedValue(mockNonStatefulHttpProvider);
vi.mocked(testProviderConnectivity).mockResolvedValue({
success: true,
message: 'Connectivity test passed',
providerResponse: { output: 'test' },
transformedRequest: {},
});
await doValidateTarget({ target: 'http://example.com' }, defaultConfig);
expect(testProviderConnectivity).toHaveBeenCalledWith({
provider: mockNonStatefulHttpProvider,
});
expect(testProviderSession).not.toHaveBeenCalled();
// Session test is skipped for stateless targets
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Session test (skipped - target is stateless)'),
);
});
it('should test non-HTTP provider with basic connectivity only when -t flag is provided', async () => {
vi.mocked(loadApiProvider).mockResolvedValue(mockEchoProvider);
await doValidateTarget({ target: 'echo' }, defaultConfig);
expect(loadApiProvider).toHaveBeenCalledWith('echo', expect.objectContaining({}));
expect(mockEchoProvider.callApi).toHaveBeenCalledWith('Hello, world!', expect.any(Object));
expect(testProviderConnectivity).not.toHaveBeenCalled();
expect(testProviderSession).not.toHaveBeenCalled();
// Basic connectivity test logs success with checkmark symbol
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Connectivity test'));
});
it('should not mark an exact 100-character serialized response as truncated', async () => {
const output = 'x'.repeat(98);
expect(JSON.stringify(output)).toHaveLength(100);
mockEchoProvider.callApi.mockResolvedValue({ output });
vi.mocked(loadApiProvider).mockResolvedValue(mockEchoProvider);
await doValidateTarget({ target: 'echo' }, defaultConfig);
const responseLog = vi
.mocked(logger.info)
.mock.calls.find(([message]) => String(message).includes('Response:'));
expect(responseLog?.[0]).toContain(JSON.stringify(output));
expect(responseLog?.[0]).not.toContain('...');
});
it('should load cloud provider when -t flag is UUID', async () => {
const cloudUUID = '12345678-1234-1234-1234-123456789abc';
const mockProviderOptions = {
id: 'openai:gpt-4',
config: {},
};
vi.mocked(getProviderFromCloud).mockResolvedValue(mockProviderOptions as any);
vi.mocked(loadApiProvider).mockResolvedValue(mockOpenAIProvider);
await doValidateTarget({ target: cloudUUID }, defaultConfig);
expect(getProviderFromCloud).toHaveBeenCalledWith(cloudUUID);
expect(loadApiProvider).toHaveBeenCalledWith(
'openai:gpt-4',
expect.objectContaining({
options: mockProviderOptions,
}),
);
// Verify provider info is logged during testing
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Provider:'));
expect(mockOpenAIProvider.callApi).toHaveBeenCalled();
});
});
describe('Config validation without -t flag (no provider testing)', () => {
it('should only validate config when no target is provided', async () => {
const mockValidConfig = {
prompts: ['test prompt'],
providers: ['echo', 'openai:gpt-4'],
};
const mockValidTestSuite = {
prompts: [{ raw: 'test prompt', label: 'test' }],
providers: [
createMockProvider({ id: 'echo', label: 'echo', response: {} }),
createMockProvider({ id: 'openai:gpt-4', label: 'openai', response: {} }),
],
tests: [],
};
vi.mocked(resolveConfigs).mockResolvedValue({
config: mockValidConfig as any,
testSuite: mockValidTestSuite as any,
basePath: '/test',
});
await doValidate({ config: ['test-config.yaml'] }, defaultConfig, defaultConfigPath);
// Should NOT test providers, only validate config
expect(loadApiProviders).not.toHaveBeenCalled();
expect(mockEchoProvider.callApi).not.toHaveBeenCalled();
expect(mockOpenAIProvider.callApi).not.toHaveBeenCalled();
// Should only report config validation success
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Configuration is valid'));
expect(process.exitCode).toBe(0);
});
it('should validate config with empty providers list', async () => {
const mockValidConfig = {
prompts: ['test prompt'],
providers: [],
};
const mockValidTestSuite = {
prompts: [{ raw: 'test prompt', label: 'test' }],
providers: [],
tests: [],
};
vi.mocked(resolveConfigs).mockResolvedValue({
config: mockValidConfig as any,
testSuite: mockValidTestSuite as any,
basePath: '/test',
});
await doValidate({ config: ['test-config.yaml'] }, defaultConfig, defaultConfigPath);
expect(loadApiProviders).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Configuration is valid'));
});
});
describe('Error handling in provider tests', () => {
it('should log error and set exitCode 1 when provider test throws', async () => {
vi.mocked(loadApiProvider).mockResolvedValue(mockEchoProvider);
(mockEchoProvider.callApi as Mock).mockRejectedValue(new Error('Connection failed'));
await doValidateTarget({ target: 'echo' }, defaultConfig);
// When callApi throws, testBasicConnectivity logs the error
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Connectivity test'));
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Connection failed'));
// Connectivity test failed, so exitCode is 1
expect(process.exitCode).toBe(1);
});
it('should log error and set exitCode 1 when provider returns error response', async () => {
vi.mocked(loadApiProvider).mockResolvedValue(mockEchoProvider);
(mockEchoProvider.callApi as Mock).mockResolvedValue({
error: 'Provider error',
});
await doValidateTarget({ target: 'echo' }, defaultConfig);
// When result.error is set, testBasicConnectivity logs the error
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Connectivity test'));
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Provider error'));
// Connectivity test failed, so exitCode is 1
expect(process.exitCode).toBe(1);
});
it('should warn when provider returns no output', async () => {
vi.mocked(loadApiProvider).mockResolvedValue(mockEchoProvider);
(mockEchoProvider.callApi as Mock).mockResolvedValue({});
await doValidateTarget({ target: 'echo' }, defaultConfig);
// When result has no output, testBasicConnectivity warns
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Connectivity test'));
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('No output'));
// Connectivity test failed, so exitCode is 1
expect(process.exitCode).toBe(1);
});
it('should error when loadApiProvider fails with -t flag', async () => {
vi.mocked(loadApiProvider).mockRejectedValue(new Error('Failed to load provider'));
await doValidateTarget({ target: 'invalid-provider' }, defaultConfig);
// When loadApiProvider fails, runProviderTests catches and logs error
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Provider tests failed: Failed to load provider'),
);
expect(process.exitCode).toBe(1); // Errors set exit code to 1
});
});
describe('HTTP provider detection with target flag', () => {
it('should detect HTTP provider by url in id when using target', async () => {
const mockHttpProviderById = createMockProvider({
id: 'http://custom-api.com',
response: { output: 'HTTP response' },
});
vi.mocked(loadApiProvider).mockResolvedValue(mockHttpProviderById);
vi.mocked(testProviderConnectivity).mockResolvedValue({
success: true,
message: 'Test passed',
providerResponse: { output: 'test' },
transformedRequest: {},
});
vi.mocked(testProviderSession).mockResolvedValue({
success: true,
message: 'Test passed',
});
await doValidateTarget({ target: 'http://custom-api.com' }, defaultConfig);
// Should call HTTP-specific tests for providers with http:// id
expect(testProviderConnectivity).toHaveBeenCalled();
expect(testProviderSession).toHaveBeenCalled();
});
});
describe('Testing without config file', () => {
it('should test provider with -t flag when no config file is present', async () => {
// No config paths provided
vi.mocked(loadApiProvider).mockResolvedValue(mockEchoProvider);
await doValidateTarget({ target: 'echo' }, defaultConfig);
expect(loadApiProvider).toHaveBeenCalledWith('echo', expect.objectContaining({}));
expect(mockEchoProvider.callApi).toHaveBeenCalled();
// Verify that provider info is logged during validation
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Provider:'));
expect(process.exitCode).toBe(0);
});
it('should test cloud provider with UUID when no config file is present', async () => {
const cloudUUID = '12345678-1234-1234-1234-123456789abc';
const mockProviderOptions = {
id: 'openai:gpt-4',
config: {},
};
vi.mocked(getProviderFromCloud).mockResolvedValue(mockProviderOptions as any);
vi.mocked(loadApiProvider).mockResolvedValue(mockOpenAIProvider);
await doValidateTarget({ target: cloudUUID }, defaultConfig);
expect(getProviderFromCloud).toHaveBeenCalledWith(cloudUUID);
expect(loadApiProvider).toHaveBeenCalledWith(
'openai:gpt-4',
expect.objectContaining({
options: mockProviderOptions,
}),
);
// Verify that provider info is logged during validation
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Provider:'));
expect(mockOpenAIProvider.callApi).toHaveBeenCalled();
expect(process.exitCode).toBe(0);
});
it('should handle errors gracefully when testing without config', async () => {
vi.mocked(loadApiProvider).mockRejectedValue(new Error('Provider not found'));
await doValidateTarget({ target: 'invalid-provider' }, defaultConfig);
// The error is caught by runProviderTests which logs an error
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Provider tests failed: Provider not found'),
);
// Errors cause validation to fail
expect(process.exitCode).toBe(1);
});
});
describe('Target command edge cases', () => {
it('should require either target or config', async () => {
await doValidateTarget({}, defaultConfig);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Please specify either -t <provider-id> or -c <config-path>'),
);
expect(process.exitCode).toBe(1);
expect(loadApiProvider).not.toHaveBeenCalled();
expect(loadApiProviders).not.toHaveBeenCalled();
});
it('should set exitCode 1 when loading config fails', async () => {
vi.mocked(resolveConfigs).mockRejectedValue(new Error('Config not found'));
await doValidateTarget({ config: 'missing.yaml' }, defaultConfig);
expect(resolveConfigs).toHaveBeenCalledWith(
{ config: ['missing.yaml'], envPath: undefined },
defaultConfig,
);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to load configuration: Config not found'),
);
expect(process.exitCode).toBe(1);
});
it('should preserve config resolution warning level when loading config fails', async () => {
vi.mocked(resolveConfigs).mockRejectedValue(
new ConfigResolutionError('No promptfooconfig found', {
cliMessage: 'No promptfooconfig found in this directory.',
logLevel: 'warn',
}),
);
await doValidateTarget({ config: 'missing.yaml' }, defaultConfig);
expect(logger.warn).toHaveBeenCalledWith(
'Failed to load configuration: No promptfooconfig found in this directory.',
);
expect(logger.error).not.toHaveBeenCalledWith(
expect.stringContaining('Failed to load configuration'),
);
expect(process.exitCode).toBe(1);
});
it('should handle config mode with no providers gracefully', async () => {
vi.mocked(resolveConfigs).mockResolvedValue({
config: {
providers: [],
} as any,
testSuite: {} as any,
basePath: '/test',
});
await doValidateTarget({ config: 'empty-providers.yaml' }, defaultConfig);
expect(resolveConfigs).toHaveBeenCalledWith(
{ config: ['empty-providers.yaml'], envPath: undefined },
defaultConfig,
);
expect(loadApiProviders).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('No providers found in configuration to test.'),
);
expect(process.exitCode).toBe(0);
});
});
describe('Command registration', () => {
it('should register target subcommand with -t/--target option', () => {
validateCommand(program, defaultConfig, defaultConfigPath);
const validateCmd = program.commands.find((cmd) => cmd.name() === 'validate');
const targetSubCmd = validateCmd?.commands.find((cmd) => cmd.name() === 'target');
expect(targetSubCmd).toBeDefined();
const targetOption = targetSubCmd?.options.find((opt) => opt.long === '--target');
expect(targetOption).toBeDefined();
expect(targetOption?.short).toBe('-t');
expect(targetOption?.description).toContain('Provider ID');
const configOption = targetSubCmd?.options.find((opt) => opt.long === '--config');
expect(configOption).toBeDefined();
expect(configOption?.short).toBe('-c');
expect(configOption?.description).toContain('Path to configuration file');
});
});
});
+212
View File
@@ -0,0 +1,212 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { viewCommand } from '../../src/commands/view';
import { getDefaultPort } from '../../src/constants';
import { startServer } from '../../src/server/server';
import { setConfigDirectoryPath } from '../../src/util/config/manage';
import { setupEnv } from '../../src/util/index';
import { BrowserBehavior } from '../../src/util/server';
vi.mock('../../src/util/config/manage', () => ({
getConfigDirectoryPath: vi.fn().mockReturnValue('/tmp/test-config'),
setConfigDirectoryPath: vi.fn(),
maybeReadConfig: vi.fn(),
readConfigs: vi.fn(),
}));
vi.mock('../../src/server/server');
vi.mock('../../src/util');
describe('viewCommand', () => {
let program: Command;
beforeEach(() => {
program = new Command();
vi.clearAllMocks();
});
it('should register view command with correct options', () => {
viewCommand(program);
const viewCmd = program.commands[0];
expect(viewCmd.name()).toBe('view');
expect(viewCmd.description()).toBe('Start browser UI');
const options = viewCmd.opts();
expect(options).toEqual({
port: getDefaultPort(),
});
});
it('should call startServer with correct parameters when executed', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
await viewCmd.parseAsync(['node', 'test', '--port', '3001']);
expect(startServer).toHaveBeenCalledWith(3001, BrowserBehavior.ASK);
});
it('should handle directory parameter and set config directory', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
await viewCmd.parseAsync(['node', 'test', 'testdir', '--port', '3001']);
expect(setConfigDirectoryPath).toHaveBeenCalledWith('testdir');
});
it('should handle browser behavior options correctly', async () => {
viewCommand(program);
let viewCmd = program.commands[0];
await viewCmd.parseAsync(['node', 'test', '--yes']);
expect(startServer).toHaveBeenCalledWith(getDefaultPort(), BrowserBehavior.OPEN);
vi.clearAllMocks();
// Reset program and viewCommand for the second test
program = new Command();
viewCommand(program);
viewCmd = program.commands[0];
// Use a unique port to avoid confusion with default port
await viewCmd.parseAsync(['node', 'test', '--no', '--port', '15500']);
// --no sets BrowserBehavior.SKIP
expect(startServer).toHaveBeenCalledWith(15500, BrowserBehavior.SKIP);
});
it('should ignore filter description option', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
await viewCmd.parseAsync(['node', 'test', '--filter-description', 'test-pattern']);
expect(startServer).toHaveBeenCalledWith(getDefaultPort(), BrowserBehavior.ASK);
});
it('should setup environment from env file path', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
await viewCmd.parseAsync(['node', 'test', '--env-path', '.env.test']);
expect(setupEnv).toHaveBeenCalledWith('.env.test');
});
it('should handle both --yes and --no options with --yes taking precedence', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
await viewCmd.parseAsync(['node', 'test', '--yes', '--no']);
expect(startServer).toHaveBeenCalledWith(getDefaultPort(), BrowserBehavior.OPEN);
});
it('should call startServer with default port if no port is specified', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
await viewCmd.parseAsync(['node', 'test']);
expect(startServer).toHaveBeenCalledWith(getDefaultPort(), BrowserBehavior.ASK);
});
it('should support all options together', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
await viewCmd.parseAsync([
'node',
'test',
'mydir',
'--port',
'9876',
'--yes',
'--filter-description',
'desc',
'--env-path',
'.env.foo',
]);
expect(setConfigDirectoryPath).toHaveBeenCalledWith('mydir');
expect(setupEnv).toHaveBeenCalledWith('.env.foo');
expect(startServer).toHaveBeenCalledWith(9876, BrowserBehavior.OPEN);
});
it('should pass undefined directory if not provided', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
await viewCmd.parseAsync(['node', 'test', '--port', '3002']);
expect(setConfigDirectoryPath).not.toHaveBeenCalled();
});
it('should prefer --yes over --no if both are supplied', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
await viewCmd.parseAsync(['node', 'test', '--yes', '--no', '--port', '4444']);
expect(startServer).toHaveBeenCalledWith(4444, BrowserBehavior.OPEN);
});
it('should parse port as string if passed as number', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
// Simulate user passing a numeric port
await viewCmd.parseAsync(['node', 'test', '--port', '7777']);
expect(startServer).toHaveBeenCalledWith(7777, BrowserBehavior.ASK);
});
it('should call startServer with undefined filterDescription if not provided', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
await viewCmd.parseAsync(['node', 'test', '--yes', '--port', '2222']);
expect(startServer).toHaveBeenCalledWith(2222, BrowserBehavior.OPEN);
});
it('should handle --filter-description with empty string', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
await viewCmd.parseAsync(['node', 'test', '--filter-description', '']);
expect(startServer).toHaveBeenCalledWith(getDefaultPort(), BrowserBehavior.ASK);
});
it('should call setupEnv with undefined if --env-path not provided', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
await viewCmd.parseAsync(['node', 'test']);
expect(setupEnv).toHaveBeenCalledWith(undefined);
});
it('should call startServer with correct port and browserBehavior when only --no is supplied and --yes is not present', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
vi.clearAllMocks();
// Only --no, no --yes
await viewCmd.parseAsync(['node', 'test', '--no', '--port', '15501']);
// Commander sets --no to true, --yes to undefined, so browserBehavior should be SKIP
expect(startServer).toHaveBeenCalledWith(15501, BrowserBehavior.SKIP);
});
it('should call startServer with correct port and browserBehavior when only --yes is supplied', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
vi.clearAllMocks();
await viewCmd.parseAsync(['node', 'test', '--yes', '--port', '16600']);
expect(startServer).toHaveBeenCalledWith(16600, BrowserBehavior.OPEN);
});
it('should call startServer with ASK when neither --yes nor --no is supplied', async () => {
viewCommand(program);
const viewCmd = program.commands[0];
vi.clearAllMocks();
await viewCmd.parseAsync(['node', 'test', '--port', '17700']);
expect(startServer).toHaveBeenCalledWith(17700, BrowserBehavior.ASK);
});
});