0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|
|
});
|