0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
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) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
CI / Shell Format Check (push) Waiting to run
CI / Check Ruby (3.4) (push) Waiting to run
CI / CI Config (push) Waiting to run
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Blocked by required conditions
CI / Build on Node ${{ matrix.node }} (push) Blocked by required conditions
CI / Style Check (push) Waiting to run
CI / Generate Assets (push) Waiting to run
CI / Check Python (3.14) (push) Waiting to run
CI / Check Python (3.9) (push) Waiting to run
CI / Build Docs (push) Waiting to run
CI / Code Scan Action (push) Waiting to run
CI / Site tests (push) Waiting to run
CI / webui tests (push) Waiting to run
CI / Run Integration Tests (push) Waiting to run
CI / Run Smoke Tests (push) Waiting to run
CI / Go Tests (push) Waiting to run
CI / Share Test (push) Waiting to run
CI / Redteam (Production API) (push) Waiting to run
CI / Redteam (Staging API) (push) Waiting to run
CI / GitHub Actions Lint (push) Waiting to run
CI / Check Ruby (3.0) (push) Waiting to run
release-please / release-please (push) Waiting to run
release-please / build (push) Blocked by required conditions
release-please / publish-npm (push) Blocked by required conditions
release-please / publish-npm-backfill (push) Waiting to run
release-please / docker (push) Blocked by required conditions
release-please / publish-code-scan-action (push) Blocked by required conditions
release-please / attest-code-scan-action (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
2091 lines
68 KiB
TypeScript
2091 lines
68 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
import dedent from 'dedent';
|
|
import { globSync } from 'glob';
|
|
import * as yaml from 'js-yaml';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { testCaseFromCsvRow } from '../../src/csv';
|
|
import { getEnvBool, getEnvString } from '../../src/envars';
|
|
import { importModule } from '../../src/esm';
|
|
import { fetchCsvFromGoogleSheet } from '../../src/googleSheets';
|
|
import { fetchHuggingFaceDataset } from '../../src/integrations/huggingfaceDatasets';
|
|
import logger from '../../src/logger';
|
|
import { fetchCsvFromSharepoint } from '../../src/microsoftSharepoint';
|
|
import { loadApiProvider } from '../../src/providers/index';
|
|
import { runPython } from '../../src/python/pythonUtils';
|
|
import { readAzureBlobText } from '../../src/util/azureBlob';
|
|
import { maybeLoadConfigFromExternalFile } from '../../src/util/file';
|
|
import {
|
|
loadTestsFromGlob,
|
|
readStandaloneTestsFile,
|
|
readTest,
|
|
readTestFiles,
|
|
readTests,
|
|
} from '../../src/util/testCaseReader';
|
|
import { createMockProvider } from '../factories/provider';
|
|
|
|
import type { AssertionType, TestCase, TestCaseWithVarsFile } from '../../src/types/index';
|
|
import type { ProviderOptions } from '../../src/types/providers';
|
|
|
|
// Spy on logger.warn for tests that check warnings
|
|
vi.spyOn(logger, 'warn').mockImplementation(() => logger);
|
|
|
|
// Mock fetchWithTimeout before any imports that might use telemetry
|
|
vi.mock('../../src/util/fetch/index', () => ({
|
|
fetchWithTimeout: vi.fn().mockResolvedValue({ ok: true }),
|
|
}));
|
|
|
|
vi.mock('proxy-agent', () => ({
|
|
ProxyAgent: vi.fn().mockImplementation(() => ({})),
|
|
}));
|
|
vi.mock('glob', () => ({
|
|
globSync: vi.fn(),
|
|
hasMagic: vi.fn((pattern: string | string[]) => {
|
|
const p = Array.isArray(pattern) ? pattern.join('') : pattern;
|
|
return p.includes('*') || p.includes('?') || p.includes('[') || p.includes('{');
|
|
}),
|
|
}));
|
|
vi.mock('../../src/providers', () => ({
|
|
loadApiProvider: vi.fn(),
|
|
}));
|
|
vi.mock('../../src/util/fetch/index.ts');
|
|
|
|
const mockReadFileSync = vi.hoisted(() => vi.fn());
|
|
const mockParseXlsxFileState = vi.hoisted(() => ({
|
|
implementation: undefined as ((filePath: string) => Promise<any[]>) | undefined,
|
|
}));
|
|
const mockMaybeLoadConfigFromExternalFileImpl = vi.hoisted(() => {
|
|
const implementation = (config: any): any => {
|
|
if (Array.isArray(config)) {
|
|
return config.map((item) => implementation(item));
|
|
}
|
|
|
|
if (typeof config === 'object' && config !== null) {
|
|
const result = { ...config };
|
|
for (const [key, value] of Object.entries(config)) {
|
|
if (typeof value === 'string' && value.startsWith('file://')) {
|
|
const filePath = value.slice('file://'.length);
|
|
const fileContent = mockReadFileSync(filePath, 'utf-8');
|
|
if (typeof fileContent === 'string') {
|
|
try {
|
|
result[key] = JSON.parse(fileContent);
|
|
} catch {
|
|
result[key] = fileContent;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return config;
|
|
};
|
|
|
|
return implementation;
|
|
});
|
|
|
|
vi.mock('fs', () => ({
|
|
readFileSync: mockReadFileSync,
|
|
writeFileSync: vi.fn(),
|
|
statSync: vi.fn(),
|
|
readdirSync: vi.fn(),
|
|
existsSync: vi.fn(),
|
|
mkdirSync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/util/xlsx', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../src/util/xlsx')>();
|
|
return {
|
|
...actual,
|
|
parseXlsxFile: (filePath: string) =>
|
|
mockParseXlsxFileState.implementation?.(filePath) ?? actual.parseXlsxFile(filePath),
|
|
};
|
|
});
|
|
|
|
vi.mock('fs/promises', () => {
|
|
const promisesFs = {
|
|
// Delegate to fs.readFileSync mock for shared test data
|
|
readFile: vi.fn((...args: unknown[]) => {
|
|
try {
|
|
return Promise.resolve(mockReadFileSync(...args));
|
|
} catch (error) {
|
|
return Promise.reject(error);
|
|
}
|
|
}),
|
|
writeFile: vi.fn(),
|
|
mkdir: vi.fn(),
|
|
access: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
return {
|
|
...promisesFs,
|
|
default: promisesFs,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/database', () => ({
|
|
getDb: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/googleSheets', () => ({
|
|
fetchCsvFromGoogleSheet: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/microsoftSharepoint', () => ({
|
|
fetchCsvFromSharepoint: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/envars', async () => ({
|
|
...(await vi.importActual('../../src/envars')),
|
|
getEnvBool: vi.fn(),
|
|
getEnvString: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/python/pythonUtils', () => ({
|
|
runPython: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/integrations/huggingfaceDatasets', () => ({
|
|
fetchHuggingFaceDataset: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/util/azureBlob', async () => ({
|
|
...(await vi.importActual<typeof import('../../src/util/azureBlob')>('../../src/util/azureBlob')),
|
|
readAzureBlobText: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/telemetry', () => {
|
|
const mockTelemetry = {
|
|
record: vi.fn().mockResolvedValue(undefined),
|
|
identify: vi.fn(),
|
|
saveConsent: vi.fn().mockResolvedValue(undefined),
|
|
disabled: false,
|
|
};
|
|
return {
|
|
__esModule: true,
|
|
default: mockTelemetry,
|
|
Telemetry: vi.fn().mockImplementation(() => mockTelemetry),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/esm', () => ({
|
|
importModule: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/util/file', () => ({
|
|
maybeLoadConfigFromExternalFile: vi.fn(mockMaybeLoadConfigFromExternalFileImpl),
|
|
}));
|
|
|
|
// Helper to clear all mocks
|
|
const clearAllMocks = () => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(globSync).mockReset();
|
|
vi.mocked(fs.readFileSync).mockReset();
|
|
vi.mocked(getEnvBool).mockReset();
|
|
vi.mocked(getEnvString).mockReset();
|
|
vi.mocked(fetchCsvFromGoogleSheet).mockReset();
|
|
vi.mocked(fetchCsvFromSharepoint).mockReset();
|
|
vi.mocked(loadApiProvider).mockReset();
|
|
vi.mocked(runPython).mockReset();
|
|
vi.mocked(fetchHuggingFaceDataset).mockReset();
|
|
vi.mocked(readAzureBlobText).mockReset();
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockReset();
|
|
vi.mocked(importModule).mockReset();
|
|
mockParseXlsxFileState.implementation = undefined;
|
|
};
|
|
|
|
describe('readStandaloneTestsFile', () => {
|
|
beforeEach(() => {
|
|
clearAllMocks();
|
|
// Reset getEnvString to default behavior
|
|
vi.mocked(getEnvString).mockImplementation((_key, defaultValue) => defaultValue || '');
|
|
// Restore maybeLoadConfigFromExternalFile mock
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(
|
|
mockMaybeLoadConfigFromExternalFileImpl,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearAllMocks();
|
|
});
|
|
|
|
it('should read CSV file and return test cases', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
'var1,var2,__expected\nvalue1,value2,expected1\nvalue3,value4,expected2',
|
|
);
|
|
const result = await readStandaloneTestsFile('test.csv');
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('test.csv'), 'utf-8');
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected1' }],
|
|
description: 'Row #1',
|
|
options: {},
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
},
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected2' }],
|
|
description: 'Row #2',
|
|
options: {},
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
['a parent directory contains #', 'test.csv', 'fixtures#1'],
|
|
['the filename contains #', 'test#1.csv', ''],
|
|
])('should read CSV when %s', async (_scenario, filePath, basePath) => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue('var1,__expected\nvalue1,expected1');
|
|
|
|
const result = await readStandaloneTestsFile(filePath, basePath);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].vars).toEqual({ var1: 'value1' });
|
|
});
|
|
|
|
it('should read CSV file with BOM (Byte Order Mark) and return test cases', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
'\uFEFFvar1,var2,__expected\nvalue1,value2,expected1\nvalue3,value4,expected2',
|
|
);
|
|
const result = await readStandaloneTestsFile('test.csv');
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('test.csv'), 'utf-8');
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected1' }],
|
|
description: 'Row #1',
|
|
options: {},
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
},
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected2' }],
|
|
description: 'Row #2',
|
|
options: {},
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should read JSON file and return test cases', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
JSON.stringify([
|
|
{
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
description: 'Test #1',
|
|
},
|
|
{
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
assert: [{ type: 'contains', value: 'expected2' }],
|
|
description: 'Test #2',
|
|
},
|
|
]),
|
|
);
|
|
const result = await readStandaloneTestsFile('test.json');
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('test.json'), 'utf-8');
|
|
expect(result).toEqual([
|
|
{
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
description: 'Test #1',
|
|
},
|
|
{
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
assert: [{ type: 'contains', value: 'expected2' }],
|
|
description: 'Test #2',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should read JSONL file and return test cases', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
`{"vars":{"var1":"value1","var2":"value2"},"assert":[{"type":"equals","value":"Hello World"}]}
|
|
{"vars":{"var1":"value3","var2":"value4"},"assert":[{"type":"equals","value":"Hello World"}]}`,
|
|
);
|
|
const result = await readStandaloneTestsFile('test.jsonl');
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('test.jsonl'), 'utf-8');
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ type: 'equals', value: 'Hello World' }],
|
|
description: 'Row #1',
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
},
|
|
{
|
|
assert: [{ type: 'equals', value: 'Hello World' }],
|
|
description: 'Row #2',
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should preserve existing description from JSONL rows', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
`{"description":"Custom Desc","vars":{"x":"y"}}
|
|
{"vars":{"a":"b"}}
|
|
{"description":"","vars":{"c":"d"}}`,
|
|
);
|
|
const result = await readStandaloneTestsFile('test.jsonl');
|
|
|
|
expect(result[0].description).toBe('Custom Desc');
|
|
// Missing and empty-string descriptions both fall back to the row label,
|
|
// matching the JSON/YAML/CSV parsers' `||` behavior.
|
|
expect(result[1].description).toBe('Row #2');
|
|
expect(result[2].description).toBe('Row #3');
|
|
});
|
|
|
|
it('should throw a descriptive error for malformed JSONL pointing at the real file line', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
`{"vars":{"x":"a"}}
|
|
{"vars":{"x":"b"} BROKEN}
|
|
{"vars":{"x":"c"}}`,
|
|
);
|
|
|
|
// The raw JSON.parse SyntaxError says "line 1" (its view of the single line);
|
|
// the wrapper must report the offending file and its real line number (2).
|
|
await expect(readStandaloneTestsFile('bad.jsonl')).rejects.toThrow(
|
|
/Failed to parse JSONL test file .*bad\.jsonl on line 2:/,
|
|
);
|
|
});
|
|
|
|
it('should count blank lines when reporting the malformed JSONL line number', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
`{"vars":{"x":"a"}}
|
|
|
|
{"vars":{"x":"b"} BROKEN}`,
|
|
);
|
|
|
|
// Line 2 is blank, so the broken row is file line 3 — must not be reported
|
|
// as 2 (its index among non-blank rows).
|
|
await expect(readStandaloneTestsFile('bad.jsonl')).rejects.toThrow(
|
|
/Failed to parse JSONL test file .*bad\.jsonl on line 3:/,
|
|
);
|
|
});
|
|
|
|
it('should throw a descriptive error for malformed JSONL loaded via glob', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
`{"vars":{"x":"a"}}
|
|
not valid json`,
|
|
);
|
|
vi.mocked(globSync).mockImplementation((pathOrGlob) => [pathOrGlob].flat() as string[]);
|
|
|
|
await expect(readTests(['bad.jsonl'])).rejects.toThrow(
|
|
/Failed to parse JSONL test file .*bad\.jsonl on line 2:/,
|
|
);
|
|
});
|
|
|
|
it('should throw a descriptive error for a malformed JSON test file loaded via glob', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue('{ "not": valid json }');
|
|
vi.mocked(globSync).mockImplementation((pathOrGlob) => [pathOrGlob].flat() as string[]);
|
|
|
|
await expect(readTests(['bad.json'])).rejects.toThrow(
|
|
/Failed to parse JSON test file .*bad\.json:/,
|
|
);
|
|
});
|
|
|
|
it('should read YAML file and return test cases', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(dedent`
|
|
- var1: value1
|
|
var2: value2
|
|
- var1: value3
|
|
var2: value4
|
|
`);
|
|
const result = await readStandaloneTestsFile('test.yaml');
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('test.yaml'), 'utf-8');
|
|
expect(result).toEqual([
|
|
{ assert: [], description: 'Row #1', options: {}, vars: { var1: 'value1', var2: 'value2' } },
|
|
{ assert: [], description: 'Row #2', options: {}, vars: { var1: 'value3', var2: 'value4' } },
|
|
]);
|
|
});
|
|
|
|
it('should read Google Sheets and return test cases', async () => {
|
|
const mockFetchCsvFromGoogleSheet = vi.mocked(fetchCsvFromGoogleSheet);
|
|
mockFetchCsvFromGoogleSheet.mockResolvedValue([
|
|
{ var1: 'value1', var2: 'value2', __expected: 'expected1' },
|
|
{ var1: 'value3', var2: 'value4', __expected: 'expected2' },
|
|
]);
|
|
const result = await readStandaloneTestsFile('https://docs.google.com/spreadsheets/d/example');
|
|
|
|
expect(mockFetchCsvFromGoogleSheet).toHaveBeenCalledWith(
|
|
'https://docs.google.com/spreadsheets/d/example',
|
|
);
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected1' }],
|
|
description: 'Row #1',
|
|
options: {},
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
},
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected2' }],
|
|
description: 'Row #2',
|
|
options: {},
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should read JSON test sets from hashed Azure Blob Storage URIs', async () => {
|
|
const blobUri =
|
|
'az://appliedciblobdata/data/ianw/cyber-evals/tests/cases.json.45856cafeef1d6df70c14f5b3c0cc353ecf3c22fa8653aa35ef0107b138f63fb';
|
|
vi.mocked(readAzureBlobText).mockResolvedValue(
|
|
JSON.stringify([{ vars: { review_id: 'review-001' } }]),
|
|
);
|
|
|
|
const result = await readStandaloneTestsFile(blobUri);
|
|
|
|
expect(readAzureBlobText).toHaveBeenCalledWith(blobUri);
|
|
expect(result).toEqual([
|
|
{
|
|
description: 'Row #1',
|
|
vars: { review_id: 'review-001' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should read JSON test sets from SAS-authenticated Azure Blob Storage URIs', async () => {
|
|
const blobUri = 'az://account/container/tests.json?sp=r&sig=abc';
|
|
vi.mocked(readAzureBlobText).mockResolvedValue(
|
|
JSON.stringify([{ vars: { review_id: 'review-002' } }]),
|
|
);
|
|
|
|
const result = await readStandaloneTestsFile(blobUri);
|
|
|
|
expect(readAzureBlobText).toHaveBeenCalledWith(blobUri);
|
|
expect(result).toEqual([
|
|
{
|
|
description: 'Row #1',
|
|
vars: { review_id: 'review-002' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should redact SAS tokens in malformed Azure Blob JSONL parse errors', async () => {
|
|
const blobUri = 'az://account/container/tests.jsonl?sp=r&sig=SECRETSIG';
|
|
vi.mocked(readAzureBlobText).mockResolvedValue('{"vars":{"x":"a"}}\n{"vars": BROKEN}');
|
|
|
|
const error = await readStandaloneTestsFile(blobUri).then(
|
|
() => {
|
|
throw new Error('expected readStandaloneTestsFile to reject');
|
|
},
|
|
(err) => err as Error,
|
|
);
|
|
expect(error.message).toContain(
|
|
'Failed to parse JSONL test file az://account/container/tests.jsonl?<redacted> on line 2:',
|
|
);
|
|
expect(error.message).not.toContain('SECRETSIG');
|
|
});
|
|
|
|
it('should throw a descriptive error for a malformed standalone JSON test file', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue('{"vars": {');
|
|
|
|
await expect(readStandaloneTestsFile('bad.json')).rejects.toThrow(
|
|
/Failed to parse JSON test file .*bad\.json:/,
|
|
);
|
|
});
|
|
|
|
it('should read CSV test sets from Azure Blob Storage URIs', async () => {
|
|
const blobUri = 'az://account/container/tests.csv';
|
|
vi.mocked(readAzureBlobText).mockResolvedValue('review_id,__expected\nreview-004,ready');
|
|
|
|
const result = await readStandaloneTestsFile(blobUri);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'ready' }],
|
|
description: 'Row #1',
|
|
options: {},
|
|
vars: { review_id: 'review-004' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should read JSONL test sets from Azure Blob Storage URIs', async () => {
|
|
const blobUri = 'az://account/container/tests.jsonl';
|
|
vi.mocked(readAzureBlobText).mockResolvedValue(
|
|
'{"vars":{"review_id":"review-005"}}\n{"vars":{"review_id":"review-006"}}',
|
|
);
|
|
|
|
const result = await readStandaloneTestsFile(blobUri);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
description: 'Row #1',
|
|
vars: { review_id: 'review-005' },
|
|
},
|
|
{
|
|
description: 'Row #2',
|
|
vars: { review_id: 'review-006' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should read YML test sets from Azure Blob Storage URIs', async () => {
|
|
const blobUri = 'az://account/container/tests.yml';
|
|
vi.mocked(readAzureBlobText).mockResolvedValue(dedent`
|
|
- description: Azure YML case
|
|
vars:
|
|
review_id: review-007
|
|
assert:
|
|
- type: equals
|
|
value: ready
|
|
`);
|
|
|
|
const result = await readStandaloneTestsFile(blobUri);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ type: 'equals', value: 'ready' }],
|
|
description: 'Azure YML case',
|
|
vars: { review_id: 'review-007' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should prefer SharePoint URL handling over local JSON parsing when URL ends with .json', async () => {
|
|
const sharepointUrls = [
|
|
'https://example.sharepoint.com/sites/team/tests.json',
|
|
'https://example.sharepoint.com/:x:/r/sites/team/tests.json',
|
|
];
|
|
vi.mocked(fetchCsvFromSharepoint).mockResolvedValue([
|
|
{ var1: 'value1', __expected: 'expected1' },
|
|
]);
|
|
|
|
for (const sharepointUrl of sharepointUrls) {
|
|
const result = await readStandaloneTestsFile(sharepointUrl);
|
|
|
|
expect(fetchCsvFromSharepoint).toHaveBeenCalledWith(sharepointUrl);
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected1' }],
|
|
description: 'Row #1',
|
|
options: {},
|
|
vars: { var1: 'value1' },
|
|
},
|
|
]);
|
|
}
|
|
});
|
|
|
|
it('should read JS file and return test cases', async () => {
|
|
const mockTestCases = [
|
|
{ vars: { var1: 'value1', var2: 'value2' } },
|
|
{ vars: { var1: 'value3', var2: 'value4' } },
|
|
];
|
|
|
|
vi.mocked(importModule).mockResolvedValue(mockTestCases);
|
|
|
|
const result = await readStandaloneTestsFile('test.js');
|
|
|
|
expect(importModule).toHaveBeenCalledWith(expect.stringContaining('test.js'), undefined);
|
|
expect(result).toEqual(mockTestCases);
|
|
});
|
|
|
|
it('should pass config to JS test generator function', async () => {
|
|
const mockFn = vi.fn().mockResolvedValue([{ vars: { a: 1 } }]);
|
|
vi.mocked(importModule).mockResolvedValue(mockFn);
|
|
|
|
const config = { foo: 'bar' };
|
|
const result = await readStandaloneTestsFile('test_gen.js', '', config);
|
|
|
|
expect(importModule).toHaveBeenCalledWith(expect.stringContaining('test_gen.js'), undefined);
|
|
expect(mockFn).toHaveBeenCalledWith(config);
|
|
expect(result).toEqual([{ vars: { a: 1 } }]);
|
|
});
|
|
|
|
it('should load file references in config for JS generator', async () => {
|
|
const mockResult = [{ vars: { a: 1 } }];
|
|
const mockFn = vi.fn().mockResolvedValue(mockResult);
|
|
vi.mocked(importModule).mockResolvedValue(mockFn);
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValueOnce(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValueOnce('{"foo": "bar"}');
|
|
|
|
const config = { data: 'file://config.json' };
|
|
const result = await readStandaloneTestsFile('test_config_gen.js', '', config);
|
|
|
|
expect(importModule).toHaveBeenCalledWith(
|
|
expect.stringContaining('test_config_gen.js'),
|
|
undefined,
|
|
);
|
|
expect(mockFn).toHaveBeenCalledWith({ data: { foo: 'bar' } });
|
|
expect(result).toEqual(mockResult);
|
|
});
|
|
|
|
it('should handle file:// prefix in file path', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue('var1,var2\nvalue1,value2');
|
|
await readStandaloneTestsFile('file://test.csv');
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.not.stringContaining('file://'), 'utf-8');
|
|
});
|
|
|
|
it('should return an empty array for unsupported file types', async () => {
|
|
await expect(readStandaloneTestsFile('test.txt')).resolves.toEqual([]);
|
|
});
|
|
|
|
it('should read CSV file with default delimiter', async () => {
|
|
vi.mocked(getEnvString).mockReturnValue(',');
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
'var1,var2,__expected\nvalue1,value2,expected1\nvalue3,value4,expected2',
|
|
);
|
|
|
|
const result = await readStandaloneTestsFile('test.csv');
|
|
|
|
expect(getEnvString).toHaveBeenCalledWith('PROMPTFOO_CSV_DELIMITER', ',');
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('test.csv'), 'utf-8');
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected1' }],
|
|
description: 'Row #1',
|
|
options: {},
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
},
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected2' }],
|
|
description: 'Row #2',
|
|
options: {},
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should read CSV file with custom delimiter', async () => {
|
|
vi.mocked(getEnvString).mockReturnValue(';');
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
'var1;var2;__expected\nvalue1;value2;expected1\nvalue3;value4;expected2',
|
|
);
|
|
|
|
const result = await readStandaloneTestsFile('test.csv');
|
|
|
|
expect(getEnvString).toHaveBeenCalledWith('PROMPTFOO_CSV_DELIMITER', ',');
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('test.csv'), 'utf-8');
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected1' }],
|
|
description: 'Row #1',
|
|
options: {},
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
},
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected2' }],
|
|
description: 'Row #2',
|
|
options: {},
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should read XLSX file and return test cases', async () => {
|
|
// Mock parseXlsxFile to return processed CsvRow[] data
|
|
const mockData = [
|
|
{ var1: 'value1', var2: 'value2', __expected: 'expected1' },
|
|
{ var1: 'value3', var2: 'value4', __expected: 'expected2' },
|
|
];
|
|
|
|
mockParseXlsxFileState.implementation = vi.fn().mockResolvedValue(mockData);
|
|
|
|
const result = await readStandaloneTestsFile('test.xlsx');
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected1' }],
|
|
description: 'Row #1',
|
|
options: {},
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
},
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected2' }],
|
|
description: 'Row #2',
|
|
options: {},
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
// Note: parseXlsxFile error handling tests (module not installed, empty sheets, etc.)
|
|
// are covered in test/util/xlsx.test.ts which uses proper hoisted mocks
|
|
|
|
it('should read real XLSX file from examples (integration test)', async () => {
|
|
// Integration test using the actual Excel file from examples
|
|
const exampleFile = path.join(__dirname, '../../examples/simple-csv/tests.xlsx');
|
|
|
|
const actualFs = await vi.importActual<typeof import('fs')>('fs');
|
|
vi.mocked(fs.existsSync).mockImplementation((filePath) => actualFs.existsSync(filePath));
|
|
|
|
const result = await readStandaloneTestsFile(exampleFile);
|
|
|
|
// Verify the structure matches expected test cases
|
|
expect(result).toHaveLength(4); // Based on the known test data
|
|
expect(result[0]).toMatchObject({
|
|
vars: expect.objectContaining({
|
|
language: expect.any(String),
|
|
body: expect.any(String),
|
|
}),
|
|
assert: expect.any(Array),
|
|
});
|
|
|
|
// Verify specific test case content
|
|
const frenchTest = result.find((test) => test.vars?.language === 'French');
|
|
expect(frenchTest).toBeDefined();
|
|
expect(frenchTest?.vars?.body).toBe('Hello world');
|
|
});
|
|
|
|
it('should handle Python files with default function name', async () => {
|
|
const pythonResult = [
|
|
{ vars: { var1: 'value1' }, assert: [{ type: 'equals', value: 'expected1' }] },
|
|
{ vars: { var2: 'value2' }, assert: [{ type: 'equals', value: 'expected2' }] },
|
|
];
|
|
vi.mocked(runPython).mockResolvedValue(pythonResult);
|
|
|
|
const result = await readStandaloneTestsFile('test.py');
|
|
|
|
expect(runPython).toHaveBeenCalledWith(
|
|
expect.stringContaining('test.py'),
|
|
'generate_tests',
|
|
[],
|
|
);
|
|
expect(result).toEqual(pythonResult);
|
|
});
|
|
|
|
it('should handle Python files with custom function name', async () => {
|
|
const pythonResult = [
|
|
{ vars: { var1: 'value1' }, assert: [{ type: 'equals', value: 'expected1' }] },
|
|
];
|
|
vi.mocked(runPython).mockResolvedValue(pythonResult);
|
|
|
|
const result = await readStandaloneTestsFile('test.py:custom_function');
|
|
|
|
expect(runPython).toHaveBeenCalledWith(
|
|
expect.stringContaining('test.py'),
|
|
'custom_function',
|
|
[],
|
|
);
|
|
expect(result).toEqual(pythonResult);
|
|
});
|
|
|
|
it('should pass config to Python generate_tests function', async () => {
|
|
const pythonResult = [{ vars: { a: 1 }, assert: [] }];
|
|
vi.mocked(runPython).mockResolvedValue(pythonResult);
|
|
|
|
const config = { dataset: 'demo' };
|
|
const result = await readStandaloneTestsFile('test.py', '', config);
|
|
|
|
expect(runPython).toHaveBeenCalledWith(expect.stringContaining('test.py'), 'generate_tests', [
|
|
config,
|
|
]);
|
|
expect(result).toEqual(pythonResult);
|
|
});
|
|
|
|
it('should load file references in config for Python generator', async () => {
|
|
const pythonResult = [{ vars: { a: 1 }, assert: [] }];
|
|
vi.mocked(runPython).mockResolvedValue(pythonResult);
|
|
|
|
// Mock maybeLoadConfigFromExternalFile to transform file:// references
|
|
const transformExternalFileConfig = (config: any): any => {
|
|
// Handle arrays first to preserve their type
|
|
if (Array.isArray(config)) {
|
|
return config.map((item) => transformExternalFileConfig(item));
|
|
}
|
|
|
|
if (typeof config === 'object' && config !== null) {
|
|
const result = { ...config };
|
|
for (const [key, value] of Object.entries(config)) {
|
|
if (typeof value === 'string' && value.startsWith('file://')) {
|
|
result[key] = { foo: 'bar' }; // Return the expected transformed value
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
return config;
|
|
};
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(transformExternalFileConfig);
|
|
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockImplementation((path) => {
|
|
if (path.toString().includes('config.json')) {
|
|
return '{"foo": "bar"}';
|
|
}
|
|
return '';
|
|
});
|
|
const config = { data: 'file://config.json' };
|
|
const result = await readStandaloneTestsFile('test.py', '', config);
|
|
|
|
expect(runPython).toHaveBeenCalledWith(expect.stringContaining('test.py'), 'generate_tests', [
|
|
{ data: { foo: 'bar' } },
|
|
]);
|
|
expect(result).toEqual(pythonResult);
|
|
});
|
|
|
|
it('should throw error when Python file returns non-array', async () => {
|
|
vi.mocked(runPython).mockResolvedValue({ not: 'an array' } as any);
|
|
|
|
await expect(readStandaloneTestsFile('test.py')).rejects.toThrow(
|
|
'Python test function must return a list of test cases, got object',
|
|
);
|
|
});
|
|
|
|
it('should handle Python files with invalid function name in readStandaloneTestsFile', async () => {
|
|
await expect(readStandaloneTestsFile('test.py:invalid:extra')).rejects.toThrow(
|
|
'Too many colons. Invalid test file script path: test.py:invalid:extra',
|
|
);
|
|
});
|
|
|
|
it('should read JSON file with a single test case object', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
JSON.stringify({
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
description: 'Single Test',
|
|
}),
|
|
);
|
|
const result = await readStandaloneTestsFile('test.json');
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('test.json'), 'utf-8');
|
|
expect(result).toEqual([
|
|
{
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
description: 'Single Test',
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('readTest', () => {
|
|
beforeEach(() => {
|
|
clearAllMocks();
|
|
// Restore maybeLoadConfigFromExternalFile mock
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(
|
|
mockMaybeLoadConfigFromExternalFileImpl,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearAllMocks();
|
|
});
|
|
|
|
it('readTest with string input (path to test config)', async () => {
|
|
const testPath = 'test1.yaml';
|
|
const testContent = {
|
|
description: 'Test 1',
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'value1' }],
|
|
};
|
|
vi.mocked(fs.readFileSync).mockReturnValueOnce(yaml.dump(testContent));
|
|
|
|
const result = await readTest(testPath);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual(testContent);
|
|
});
|
|
|
|
it('readTest with TestCase input', async () => {
|
|
const input: TestCase = {
|
|
description: 'Test 1',
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'value1' }],
|
|
};
|
|
|
|
const result = await readTest(input);
|
|
|
|
expect(result).toEqual(input);
|
|
});
|
|
|
|
it('readTest with invalid input', async () => {
|
|
const input: any = 123;
|
|
|
|
await expect(readTest(input)).rejects.toThrow(
|
|
'Test case must contain one of the following properties: assert, vars, options, metadata, provider, providerOutput, threshold.\n\nInstead got:\n{}',
|
|
);
|
|
});
|
|
|
|
it('readTest with TestCase that contains a vars glob input', async () => {
|
|
const input: TestCaseWithVarsFile = {
|
|
description: 'Test 1',
|
|
vars: 'vars/*.yaml',
|
|
assert: [{ type: 'equals' as AssertionType, value: 'value1' }],
|
|
};
|
|
const varsContent1 = { var1: 'value1' };
|
|
const varsContent2 = { var2: 'value2' };
|
|
vi.mocked(globSync).mockReturnValueOnce(['vars/vars1.yaml', 'vars/vars2.yaml']);
|
|
vi.mocked(fs.readFileSync)
|
|
.mockReturnValueOnce(yaml.dump(varsContent1))
|
|
.mockReturnValueOnce(yaml.dump(varsContent2));
|
|
|
|
const result = await readTest(input);
|
|
|
|
expect(globSync).toHaveBeenCalledTimes(1);
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(2);
|
|
expect(result).toEqual({
|
|
description: 'Test 1',
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'value1' }],
|
|
});
|
|
});
|
|
|
|
describe('readTest with provider', () => {
|
|
it('should load provider when provider is a string', async () => {
|
|
const mockProvider = createMockProvider({ id: 'mock-provider' });
|
|
vi.mocked(loadApiProvider).mockResolvedValue(mockProvider);
|
|
|
|
const testCase: TestCase = {
|
|
description: 'Test with string provider',
|
|
provider: 'mock-provider',
|
|
assert: [{ type: 'equals', value: 'expected' }],
|
|
};
|
|
|
|
const result = await readTest(testCase);
|
|
|
|
expect(loadApiProvider).toHaveBeenCalledWith('mock-provider', { basePath: '' });
|
|
expect(result.provider).toBe(mockProvider);
|
|
});
|
|
|
|
it('should load provider when provider is an object with id', async () => {
|
|
const mockProvider = createMockProvider({ id: 'mock-provider' });
|
|
vi.mocked(loadApiProvider).mockResolvedValue(mockProvider);
|
|
|
|
const providerInput: ProviderOptions & { callApi: ReturnType<typeof vi.fn> } = {
|
|
id: 'mock-provider',
|
|
callApi: vi.fn(),
|
|
};
|
|
const testCase: TestCase = {
|
|
description: 'Test with provider object',
|
|
provider: providerInput,
|
|
assert: [{ type: 'equals', value: 'expected' }],
|
|
};
|
|
|
|
const result = await readTest(testCase);
|
|
|
|
expect(loadApiProvider).toHaveBeenCalledWith('mock-provider', {
|
|
options: { id: 'mock-provider', callApi: expect.any(Function) },
|
|
basePath: '',
|
|
});
|
|
expect(result.provider).toBe(mockProvider);
|
|
});
|
|
});
|
|
|
|
it('should skip validation when isDefaultTest is true', async () => {
|
|
const defaultTestInput = {
|
|
options: {
|
|
provider: {
|
|
embedding: {
|
|
id: 'bedrock:embeddings:amazon.titan-embed-text-v2:0',
|
|
config: {
|
|
region: 'us-east-1',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
// This should not throw even though it doesn't have required properties
|
|
const result = await readTest(defaultTestInput, '', true);
|
|
expect(result.options).toEqual(defaultTestInput.options);
|
|
expect(result.vars).toBeUndefined();
|
|
});
|
|
|
|
it('should skip validation for defaultTest with model-graded eval provider', async () => {
|
|
const defaultTestInput = {
|
|
options: {
|
|
provider: 'openai:gpt-4.1-mini-0613',
|
|
},
|
|
};
|
|
|
|
// This should not throw even though it doesn't have required properties
|
|
const result = await readTest(defaultTestInput, '', true);
|
|
expect(result.options?.provider).toBe('openai:gpt-4.1-mini-0613');
|
|
expect(result.vars).toBeUndefined();
|
|
});
|
|
|
|
it('should skip validation for defaultTest with text provider configuration', async () => {
|
|
const defaultTestInput = {
|
|
options: {
|
|
provider: {
|
|
text: {
|
|
id: 'openai:gpt-5.1-mini',
|
|
config: {
|
|
temperature: 0.7,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
// This should not throw even though it doesn't have required properties
|
|
const result = await readTest(defaultTestInput, '', true);
|
|
expect(result.options?.provider).toBeDefined();
|
|
expect(result.options?.provider).toEqual({
|
|
text: {
|
|
id: 'openai:gpt-5.1-mini',
|
|
config: {
|
|
temperature: 0.7,
|
|
},
|
|
},
|
|
});
|
|
expect(result.vars).toBeUndefined();
|
|
});
|
|
|
|
it('should skip validation for defaultTest with provider object configuration', async () => {
|
|
const defaultTestInput = {
|
|
options: {
|
|
provider: {
|
|
id: 'anthropic:claude-3-opus',
|
|
config: {
|
|
temperature: 0.5,
|
|
max_tokens: 1000,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
// This should not throw even though it doesn't have required properties
|
|
const result = await readTest(defaultTestInput, '', true);
|
|
expect(result.options?.provider).toEqual({
|
|
id: 'anthropic:claude-3-opus',
|
|
config: {
|
|
temperature: 0.5,
|
|
max_tokens: 1000,
|
|
},
|
|
});
|
|
expect(result.vars).toBeUndefined();
|
|
});
|
|
|
|
it('should throw when not a defaultTest and missing required properties', async () => {
|
|
// Create a test input that truly has no valid properties after loadTestWithVars
|
|
const invalidTestInput = {
|
|
someInvalidProperty: 'invalid',
|
|
} as any; // Cast to any to bypass type checking for invalid input
|
|
|
|
await expect(readTest(invalidTestInput, '', false)).rejects.toThrow(
|
|
'Test case must contain one of the following properties',
|
|
);
|
|
});
|
|
|
|
it('should read test from file', async () => {
|
|
const testPath = 'test1.yaml';
|
|
const testContent = {
|
|
description: 'Test 1',
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'value1' }],
|
|
};
|
|
vi.mocked(fs.readFileSync).mockReturnValueOnce(yaml.dump(testContent));
|
|
|
|
const result = await readTest(testPath);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual(testContent);
|
|
});
|
|
});
|
|
|
|
describe('readTests', () => {
|
|
beforeEach(() => {
|
|
clearAllMocks();
|
|
vi.mocked(globSync).mockReturnValue([]);
|
|
// Restore maybeLoadConfigFromExternalFile mock for readTests tests
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(
|
|
mockMaybeLoadConfigFromExternalFileImpl,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearAllMocks();
|
|
});
|
|
|
|
it('readTests with string input (CSV file path)', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
'var1,var2,__expected\nvalue1,value2,value1\nvalue3,value4,fn:value5',
|
|
);
|
|
const testsPath = 'tests.csv';
|
|
|
|
const result = await readTests(testsPath);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual([
|
|
{
|
|
description: 'Row #1',
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'value1' }],
|
|
options: {},
|
|
},
|
|
{
|
|
description: 'Row #2',
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
assert: [{ type: 'javascript', value: 'value5' }],
|
|
options: {},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('readTests with string input (CSV file path with file:// prefix)', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
'var1,var2,__expected\nvalue1,value2,value1\nvalue3,value4,fn:value5',
|
|
);
|
|
const testsPath = 'file://tests.csv';
|
|
|
|
const result = await readTests(testsPath);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual([
|
|
{
|
|
description: 'Row #1',
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'value1' }],
|
|
options: {},
|
|
},
|
|
{
|
|
description: 'Row #2',
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
assert: [{ type: 'javascript', value: 'value5' }],
|
|
options: {},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('readTests with a hashed Azure Blob Storage JSON test set', async () => {
|
|
const blobUri =
|
|
'az://appliedciblobdata/data/ianw/cyber-evals/tests/cases.json.45856cafeef1d6df70c14f5b3c0cc353ecf3c22fa8653aa35ef0107b138f63fb';
|
|
vi.mocked(readAzureBlobText).mockResolvedValue(
|
|
JSON.stringify([{ vars: { review_id: 'review-001' } }]),
|
|
);
|
|
|
|
const result = await readTests(blobUri);
|
|
|
|
expect(readAzureBlobText).toHaveBeenCalledWith(blobUri);
|
|
expect(result).toEqual([
|
|
{
|
|
description: 'Row #1',
|
|
vars: { review_id: 'review-001' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('readTests with a scalar Azure Blob Storage YAML test set', async () => {
|
|
const blobUri = 'az://account/container/tests.yaml';
|
|
vi.mocked(readAzureBlobText).mockResolvedValue(dedent`
|
|
- description: Azure YAML case
|
|
vars:
|
|
review_id: review-003
|
|
assert:
|
|
- type: equals
|
|
value: ready
|
|
`);
|
|
|
|
const result = await readTests(blobUri);
|
|
|
|
expect(readAzureBlobText).toHaveBeenCalledWith(blobUri);
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ type: 'equals', value: 'ready' }],
|
|
description: 'Azure YAML case',
|
|
vars: { review_id: 'review-003' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('readTests with Azure YAML keeps blob content as remote test data', async () => {
|
|
const blobUri = 'az://account/container/tests.yaml';
|
|
vi.mocked(readAzureBlobText).mockResolvedValue(dedent`
|
|
- description: Azure YAML remote data case
|
|
vars: vars1.yaml
|
|
provider: file://providers/local.js
|
|
assert:
|
|
- type: equals
|
|
value: ready
|
|
`);
|
|
|
|
const result = await readTests(blobUri);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ type: 'equals', value: 'ready' }],
|
|
description: 'Azure YAML remote data case',
|
|
provider: 'file://providers/local.js',
|
|
vars: 'vars1.yaml',
|
|
},
|
|
]);
|
|
expect(globSync).not.toHaveBeenCalled();
|
|
expect(loadApiProvider).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('readTests with multiple __expected in CSV', async () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
'var1,var2,__expected1,__expected2,__expected3\nvalue1,value2,value1,value1.2,value1.3\nvalue3,value4,fn:value5,fn:value5.2,fn:value5.3',
|
|
);
|
|
const testsPath = 'tests.csv';
|
|
|
|
const result = await readTests(testsPath);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual([
|
|
{
|
|
description: 'Row #1',
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [
|
|
{ type: 'equals', value: 'value1' },
|
|
{ type: 'equals', value: 'value1.2' },
|
|
{ type: 'equals', value: 'value1.3' },
|
|
],
|
|
options: {},
|
|
},
|
|
{
|
|
description: 'Row #2',
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
assert: [
|
|
{ type: 'javascript', value: 'value5' },
|
|
{ type: 'javascript', value: 'value5.2' },
|
|
{ type: 'javascript', value: 'value5.3' },
|
|
],
|
|
options: {},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('readTests with array input (TestCase[])', async () => {
|
|
const input: TestCase[] = [
|
|
{
|
|
description: 'Test 1',
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'value1' }],
|
|
},
|
|
{
|
|
description: 'Test 2',
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
assert: [{ type: 'contains-json', value: 'value3' }],
|
|
},
|
|
];
|
|
|
|
const result = await readTests(input);
|
|
|
|
expect(result).toEqual(input);
|
|
});
|
|
|
|
it('readTests with string array input (paths to test configs)', async () => {
|
|
const testsPaths = ['test1.yaml', 'test2.yaml'];
|
|
const test1Content = [
|
|
{
|
|
description: 'Test 1',
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'value1' }],
|
|
},
|
|
];
|
|
const test2Content = [
|
|
{
|
|
description: 'Test 2',
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
assert: [{ type: 'contains-json', value: 'value3' }],
|
|
},
|
|
];
|
|
vi.mocked(fs.readFileSync)
|
|
.mockReturnValueOnce(yaml.dump(test1Content))
|
|
.mockReturnValueOnce(yaml.dump(test2Content));
|
|
vi.mocked(globSync).mockImplementation((pathOrGlob) => [pathOrGlob].flat());
|
|
|
|
const result = await readTests(testsPaths);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(2);
|
|
expect(result).toEqual([...test1Content, ...test2Content]);
|
|
});
|
|
|
|
it('readTests with vars glob input (paths to vars configs)', async () => {
|
|
const testsPaths = ['test1.yaml'];
|
|
const test1Content = [
|
|
{
|
|
description: 'Test 1',
|
|
vars: 'vars1.yaml',
|
|
assert: [{ type: 'equals', value: 'value1' }],
|
|
},
|
|
];
|
|
const vars1Content = {
|
|
var1: 'value1',
|
|
var2: 'value2',
|
|
};
|
|
vi.mocked(fs.readFileSync)
|
|
.mockReturnValueOnce(yaml.dump(test1Content))
|
|
.mockReturnValueOnce(yaml.dump(vars1Content));
|
|
vi.mocked(globSync).mockImplementation((pathOrGlob) => [pathOrGlob].flat());
|
|
|
|
const result = await readTests(testsPaths);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(2);
|
|
expect(result).toEqual([Object.assign({}, test1Content[0], { vars: vars1Content })]);
|
|
});
|
|
|
|
it('readTests with single TestCase content', async () => {
|
|
const testsPaths = ['test1.yaml'];
|
|
const test1Content = {
|
|
description: 'Test 1',
|
|
assert: [{ type: 'equals', value: 'value1' }],
|
|
};
|
|
vi.mocked(fs.readFileSync).mockReturnValueOnce(yaml.dump(test1Content));
|
|
vi.mocked(globSync).mockImplementation((pathOrGlob) => [pathOrGlob].flat());
|
|
|
|
const result = await readTests(testsPaths);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(1);
|
|
expect(result).toEqual([test1Content]);
|
|
});
|
|
|
|
it('should read tests from a Google Sheets URL', async () => {
|
|
vi.mocked(fetchCsvFromGoogleSheet).mockResolvedValue([
|
|
{ var1: 'value1', var2: 'value2', __expected: 'expected1' },
|
|
{ var1: 'value3', var2: 'value4', __expected: 'expected2' },
|
|
]);
|
|
|
|
const result = await readTests('https://docs.google.com/spreadsheets/d/example');
|
|
|
|
expect(fetchCsvFromGoogleSheet).toHaveBeenCalledWith(
|
|
'https://docs.google.com/spreadsheets/d/example',
|
|
);
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]).toMatchObject({
|
|
description: 'Row #1',
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
});
|
|
expect(result[1]).toMatchObject({
|
|
description: 'Row #2',
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
assert: [{ type: 'equals', value: 'expected2' }],
|
|
});
|
|
});
|
|
|
|
it('should log a warning for unsupported test format', async () => {
|
|
const unsupportedTests = { invalid: 'format' };
|
|
|
|
await readTests(unsupportedTests as any);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining("Warning: Unsupported 'tests' format in promptfooconfig.yaml."),
|
|
);
|
|
});
|
|
|
|
it('should not log a warning if tests is undefined', async () => {
|
|
await readTests(undefined);
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should read tests from multiple Google Sheets URLs', async () => {
|
|
vi.mocked(globSync).mockReturnValueOnce([]);
|
|
const mockFetchCsvFromGoogleSheet = vi.mocked(fetchCsvFromGoogleSheet);
|
|
mockFetchCsvFromGoogleSheet
|
|
.mockResolvedValueOnce([
|
|
{ var1: 'value1', var2: 'value2', __expected: 'expected1' },
|
|
{ var1: 'value3', var2: 'value4', __expected: 'expected2' },
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{ var1: 'value5', var2: 'value6', __expected: 'expected3' },
|
|
{ var1: 'value7', var2: 'value8', __expected: 'expected4' },
|
|
]);
|
|
|
|
const result = await readTests([
|
|
'https://docs.google.com/spreadsheets/d/example1',
|
|
'https://docs.google.com/spreadsheets/d/example2',
|
|
]);
|
|
|
|
expect(mockFetchCsvFromGoogleSheet).toHaveBeenCalledTimes(2);
|
|
expect(mockFetchCsvFromGoogleSheet).toHaveBeenCalledWith(
|
|
'https://docs.google.com/spreadsheets/d/example1',
|
|
);
|
|
expect(mockFetchCsvFromGoogleSheet).toHaveBeenCalledWith(
|
|
'https://docs.google.com/spreadsheets/d/example2',
|
|
);
|
|
expect(result).toHaveLength(4);
|
|
expect(result[0]).toMatchObject({
|
|
description: 'Row #1',
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
});
|
|
expect(result[2]).toMatchObject({
|
|
description: 'Row #1',
|
|
vars: { var1: 'value5', var2: 'value6' },
|
|
assert: [{ type: 'equals', value: 'expected3' }],
|
|
});
|
|
});
|
|
|
|
it('should handle HuggingFace dataset URLs', async () => {
|
|
const mockDataset: TestCase[] = [
|
|
{
|
|
description: 'Test 1',
|
|
vars: { var1: 'value1' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
options: {},
|
|
},
|
|
{
|
|
description: 'Test 2',
|
|
vars: { var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'expected2' }],
|
|
options: {},
|
|
},
|
|
];
|
|
vi.mocked(fetchHuggingFaceDataset).mockResolvedValue(mockDataset);
|
|
|
|
const result = await loadTestsFromGlob('huggingface://datasets/example/dataset');
|
|
|
|
expect(fetchHuggingFaceDataset).toHaveBeenCalledWith('huggingface://datasets/example/dataset');
|
|
expect(result).toEqual(mockDataset);
|
|
});
|
|
|
|
it('should handle JSONL files', async () => {
|
|
const expectedTests: TestCase[] = [
|
|
{
|
|
description: 'Test 1',
|
|
vars: { var1: 'value1' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
options: {},
|
|
},
|
|
{
|
|
description: 'Test 2',
|
|
vars: { var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'expected2' }],
|
|
options: {},
|
|
},
|
|
];
|
|
const jsonlContent = expectedTests.map((test) => JSON.stringify(test)).join('\n');
|
|
vi.mocked(fs.readFileSync).mockReturnValueOnce(jsonlContent);
|
|
vi.mocked(globSync).mockImplementation((pathOrGlob) => [pathOrGlob].flat());
|
|
|
|
const result = await readTests(['test.jsonl']);
|
|
|
|
expect(result).toEqual(expectedTests);
|
|
});
|
|
|
|
it('should handle file read errors gracefully', async () => {
|
|
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
|
throw new Error('File read error');
|
|
});
|
|
vi.mocked(globSync).mockImplementation((pathOrGlob) => [pathOrGlob].flat());
|
|
|
|
await expect(readTests(['test.yaml'])).rejects.toThrow('File read error');
|
|
});
|
|
|
|
it('should handle Python files in readTests', async () => {
|
|
const pythonTests: TestCase[] = [
|
|
{
|
|
description: 'Python Test 1',
|
|
vars: { var1: 'value1' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
options: {},
|
|
},
|
|
{
|
|
description: 'Python Test 2',
|
|
vars: { var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'expected2' }],
|
|
options: {},
|
|
},
|
|
];
|
|
vi.mocked(runPython).mockResolvedValue(pythonTests);
|
|
vi.mocked(globSync).mockReturnValueOnce(['test.py']);
|
|
|
|
const result = await readTests(['test.py']);
|
|
|
|
expect(runPython).toHaveBeenCalledWith(
|
|
expect.stringContaining('test.py'),
|
|
'generate_tests',
|
|
[],
|
|
);
|
|
expect(result).toHaveLength(2);
|
|
expect(result).toEqual(pythonTests);
|
|
});
|
|
|
|
it('should handle Python files with custom function in readTests', async () => {
|
|
const pythonTests: TestCase[] = [
|
|
{
|
|
description: 'Python Test 1',
|
|
vars: { var1: 'value1' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
options: {},
|
|
},
|
|
];
|
|
vi.mocked(runPython).mockResolvedValue(pythonTests);
|
|
vi.mocked(globSync).mockReturnValueOnce(['test.py']);
|
|
|
|
const result = await readTests(['test.py:custom_function']);
|
|
|
|
expect(runPython).toHaveBeenCalledWith(
|
|
expect.stringContaining('test.py'),
|
|
'custom_function',
|
|
[],
|
|
);
|
|
expect(result).toEqual(pythonTests);
|
|
});
|
|
|
|
it('should pass config to Python generator in readTests', async () => {
|
|
const pythonTests: TestCase[] = [
|
|
{
|
|
description: 'Python Test 1',
|
|
vars: { a: '1' },
|
|
assert: [],
|
|
options: {},
|
|
},
|
|
];
|
|
vi.mocked(runPython).mockResolvedValue(pythonTests);
|
|
vi.mocked(globSync).mockReturnValueOnce(['test.py']);
|
|
|
|
const config = { foo: 'bar' };
|
|
const result = await readTests([{ path: 'test.py', config }]);
|
|
|
|
expect(runPython).toHaveBeenCalledWith(expect.stringContaining('test.py'), 'generate_tests', [
|
|
config,
|
|
]);
|
|
expect(result).toEqual(pythonTests);
|
|
});
|
|
|
|
it('should handle Python files with invalid function name in readTests', async () => {
|
|
await expect(readTests(['test.py:invalid:extra'])).rejects.toThrow(
|
|
'Too many colons. Invalid test file script path: test.py:invalid:extra',
|
|
);
|
|
});
|
|
|
|
it('should handle Python files that return non-array in readTests', async () => {
|
|
vi.mocked(runPython).mockResolvedValue({ not: 'an array' } as any);
|
|
vi.mocked(globSync).mockReturnValueOnce(['test.py']);
|
|
|
|
await expect(readTests(['test.py'])).rejects.toThrow(
|
|
'Python test function must return a list of test cases, got object',
|
|
);
|
|
});
|
|
|
|
it('should handle file:// URLs with YAML files correctly in readTests', async () => {
|
|
const yamlTests = [
|
|
{
|
|
description: 'Test 1',
|
|
vars: { key1: 'value1' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
},
|
|
{
|
|
description: 'Test 2',
|
|
vars: { key2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'expected2' }],
|
|
},
|
|
];
|
|
|
|
vi.mocked(fs.readFileSync).mockReturnValue(yaml.dump(yamlTests));
|
|
vi.mocked(globSync).mockReturnValue(['products.yaml']);
|
|
|
|
const result = await readTests(['file://products.yaml']);
|
|
|
|
expect(result).toEqual(yamlTests);
|
|
expect(globSync).toHaveBeenCalledWith(
|
|
expect.stringContaining('products.yaml'),
|
|
expect.any(Object),
|
|
);
|
|
});
|
|
|
|
it('should warn when assert is found in vars', async () => {
|
|
const testWithAssertInVars = [
|
|
{
|
|
description: 'Test case',
|
|
vars: {
|
|
assert: [{ type: 'equals', value: 'test' }],
|
|
},
|
|
},
|
|
];
|
|
vi.mocked(fs.readFileSync).mockReturnValue(yaml.dump(testWithAssertInVars));
|
|
vi.mocked(globSync).mockReturnValue(['test.yaml']);
|
|
vi.mocked(getEnvBool).mockImplementation(
|
|
(key) => !key.includes('PROMPTFOO_NO_TESTCASE_ASSERT_WARNING'),
|
|
);
|
|
|
|
const result = await readTests(['test.yaml']);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0]).toEqual(testWithAssertInVars[0]);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
expect.stringContaining('PROMPTFOO_NO_TESTCASE_ASSERT_WARNING'),
|
|
);
|
|
});
|
|
|
|
it('should not warn about assert in vars when environment variable is set', async () => {
|
|
const testWithAssertInVars = [
|
|
{
|
|
description: 'Test case',
|
|
vars: {
|
|
assert: { type: 'equals', value: 'test' },
|
|
},
|
|
},
|
|
];
|
|
vi.mocked(fs.readFileSync).mockReturnValue(yaml.dump(testWithAssertInVars));
|
|
vi.mocked(globSync).mockReturnValue(['test.yaml']);
|
|
vi.mocked(getEnvBool).mockImplementation(
|
|
(key) => key === 'PROMPTFOO_NO_TESTCASE_ASSERT_WARNING',
|
|
);
|
|
|
|
await readTests('test.yaml');
|
|
|
|
expect(logger.warn).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('PROMPTFOO_NO_TESTCASE_ASSERT_WARNING'),
|
|
);
|
|
});
|
|
|
|
it('should handle file:// URLs with function names in readTests', async () => {
|
|
const pythonTests: TestCase[] = [
|
|
{
|
|
description: 'Python Test with file:// URL',
|
|
vars: { var1: 'value1' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
options: {},
|
|
},
|
|
];
|
|
vi.mocked(runPython).mockResolvedValue(pythonTests);
|
|
vi.mocked(globSync).mockReturnValueOnce(['test.py']);
|
|
|
|
const result = await readTests(['file://test.py:custom_function']);
|
|
|
|
expect(runPython).toHaveBeenCalledWith(
|
|
expect.stringContaining('test.py'),
|
|
'custom_function',
|
|
[],
|
|
);
|
|
expect(result).toEqual(pythonTests);
|
|
});
|
|
|
|
it('should handle xlsx files in array format', async () => {
|
|
// Mock parseXlsxFile to return processed CsvRow[] data
|
|
const mockData = [
|
|
{ var1: 'value1', var2: 'value2', __expected: 'expected1' },
|
|
{ var1: 'value3', var2: 'value4', __expected: 'expected2' },
|
|
];
|
|
|
|
mockParseXlsxFileState.implementation = vi.fn().mockResolvedValue(mockData);
|
|
|
|
const result = await readTests(['test.xlsx']);
|
|
|
|
expect(result).toEqual([
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected1' }],
|
|
description: 'Row #1',
|
|
options: {},
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
},
|
|
{
|
|
assert: [{ metric: undefined, type: 'equals', value: 'expected2' }],
|
|
description: 'Row #2',
|
|
options: {},
|
|
vars: { var1: 'value3', var2: 'value4' },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('should handle xlsx files with sheet specifier in array format', async () => {
|
|
// Mock parseXlsxFile to return processed CsvRow[] data
|
|
const mockData = [{ name: 'test1', value: 'result1' }];
|
|
|
|
mockParseXlsxFileState.implementation = vi.fn().mockResolvedValue(mockData);
|
|
|
|
const result = await readTests(['test.xlsx#DataSheet']);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].vars).toEqual({ name: 'test1', value: 'result1' });
|
|
});
|
|
|
|
it('should handle xlsx sheet names containing dots in array format', async () => {
|
|
const mockData = [{ name: 'decimal-sheet', value: 'result' }];
|
|
const parseXlsxFileMock = vi.fn().mockResolvedValue(mockData);
|
|
mockParseXlsxFileState.implementation = parseXlsxFileMock;
|
|
|
|
const result = await readTests(['file://test.xlsx#2.9']);
|
|
|
|
expect(parseXlsxFileMock).toHaveBeenCalledWith(expect.stringContaining('test.xlsx#2.9'));
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].vars).toEqual({ name: 'decimal-sheet', value: 'result' });
|
|
});
|
|
|
|
it('should handle xls files in array format', async () => {
|
|
// Mock parseXlsxFile to return processed CsvRow[] data
|
|
const mockData = [{ col1: 'data1', col2: 'data2' }];
|
|
|
|
mockParseXlsxFileState.implementation = vi.fn().mockResolvedValue(mockData);
|
|
|
|
const result = await readTests(['legacy.xls']);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].vars).toEqual({ col1: 'data1', col2: 'data2' });
|
|
});
|
|
|
|
it('should handle file:// prefix with xlsx files in array format', async () => {
|
|
// Mock parseXlsxFile to return processed CsvRow[] data
|
|
const mockData = [{ input: 'hello', expected: 'world' }];
|
|
|
|
mockParseXlsxFileState.implementation = vi.fn().mockResolvedValue(mockData);
|
|
|
|
const result = await readTests(['file://test.xlsx']);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].vars).toEqual({ input: 'hello', expected: 'world' });
|
|
});
|
|
});
|
|
|
|
describe('testCaseFromCsvRow', () => {
|
|
it('should convert a CSV row to a TestCase object', () => {
|
|
const csvRow = {
|
|
var1: 'value1',
|
|
var2: 'value2',
|
|
__expected: 'foobar',
|
|
__expected1: 'is-json',
|
|
__prefix: 'test-prefix',
|
|
__suffix: 'test-suffix',
|
|
};
|
|
const testCase = testCaseFromCsvRow(csvRow);
|
|
expect(testCase).toEqual({
|
|
vars: {
|
|
var1: 'value1',
|
|
var2: 'value2',
|
|
},
|
|
assert: [
|
|
{
|
|
type: 'equals',
|
|
value: 'foobar',
|
|
},
|
|
{
|
|
type: 'is-json',
|
|
},
|
|
],
|
|
options: {
|
|
prefix: 'test-prefix',
|
|
suffix: 'test-suffix',
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('readVarsFiles', () => {
|
|
beforeEach(() => {
|
|
clearAllMocks();
|
|
// Restore maybeLoadConfigFromExternalFile mock
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation(
|
|
mockMaybeLoadConfigFromExternalFileImpl,
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearAllMocks();
|
|
});
|
|
|
|
it('should read variables from a single YAML file', async () => {
|
|
const yamlContent = 'var1: value1\nvar2: value2';
|
|
vi.mocked(fs.readFileSync).mockReturnValue(yamlContent);
|
|
vi.mocked(globSync).mockReturnValue(['vars.yaml']);
|
|
|
|
const result = await readTestFiles('vars.yaml');
|
|
|
|
expect(result).toEqual({ var1: 'value1', var2: 'value2' });
|
|
});
|
|
|
|
it('should read variables from multiple YAML files', async () => {
|
|
const yamlContent1 = 'var1: value1';
|
|
const yamlContent2 = 'var2: value2';
|
|
|
|
// Mock globSync to return both file paths
|
|
vi.mocked(globSync).mockImplementation((pattern) => {
|
|
if (pattern.includes('vars1.yaml')) {
|
|
return ['vars1.yaml'];
|
|
} else if (pattern.includes('vars2.yaml')) {
|
|
return ['vars2.yaml'];
|
|
}
|
|
return [];
|
|
});
|
|
|
|
// Mock readFileSync to return different content for each file
|
|
vi.mocked(fs.readFileSync).mockImplementation((path) => {
|
|
if (path.toString().includes('vars1.yaml')) {
|
|
return yamlContent1;
|
|
} else if (path.toString().includes('vars2.yaml')) {
|
|
return yamlContent2;
|
|
}
|
|
return '';
|
|
});
|
|
|
|
const result = await readTestFiles(['vars1.yaml', 'vars2.yaml']);
|
|
|
|
expect(result).toEqual({ var1: 'value1', var2: 'value2' });
|
|
});
|
|
});
|
|
|
|
describe('loadTestsFromGlob', () => {
|
|
beforeEach(() => {
|
|
clearAllMocks();
|
|
// Explicitly set a simple pass-through mock to ensure no pollution from previous describe blocks
|
|
// Individual tests will override this with their own specific implementations
|
|
vi.mocked(maybeLoadConfigFromExternalFile).mockImplementation((config: any) => config);
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearAllMocks();
|
|
});
|
|
|
|
it('should handle Hugging Face dataset URLs', async () => {
|
|
const mockDataset: TestCase[] = [
|
|
{
|
|
description: 'Test 1',
|
|
vars: { var1: 'value1' },
|
|
assert: [{ type: 'equals', value: 'expected1' }],
|
|
options: {},
|
|
},
|
|
{
|
|
description: 'Test 2',
|
|
vars: { var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'expected2' }],
|
|
options: {},
|
|
},
|
|
];
|
|
vi.mocked(fetchHuggingFaceDataset).mockResolvedValue(mockDataset);
|
|
|
|
const result = await loadTestsFromGlob('huggingface://datasets/example/dataset');
|
|
|
|
expect(fetchHuggingFaceDataset).toHaveBeenCalledWith('huggingface://datasets/example/dataset');
|
|
expect(result).toEqual(mockDataset);
|
|
});
|
|
|
|
it('should recursively resolve file:// references in YAML test files', async () => {
|
|
// Set up mock implementation FIRST, before any other setup
|
|
// Use vi.mocked() for consistency with clearAllMocks()
|
|
const mockMaybeLoadConfig = vi.mocked(maybeLoadConfigFromExternalFile);
|
|
mockMaybeLoadConfig.mockReset();
|
|
|
|
const yamlContentWithRefs = [
|
|
{
|
|
description: 'Test with file refs',
|
|
vars: {
|
|
input: 'file://input.txt',
|
|
expected: 'file://expected.json',
|
|
},
|
|
assert: ['file://assertions.yaml'],
|
|
},
|
|
];
|
|
|
|
const resolvedContent = [
|
|
{
|
|
description: 'Test with file refs',
|
|
vars: {
|
|
input: 'What is 2 + 2?',
|
|
expected: { answer: '4' },
|
|
},
|
|
assert: [{ type: 'equals', value: '4' }],
|
|
},
|
|
];
|
|
|
|
// Mock maybeLoadConfigFromExternalFile to resolve file:// references
|
|
mockMaybeLoadConfig.mockImplementation((config: any) => {
|
|
// Handle arrays - preserve array type and return resolved content
|
|
if (Array.isArray(config)) {
|
|
// Map over array to handle each element
|
|
return config.map((item) => {
|
|
// If it matches our test data structure, return the resolved version
|
|
if (item && item.description === 'Test with file refs') {
|
|
return resolvedContent[0];
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
// For objects, preserve them as-is
|
|
if (config && typeof config === 'object') {
|
|
return config;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// Set up file system mocks AFTER setting up the maybeLoadConfigFromExternalFile mock
|
|
vi.mocked(globSync).mockReturnValue(['tests.yaml']);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(yaml.dump(yamlContentWithRefs));
|
|
|
|
const result = await loadTestsFromGlob('tests.yaml');
|
|
|
|
// The mock should be called with the array from YAML
|
|
expect(mockMaybeLoadConfig).toHaveBeenCalled();
|
|
expect(result).toEqual(resolvedContent);
|
|
});
|
|
|
|
it('should handle nested file:// references in complex test structures', async () => {
|
|
// Set up mock implementation FIRST, before any other setup
|
|
// Use vi.mocked() for consistency with clearAllMocks()
|
|
const mockMaybeLoadConfig = vi.mocked(maybeLoadConfigFromExternalFile);
|
|
mockMaybeLoadConfig.mockReset();
|
|
|
|
const complexYamlWithRefs = [
|
|
{
|
|
description: 'Complex test',
|
|
vars: {
|
|
config: 'file://config.yaml',
|
|
prompts: ['file://prompt1.txt', 'file://prompt2.txt'],
|
|
},
|
|
assert: ['file://assert1.yaml', 'file://assert2.yaml'],
|
|
},
|
|
];
|
|
|
|
const resolvedContent = [
|
|
{
|
|
description: 'Complex test',
|
|
vars: {
|
|
config: { temperature: 0.7, maxTokens: 100 },
|
|
prompts: ['First prompt', 'Second prompt'],
|
|
},
|
|
assert: [
|
|
{ type: 'equals', value: 'expected1' },
|
|
{ type: 'contains', value: 'expected2' },
|
|
],
|
|
},
|
|
];
|
|
|
|
// Mock maybeLoadConfigFromExternalFile to resolve file:// references
|
|
mockMaybeLoadConfig.mockImplementation((config: any) => {
|
|
// Handle arrays - preserve array type and return resolved content
|
|
if (Array.isArray(config)) {
|
|
return config.map((item) => {
|
|
if (item && item.description === 'Complex test') {
|
|
return resolvedContent[0];
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
// For objects, preserve them as-is
|
|
if (config && typeof config === 'object') {
|
|
return config;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// Set up file system mocks AFTER setting up the maybeLoadConfigFromExternalFile mock
|
|
vi.mocked(globSync).mockReturnValue(['complex-tests.yaml']);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(yaml.dump(complexYamlWithRefs));
|
|
|
|
const result = await loadTestsFromGlob('complex-tests.yaml');
|
|
|
|
expect(mockMaybeLoadConfig).toHaveBeenCalled();
|
|
// Note: provider field is not included in the resolved content from our test file structure
|
|
expect(result[0].description).toEqual(resolvedContent[0].description);
|
|
expect(result[0].vars).toEqual(resolvedContent[0].vars);
|
|
expect(result[0].assert).toEqual(resolvedContent[0].assert);
|
|
});
|
|
|
|
it('should preserve Python assertion file references when loading YAML tests', async () => {
|
|
// This test verifies the fix for issue #5519
|
|
// Set up mock implementation FIRST, before any other setup
|
|
// Use vi.mocked() for consistency with clearAllMocks()
|
|
const mockMaybeLoadConfig = vi.mocked(maybeLoadConfigFromExternalFile);
|
|
mockMaybeLoadConfig.mockReset();
|
|
|
|
const yamlContentWithPythonAssertion = [
|
|
{
|
|
vars: { name: 'Should PASS' },
|
|
assert: [
|
|
{
|
|
type: 'python',
|
|
value: 'file://good_assertion.py',
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
// Mock maybeLoadConfigFromExternalFile to preserve Python files in assertion contexts
|
|
mockMaybeLoadConfig.mockImplementation((config: any) => {
|
|
// Handle arrays - preserve array type
|
|
if (Array.isArray(config)) {
|
|
return config;
|
|
}
|
|
// For objects, preserve them as-is (including file:// references for Python assertions)
|
|
if (config && typeof config === 'object') {
|
|
return config;
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// Set up file system mocks AFTER setting up the maybeLoadConfigFromExternalFile mock
|
|
vi.mocked(globSync).mockReturnValue(['tests.yaml']);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(yaml.dump(yamlContentWithPythonAssertion));
|
|
|
|
const result = await loadTestsFromGlob('tests.yaml');
|
|
|
|
expect(mockMaybeLoadConfig).toHaveBeenCalled();
|
|
expect((result[0].assert![0] as any).value).toBe('file://good_assertion.py'); // Should remain as file reference
|
|
});
|
|
});
|
|
|
|
describe('CSV parsing with JSON fields', () => {
|
|
beforeEach(() => {
|
|
clearAllMocks();
|
|
vi.mocked(getEnvBool).mockImplementation((_key, defaultValue = false) => defaultValue);
|
|
vi.mocked(getEnvString).mockImplementation((_key, defaultValue) => defaultValue || '');
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearAllMocks();
|
|
});
|
|
|
|
it('should parse CSV file containing properly escaped JSON fields in strict mode', async () => {
|
|
const csvContent = `label,query,expected_json_format,context
|
|
my_test_label,What is the date?,"{\""answer\"":""""}",file://../get_context.py`;
|
|
|
|
vi.spyOn(fs, 'readFileSync').mockReturnValue(csvContent);
|
|
|
|
const testCases = await readStandaloneTestsFile('dummy.csv');
|
|
|
|
expect(testCases).toHaveLength(1);
|
|
expect(testCases[0].vars).toEqual({
|
|
label: 'my_test_label',
|
|
query: 'What is the date?',
|
|
expected_json_format: '{"answer":""}',
|
|
context: 'file://../get_context.py',
|
|
});
|
|
|
|
vi.mocked(fs.readFileSync).mockRestore();
|
|
});
|
|
|
|
it('should fall back to relaxed parsing for unescaped JSON fields', async () => {
|
|
const csvContent = `label,query,expected_json_format,context
|
|
my_test_label,What is the date?,{"answer":""},file://../get_context.py`;
|
|
|
|
vi.spyOn(fs, 'readFileSync').mockReturnValue(csvContent);
|
|
|
|
const testCases = await readStandaloneTestsFile('dummy.csv');
|
|
|
|
expect(testCases).toHaveLength(1);
|
|
expect(testCases[0].vars).toEqual({
|
|
label: 'my_test_label',
|
|
query: 'What is the date?',
|
|
expected_json_format: '{"answer":""}',
|
|
context: 'file://../get_context.py',
|
|
});
|
|
|
|
vi.mocked(fs.readFileSync).mockRestore();
|
|
});
|
|
|
|
it('should enforce strict mode when PROMPTFOO_CSV_STRICT=true', async () => {
|
|
vi.mocked(getEnvBool).mockImplementation((key, defaultValue = false) =>
|
|
key === 'PROMPTFOO_CSV_STRICT' ? true : defaultValue,
|
|
);
|
|
|
|
const csvContent = `label,query,expected_json_format,context
|
|
my_test_label,What is the date?,{"answer":""},file://../get_context.py`;
|
|
|
|
vi.spyOn(fs, 'readFileSync').mockReturnValue(csvContent);
|
|
|
|
await expect(readStandaloneTestsFile('dummy.csv')).rejects.toThrow(
|
|
'Invalid Opening Quote: a quote is found on field',
|
|
);
|
|
|
|
vi.mocked(fs.readFileSync).mockRestore();
|
|
});
|
|
|
|
it('should propagate non-quote-related CSV errors', async () => {
|
|
// Create CSV content with inconsistent column count to trigger "Invalid Record Length" error
|
|
const csvContent = `label,query,expected_json_format,context
|
|
my_test_label,What is the date?
|
|
another_label,What is the time?,too,many,columns,here`;
|
|
|
|
vi.spyOn(fs, 'readFileSync').mockReturnValue(csvContent);
|
|
|
|
// Use default settings (not strict mode) to get past quote checking
|
|
vi.mocked(getEnvBool).mockImplementation((_key, defaultValue = false) => defaultValue);
|
|
vi.mocked(getEnvString).mockImplementation((key, defaultValue) =>
|
|
key === 'PROMPTFOO_CSV_DELIMITER' ? ',' : defaultValue || '',
|
|
);
|
|
|
|
// The CSV parser should throw an error about inconsistent column count
|
|
await expect(readStandaloneTestsFile('dummy.csv')).rejects.toThrow('Invalid Record Length');
|
|
|
|
vi.mocked(fs.readFileSync).mockRestore();
|
|
});
|
|
});
|