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