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,253 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// ── Hoisted mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
const mockIo = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('socket.io-client', () => ({ io: mockIo }));
|
||||
|
||||
vi.mock('../../../src/globalConfig/cloud', () => ({
|
||||
cloudConfig: { getApiHost: () => 'http://localhost:3000' },
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/util/agent/agentAuth', () => ({
|
||||
resolveBaseAuthCredentials: () => ({ apiKey: 'test-key' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/logger', () => ({
|
||||
default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// ── Fake socket factory ────────────────────────────────────────────────────────
|
||||
|
||||
function createFakeSocket() {
|
||||
const emitter = new EventEmitter();
|
||||
const ioEmitter = new EventEmitter();
|
||||
const emittedToServer: Array<{ event: string; args: unknown[] }> = [];
|
||||
|
||||
const socket = {
|
||||
id: 'fake-socket-id',
|
||||
connected: false,
|
||||
io: {
|
||||
reconnection: vi.fn(),
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
ioEmitter.on(event, handler);
|
||||
return socket.io;
|
||||
},
|
||||
},
|
||||
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
emitter.on(event, handler);
|
||||
return socket;
|
||||
},
|
||||
once(event: string, handler: (...args: unknown[]) => void) {
|
||||
emitter.once(event, handler);
|
||||
return socket;
|
||||
},
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
emittedToServer.push({ event, args });
|
||||
},
|
||||
removeAllListeners() {
|
||||
emitter.removeAllListeners();
|
||||
return socket;
|
||||
},
|
||||
close: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
|
||||
// Test helpers
|
||||
_emittedToServer: emittedToServer,
|
||||
_simulateEvent(event: string, ...args: unknown[]) {
|
||||
emitter.emit(event, ...args);
|
||||
},
|
||||
_simulateConnect() {
|
||||
socket.connected = true;
|
||||
socket.id = `socket-${Math.random().toString(36).slice(2, 8)}`;
|
||||
emitter.emit('connect');
|
||||
},
|
||||
_simulateDisconnect(reason = 'transport close') {
|
||||
socket.connected = false;
|
||||
emitter.emit('disconnect', reason);
|
||||
},
|
||||
_simulateConnectError(message = 'Connection refused') {
|
||||
emitter.emit('connect_error', new Error(message));
|
||||
},
|
||||
_simulateReconnectFailed() {
|
||||
ioEmitter.emit('reconnect_failed');
|
||||
},
|
||||
};
|
||||
return socket;
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createAgentClient', () => {
|
||||
let fakeSocket: ReturnType<typeof createFakeSocket>;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeSocket = createFakeSocket();
|
||||
mockIo.mockReset();
|
||||
mockIo.mockReturnValue(fakeSocket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('emits agent:join on initial connect', async () => {
|
||||
const { createAgentClient } = await import('../../../src/util/agent/agentClient');
|
||||
|
||||
const promise = createAgentClient({
|
||||
agent: 'test-agent',
|
||||
host: 'http://localhost:3000',
|
||||
auth: { apiKey: 'k' },
|
||||
sessionId: 'sess-123',
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
fakeSocket._simulateConnect();
|
||||
const client = await promise;
|
||||
|
||||
expect(client.sessionId).toBe('sess-123');
|
||||
|
||||
const joins = fakeSocket._emittedToServer.filter((e) => e.event === 'agent:join');
|
||||
expect(joins).toHaveLength(1);
|
||||
expect(joins[0].args[0]).toEqual({ sessionId: 'sess-123' });
|
||||
});
|
||||
|
||||
it('re-emits agent:join after disconnect + reconnect', async () => {
|
||||
const { createAgentClient } = await import('../../../src/util/agent/agentClient');
|
||||
|
||||
const promise = createAgentClient({
|
||||
agent: 'test-agent',
|
||||
host: 'http://localhost:3000',
|
||||
auth: { apiKey: 'k' },
|
||||
sessionId: 'sess-456',
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
// Initial connect
|
||||
fakeSocket._simulateConnect();
|
||||
await promise;
|
||||
|
||||
// Disconnect then reconnect
|
||||
fakeSocket._simulateDisconnect('transport close');
|
||||
fakeSocket._simulateConnect();
|
||||
|
||||
const joins = fakeSocket._emittedToServer.filter((e) => e.event === 'agent:join');
|
||||
expect(joins).toHaveLength(2);
|
||||
expect(joins[0].args[0]).toEqual({ sessionId: 'sess-456' });
|
||||
expect(joins[1].args[0]).toEqual({ sessionId: 'sess-456' });
|
||||
});
|
||||
|
||||
it('resolves the promise only once even after reconnect', async () => {
|
||||
const { createAgentClient } = await import('../../../src/util/agent/agentClient');
|
||||
|
||||
let resolveCount = 0;
|
||||
const promise = createAgentClient({
|
||||
agent: 'test-agent',
|
||||
host: 'http://localhost:3000',
|
||||
auth: { apiKey: 'k' },
|
||||
sessionId: 'sess-789',
|
||||
timeoutMs: 1000,
|
||||
}).then((client) => {
|
||||
resolveCount++;
|
||||
return client;
|
||||
});
|
||||
|
||||
fakeSocket._simulateConnect();
|
||||
await promise;
|
||||
expect(resolveCount).toBe(1);
|
||||
|
||||
// Reconnect — should NOT cause a second resolution. Flush several
|
||||
// microtask rounds to give any (buggy) re-resolve handler a chance to fire.
|
||||
fakeSocket._simulateDisconnect('transport close');
|
||||
fakeSocket._simulateConnect();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
expect(resolveCount).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects with connect_error before initial connection', async () => {
|
||||
const { createAgentClient } = await import('../../../src/util/agent/agentClient');
|
||||
|
||||
const promise = createAgentClient({
|
||||
agent: 'test-agent',
|
||||
host: 'http://localhost:3000',
|
||||
auth: { apiKey: 'k' },
|
||||
sessionId: 'sess-err',
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
fakeSocket._simulateConnectError('Auth failed');
|
||||
|
||||
await expect(promise).rejects.toThrow('Failed to connect to server: Auth failed');
|
||||
});
|
||||
|
||||
it('ignores connect_error after successful connection', async () => {
|
||||
const { createAgentClient } = await import('../../../src/util/agent/agentClient');
|
||||
|
||||
const promise = createAgentClient({
|
||||
agent: 'test-agent',
|
||||
host: 'http://localhost:3000',
|
||||
auth: { apiKey: 'k' },
|
||||
sessionId: 'sess-after-err',
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
// Connect first
|
||||
fakeSocket._simulateConnect();
|
||||
const client = await promise;
|
||||
|
||||
// connect_error after settlement should not cause problems
|
||||
fakeSocket._simulateConnectError('Late error');
|
||||
|
||||
// Client should still be usable
|
||||
expect(client.sessionId).toBe('sess-after-err');
|
||||
});
|
||||
|
||||
it('onCancelled receives agent:cancelled event', async () => {
|
||||
const { createAgentClient } = await import('../../../src/util/agent/agentClient');
|
||||
|
||||
const promise = createAgentClient({
|
||||
agent: 'test-agent',
|
||||
host: 'http://localhost:3000',
|
||||
auth: { apiKey: 'k' },
|
||||
sessionId: 'sess-cancel',
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
fakeSocket._simulateConnect();
|
||||
const client = await promise;
|
||||
|
||||
const cb = vi.fn();
|
||||
client.onCancelled(cb);
|
||||
|
||||
fakeSocket._simulateEvent('agent:cancelled', { clientType: 'web' });
|
||||
|
||||
expect(cb).toHaveBeenCalledWith({ clientType: 'web' });
|
||||
});
|
||||
|
||||
it('rejects on timeout when no connection', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { createAgentClient } = await import('../../../src/util/agent/agentClient');
|
||||
|
||||
const promise = createAgentClient({
|
||||
agent: 'test-agent',
|
||||
host: 'http://localhost:3000',
|
||||
auth: { apiKey: 'k' },
|
||||
sessionId: 'sess-timeout',
|
||||
timeoutMs: 500,
|
||||
});
|
||||
|
||||
// Advance past timeout
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
await expect(promise).rejects.toThrow('Connection timeout after 500ms');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fsPromisesMock = vi.hoisted(() => ({
|
||||
actualReadFile: undefined as undefined | typeof fs.readFile,
|
||||
readFileSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
function getActualReadFile(): typeof fs.readFile {
|
||||
if (!fsPromisesMock.actualReadFile) {
|
||||
throw new Error('readFile mock not initialized');
|
||||
}
|
||||
return fsPromisesMock.actualReadFile;
|
||||
}
|
||||
|
||||
vi.mock('node:fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
|
||||
fsPromisesMock.actualReadFile = actual.readFile.bind(actual);
|
||||
fsPromisesMock.readFileSpy.mockImplementation(getActualReadFile());
|
||||
|
||||
return {
|
||||
...actual,
|
||||
readFile: fsPromisesMock.readFileSpy,
|
||||
};
|
||||
});
|
||||
|
||||
import { readFile } from '../../../src/util/agent/fsOperations';
|
||||
|
||||
describe('agent fsOperations readFile', () => {
|
||||
let rootDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
fsPromisesMock.readFileSpy.mockReset();
|
||||
fsPromisesMock.readFileSpy.mockImplementation(getActualReadFile());
|
||||
rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-fs-operations-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await fs.rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns the full contents for files within the size limit', async () => {
|
||||
await fs.writeFile(path.join(rootDir, 'small.txt'), 'small file contents', 'utf8');
|
||||
|
||||
await expect(readFile('small.txt', rootDir)).resolves.toBe('small file contents');
|
||||
});
|
||||
|
||||
it('returns full multibyte content when decoded length fits within the limit', async () => {
|
||||
const multibyteContent = '你'.repeat(60_000);
|
||||
await fs.writeFile(path.join(rootDir, 'multibyte.txt'), multibyteContent, 'utf8');
|
||||
|
||||
await expect(readFile('multibyte.txt', rootDir)).resolves.toBe(multibyteContent);
|
||||
});
|
||||
|
||||
it('truncates very large files without using fs.readFile', async () => {
|
||||
const maxFileSize = 100_000;
|
||||
const oversizedContent = 'a'.repeat(450_000);
|
||||
await fs.writeFile(path.join(rootDir, 'large.txt'), oversizedContent, 'utf8');
|
||||
|
||||
const result = await readFile('large.txt', rootDir);
|
||||
|
||||
expect(fsPromisesMock.readFileSpy).not.toHaveBeenCalled();
|
||||
expect(result).toBe(
|
||||
oversizedContent.slice(0, maxFileSize) +
|
||||
`\n\n[truncated — file is ${oversizedContent.length} bytes]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TargetLinkEvents } from '../../../src/types/targetLink';
|
||||
|
||||
vi.mock('../../../src/logger', () => ({
|
||||
default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// ── Fake client factory ───────────────────────────────────────────────────────
|
||||
|
||||
function createFakeClient() {
|
||||
const emitter = new EventEmitter();
|
||||
const emittedToServer: Array<{ event: string; args: unknown[] }> = [];
|
||||
|
||||
return {
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
emitter.on(event, handler);
|
||||
},
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
emittedToServer.push({ event, args });
|
||||
},
|
||||
// Test helpers
|
||||
_emittedToServer: emittedToServer,
|
||||
_simulateEvent(event: string, ...args: unknown[]) {
|
||||
emitter.emit(event, ...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('attachTargetLink', () => {
|
||||
let fakeClient: ReturnType<typeof createFakeClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeClient = createFakeClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('emits link:ready on attach', async () => {
|
||||
const { attachTargetLink } = await import('../../../src/util/agent/targetLink');
|
||||
|
||||
const provider = { id: () => 'test', callApi: vi.fn() };
|
||||
attachTargetLink(fakeClient as any, provider);
|
||||
|
||||
const readyEvents = fakeClient._emittedToServer.filter(
|
||||
(e) => e.event === TargetLinkEvents.READY,
|
||||
);
|
||||
expect(readyEvents).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sets up handler before emitting ready', async () => {
|
||||
const { attachTargetLink } = await import('../../../src/util/agent/targetLink');
|
||||
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const originalOn = fakeClient.on.bind(fakeClient);
|
||||
fakeClient.on = (event: string, handler: (...args: unknown[]) => void) => {
|
||||
if (event === TargetLinkEvents.PROBE) {
|
||||
callOrder.push('handler_registered');
|
||||
}
|
||||
return originalOn(event, handler);
|
||||
};
|
||||
|
||||
const originalEmit = fakeClient.emit.bind(fakeClient);
|
||||
fakeClient.emit = (event: string, ...args: unknown[]) => {
|
||||
if (event === TargetLinkEvents.READY) {
|
||||
callOrder.push('ready_emitted');
|
||||
}
|
||||
return originalEmit(event, ...args);
|
||||
};
|
||||
|
||||
const provider = { id: () => 'test', callApi: vi.fn() };
|
||||
attachTargetLink(fakeClient as any, provider);
|
||||
|
||||
expect(callOrder).toEqual(['handler_registered', 'ready_emitted']);
|
||||
});
|
||||
|
||||
it('calls provider.callApi on probe and emits result with output', async () => {
|
||||
const { attachTargetLink } = await import('../../../src/util/agent/targetLink');
|
||||
|
||||
const provider = {
|
||||
id: () => 'test',
|
||||
callApi: vi.fn().mockResolvedValue({
|
||||
output: 'Hello world',
|
||||
tokenUsage: { prompt: 10, completion: 20, total: 30 },
|
||||
}),
|
||||
};
|
||||
|
||||
attachTargetLink(fakeClient as any, provider);
|
||||
|
||||
fakeClient._simulateEvent(TargetLinkEvents.PROBE, {
|
||||
requestId: 'req-1',
|
||||
prompt: 'What is your purpose?',
|
||||
});
|
||||
|
||||
// Wait for async handler
|
||||
await vi.waitFor(() => {
|
||||
expect(provider.callApi).toHaveBeenCalledWith('What is your purpose?', {
|
||||
vars: {},
|
||||
prompt: { raw: 'What is your purpose?', label: 'target-link-probe' },
|
||||
});
|
||||
});
|
||||
|
||||
const results = fakeClient._emittedToServer.filter(
|
||||
(e) => e.event === TargetLinkEvents.PROBE_RESULT,
|
||||
);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].args[0]).toEqual({
|
||||
requestId: 'req-1',
|
||||
output: 'Hello world',
|
||||
tokenUsage: { input: 10, output: 20, total: 30 },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps tokenUsage prompt/completion to input/output', async () => {
|
||||
const { attachTargetLink } = await import('../../../src/util/agent/targetLink');
|
||||
|
||||
const provider = {
|
||||
id: () => 'test',
|
||||
callApi: vi.fn().mockResolvedValue({
|
||||
output: 'ok',
|
||||
tokenUsage: { prompt: 5, completion: 15, total: 20 },
|
||||
}),
|
||||
};
|
||||
|
||||
attachTargetLink(fakeClient as any, provider);
|
||||
|
||||
fakeClient._simulateEvent(TargetLinkEvents.PROBE, {
|
||||
requestId: 'req-map',
|
||||
prompt: 'test',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(provider.callApi).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const results = fakeClient._emittedToServer.filter(
|
||||
(e) => e.event === TargetLinkEvents.PROBE_RESULT,
|
||||
);
|
||||
expect(results[0].args[0]).toMatchObject({
|
||||
tokenUsage: { input: 5, output: 15, total: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
it('handles provider error — emits result with error string', async () => {
|
||||
const { attachTargetLink } = await import('../../../src/util/agent/targetLink');
|
||||
|
||||
const provider = {
|
||||
id: () => 'test',
|
||||
callApi: vi.fn().mockRejectedValue(new Error('Provider failed')),
|
||||
};
|
||||
|
||||
attachTargetLink(fakeClient as any, provider);
|
||||
|
||||
fakeClient._simulateEvent(TargetLinkEvents.PROBE, {
|
||||
requestId: 'req-err',
|
||||
prompt: 'test',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const results = fakeClient._emittedToServer.filter(
|
||||
(e) => e.event === TargetLinkEvents.PROBE_RESULT,
|
||||
);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].args[0]).toEqual({
|
||||
requestId: 'req-err',
|
||||
error: 'Provider failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles object output — JSON.stringifies it', async () => {
|
||||
const { attachTargetLink } = await import('../../../src/util/agent/targetLink');
|
||||
|
||||
const provider = {
|
||||
id: () => 'test',
|
||||
callApi: vi.fn().mockResolvedValue({
|
||||
output: { key: 'value' },
|
||||
}),
|
||||
};
|
||||
|
||||
attachTargetLink(fakeClient as any, provider);
|
||||
|
||||
fakeClient._simulateEvent(TargetLinkEvents.PROBE, {
|
||||
requestId: 'req-obj',
|
||||
prompt: 'test',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const results = fakeClient._emittedToServer.filter(
|
||||
(e) => e.event === TargetLinkEvents.PROBE_RESULT,
|
||||
);
|
||||
expect(results).toHaveLength(1);
|
||||
expect((results[0].args[0] as any).output).toBe('{"key":"value"}');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing tokenUsage — omits field', async () => {
|
||||
const { attachTargetLink } = await import('../../../src/util/agent/targetLink');
|
||||
|
||||
const provider = {
|
||||
id: () => 'test',
|
||||
callApi: vi.fn().mockResolvedValue({
|
||||
output: 'no tokens',
|
||||
}),
|
||||
};
|
||||
|
||||
attachTargetLink(fakeClient as any, provider);
|
||||
|
||||
fakeClient._simulateEvent(TargetLinkEvents.PROBE, {
|
||||
requestId: 'req-no-tokens',
|
||||
prompt: 'test',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const results = fakeClient._emittedToServer.filter(
|
||||
(e) => e.event === TargetLinkEvents.PROBE_RESULT,
|
||||
);
|
||||
expect(results).toHaveLength(1);
|
||||
expect((results[0].args[0] as any).tokenUsage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getRemoteHealthUrl } from '../../src/redteam/remoteGeneration';
|
||||
import { checkRemoteHealth } from '../../src/util/apiHealth';
|
||||
import { fetchWithTimeout } from '../../src/util/fetch/index';
|
||||
import { mockProcessEnv } from './utils';
|
||||
|
||||
// Mock fetchWithTimeout module
|
||||
vi.mock('../../src/util/fetch/index', () => ({
|
||||
fetchWithTimeout: vi.fn(),
|
||||
}));
|
||||
|
||||
// Hoisted mock functions for CloudConfig
|
||||
const { mockIsEnabled, mockGetApiHost } = vi.hoisted(() => ({
|
||||
mockIsEnabled: vi.fn().mockReturnValue(false),
|
||||
mockGetApiHost: vi.fn().mockReturnValue('https://custom.api.com'),
|
||||
}));
|
||||
|
||||
// Mock CloudConfig as a class
|
||||
vi.mock('../../src/globalConfig/cloud', () => {
|
||||
return {
|
||||
CloudConfig: class MockCloudConfig {
|
||||
isEnabled = mockIsEnabled;
|
||||
getApiHost = mockGetApiHost;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Get the mocked fetchWithTimeout
|
||||
const mockedFetchWithTimeout = vi.mocked(fetchWithTimeout);
|
||||
|
||||
describe('API Health Utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_REDTEAM_REMOTE_GENERATION: undefined });
|
||||
mockProcessEnv({ PROMPTFOO_REMOTE_GENERATION_URL: undefined });
|
||||
|
||||
mockIsEnabled.mockReset();
|
||||
mockGetApiHost.mockReset();
|
||||
mockIsEnabled.mockReturnValue(false);
|
||||
mockGetApiHost.mockReturnValue('https://custom.api.com');
|
||||
});
|
||||
|
||||
describe('getRemoteHealthUrl', () => {
|
||||
it('should return null when remote generation is disabled', () => {
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_REDTEAM_REMOTE_GENERATION: 'true' });
|
||||
expect(getRemoteHealthUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('should use custom URL when provided', () => {
|
||||
mockProcessEnv({ PROMPTFOO_REMOTE_GENERATION_URL: 'https://custom-api.example.com/task' });
|
||||
expect(getRemoteHealthUrl()).toBe('https://custom-api.example.com/health');
|
||||
});
|
||||
|
||||
it('should use cloud config when enabled', () => {
|
||||
mockIsEnabled.mockReturnValue(true);
|
||||
mockGetApiHost.mockReturnValue('https://cloud.example.com');
|
||||
expect(getRemoteHealthUrl()).toBe('https://cloud.example.com/health');
|
||||
});
|
||||
|
||||
it('should use default URL when no configuration is provided', () => {
|
||||
mockIsEnabled.mockReturnValue(false);
|
||||
expect(getRemoteHealthUrl()).toBe('https://api.promptfoo.app/health');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRemoteHealth', () => {
|
||||
it('should return OK status when API is healthy', async () => {
|
||||
mockIsEnabled.mockReturnValue(false);
|
||||
mockedFetchWithTimeout.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: 'OK' }),
|
||||
} as Response);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.message).toBe('Cloud API is healthy');
|
||||
});
|
||||
|
||||
it('should include custom endpoint message when cloud config is enabled', async () => {
|
||||
mockIsEnabled.mockReturnValue(true);
|
||||
mockedFetchWithTimeout.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: 'OK' }),
|
||||
} as Response);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.message).toBe('Cloud API is healthy (using custom endpoint)');
|
||||
});
|
||||
|
||||
it('should handle non-OK response', async () => {
|
||||
mockedFetchWithTimeout.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
} as Response);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toContain('Failed to connect');
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toContain('Network error');
|
||||
});
|
||||
|
||||
it('should handle malformed JSON', async () => {
|
||||
mockedFetchWithTimeout.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.reject(new Error('Invalid JSON')),
|
||||
} as Response);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toContain('Invalid JSON');
|
||||
});
|
||||
|
||||
it('should handle ECONNREFUSED errors', async () => {
|
||||
const connectionError: any = new Error('Connection refused');
|
||||
connectionError.cause = { code: 'ECONNREFUSED' };
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce(connectionError);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toBe('API is not reachable');
|
||||
});
|
||||
|
||||
it('should handle ECONNREFUSED with nested cause structure', async () => {
|
||||
const connectionError: any = new Error('Connection error');
|
||||
connectionError['cause'] = { code: 'ECONNREFUSED' };
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce(connectionError);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toBe('API is not reachable');
|
||||
});
|
||||
|
||||
it('should handle errors with cause but no ECONNREFUSED code', async () => {
|
||||
const errorWithCause: any = new Error('Some network error');
|
||||
errorWithCause.cause = { code: 'ETIMEDOUT' };
|
||||
errorWithCause.code = 'NETWORK_ERROR';
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce(errorWithCause);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toContain('Network error [NETWORK_ERROR]');
|
||||
expect(result.message).toContain('Some network error');
|
||||
expect(result.message).toContain('(Cause: [object Object])');
|
||||
expect(result.message).toContain('URL: https://test.api/health');
|
||||
});
|
||||
|
||||
it('should handle timeout error from fetchWithTimeout gracefully', async () => {
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce(new Error('Request timed out after 5000 ms'));
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('OK');
|
||||
expect(result.message).toBe('API health check timed out, proceeding anyway');
|
||||
});
|
||||
|
||||
it('should handle certificate errors specifically', async () => {
|
||||
const certError = new Error('unable to verify the first certificate');
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce(certError);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toContain('SSL/Certificate issue detected');
|
||||
expect(result.message).toContain('unable to verify the first certificate');
|
||||
});
|
||||
|
||||
it('should handle self-signed certificate errors', async () => {
|
||||
const selfSignedError = new Error('self signed certificate in certificate chain');
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce(selfSignedError);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toContain('SSL/Certificate issue detected');
|
||||
expect(result.message).toContain('self signed certificate');
|
||||
});
|
||||
|
||||
it('should handle DISABLED status from API', async () => {
|
||||
mockedFetchWithTimeout.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: 'DISABLED' }),
|
||||
} as Response);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('DISABLED');
|
||||
expect(result.message).toBe('remote generation and grading are disabled');
|
||||
});
|
||||
|
||||
it('should handle unknown status from API', async () => {
|
||||
mockedFetchWithTimeout.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ status: 'UNKNOWN' }),
|
||||
} as Response);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toBe('Unknown error');
|
||||
});
|
||||
|
||||
it('should handle unknown status with custom message', async () => {
|
||||
mockedFetchWithTimeout.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
status: 'MAINTENANCE',
|
||||
message: 'System is under maintenance',
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toBe('System is under maintenance');
|
||||
});
|
||||
|
||||
it('should handle errors without cause property', async () => {
|
||||
const simpleError = new Error('Simple network error');
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce(simpleError);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toContain('Network error: Simple network error');
|
||||
expect(result.message).toContain('URL: https://test.api/health');
|
||||
expect(result.message).not.toContain('Cause:');
|
||||
});
|
||||
|
||||
it('should handle non-Error objects thrown as errors', async () => {
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce('String error');
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toContain('Network error: String error');
|
||||
expect(result.message).toContain('URL: https://test.api/health');
|
||||
});
|
||||
|
||||
it('should handle errors with code property but no cause', async () => {
|
||||
const errorWithCode: any = new Error('Connection error');
|
||||
errorWithCode.code = 'ERR_SOCKET_TIMEOUT';
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce(errorWithCode);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toContain('Network error [ERR_SOCKET_TIMEOUT]');
|
||||
expect(result.message).toContain('Connection error');
|
||||
expect(result.message).not.toContain('Cause:');
|
||||
});
|
||||
|
||||
it('should handle errors with both code and cause', async () => {
|
||||
const complexError: any = new Error('Complex network failure');
|
||||
complexError.code = 'ERR_NETWORK';
|
||||
complexError.cause = 'Underlying system error';
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce(complexError);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toContain('Network error [ERR_NETWORK]');
|
||||
expect(result.message).toContain('Complex network failure');
|
||||
expect(result.message).toContain('(Cause: Underlying system error)');
|
||||
expect(result.message).toContain('URL: https://test.api/health');
|
||||
});
|
||||
|
||||
it('should handle null or undefined errors gracefully', async () => {
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce(null);
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
// When null is passed through String(), it becomes "null"
|
||||
expect(result.message).toContain('Network error: ');
|
||||
expect(result.message).toContain('URL: https://test.api/health');
|
||||
});
|
||||
|
||||
it('should handle object errors that are not Error instances', async () => {
|
||||
mockedFetchWithTimeout.mockRejectedValueOnce({ error: 'Custom error object', code: 500 });
|
||||
|
||||
const result = await checkRemoteHealth('https://test.api/health');
|
||||
expect(result.status).toBe('ERROR');
|
||||
expect(result.message).toContain('Network error: [object Object]');
|
||||
expect(result.message).toContain('URL: https://test.api/health');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getEnvString } from '../../src/envars';
|
||||
import { parseAzureBlobUri, readAzureBlobText } from '../../src/util/azureBlob';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const downloadToBuffer = vi.fn();
|
||||
const getBlobClient = vi.fn(() => ({ downloadToBuffer }));
|
||||
const getContainerClient = vi.fn(() => ({ getBlobClient }));
|
||||
const serviceClient = { getContainerClient };
|
||||
const fromConnectionString = vi.fn(() => serviceClient);
|
||||
const blobServiceClient = vi.fn(function BlobServiceClientMock() {
|
||||
return serviceClient;
|
||||
});
|
||||
Object.assign(blobServiceClient, { fromConnectionString });
|
||||
|
||||
return {
|
||||
blobServiceClient,
|
||||
downloadToBuffer,
|
||||
fromConnectionString,
|
||||
getBlobClient,
|
||||
getContainerClient,
|
||||
serviceClient,
|
||||
defaultAzureCredential: vi.fn(function DefaultAzureCredentialMock() {
|
||||
return { credential: 'default' };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@azure/storage-blob', () => ({
|
||||
BlobServiceClient: mocks.blobServiceClient,
|
||||
}));
|
||||
|
||||
vi.mock('@azure/identity', () => ({
|
||||
DefaultAzureCredential: mocks.defaultAzureCredential,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/envars', async () => ({
|
||||
...(await vi.importActual('../../src/envars')),
|
||||
getEnvString: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Azure Blob test-set loading', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvString).mockReset().mockReturnValue('');
|
||||
mocks.downloadToBuffer.mockReset().mockResolvedValue(Buffer.from('blob text', 'utf8'));
|
||||
mocks.getBlobClient.mockReset().mockReturnValue({
|
||||
downloadToBuffer: mocks.downloadToBuffer,
|
||||
});
|
||||
mocks.getContainerClient.mockReset().mockReturnValue({
|
||||
getBlobClient: mocks.getBlobClient,
|
||||
});
|
||||
mocks.fromConnectionString.mockReset().mockReturnValue(mocks.serviceClient);
|
||||
mocks.blobServiceClient.mockReset().mockImplementation(function BlobServiceClientMock() {
|
||||
return mocks.serviceClient;
|
||||
});
|
||||
mocks.defaultAzureCredential
|
||||
.mockReset()
|
||||
.mockImplementation(function DefaultAzureCredentialMock() {
|
||||
return { credential: 'default' };
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('parses az:// account, container, blob, and SAS query string', () => {
|
||||
expect(
|
||||
parseAzureBlobUri('az://appliedciblobdata/data/ianw/cyber-evals/tests.json?sp=r&sig=abc'),
|
||||
).toEqual({
|
||||
accountName: 'appliedciblobdata',
|
||||
containerName: 'data',
|
||||
blobName: 'ianw/cyber-evals/tests.json',
|
||||
sasToken: '?sp=r&sig=abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves leading and repeated slashes in Azure blob names', () => {
|
||||
expect(parseAzureBlobUri('az://account/container//folder//tests.json')).toEqual({
|
||||
accountName: 'account',
|
||||
containerName: 'container',
|
||||
blobName: '/folder//tests.json',
|
||||
sasToken: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects URIs without a blob path', () => {
|
||||
expect(() => parseAzureBlobUri('az://account/container')).toThrow('blob path is missing');
|
||||
});
|
||||
|
||||
it('redacts SAS query strings while preserving URI fragments in parse errors', () => {
|
||||
expect(() => parseAzureBlobUri('az://account/container?sp=r&sig=secret#preview')).toThrow(
|
||||
'Invalid Azure Blob Storage URI "az://account/container?<redacted>#preview": blob path is missing.',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses URI SAS authentication when a query string is present', async () => {
|
||||
const result = await readAzureBlobText('az://account/container/path/tests.json?sp=r&sig=abc');
|
||||
|
||||
expect(result).toBe('blob text');
|
||||
expect(mocks.blobServiceClient).toHaveBeenCalledWith(
|
||||
'https://account.blob.core.windows.net?sp=r&sig=abc',
|
||||
);
|
||||
expect(mocks.fromConnectionString).not.toHaveBeenCalled();
|
||||
expect(mocks.defaultAzureCredential).not.toHaveBeenCalled();
|
||||
expect(mocks.getContainerClient).toHaveBeenCalledWith('container');
|
||||
expect(mocks.getBlobClient).toHaveBeenCalledWith('path/tests.json');
|
||||
});
|
||||
|
||||
it('uses AZURE_STORAGE_CONNECTION_STRING when no SAS query string is present', async () => {
|
||||
vi.mocked(getEnvString).mockReturnValue('UseDevelopmentStorage=true');
|
||||
|
||||
const result = await readAzureBlobText('az://account/container/path/tests.json');
|
||||
|
||||
expect(result).toBe('blob text');
|
||||
expect(getEnvString).toHaveBeenCalledWith('AZURE_STORAGE_CONNECTION_STRING');
|
||||
expect(mocks.fromConnectionString).toHaveBeenCalledWith('UseDevelopmentStorage=true');
|
||||
expect(mocks.defaultAzureCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects connection strings that target a different storage account', async () => {
|
||||
vi.mocked(getEnvString).mockReturnValue(
|
||||
'DefaultEndpointsProtocol=https;AccountName=otheraccount;AccountKey=secret',
|
||||
);
|
||||
|
||||
await expect(readAzureBlobText('az://account/container/path/tests.json')).rejects.toThrow(
|
||||
'AZURE_STORAGE_CONNECTION_STRING targets storage account "otheraccount", but the az:// URI targets "account".',
|
||||
);
|
||||
expect(mocks.fromConnectionString).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to DefaultAzureCredential when no SAS or connection string is present', async () => {
|
||||
const result = await readAzureBlobText('az://account/container/path/tests.json');
|
||||
|
||||
expect(result).toBe('blob text');
|
||||
expect(mocks.defaultAzureCredential).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.blobServiceClient).toHaveBeenCalledWith('https://account.blob.core.windows.net', {
|
||||
credential: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-SAS query strings instead of dropping configured credentials', async () => {
|
||||
vi.mocked(getEnvString).mockReturnValue(
|
||||
'DefaultEndpointsProtocol=https;AccountName=account;AccountKey=secret',
|
||||
);
|
||||
|
||||
await expect(
|
||||
readAzureBlobText('az://account/container/path/tests.json?snapshot=2026-05-19'),
|
||||
).rejects.toThrow('query string must be a SAS token containing a sig parameter');
|
||||
expect(mocks.fromConnectionString).not.toHaveBeenCalled();
|
||||
expect(mocks.defaultAzureCredential).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('redacts SAS query strings from read errors', async () => {
|
||||
mocks.downloadToBuffer.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
await expect(
|
||||
readAzureBlobText('az://account/container/path/tests.json?sp=r&sig=secret'),
|
||||
).rejects.toThrow(
|
||||
'Failed to read Azure Blob Storage URI "az://account/container/path/tests.json?<redacted>": boom',
|
||||
);
|
||||
});
|
||||
|
||||
it('redacts SAS query strings repeated in SDK error details', async () => {
|
||||
mocks.downloadToBuffer.mockRejectedValueOnce(
|
||||
new Error(
|
||||
'Request failed for https://account.blob.core.windows.net/container/path/tests.json?sp=r&sig=secret',
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
readAzureBlobText('az://account/container/path/tests.json?sp=r&sig=secret'),
|
||||
).rejects.toThrow(
|
||||
'Request failed for https://account.blob.core.windows.net/container/path/tests.json?<redacted>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Unit tests for calculateFilteredMetrics utility.
|
||||
*
|
||||
* Tests the optimized SQL aggregation approach for calculating metrics
|
||||
* on filtered evaluation results.
|
||||
*/
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getDb } from '../../src/database/index';
|
||||
import { runDbMigrations } from '../../src/migrate';
|
||||
import Eval from '../../src/models/eval';
|
||||
import { ResultFailureReason } from '../../src/types/index';
|
||||
import { calculateFilteredMetrics } from '../../src/util/calculateFilteredMetrics';
|
||||
import EvalFactory from '../factories/evalFactory';
|
||||
|
||||
describe('calculateFilteredMetrics', () => {
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const db = await getDb();
|
||||
await db.run('DELETE FROM eval_results');
|
||||
await db.run('DELETE FROM evals_to_datasets');
|
||||
await db.run('DELETE FROM evals_to_prompts');
|
||||
await db.run('DELETE FROM evals_to_tags');
|
||||
await db.run('DELETE FROM evals');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('basic metrics aggregation', () => {
|
||||
it('should aggregate basic metrics for all results', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'], // Cycles through these
|
||||
});
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql: sql`eval_id = ${eval_.id}`,
|
||||
});
|
||||
|
||||
expect(metrics).toHaveLength(1);
|
||||
expect(metrics[0]).toMatchObject({
|
||||
score: expect.any(Number),
|
||||
testPassCount: expect.any(Number),
|
||||
testFailCount: expect.any(Number),
|
||||
testErrorCount: expect.any(Number),
|
||||
assertPassCount: expect.any(Number),
|
||||
assertFailCount: expect.any(Number),
|
||||
totalLatencyMs: expect.any(Number),
|
||||
cost: expect.any(Number),
|
||||
});
|
||||
|
||||
// Total should be 10
|
||||
const total = metrics[0].testPassCount + metrics[0].testFailCount + metrics[0].testErrorCount;
|
||||
expect(total).toBe(10);
|
||||
});
|
||||
|
||||
it('should aggregate metrics across multiple prompts', async () => {
|
||||
// Create eval with multiple prompts
|
||||
const eval_ = await Eval.create(
|
||||
{
|
||||
providers: [{ id: 'test-provider' }],
|
||||
prompts: ['Prompt 1', 'Prompt 2', 'Prompt 3'],
|
||||
tests: [{ vars: { test: 'value' } }],
|
||||
},
|
||||
[
|
||||
{ raw: 'Prompt 1', label: 'Prompt 1' },
|
||||
{ raw: 'Prompt 2', label: 'Prompt 2' },
|
||||
{ raw: 'Prompt 3', label: 'Prompt 3' },
|
||||
],
|
||||
);
|
||||
|
||||
// Add results for each prompt
|
||||
for (let promptIdx = 0; promptIdx < 3; promptIdx++) {
|
||||
for (let testIdx = 0; testIdx < 5; testIdx++) {
|
||||
await eval_.addResult({
|
||||
promptIdx,
|
||||
testIdx,
|
||||
testCase: { vars: { test: 'value' } },
|
||||
promptId: `prompt-${promptIdx}`,
|
||||
provider: { id: 'test-provider', label: 'test' },
|
||||
prompt: { raw: `Prompt ${promptIdx + 1}`, label: `Prompt ${promptIdx + 1}` },
|
||||
vars: { test: 'value' },
|
||||
response: {
|
||||
output: 'test output',
|
||||
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0 },
|
||||
},
|
||||
error: null,
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
success: testIdx % 2 === 0, // Alternate success/failure
|
||||
score: testIdx % 2 === 0 ? 1 : 0,
|
||||
latencyMs: 100,
|
||||
gradingResult: {
|
||||
pass: testIdx % 2 === 0,
|
||||
score: testIdx % 2 === 0 ? 1 : 0,
|
||||
reason: 'Test reason',
|
||||
componentResults: [
|
||||
{
|
||||
pass: testIdx % 2 === 0,
|
||||
score: testIdx % 2 === 0 ? 1 : 0,
|
||||
reason: 'Test reason',
|
||||
assertion: { type: 'equals', value: 'test' },
|
||||
},
|
||||
],
|
||||
},
|
||||
namedScores: {},
|
||||
cost: 0.001,
|
||||
metadata: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 3,
|
||||
whereSql: sql`eval_id = ${eval_.id}`,
|
||||
});
|
||||
|
||||
expect(metrics).toHaveLength(3);
|
||||
|
||||
// Each prompt should have 5 results
|
||||
for (const promptMetric of metrics) {
|
||||
const total =
|
||||
promptMetric.testPassCount + promptMetric.testFailCount + promptMetric.testErrorCount;
|
||||
expect(total).toBe(5);
|
||||
expect(promptMetric.testPassCount).toBe(3); // indices 0, 2, 4
|
||||
expect(promptMetric.testFailCount).toBe(2); // indices 1, 3
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('token usage aggregation', () => {
|
||||
it('should aggregate token usage correctly', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 5,
|
||||
resultTypes: ['success'],
|
||||
});
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql: sql`eval_id = ${eval_.id}`,
|
||||
});
|
||||
|
||||
expect(metrics[0].tokenUsage).toMatchObject({
|
||||
total: expect.any(Number),
|
||||
prompt: expect.any(Number),
|
||||
completion: expect.any(Number),
|
||||
cached: expect.any(Number),
|
||||
numRequests: expect.any(Number),
|
||||
});
|
||||
|
||||
// Each result has 10 total tokens (from factory)
|
||||
expect(metrics[0].tokenUsage.total).toBe(50);
|
||||
expect(metrics[0].tokenUsage.prompt).toBe(25); // 5 requests * 5 tokens
|
||||
expect(metrics[0].tokenUsage.completion).toBe(25); // 5 requests * 5 tokens
|
||||
});
|
||||
|
||||
it('should handle results without token usage', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 0,
|
||||
});
|
||||
|
||||
// Add a result without token usage
|
||||
await eval_.addResult({
|
||||
promptIdx: 0,
|
||||
testIdx: 0,
|
||||
testCase: { vars: { test: 'value' } },
|
||||
promptId: 'test-prompt',
|
||||
provider: { id: 'test-provider', label: 'test' },
|
||||
prompt: { raw: 'Test prompt', label: 'Test prompt' },
|
||||
vars: { test: 'value' },
|
||||
response: { output: 'test output' }, // No token usage
|
||||
error: null,
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
success: true,
|
||||
score: 1,
|
||||
latencyMs: 100,
|
||||
gradingResult: {
|
||||
pass: true,
|
||||
score: 1,
|
||||
reason: 'Test reason',
|
||||
},
|
||||
namedScores: {},
|
||||
cost: 0.001,
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql: sql`eval_id = ${eval_.id}`,
|
||||
});
|
||||
|
||||
expect(metrics[0].tokenUsage).toMatchObject({
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('named scores aggregation', () => {
|
||||
it('should aggregate named scores using SQL json_each', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'failure'],
|
||||
withNamedScores: true,
|
||||
});
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql: sql`eval_id = ${eval_.id}`,
|
||||
});
|
||||
|
||||
expect(metrics[0].namedScores).toHaveProperty('accuracy');
|
||||
expect(metrics[0].namedScores).toHaveProperty('relevance');
|
||||
expect(metrics[0].namedScoresCount).toHaveProperty('accuracy');
|
||||
expect(metrics[0].namedScoresCount).toHaveProperty('relevance');
|
||||
|
||||
// All 10 results should have both scores
|
||||
expect(metrics[0].namedScoresCount.accuracy).toBe(10);
|
||||
expect(metrics[0].namedScoresCount.relevance).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle results without named scores', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success'],
|
||||
withNamedScores: false,
|
||||
});
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql: sql`eval_id = ${eval_.id}`,
|
||||
});
|
||||
|
||||
expect(metrics[0].namedScores).toEqual({});
|
||||
expect(metrics[0].namedScoresCount).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle partial named scores across results', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 0,
|
||||
});
|
||||
|
||||
// Add results with different named scores
|
||||
await eval_.addResult({
|
||||
promptIdx: 0,
|
||||
testIdx: 0,
|
||||
testCase: { vars: {} },
|
||||
promptId: 'test',
|
||||
provider: { id: 'test', label: 'test' },
|
||||
prompt: { raw: 'test', label: 'test' },
|
||||
vars: {},
|
||||
response: {
|
||||
output: 'test',
|
||||
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0 },
|
||||
},
|
||||
error: null,
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
success: true,
|
||||
score: 1,
|
||||
latencyMs: 100,
|
||||
namedScores: { accuracy: 0.9, relevance: 0.8 },
|
||||
cost: 0.001,
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
await eval_.addResult({
|
||||
promptIdx: 0,
|
||||
testIdx: 1,
|
||||
testCase: { vars: {} },
|
||||
promptId: 'test',
|
||||
provider: { id: 'test', label: 'test' },
|
||||
prompt: { raw: 'test', label: 'test' },
|
||||
vars: {},
|
||||
response: {
|
||||
output: 'test',
|
||||
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0 },
|
||||
},
|
||||
error: null,
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
success: true,
|
||||
score: 1,
|
||||
latencyMs: 100,
|
||||
namedScores: { accuracy: 0.7 }, // Only accuracy
|
||||
cost: 0.001,
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql: sql`eval_id = ${eval_.id}`,
|
||||
});
|
||||
|
||||
expect(metrics[0].namedScores.accuracy).toBeCloseTo(1.6, 1); // 0.9 + 0.7
|
||||
expect(metrics[0].namedScores.relevance).toBeCloseTo(0.8, 1); // Only from first result
|
||||
expect(metrics[0].namedScoresCount.accuracy).toBe(2);
|
||||
expect(metrics[0].namedScoresCount.relevance).toBe(1);
|
||||
expect(metrics[0].namedScoreWeights?.accuracy).toBe(2);
|
||||
expect(metrics[0].namedScoreWeights?.relevance).toBe(1);
|
||||
});
|
||||
|
||||
it('should aggregate weighted named scores using grading result denominators', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 0,
|
||||
});
|
||||
|
||||
await eval_.addResult({
|
||||
promptIdx: 0,
|
||||
testIdx: 0,
|
||||
testCase: { vars: {} },
|
||||
promptId: 'weighted-test',
|
||||
provider: { id: 'test', label: 'test' },
|
||||
prompt: { raw: 'test', label: 'test' },
|
||||
vars: {},
|
||||
response: {
|
||||
output: 'test',
|
||||
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0 },
|
||||
},
|
||||
error: null,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
success: false,
|
||||
score: 0.75,
|
||||
latencyMs: 100,
|
||||
gradingResult: {
|
||||
pass: false,
|
||||
score: 0.75,
|
||||
reason: 'weighted metric',
|
||||
namedScoreWeights: {
|
||||
accuracy: 4,
|
||||
},
|
||||
},
|
||||
namedScores: { accuracy: 0.75 },
|
||||
cost: 0.001,
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql: sql`eval_id = ${eval_.id}`,
|
||||
});
|
||||
|
||||
expect(metrics[0].namedScores.accuracy).toBeCloseTo(3, 10);
|
||||
expect(metrics[0].namedScoreWeights?.accuracy).toBe(4);
|
||||
expect(metrics[0].namedScoresCount.accuracy).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertion counts aggregation', () => {
|
||||
it('should aggregate assertion pass/fail counts from componentResults', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'failure'],
|
||||
});
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql: sql`eval_id = ${eval_.id}`,
|
||||
});
|
||||
|
||||
expect(metrics[0].assertPassCount).toBeGreaterThan(0);
|
||||
expect(metrics[0].assertFailCount).toBeGreaterThan(0);
|
||||
|
||||
// Total assertions should equal results (1 assertion per result)
|
||||
const totalAssertions = metrics[0].assertPassCount + metrics[0].assertFailCount;
|
||||
expect(totalAssertions).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle results without grading results', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 0,
|
||||
});
|
||||
|
||||
await eval_.addResult({
|
||||
promptIdx: 0,
|
||||
testIdx: 0,
|
||||
testCase: { vars: {} },
|
||||
promptId: 'test',
|
||||
provider: { id: 'test', label: 'test' },
|
||||
prompt: { raw: 'test', label: 'test' },
|
||||
vars: {},
|
||||
response: {
|
||||
output: 'test',
|
||||
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0 },
|
||||
},
|
||||
error: null,
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
success: true,
|
||||
score: 1,
|
||||
latencyMs: 100,
|
||||
namedScores: {},
|
||||
cost: 0.001,
|
||||
metadata: {},
|
||||
// No gradingResult
|
||||
});
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql: sql`eval_id = ${eval_.id}`,
|
||||
});
|
||||
|
||||
expect(metrics[0].assertPassCount).toBe(0);
|
||||
expect(metrics[0].assertFailCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filtering with WHERE clause', () => {
|
||||
it('should only aggregate results matching WHERE clause', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 20,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
// Filter for only errors
|
||||
const whereSql = sql`eval_id = ${eval_.id} AND failure_reason = ${ResultFailureReason.ERROR}`;
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql,
|
||||
});
|
||||
|
||||
// Only error results
|
||||
expect(metrics[0].testErrorCount).toBeGreaterThan(0);
|
||||
expect(metrics[0].testPassCount).toBe(0);
|
||||
expect(metrics[0].testFailCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty metrics for WHERE clause matching nothing', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success'],
|
||||
});
|
||||
|
||||
// Filter for errors when there are none
|
||||
const whereSql = sql`eval_id = ${eval_.id} AND failure_reason = ${ResultFailureReason.ERROR}`;
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql,
|
||||
});
|
||||
|
||||
expect(metrics[0].testPassCount).toBe(0);
|
||||
expect(metrics[0].testFailCount).toBe(0);
|
||||
expect(metrics[0].testErrorCount).toBe(0);
|
||||
expect(metrics[0].score).toBe(0);
|
||||
expect(metrics[0].totalLatencyMs).toBe(0);
|
||||
expect(metrics[0].cost).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OOM protection', () => {
|
||||
it('should throw error when result count exceeds limit', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success'],
|
||||
});
|
||||
|
||||
// Mock a WHERE clause that would return too many results
|
||||
// We can't actually create 50k+ results in the test, but we can test the check
|
||||
const whereSql = sql`eval_id = ${eval_.id}`;
|
||||
|
||||
// This should succeed (10 results < 50000)
|
||||
await expect(
|
||||
calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql,
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return empty metrics array on database error', async () => {
|
||||
// Invalid eval ID
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: 'nonexistent-eval-id',
|
||||
numPrompts: 2,
|
||||
whereSql: sql`eval_id = ${'nonexistent-eval-id'}`,
|
||||
});
|
||||
|
||||
expect(metrics).toHaveLength(2);
|
||||
expect(metrics[0]).toMatchObject({
|
||||
score: 0,
|
||||
testPassCount: 0,
|
||||
testFailCount: 0,
|
||||
testErrorCount: 0,
|
||||
assertPassCount: 0,
|
||||
assertFailCount: 0,
|
||||
totalLatencyMs: 0,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
},
|
||||
namedScores: {},
|
||||
namedScoresCount: {},
|
||||
cost: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid WHERE SQL gracefully', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success'],
|
||||
});
|
||||
|
||||
// Invalid SQL syntax - using sql.raw to simulate malformed SQL fragment
|
||||
// Note: In practice, with SQL fragments this is harder to achieve,
|
||||
// but we test the error handling path
|
||||
const whereSql = sql`INVALID SQL SYNTAX HERE`;
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql,
|
||||
});
|
||||
|
||||
// Should fallback to empty metrics
|
||||
expect(metrics).toHaveLength(1);
|
||||
expect(metrics[0].testPassCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle eval with no results', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 0,
|
||||
});
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1,
|
||||
whereSql: sql`eval_id = ${eval_.id}`,
|
||||
});
|
||||
|
||||
expect(metrics).toHaveLength(1);
|
||||
expect(metrics[0]).toMatchObject({
|
||||
score: 0,
|
||||
testPassCount: 0,
|
||||
testFailCount: 0,
|
||||
testErrorCount: 0,
|
||||
assertPassCount: 0,
|
||||
assertFailCount: 0,
|
||||
totalLatencyMs: 0,
|
||||
cost: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle prompt_idx out of range', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 5,
|
||||
resultTypes: ['success'],
|
||||
});
|
||||
|
||||
// Manually insert result with invalid prompt_idx
|
||||
const db = await getDb();
|
||||
await db.run(`
|
||||
INSERT INTO eval_results (
|
||||
id, eval_id, prompt_idx, test_idx, test_case, prompt, provider,
|
||||
success, score, latency_ms, cost
|
||||
) VALUES (
|
||||
'invalid-idx', '${eval_.id}', 999, 0, '{}', '{}', '{}', 1, 1.0, 100, 0.001
|
||||
)
|
||||
`);
|
||||
|
||||
const metrics = await calculateFilteredMetrics({
|
||||
evalId: eval_.id,
|
||||
numPrompts: 1, // Only expect 1 prompt
|
||||
whereSql: sql`eval_id = ${eval_.id}`,
|
||||
});
|
||||
|
||||
// Should not crash, and should handle the out-of-range index gracefully
|
||||
expect(metrics).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mapSnakeCaseToCamelCase } from '../../src/util/caseMapping';
|
||||
|
||||
describe('caseMapping utilities', () => {
|
||||
describe('mapSnakeCaseToCamelCase', () => {
|
||||
it('should map pass_ to pass', () => {
|
||||
const result = mapSnakeCaseToCamelCase({ pass_: true, reason: 'test' });
|
||||
expect(result.pass).toBe(true);
|
||||
expect(result.pass_).toBe(true); // Original key preserved
|
||||
});
|
||||
|
||||
it('should not override existing pass key', () => {
|
||||
const result = mapSnakeCaseToCamelCase({ pass: false, pass_: true });
|
||||
expect(result.pass).toBe(false);
|
||||
});
|
||||
|
||||
it('should map named_scores to namedScores', () => {
|
||||
const result = mapSnakeCaseToCamelCase({
|
||||
named_scores: { accuracy: 0.9 },
|
||||
});
|
||||
expect(result.namedScores).toEqual({ accuracy: 0.9 });
|
||||
});
|
||||
|
||||
it('should not override existing namedScores key', () => {
|
||||
const result = mapSnakeCaseToCamelCase({
|
||||
namedScores: { accuracy: 0.8 },
|
||||
named_scores: { accuracy: 0.9 },
|
||||
});
|
||||
expect(result.namedScores).toEqual({ accuracy: 0.8 });
|
||||
});
|
||||
|
||||
it('should map component_results to componentResults', () => {
|
||||
const result = mapSnakeCaseToCamelCase({
|
||||
component_results: [{ pass: true }],
|
||||
});
|
||||
expect(result.componentResults).toEqual([{ pass: true }]);
|
||||
});
|
||||
|
||||
it('should map tokens_used to tokensUsed', () => {
|
||||
const result = mapSnakeCaseToCamelCase({
|
||||
tokens_used: { total: 100 },
|
||||
});
|
||||
expect(result.tokensUsed).toEqual({ total: 100 });
|
||||
});
|
||||
|
||||
it('should recursively map nested component results', () => {
|
||||
const result = mapSnakeCaseToCamelCase({
|
||||
component_results: [
|
||||
{ pass_: true, named_scores: { score1: 0.5 } },
|
||||
{ pass_: false, named_scores: { score2: 0.3 } },
|
||||
],
|
||||
});
|
||||
expect(result.componentResults).toHaveLength(2);
|
||||
expect(result.componentResults[0].pass).toBe(true);
|
||||
expect(result.componentResults[0].namedScores).toEqual({ score1: 0.5 });
|
||||
expect(result.componentResults[1].pass).toBe(false);
|
||||
expect(result.componentResults[1].namedScores).toEqual({ score2: 0.3 });
|
||||
});
|
||||
|
||||
it('should handle deeply nested component results', () => {
|
||||
const result = mapSnakeCaseToCamelCase({
|
||||
component_results: [
|
||||
{
|
||||
pass_: true,
|
||||
component_results: [{ pass_: false, named_scores: { inner: 1 } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.componentResults[0].pass).toBe(true);
|
||||
expect(result.componentResults[0].componentResults[0].pass).toBe(false);
|
||||
expect(result.componentResults[0].componentResults[0].namedScores).toEqual({ inner: 1 });
|
||||
});
|
||||
|
||||
it('should handle non-object items in componentResults array', () => {
|
||||
const result = mapSnakeCaseToCamelCase({
|
||||
component_results: [null, undefined, 'string', 123, { pass_: true }],
|
||||
});
|
||||
expect(result.componentResults).toEqual([
|
||||
null,
|
||||
undefined,
|
||||
'string',
|
||||
123,
|
||||
{ pass_: true, pass: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not mutate the original object', () => {
|
||||
const original = { pass_: true, named_scores: { a: 1 } };
|
||||
const originalCopy = JSON.parse(JSON.stringify(original));
|
||||
mapSnakeCaseToCamelCase(original);
|
||||
expect(original).toEqual(originalCopy);
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const result = mapSnakeCaseToCamelCase({});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should preserve other keys unchanged', () => {
|
||||
const result = mapSnakeCaseToCamelCase({
|
||||
pass_: true,
|
||||
reason: 'test reason',
|
||||
score: 0.95,
|
||||
customField: 'value',
|
||||
});
|
||||
expect(result.reason).toBe('test reason');
|
||||
expect(result.score).toBe(0.95);
|
||||
expect(result.customField).toBe('value');
|
||||
});
|
||||
|
||||
it('should handle all mappings together', () => {
|
||||
const result = mapSnakeCaseToCamelCase({
|
||||
pass_: true,
|
||||
named_scores: { accuracy: 0.9 },
|
||||
component_results: [{ pass_: false }],
|
||||
tokens_used: { total: 50 },
|
||||
});
|
||||
expect(result.pass).toBe(true);
|
||||
expect(result.namedScores).toEqual({ accuracy: 0.9 });
|
||||
expect(result.componentResults[0].pass).toBe(false);
|
||||
expect(result.tokensUsed).toEqual({ total: 50 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { InvalidArgumentError } from 'commander';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collectKeyValueOption, normalizeTagOption } from '../../src/util/cliOptions';
|
||||
|
||||
describe('collectKeyValueOption', () => {
|
||||
it('parses a key=value pair into a record', () => {
|
||||
expect(collectKeyValueOption('--tag', 'env=ci', undefined)).toEqual({ env: 'ci' });
|
||||
});
|
||||
|
||||
it('merges onto the previous accumulator (repeatable option)', () => {
|
||||
expect(collectKeyValueOption('--tag', 'run=123', { env: 'ci' })).toEqual({
|
||||
env: 'ci',
|
||||
run: '123',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps everything after the first "=" so values may contain "="', () => {
|
||||
expect(collectKeyValueOption('--tag', 'token=a=b=c', undefined)).toEqual({ token: 'a=b=c' });
|
||||
});
|
||||
|
||||
it('lets a later occurrence of the same key override an earlier one', () => {
|
||||
const first = collectKeyValueOption('--tag', 'build=first', undefined);
|
||||
expect(collectKeyValueOption('--tag', 'build=second', first)).toEqual({ build: 'second' });
|
||||
});
|
||||
|
||||
it('accepts an explicitly empty value (key=)', () => {
|
||||
expect(collectKeyValueOption('--tag', 'empty=', undefined)).toEqual({ empty: '' });
|
||||
});
|
||||
|
||||
it.each(['invalid', '=value'])('throws InvalidArgumentError for malformed input %p', (value) => {
|
||||
expect(() => collectKeyValueOption('--tag', value, undefined)).toThrow(InvalidArgumentError);
|
||||
expect(() => collectKeyValueOption('--tag', value, undefined)).toThrow(
|
||||
'--tag must be specified in key=value format.',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the option name in the error message', () => {
|
||||
expect(() => collectKeyValueOption('--var', 'nope', undefined)).toThrow(
|
||||
'--var must be specified in key=value format.',
|
||||
);
|
||||
});
|
||||
|
||||
it('stores a "__proto__" key as a harmless own property without polluting Object.prototype', () => {
|
||||
const result = collectKeyValueOption('--tag', '__proto__=evil', { a: '1' });
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true);
|
||||
expect(Object.getOwnPropertyDescriptor(result, '__proto__')?.value).toBe('evil');
|
||||
// The prototype chain must remain untouched.
|
||||
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
|
||||
expect(({} as Record<string, unknown>).evil).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTagOption', () => {
|
||||
it('rewrites the Commander "tag" alias to the canonical "tags" field', () => {
|
||||
const result = normalizeTagOption({ tag: { env: 'ci' } });
|
||||
|
||||
expect(result).toEqual({ tags: { env: 'ci' } });
|
||||
expect(result).not.toHaveProperty('tag');
|
||||
});
|
||||
|
||||
it('passes through an existing "tags" field when no "tag" alias is present', () => {
|
||||
expect(normalizeTagOption({ tags: { env: 'ci' } })).toEqual({ tags: { env: 'ci' } });
|
||||
});
|
||||
|
||||
it('prefers the CLI "tag" alias over a pre-existing "tags" field', () => {
|
||||
const result = normalizeTagOption({ tag: { env: 'cli' }, tags: { env: 'config' } });
|
||||
|
||||
expect(result).toEqual({ tags: { env: 'cli' } });
|
||||
expect(result).not.toHaveProperty('tag');
|
||||
});
|
||||
|
||||
it('adds no "tags" key when neither "tag" nor "tags" is present', () => {
|
||||
const input: { config: string; tag?: Record<string, string>; tags?: Record<string, string> } = {
|
||||
config: 'promptfooconfig.yaml',
|
||||
};
|
||||
const result = normalizeTagOption(input);
|
||||
|
||||
expect(result).toEqual({ config: 'promptfooconfig.yaml' });
|
||||
expect(result).not.toHaveProperty('tags');
|
||||
expect(result).not.toHaveProperty('tag');
|
||||
});
|
||||
|
||||
it('preserves unrelated options and does not mutate the input', () => {
|
||||
const input = { config: 'a.yaml', verbose: true, tag: { env: 'ci' } };
|
||||
const result = normalizeTagOption(input);
|
||||
|
||||
expect(result).toEqual({ config: 'a.yaml', verbose: true, tags: { env: 'ci' } });
|
||||
// Input is left untouched.
|
||||
expect(input).toEqual({ config: 'a.yaml', verbose: true, tag: { env: 'ci' } });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,612 @@
|
||||
import * as path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
deduplicateTestCases,
|
||||
extractRuntimeVars,
|
||||
filterRuntimeVars,
|
||||
getTestCaseDeduplicationKey,
|
||||
isRuntimeVar,
|
||||
resultIsForTestCase,
|
||||
varsMatch,
|
||||
} from '../../src/util/comparison';
|
||||
import { createEvaluateResult } from '../factories/eval';
|
||||
|
||||
import type { TestCase } from '../../src/types/index';
|
||||
|
||||
describe('varsMatch', () => {
|
||||
it('true with both undefined', () => {
|
||||
expect(varsMatch(undefined, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('false with one undefined', () => {
|
||||
expect(varsMatch(undefined, {})).toBe(false);
|
||||
expect(varsMatch({}, undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('true with matching non-empty objects', () => {
|
||||
expect(varsMatch({ key: 'value' }, { key: 'value' })).toBe(true);
|
||||
expect(varsMatch({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true);
|
||||
});
|
||||
|
||||
it('false with different values', () => {
|
||||
expect(varsMatch({ key: 'value1' }, { key: 'value2' })).toBe(false);
|
||||
expect(varsMatch({ a: 1 }, { a: 2 })).toBe(false);
|
||||
});
|
||||
|
||||
it('false with different keys', () => {
|
||||
expect(varsMatch({ a: 1 }, { b: 1 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resultIsForTestCase', () => {
|
||||
const testCase: TestCase = {
|
||||
provider: 'provider',
|
||||
vars: {
|
||||
key: 'value',
|
||||
},
|
||||
};
|
||||
const result = createEvaluateResult({
|
||||
provider: 'provider' as any,
|
||||
vars: { key: 'value' },
|
||||
});
|
||||
|
||||
it('is true', async () => {
|
||||
expect(resultIsForTestCase(result, testCase)).toBe(true);
|
||||
});
|
||||
|
||||
it('is false if provider is different', async () => {
|
||||
const nonMatchTestCase: TestCase = {
|
||||
provider: 'different',
|
||||
vars: {
|
||||
key: 'value',
|
||||
},
|
||||
};
|
||||
|
||||
expect(resultIsForTestCase(result, nonMatchTestCase)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches when test has provider but result provider is null', async () => {
|
||||
// This covers agentic providers (like agentic:memory-poisoning) where
|
||||
// the result's provider is null/undefined (e.g., from cloud results)
|
||||
const testCaseWithProvider: TestCase = {
|
||||
provider: 'agentic:memory-poisoning',
|
||||
vars: { key: 'value' },
|
||||
};
|
||||
|
||||
const resultWithNullProvider = createEvaluateResult({
|
||||
provider: null as any,
|
||||
vars: { key: 'value' },
|
||||
});
|
||||
|
||||
// Should match because we can't compare when result provider is missing
|
||||
expect(resultIsForTestCase(resultWithNullProvider, testCaseWithProvider)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when test has provider but result provider is undefined', async () => {
|
||||
const testCaseWithProvider: TestCase = {
|
||||
provider: 'agentic:memory-poisoning',
|
||||
vars: { key: 'value' },
|
||||
};
|
||||
|
||||
const resultWithUndefinedProvider = createEvaluateResult({
|
||||
provider: undefined as any,
|
||||
vars: { key: 'value' },
|
||||
});
|
||||
|
||||
expect(resultIsForTestCase(resultWithUndefinedProvider, testCaseWithProvider)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when test has no provider and result has provider', async () => {
|
||||
const testCaseNoProvider: TestCase = {
|
||||
vars: { key: 'value' },
|
||||
};
|
||||
|
||||
const resultWithProvider = createEvaluateResult({
|
||||
provider: { id: 'some-provider' },
|
||||
vars: { key: 'value' },
|
||||
});
|
||||
|
||||
expect(resultIsForTestCase(resultWithProvider, testCaseNoProvider)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match when agentic provider differs from result target provider (both present)', async () => {
|
||||
// This documents intentional strict behavior: when BOTH providers are present,
|
||||
// they must match. Agentic providers (like agentic:memory-poisoning) that differ
|
||||
// from the target provider in the result should NOT match.
|
||||
// Lenient matching only applies when one side is missing provider info.
|
||||
const testCaseWithAgenticProvider: TestCase = {
|
||||
provider: 'agentic:memory-poisoning',
|
||||
vars: { key: 'value' },
|
||||
};
|
||||
|
||||
const resultWithTargetProvider = createEvaluateResult({
|
||||
provider: { id: 'openai:gpt-4' },
|
||||
vars: { key: 'value' },
|
||||
});
|
||||
|
||||
// Both providers present and different → no match (strict comparison)
|
||||
expect(resultIsForTestCase(resultWithTargetProvider, testCaseWithAgenticProvider)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false if vars are different', async () => {
|
||||
const nonMatchTestCase: TestCase = {
|
||||
provider: 'provider',
|
||||
vars: {
|
||||
key: 'different',
|
||||
},
|
||||
};
|
||||
|
||||
expect(resultIsForTestCase(result, nonMatchTestCase)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches when test provider is label and result provider has label and id', async () => {
|
||||
const labelledResult = createEvaluateResult({
|
||||
provider: { id: 'file://provider.js', label: 'provider' },
|
||||
vars: { key: 'value' },
|
||||
});
|
||||
|
||||
expect(resultIsForTestCase(labelledResult, testCase)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when test provider is relative path and result provider is absolute', async () => {
|
||||
const relativePathTestCase: TestCase = {
|
||||
provider: 'file://./provider.js',
|
||||
vars: { key: 'value' },
|
||||
};
|
||||
|
||||
const absolutePathResult = createEvaluateResult({
|
||||
provider: { id: `file://${path.join(process.cwd(), 'provider.js')}` },
|
||||
vars: { key: 'value' },
|
||||
});
|
||||
|
||||
expect(resultIsForTestCase(absolutePathResult, relativePathTestCase)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when test provider has no file:// prefix and result has absolute path', async () => {
|
||||
const noPathTestCase: TestCase = {
|
||||
provider: './provider.js',
|
||||
vars: { key: 'value' },
|
||||
};
|
||||
|
||||
const absolutePathResult = createEvaluateResult({
|
||||
provider: `file://${path.join(process.cwd(), 'provider.js')}` as any,
|
||||
vars: { key: 'value' },
|
||||
});
|
||||
|
||||
expect(resultIsForTestCase(absolutePathResult, noPathTestCase)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when result.vars has runtime variables like _conversation', async () => {
|
||||
// This tests the fix for issue #5849 - cache behavior regression
|
||||
// result.vars contains runtime variables that should be filtered out
|
||||
const testCase: TestCase = {
|
||||
provider: 'provider',
|
||||
vars: {
|
||||
input: 'hello',
|
||||
language: 'en',
|
||||
},
|
||||
};
|
||||
|
||||
const result = createEvaluateResult({
|
||||
provider: 'provider' as any,
|
||||
vars: {
|
||||
input: 'hello',
|
||||
language: 'en',
|
||||
_conversation: [], // Runtime variable added during evaluation
|
||||
},
|
||||
testCase: {
|
||||
vars: {
|
||||
input: 'hello',
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Should match because _conversation is filtered out
|
||||
expect(resultIsForTestCase(result, testCase)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when result.vars has runtime variables and testCase also has them', async () => {
|
||||
// Edge case: both have runtime vars (shouldn't happen in practice but should still work)
|
||||
const testCase: TestCase = {
|
||||
provider: 'provider',
|
||||
vars: {
|
||||
input: 'hello',
|
||||
language: 'en',
|
||||
_conversation: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = createEvaluateResult({
|
||||
provider: 'provider' as any,
|
||||
vars: {
|
||||
input: 'hello',
|
||||
language: 'en',
|
||||
_conversation: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Should match because both have same vars after filtering
|
||||
expect(resultIsForTestCase(result, testCase)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when result.vars has sessionId runtime variable from GOAT/Crescendo providers', async () => {
|
||||
// This tests the fix for multi-turn strategy providers (GOAT, Crescendo)
|
||||
// that add sessionId to vars during execution
|
||||
const testCase: TestCase = {
|
||||
provider: 'provider',
|
||||
vars: {
|
||||
prompt: 'test prompt',
|
||||
goal: 'test goal',
|
||||
},
|
||||
};
|
||||
|
||||
const result = createEvaluateResult({
|
||||
provider: 'provider' as any,
|
||||
vars: {
|
||||
prompt: 'test prompt',
|
||||
goal: 'test goal',
|
||||
sessionId: 'goat-session-abc123', // Added by GOAT provider during evaluation
|
||||
},
|
||||
});
|
||||
|
||||
// Should match because sessionId is filtered out
|
||||
expect(resultIsForTestCase(result, testCase)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when result.vars has both _conversation and sessionId runtime variables', async () => {
|
||||
const testCase: TestCase = {
|
||||
provider: 'provider',
|
||||
vars: {
|
||||
input: 'hello',
|
||||
},
|
||||
};
|
||||
|
||||
const result = createEvaluateResult({
|
||||
provider: 'provider' as any,
|
||||
vars: {
|
||||
input: 'hello',
|
||||
_conversation: [{ role: 'user', content: 'hi' }],
|
||||
sessionId: 'session-xyz789',
|
||||
},
|
||||
});
|
||||
|
||||
// Should match because both runtime vars are filtered out
|
||||
expect(resultIsForTestCase(result, testCase)).toBe(true);
|
||||
});
|
||||
|
||||
it('matches when testCase also has sessionId (both filtered)', async () => {
|
||||
// Edge case: if user explicitly sets sessionId in test config, it should still match
|
||||
const testCase: TestCase = {
|
||||
provider: 'provider',
|
||||
vars: {
|
||||
prompt: 'test',
|
||||
sessionId: 'user-defined-session',
|
||||
},
|
||||
};
|
||||
|
||||
const result = createEvaluateResult({
|
||||
provider: 'provider' as any,
|
||||
vars: {
|
||||
prompt: 'test',
|
||||
sessionId: 'different-runtime-session',
|
||||
},
|
||||
});
|
||||
|
||||
// Should match because sessionId is filtered from both sides
|
||||
expect(resultIsForTestCase(result, testCase)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRuntimeVar', () => {
|
||||
it('should return true for underscore-prefixed variables', () => {
|
||||
expect(isRuntimeVar('_conversation')).toBe(true);
|
||||
expect(isRuntimeVar('_metadata')).toBe(true);
|
||||
expect(isRuntimeVar('_internal')).toBe(true);
|
||||
expect(isRuntimeVar('_')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for explicit runtime vars like sessionId', () => {
|
||||
expect(isRuntimeVar('sessionId')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for regular variables', () => {
|
||||
expect(isRuntimeVar('prompt')).toBe(false);
|
||||
expect(isRuntimeVar('input')).toBe(false);
|
||||
expect(isRuntimeVar('context')).toBe(false);
|
||||
expect(isRuntimeVar('goal')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for variables that contain underscore but do not start with it', () => {
|
||||
expect(isRuntimeVar('my_variable')).toBe(false);
|
||||
expect(isRuntimeVar('some_conversation')).toBe(false);
|
||||
expect(isRuntimeVar('session_id')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterRuntimeVars', () => {
|
||||
it('should filter out _conversation', () => {
|
||||
const vars = { input: 'hello', _conversation: [{ role: 'user', content: 'hi' }] };
|
||||
const result = filterRuntimeVars(vars);
|
||||
expect(result).toEqual({ input: 'hello' });
|
||||
expect(result).not.toHaveProperty('_conversation');
|
||||
});
|
||||
|
||||
it('should filter out sessionId', () => {
|
||||
const vars = { input: 'hello', sessionId: 'test-session-123' };
|
||||
const result = filterRuntimeVars(vars);
|
||||
expect(result).toEqual({ input: 'hello' });
|
||||
expect(result).not.toHaveProperty('sessionId');
|
||||
});
|
||||
|
||||
it('should filter out any underscore-prefixed variables', () => {
|
||||
const vars = {
|
||||
input: 'hello',
|
||||
_conversation: [],
|
||||
_metadata: { key: 'value' },
|
||||
_internal: 'data',
|
||||
_customRuntimeVar: 'test',
|
||||
};
|
||||
const result = filterRuntimeVars(vars);
|
||||
expect(result).toEqual({ input: 'hello' });
|
||||
expect(result).not.toHaveProperty('_conversation');
|
||||
expect(result).not.toHaveProperty('_metadata');
|
||||
expect(result).not.toHaveProperty('_internal');
|
||||
expect(result).not.toHaveProperty('_customRuntimeVar');
|
||||
});
|
||||
|
||||
it('should filter out multiple runtime vars', () => {
|
||||
const vars = {
|
||||
input: 'hello',
|
||||
goal: 'test goal',
|
||||
_conversation: [],
|
||||
sessionId: 'test-session-123',
|
||||
};
|
||||
const result = filterRuntimeVars(vars);
|
||||
expect(result).toEqual({ input: 'hello', goal: 'test goal' });
|
||||
expect(result).not.toHaveProperty('_conversation');
|
||||
expect(result).not.toHaveProperty('sessionId');
|
||||
});
|
||||
|
||||
it('should handle undefined vars', () => {
|
||||
expect(filterRuntimeVars(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty vars', () => {
|
||||
expect(filterRuntimeVars({})).toEqual({});
|
||||
});
|
||||
|
||||
it('should not mutate original vars', () => {
|
||||
const vars = { input: 'hello', sessionId: 'test-123', _conversation: [] };
|
||||
const original = { ...vars };
|
||||
filterRuntimeVars(vars);
|
||||
expect(vars).toEqual(original);
|
||||
});
|
||||
|
||||
it('should preserve all non-runtime vars', () => {
|
||||
const vars = {
|
||||
input: 'hello',
|
||||
output: 'world',
|
||||
context: 'some context',
|
||||
nested: { key: 'value' },
|
||||
sessionId: 'to-be-removed',
|
||||
};
|
||||
const result = filterRuntimeVars(vars);
|
||||
expect(result).toEqual({
|
||||
input: 'hello',
|
||||
output: 'world',
|
||||
context: 'some context',
|
||||
nested: { key: 'value' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve variables with underscore in middle of name', () => {
|
||||
const vars = {
|
||||
my_variable: 'value1',
|
||||
some_conversation: 'value2',
|
||||
session_id: 'value3',
|
||||
_runtime: 'filtered',
|
||||
};
|
||||
const result = filterRuntimeVars(vars);
|
||||
expect(result).toEqual({
|
||||
my_variable: 'value1',
|
||||
some_conversation: 'value2',
|
||||
session_id: 'value3',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRuntimeVars', () => {
|
||||
it('should extract _conversation', () => {
|
||||
const vars = { input: 'hello', _conversation: [{ role: 'user', content: 'hi' }] };
|
||||
const result = extractRuntimeVars(vars);
|
||||
expect(result).toEqual({ _conversation: [{ role: 'user', content: 'hi' }] });
|
||||
});
|
||||
|
||||
it('should extract sessionId', () => {
|
||||
const vars = { input: 'hello', sessionId: 'test-session-123' };
|
||||
const result = extractRuntimeVars(vars);
|
||||
expect(result).toEqual({ sessionId: 'test-session-123' });
|
||||
});
|
||||
|
||||
it('should extract any underscore-prefixed variables', () => {
|
||||
const vars = {
|
||||
input: 'hello',
|
||||
_conversation: [],
|
||||
_metadata: { key: 'value' },
|
||||
_internal: 'data',
|
||||
_customRuntimeVar: 'test',
|
||||
};
|
||||
const result = extractRuntimeVars(vars);
|
||||
expect(result).toEqual({
|
||||
_conversation: [],
|
||||
_metadata: { key: 'value' },
|
||||
_internal: 'data',
|
||||
_customRuntimeVar: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract multiple runtime vars', () => {
|
||||
const vars = {
|
||||
input: 'hello',
|
||||
goal: 'test goal',
|
||||
_conversation: [{ role: 'assistant', content: 'response' }],
|
||||
sessionId: 'test-session-123',
|
||||
};
|
||||
const result = extractRuntimeVars(vars);
|
||||
expect(result).toEqual({
|
||||
_conversation: [{ role: 'assistant', content: 'response' }],
|
||||
sessionId: 'test-session-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for undefined vars', () => {
|
||||
expect(extractRuntimeVars(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for empty vars', () => {
|
||||
expect(extractRuntimeVars({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when no runtime vars exist', () => {
|
||||
const vars = { input: 'hello', output: 'world' };
|
||||
expect(extractRuntimeVars(vars)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not mutate original vars', () => {
|
||||
const vars = { input: 'hello', sessionId: 'test-123', _conversation: [] };
|
||||
const original = { ...vars };
|
||||
extractRuntimeVars(vars);
|
||||
expect(vars).toEqual(original);
|
||||
});
|
||||
|
||||
it('should not extract variables with underscore in middle of name', () => {
|
||||
const vars = {
|
||||
my_variable: 'value1',
|
||||
some_conversation: 'value2',
|
||||
session_id: 'value3',
|
||||
_runtime: 'filtered',
|
||||
};
|
||||
const result = extractRuntimeVars(vars);
|
||||
expect(result).toEqual({ _runtime: 'filtered' });
|
||||
expect(result).not.toHaveProperty('my_variable');
|
||||
expect(result).not.toHaveProperty('some_conversation');
|
||||
expect(result).not.toHaveProperty('session_id');
|
||||
});
|
||||
|
||||
it('should be inverse of filterRuntimeVars', () => {
|
||||
const vars = {
|
||||
input: 'hello',
|
||||
goal: 'test',
|
||||
_conversation: [{ role: 'user', content: 'hi' }],
|
||||
sessionId: 'session-123',
|
||||
_metadata: { custom: true },
|
||||
};
|
||||
const filtered = filterRuntimeVars(vars);
|
||||
const extracted = extractRuntimeVars(vars);
|
||||
|
||||
// Combining filtered and extracted should give back original vars
|
||||
expect({ ...filtered, ...extracted }).toEqual(vars);
|
||||
|
||||
// No overlap between filtered and extracted
|
||||
const filteredKeys = Object.keys(filtered || {});
|
||||
const extractedKeys = Object.keys(extracted || {});
|
||||
expect(filteredKeys.filter((k) => extractedKeys.includes(k))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTestCaseDeduplicationKey', () => {
|
||||
it('should generate key from vars and strategyId', () => {
|
||||
const testCase: TestCase = {
|
||||
vars: { prompt: 'hello' },
|
||||
metadata: { strategyId: 'jailbreak' },
|
||||
};
|
||||
const key = getTestCaseDeduplicationKey(testCase);
|
||||
expect(JSON.parse(key)).toEqual({
|
||||
vars: { prompt: 'hello' },
|
||||
strategyId: 'jailbreak',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use "none" for tests without strategyId', () => {
|
||||
const testCase: TestCase = {
|
||||
vars: { prompt: 'hello' },
|
||||
};
|
||||
const key = getTestCaseDeduplicationKey(testCase);
|
||||
expect(JSON.parse(key)).toEqual({
|
||||
vars: { prompt: 'hello' },
|
||||
strategyId: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out runtime vars from key', () => {
|
||||
const testCase: TestCase = {
|
||||
vars: { prompt: 'hello', _conversation: [], sessionId: 'abc' },
|
||||
metadata: { strategyId: 'basic' },
|
||||
};
|
||||
const key = getTestCaseDeduplicationKey(testCase);
|
||||
expect(JSON.parse(key)).toEqual({
|
||||
vars: { prompt: 'hello' },
|
||||
strategyId: 'basic',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined vars', () => {
|
||||
const testCase: TestCase = {
|
||||
metadata: { strategyId: 'test' },
|
||||
};
|
||||
const key = getTestCaseDeduplicationKey(testCase);
|
||||
expect(JSON.parse(key)).toEqual({
|
||||
vars: undefined,
|
||||
strategyId: 'test',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deduplicateTestCases', () => {
|
||||
it('should remove duplicate tests with same vars and strategyId', () => {
|
||||
const tests: TestCase[] = [
|
||||
{ vars: { prompt: 'hello' }, metadata: { strategyId: 'basic' } },
|
||||
{ vars: { prompt: 'hello' }, metadata: { strategyId: 'basic' } },
|
||||
{ vars: { prompt: 'world' }, metadata: { strategyId: 'basic' } },
|
||||
];
|
||||
const result = deduplicateTestCases(tests);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].vars).toEqual({ prompt: 'hello' });
|
||||
expect(result[1].vars).toEqual({ prompt: 'world' });
|
||||
});
|
||||
|
||||
it('should keep tests with same vars but different strategyId', () => {
|
||||
const tests: TestCase[] = [
|
||||
{ vars: { prompt: 'hello' }, metadata: { strategyId: 'basic' } },
|
||||
{ vars: { prompt: 'hello' }, metadata: { strategyId: 'jailbreak' } },
|
||||
];
|
||||
const result = deduplicateTestCases(tests);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should filter runtime vars when checking for duplicates', () => {
|
||||
const tests: TestCase[] = [
|
||||
{ vars: { prompt: 'hello', sessionId: 'abc' }, metadata: { strategyId: 'basic' } },
|
||||
{ vars: { prompt: 'hello', sessionId: 'xyz' }, metadata: { strategyId: 'basic' } },
|
||||
];
|
||||
const result = deduplicateTestCases(tests);
|
||||
// These should be considered duplicates after filtering sessionId
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(deduplicateTestCases([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve order (keep first occurrence)', () => {
|
||||
const tests: TestCase[] = [
|
||||
{ vars: { prompt: 'first' }, metadata: { strategyId: 'basic', order: 1 } },
|
||||
{ vars: { prompt: 'first' }, metadata: { strategyId: 'basic', order: 2 } },
|
||||
];
|
||||
const result = deduplicateTestCases(tests);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].metadata?.order).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import path from 'path';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { configCache, loadDefaultConfig } from '../../../src/util/config/default';
|
||||
import { maybeReadConfig } from '../../../src/util/config/load';
|
||||
|
||||
vi.mock('../../../src/util/config/load', () => ({
|
||||
maybeReadConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('loadDefaultConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(process, 'cwd').mockImplementation(() => '/test/path');
|
||||
configCache.clear();
|
||||
});
|
||||
|
||||
it('should return empty config when no config file is found', async () => {
|
||||
vi.mocked(maybeReadConfig).mockResolvedValue(undefined);
|
||||
|
||||
const result = await loadDefaultConfig();
|
||||
expect(result).toEqual({
|
||||
defaultConfig: {},
|
||||
defaultConfigPath: undefined,
|
||||
});
|
||||
expect(maybeReadConfig).toHaveBeenCalledTimes(9);
|
||||
expect(maybeReadConfig).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
path.normalize('/test/path/promptfooconfig.yaml'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the first valid config file found', async () => {
|
||||
const mockConfig = { prompts: ['Some prompt'], providers: [], tests: [] };
|
||||
vi.mocked(maybeReadConfig)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(mockConfig);
|
||||
|
||||
const result = await loadDefaultConfig();
|
||||
expect(result).toEqual({
|
||||
defaultConfig: mockConfig,
|
||||
defaultConfigPath: path.normalize('/test/path/promptfooconfig.json'),
|
||||
});
|
||||
expect(maybeReadConfig).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should stop checking extensions after finding a valid config', async () => {
|
||||
const mockConfig = { prompts: ['Some prompt'], providers: [], tests: [] };
|
||||
vi.mocked(maybeReadConfig).mockResolvedValueOnce(undefined).mockResolvedValueOnce(mockConfig);
|
||||
|
||||
await loadDefaultConfig();
|
||||
|
||||
expect(maybeReadConfig).toHaveBeenCalledTimes(2);
|
||||
expect(maybeReadConfig).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
path.normalize('/test/path/promptfooconfig.yaml'),
|
||||
);
|
||||
expect(maybeReadConfig).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
path.normalize('/test/path/promptfooconfig.yml'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided directory when specified', async () => {
|
||||
const mockConfig = { prompts: ['Some prompt'], providers: [], tests: [] };
|
||||
vi.mocked(maybeReadConfig).mockResolvedValueOnce(mockConfig);
|
||||
|
||||
const customDir = '/custom/directory';
|
||||
const result = await loadDefaultConfig(customDir);
|
||||
expect(result).toEqual({
|
||||
defaultConfig: mockConfig,
|
||||
defaultConfigPath: path.join(customDir, 'promptfooconfig.yaml'),
|
||||
});
|
||||
expect(maybeReadConfig).toHaveBeenCalledWith(path.join(customDir, 'promptfooconfig.yaml'));
|
||||
});
|
||||
|
||||
it('should use custom config name when provided', async () => {
|
||||
const mockConfig = { prompts: ['Custom config'], providers: [], tests: [] };
|
||||
vi.mocked(maybeReadConfig).mockResolvedValueOnce(mockConfig);
|
||||
|
||||
const result = await loadDefaultConfig(undefined, 'redteam');
|
||||
expect(result).toEqual({
|
||||
defaultConfig: mockConfig,
|
||||
defaultConfigPath: path.normalize('/test/path/redteam.yaml'),
|
||||
});
|
||||
expect(maybeReadConfig).toHaveBeenCalledWith(path.normalize('/test/path/redteam.yaml'));
|
||||
});
|
||||
|
||||
it('should use different caches for different config names', async () => {
|
||||
const mockConfig1 = { prompts: ['Config 1'], providers: [], tests: [] };
|
||||
const mockConfig2 = { prompts: ['Config 2'], providers: [], tests: [] };
|
||||
|
||||
vi.mocked(maybeReadConfig)
|
||||
.mockResolvedValueOnce(mockConfig1)
|
||||
.mockResolvedValueOnce(mockConfig2);
|
||||
|
||||
const result1 = await loadDefaultConfig(undefined, 'promptfooconfig');
|
||||
const result2 = await loadDefaultConfig(undefined, 'redteam');
|
||||
|
||||
expect(result1).not.toEqual(result2);
|
||||
expect(result1.defaultConfig).toEqual(mockConfig1);
|
||||
expect(result2.defaultConfig).toEqual(mockConfig2);
|
||||
|
||||
const cachedResult1 = await loadDefaultConfig(undefined, 'promptfooconfig');
|
||||
const cachedResult2 = await loadDefaultConfig(undefined, 'redteam');
|
||||
|
||||
expect(cachedResult1).toEqual(result1);
|
||||
expect(cachedResult2).toEqual(result2);
|
||||
expect(maybeReadConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should use different caches for different directories', async () => {
|
||||
const mockConfig1 = { prompts: ['Config 1'], providers: [], tests: [] };
|
||||
const mockConfig2 = { prompts: ['Config 2'], providers: [], tests: [] };
|
||||
|
||||
vi.mocked(maybeReadConfig)
|
||||
.mockResolvedValueOnce(mockConfig1)
|
||||
.mockResolvedValueOnce(mockConfig2);
|
||||
|
||||
const dir1 = '/dir1';
|
||||
const dir2 = '/dir2';
|
||||
|
||||
const result1 = await loadDefaultConfig(dir1);
|
||||
const result2 = await loadDefaultConfig(dir2);
|
||||
|
||||
expect(result1).not.toEqual(result2);
|
||||
expect(result1.defaultConfig).toEqual(mockConfig1);
|
||||
expect(result2.defaultConfig).toEqual(mockConfig2);
|
||||
|
||||
const cachedResult1 = await loadDefaultConfig(dir1);
|
||||
const cachedResult2 = await loadDefaultConfig(dir2);
|
||||
|
||||
expect(cachedResult1).toEqual(result1);
|
||||
expect(cachedResult2).toEqual(result2);
|
||||
expect(maybeReadConfig).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should use cache for subsequent calls with same parameters', async () => {
|
||||
const mockConfig = { prompts: ['Cached config'], providers: [], tests: [] };
|
||||
vi.mocked(maybeReadConfig).mockResolvedValueOnce(mockConfig);
|
||||
|
||||
const result1 = await loadDefaultConfig();
|
||||
const result2 = await loadDefaultConfig();
|
||||
|
||||
expect(result1).toEqual(result2);
|
||||
expect(maybeReadConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle errors when reading config files', async () => {
|
||||
vi.mocked(maybeReadConfig).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
await expect(loadDefaultConfig()).rejects.toThrow('Permission denied');
|
||||
});
|
||||
|
||||
it('should handle various config names', async () => {
|
||||
const mockConfig = { prompts: ['Test config'], providers: [], tests: [] };
|
||||
vi.mocked(maybeReadConfig).mockResolvedValue(mockConfig);
|
||||
|
||||
const configNames = ['test1', 'test2', 'test3'];
|
||||
for (const name of configNames) {
|
||||
const result = await loadDefaultConfig(undefined, name);
|
||||
expect(result.defaultConfigPath).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle interaction between configName and directory', async () => {
|
||||
const mockConfig = { prompts: ['Combined config'], providers: [], tests: [] };
|
||||
vi.mocked(maybeReadConfig).mockResolvedValue(mockConfig);
|
||||
|
||||
const customDir = '/custom/dir';
|
||||
const customName = 'customconfig';
|
||||
const result = await loadDefaultConfig(customDir, customName);
|
||||
|
||||
expect(result.defaultConfigPath).toEqual(path.join(customDir, `${customName}.yaml`));
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as yaml from 'js-yaml';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import cliState from '../../../src/cliState';
|
||||
import logger from '../../../src/logger';
|
||||
import {
|
||||
getConfigDirectoryPath,
|
||||
refreshConfigDirectoryPathFromEnv,
|
||||
setConfigDirectoryPath,
|
||||
} from '../../../src/util/config/manage';
|
||||
import { writePromptfooConfig } from '../../../src/util/config/writer';
|
||||
import { mockProcessEnv } from '../../util/utils';
|
||||
|
||||
import type { UnifiedConfig } from '../../../src/types/index';
|
||||
|
||||
// Create hoisted mock functions for fs
|
||||
const mockFs = vi.hoisted(() => ({
|
||||
existsSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
rmdirSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
accessSync: vi.fn(),
|
||||
chmodSync: vi.fn(),
|
||||
constants: {},
|
||||
}));
|
||||
|
||||
// Mock both 'fs' and 'node:fs' to cover all import patterns
|
||||
vi.mock('fs', () => ({
|
||||
...mockFs,
|
||||
default: mockFs,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
...mockFs,
|
||||
default: mockFs,
|
||||
}));
|
||||
|
||||
vi.mock('os');
|
||||
// js-yaml v5 is native ESM with a sealed namespace, so vi.spyOn cannot patch it.
|
||||
// Wrap dump in a spy-able mock that keeps the real implementation.
|
||||
vi.mock('js-yaml', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('js-yaml')>();
|
||||
return { ...actual, dump: vi.fn(actual.dump) };
|
||||
});
|
||||
vi.mock('../../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('config', () => {
|
||||
const mockHomedir = '/mock/home';
|
||||
const defaultConfigPath = path.join(mockHomedir, '.promptfoo');
|
||||
let restoreEnv: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHomedir);
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
restoreEnv = mockProcessEnv({ PROMPTFOO_CONFIG_DIR: undefined });
|
||||
refreshConfigDirectoryPathFromEnv();
|
||||
setConfigDirectoryPath(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv();
|
||||
refreshConfigDirectoryPathFromEnv();
|
||||
setConfigDirectoryPath(undefined);
|
||||
});
|
||||
|
||||
describe('getConfigDirectoryPath', () => {
|
||||
it('returns default path when no custom path is set', () => {
|
||||
expect(getConfigDirectoryPath()).toBe(defaultConfigPath);
|
||||
});
|
||||
|
||||
it('reads a config directory refreshed after early env-file loading', () => {
|
||||
const restoreConfigDir = mockProcessEnv({ PROMPTFOO_CONFIG_DIR: '/env-file/config' });
|
||||
try {
|
||||
refreshConfigDirectoryPathFromEnv();
|
||||
expect(getConfigDirectoryPath()).toBe('/env-file/config');
|
||||
} finally {
|
||||
restoreConfigDir();
|
||||
refreshConfigDirectoryPathFromEnv();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let eval config env overrides move the global config directory', () => {
|
||||
const originalConfig = cliState.config;
|
||||
cliState.config = { env: { PROMPTFOO_CONFIG_DIR: '/eval-config/path' } };
|
||||
try {
|
||||
expect(getConfigDirectoryPath()).toBe(defaultConfigPath);
|
||||
} finally {
|
||||
cliState.config = originalConfig;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not move after later process environment changes', () => {
|
||||
const restoreConfigDir = mockProcessEnv({ PROMPTFOO_CONFIG_DIR: '/late/config' });
|
||||
try {
|
||||
expect(getConfigDirectoryPath()).toBe(defaultConfigPath);
|
||||
} finally {
|
||||
restoreConfigDir();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not create directory when createIfNotExists is false', () => {
|
||||
getConfigDirectoryPath(false);
|
||||
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Note: The directory creation test cannot verify mkdirSync was called because
|
||||
// the source uses require('fs') inside the function which bypasses Vitest's ESM mocking.
|
||||
// We verify the function completes successfully with the correct return value instead.
|
||||
it('handles createIfNotExists flag and returns correct path', () => {
|
||||
const result = getConfigDirectoryPath(true);
|
||||
expect(result).toBe(defaultConfigPath);
|
||||
});
|
||||
|
||||
it('does not create directory when it already exists', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
const result = getConfigDirectoryPath(true);
|
||||
expect(result).toBe(defaultConfigPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setConfigDirectoryPath', () => {
|
||||
it('updates the config directory path', () => {
|
||||
const newPath = '/new/config/path';
|
||||
setConfigDirectoryPath(newPath);
|
||||
expect(getConfigDirectoryPath()).toBe(newPath);
|
||||
});
|
||||
|
||||
it('overrides the environment variable', () => {
|
||||
const envPath = '/env/path';
|
||||
const newPath = '/new/path';
|
||||
const restoreConfigDir = mockProcessEnv({ PROMPTFOO_CONFIG_DIR: envPath });
|
||||
try {
|
||||
setConfigDirectoryPath(newPath);
|
||||
expect(getConfigDirectoryPath()).toBe(newPath);
|
||||
} finally {
|
||||
restoreConfigDir();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writePromptfooConfig', () => {
|
||||
const mockOutputPath = '/mock/output/path.yaml';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('writes a basic config to the specified path', () => {
|
||||
const mockConfig: Partial<UnifiedConfig> = { description: 'Test config' };
|
||||
const mockYaml = 'description: Test config\n';
|
||||
vi.mocked(yaml.dump).mockReturnValue(mockYaml);
|
||||
|
||||
writePromptfooConfig(mockConfig, mockOutputPath);
|
||||
|
||||
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
||||
mockOutputPath,
|
||||
`# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json\n${mockYaml}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('orders the keys of the config correctly', () => {
|
||||
const mockConfig: Partial<UnifiedConfig> = {
|
||||
tests: [{ assert: [{ type: 'equals', value: 'test assertion' }] }],
|
||||
description: 'Test config',
|
||||
prompts: ['prompt1'],
|
||||
providers: ['provider1'],
|
||||
defaultTest: { assert: [{ type: 'equals', value: 'default assertion' }] },
|
||||
};
|
||||
|
||||
writePromptfooConfig(mockConfig, mockOutputPath);
|
||||
|
||||
const dumpCall = vi.mocked(yaml.dump).mock.calls[0][0];
|
||||
const keys = Object.keys(dumpCall);
|
||||
expect(keys).toEqual(['description', 'prompts', 'providers', 'defaultTest', 'tests']);
|
||||
});
|
||||
|
||||
it('uses js-yaml to dump the config with skipInvalid option', () => {
|
||||
const mockConfig: Partial<UnifiedConfig> = { description: 'Test config' };
|
||||
|
||||
writePromptfooConfig(mockConfig, mockOutputPath);
|
||||
|
||||
expect(yaml.dump).toHaveBeenCalledWith(expect.anything(), { skipInvalid: true });
|
||||
});
|
||||
|
||||
it('handles empty config', () => {
|
||||
vi.mocked(yaml.dump).mockReturnValueOnce('');
|
||||
const mockConfig: Partial<UnifiedConfig> = {};
|
||||
|
||||
writePromptfooConfig(mockConfig, mockOutputPath);
|
||||
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith('Warning: config is empty, skipping write');
|
||||
});
|
||||
|
||||
it('preserves all fields of the UnifiedConfig', () => {
|
||||
const mockConfig: Partial<UnifiedConfig> = {
|
||||
description: 'Full config test',
|
||||
prompts: ['prompt1', 'prompt2'],
|
||||
providers: ['provider1', 'provider2'],
|
||||
defaultTest: { assert: [{ type: 'equals', value: 'default assertion' }] },
|
||||
tests: [
|
||||
{ assert: [{ type: 'equals', value: 'test assertion 1' }] },
|
||||
{ assert: [{ type: 'equals', value: 'test assertion 2' }] },
|
||||
],
|
||||
outputPath: './output',
|
||||
};
|
||||
|
||||
writePromptfooConfig(mockConfig, mockOutputPath);
|
||||
|
||||
const dumpCall = vi.mocked(yaml.dump).mock.calls[0][0];
|
||||
expect(dumpCall).toEqual(expect.objectContaining(mockConfig));
|
||||
});
|
||||
|
||||
it('handles config with undefined values', () => {
|
||||
const mockConfig: Partial<UnifiedConfig> = {
|
||||
description: 'Config with undefined',
|
||||
prompts: undefined,
|
||||
providers: ['provider1'],
|
||||
};
|
||||
|
||||
writePromptfooConfig(mockConfig, mockOutputPath);
|
||||
|
||||
const dumpCall = vi.mocked(yaml.dump).mock.calls[0][0];
|
||||
expect(dumpCall).toHaveProperty('description', 'Config with undefined');
|
||||
expect(dumpCall).toHaveProperty('providers', ['provider1']);
|
||||
expect(dumpCall).not.toHaveProperty('prompts');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as yaml from 'js-yaml';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getConfigDirectoryPath,
|
||||
refreshConfigDirectoryPathFromEnv,
|
||||
setConfigDirectoryPath,
|
||||
} from '../../../src/util/config/manage';
|
||||
import { writePromptfooConfig } from '../../../src/util/config/writer';
|
||||
import { mockProcessEnv } from '../../util/utils';
|
||||
|
||||
// Create hoisted mock functions for fs
|
||||
const mockFs = vi.hoisted(() => ({
|
||||
existsSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
statSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
rmdirSync: vi.fn(),
|
||||
copyFileSync: vi.fn(),
|
||||
renameSync: vi.fn(),
|
||||
accessSync: vi.fn(),
|
||||
chmodSync: vi.fn(),
|
||||
constants: {},
|
||||
}));
|
||||
|
||||
// Mock both 'fs' and 'node:fs' to cover all import patterns
|
||||
vi.mock('fs', () => ({
|
||||
...mockFs,
|
||||
default: mockFs,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
...mockFs,
|
||||
default: mockFs,
|
||||
}));
|
||||
|
||||
// js-yaml v5 is native ESM with a sealed namespace, so vi.spyOn cannot patch it.
|
||||
// Wrap dump in a spy-able mock that keeps the real implementation.
|
||||
vi.mock('js-yaml', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('js-yaml')>();
|
||||
return { ...actual, dump: vi.fn(actual.dump) };
|
||||
});
|
||||
|
||||
// Mock os module
|
||||
vi.mock('os');
|
||||
vi.mock('../../../src/logger', () => ({
|
||||
default: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('config management', () => {
|
||||
let restoreEnv: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(os.homedir).mockReturnValue('/home/user');
|
||||
restoreEnv = mockProcessEnv({ PROMPTFOO_CONFIG_DIR: undefined });
|
||||
refreshConfigDirectoryPathFromEnv();
|
||||
setConfigDirectoryPath(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv();
|
||||
refreshConfigDirectoryPathFromEnv();
|
||||
setConfigDirectoryPath(undefined);
|
||||
});
|
||||
|
||||
describe('getConfigDirectoryPath', () => {
|
||||
it('should return default config path when no custom path set', () => {
|
||||
setConfigDirectoryPath(undefined);
|
||||
const configPath = getConfigDirectoryPath();
|
||||
expect(configPath).toBe(path.join('/home/user', '.promptfoo'));
|
||||
});
|
||||
|
||||
// Note: The directory creation test cannot verify mkdirSync was called because
|
||||
// the source uses require('fs') inside the function which bypasses Vitest's ESM mocking.
|
||||
// We verify the function completes successfully with the correct return value instead.
|
||||
it('should handle createIfNotExists flag and return correct path', () => {
|
||||
mockFs.existsSync.mockReturnValue(false);
|
||||
setConfigDirectoryPath(undefined);
|
||||
const result = getConfigDirectoryPath(true);
|
||||
expect(result).toBe(path.join('/home/user', '.promptfoo'));
|
||||
});
|
||||
|
||||
it('should not create directory if it already exists', () => {
|
||||
mockFs.existsSync.mockReturnValue(true);
|
||||
const result = getConfigDirectoryPath(true);
|
||||
expect(result).toBe(path.join('/home/user', '.promptfoo'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('setConfigDirectoryPath', () => {
|
||||
it('should set custom config directory path', () => {
|
||||
const customPath = '/custom/path';
|
||||
setConfigDirectoryPath(customPath);
|
||||
const configPath = getConfigDirectoryPath();
|
||||
expect(configPath).toBe(customPath);
|
||||
});
|
||||
|
||||
it('should handle undefined path', () => {
|
||||
setConfigDirectoryPath(undefined);
|
||||
const configPath = getConfigDirectoryPath();
|
||||
expect(configPath).toBe(path.join('/home/user', '.promptfoo'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('writePromptfooConfig', () => {
|
||||
const outputPath = 'config.yaml';
|
||||
|
||||
it('should write config with schema comment', () => {
|
||||
const config = {
|
||||
description: 'test config',
|
||||
prompts: ['prompt1'],
|
||||
};
|
||||
|
||||
writePromptfooConfig(config, outputPath);
|
||||
|
||||
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
||||
outputPath,
|
||||
`# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json\n${yaml.dump(config)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should write config with header comments', () => {
|
||||
const config = {
|
||||
description: 'test config',
|
||||
};
|
||||
const headerComments = ['Comment 1', 'Comment 2'];
|
||||
|
||||
writePromptfooConfig(config, outputPath, headerComments);
|
||||
|
||||
const expectedContent =
|
||||
`# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json\n` +
|
||||
`# Comment 1\n# Comment 2\n${yaml.dump(config)}`;
|
||||
|
||||
expect(mockFs.writeFileSync).toHaveBeenCalledWith(outputPath, expectedContent);
|
||||
});
|
||||
|
||||
it('should handle empty config', () => {
|
||||
const config = {};
|
||||
const result = writePromptfooConfig(config, outputPath);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should order config keys', () => {
|
||||
const config = {
|
||||
tests: ['test1'],
|
||||
description: 'desc',
|
||||
prompts: ['prompt1'],
|
||||
};
|
||||
|
||||
const result = writePromptfooConfig(config, outputPath);
|
||||
|
||||
expect(Object.keys(result)[0]).toBe('description');
|
||||
expect(Object.keys(result)[1]).toBe('prompts');
|
||||
expect(Object.keys(result)[2]).toBe('tests');
|
||||
});
|
||||
|
||||
it('should handle empty yaml content', () => {
|
||||
vi.mocked(yaml.dump).mockReturnValueOnce('');
|
||||
const config = { description: 'test' };
|
||||
const result = writePromptfooConfig(config, outputPath);
|
||||
expect(result).toEqual(config);
|
||||
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import * as fs from 'fs';
|
||||
import * as fsPromises from 'fs/promises';
|
||||
|
||||
import { globSync } from 'glob';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { validateAssertions } from '../../../src/assertions/validateAssertions';
|
||||
import cliState from '../../../src/cliState';
|
||||
import { readPrompts, readProviderPromptMap } from '../../../src/prompts/index';
|
||||
import { loadApiProviders } from '../../../src/providers/index';
|
||||
import { type TestCase } from '../../../src/types/index';
|
||||
// Import after mocking
|
||||
import { resolveConfigs } from '../../../src/util/config/load';
|
||||
import { maybeLoadFromExternalFile } from '../../../src/util/file';
|
||||
import { readFilters } from '../../../src/util/index';
|
||||
import { readTests } from '../../../src/util/testCaseReader';
|
||||
import { createMockProvider } from '../../factories/provider';
|
||||
|
||||
vi.mock('fs');
|
||||
vi.mock('fs/promises');
|
||||
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('{');
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock all the dependencies first
|
||||
vi.mock('../../../src/util/file', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../../src/util/file')>('../../../src/util/file');
|
||||
return {
|
||||
...actual,
|
||||
maybeLoadFromExternalFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../../src/prompts');
|
||||
vi.mock('../../../src/providers');
|
||||
vi.mock('../../../src/util/testCaseReader');
|
||||
vi.mock('../../../src/util', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../src/util')>('../../../src/util');
|
||||
return {
|
||||
...actual,
|
||||
readFilters: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../../src/assertions/validateAssertions');
|
||||
|
||||
describe('Scenario loading with glob patterns', () => {
|
||||
const originalBasePath = cliState.basePath;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cliState.basePath = '/test/path';
|
||||
|
||||
// Setup default mocks
|
||||
vi.mocked(readPrompts).mockResolvedValue([{ raw: 'Test prompt', label: 'Test prompt' }]);
|
||||
vi.mocked(readProviderPromptMap).mockReturnValue({});
|
||||
vi.mocked(loadApiProviders).mockResolvedValue([
|
||||
createMockProvider({ id: 'openai:gpt-3.5-turbo' }),
|
||||
]);
|
||||
vi.mocked(readTests).mockImplementation(async (tests) =>
|
||||
Array.isArray(tests) ? (tests as TestCase[]) : [],
|
||||
);
|
||||
vi.mocked(readFilters).mockResolvedValue({});
|
||||
vi.mocked(validateAssertions).mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cliState.basePath = originalBasePath;
|
||||
});
|
||||
|
||||
it('should flatten scenarios when loaded with glob patterns', async () => {
|
||||
const scenario1 = {
|
||||
description: 'Scenario 1',
|
||||
config: [{ vars: { name: 'Alice' } }],
|
||||
tests: [{ vars: { question: 'Test 1' } }],
|
||||
};
|
||||
|
||||
const scenario2 = {
|
||||
description: 'Scenario 2',
|
||||
config: [{ vars: { name: 'Bob' } }],
|
||||
tests: [{ vars: { question: 'Test 2' } }],
|
||||
};
|
||||
|
||||
// Mock file system
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fsPromises.readFile).mockImplementation((filePath) => {
|
||||
if (filePath === 'config.yaml') {
|
||||
return Promise.resolve(
|
||||
yaml.dump({
|
||||
prompts: ['Test prompt'],
|
||||
providers: ['openai:gpt-3.5-turbo'],
|
||||
scenarios: ['file://scenarios/*.yaml'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve('');
|
||||
});
|
||||
|
||||
// Mock glob to return config file
|
||||
vi.mocked(globSync).mockReturnValue(['config.yaml']);
|
||||
|
||||
// Mock maybeLoadFromExternalFile to return nested array (simulating glob expansion)
|
||||
vi.mocked(maybeLoadFromExternalFile).mockImplementation((input) => {
|
||||
if (Array.isArray(input) && input[0] === 'file://scenarios/*.yaml') {
|
||||
// Return nested array as would happen with glob pattern
|
||||
return [[scenario1, scenario2]];
|
||||
}
|
||||
return input;
|
||||
});
|
||||
|
||||
const cmdObj = { config: ['config.yaml'] };
|
||||
const defaultConfig = {};
|
||||
|
||||
const { testSuite } = await resolveConfigs(cmdObj, defaultConfig);
|
||||
|
||||
// Check if maybeLoadFromExternalFile was called with the expected argument
|
||||
expect(maybeLoadFromExternalFile).toHaveBeenCalledWith(['file://scenarios/*.yaml']);
|
||||
|
||||
// Verify scenarios are flattened correctly
|
||||
expect(testSuite.scenarios).toHaveLength(2);
|
||||
expect(testSuite.scenarios![0]).toEqual(scenario1);
|
||||
expect(testSuite.scenarios![1]).toEqual(scenario2);
|
||||
});
|
||||
|
||||
it('should handle multiple scenario files with glob patterns', async () => {
|
||||
const scenarios = [
|
||||
{
|
||||
description: 'Scenario A',
|
||||
config: [{ vars: { test: 'A' } }],
|
||||
tests: [{ vars: { input: '1' } }],
|
||||
},
|
||||
{
|
||||
description: 'Scenario B',
|
||||
config: [{ vars: { test: 'B' } }],
|
||||
tests: [{ vars: { input: '2' } }],
|
||||
},
|
||||
{
|
||||
description: 'Scenario C',
|
||||
config: [{ vars: { test: 'C' } }],
|
||||
tests: [{ vars: { input: '3' } }],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fsPromises.readFile).mockImplementation((filePath) => {
|
||||
if (filePath === 'config.yaml') {
|
||||
return Promise.resolve(
|
||||
yaml.dump({
|
||||
prompts: ['Test prompt'],
|
||||
providers: ['openai:gpt-3.5-turbo'],
|
||||
scenarios: ['file://group1/*.yaml', 'file://group2/*.yaml'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve('');
|
||||
});
|
||||
|
||||
vi.mocked(globSync).mockReturnValue(['config.yaml']);
|
||||
|
||||
vi.mocked(maybeLoadFromExternalFile).mockImplementation((input) => {
|
||||
if (Array.isArray(input)) {
|
||||
// Simulate two glob patterns each returning different scenarios
|
||||
return [
|
||||
[scenarios[0], scenarios[1]], // group1/*.yaml
|
||||
[scenarios[2]], // group2/*.yaml
|
||||
];
|
||||
}
|
||||
return input;
|
||||
});
|
||||
|
||||
const cmdObj = { config: ['config.yaml'] };
|
||||
const defaultConfig = {};
|
||||
|
||||
const { testSuite } = await resolveConfigs(cmdObj, defaultConfig);
|
||||
|
||||
// Verify all scenarios are flattened into a single array
|
||||
expect(testSuite.scenarios).toHaveLength(3);
|
||||
expect(testSuite.scenarios).toEqual(scenarios);
|
||||
});
|
||||
|
||||
it('should handle mixed scenario loading (direct and glob)', async () => {
|
||||
const directScenario = {
|
||||
description: 'Direct scenario',
|
||||
config: [{ vars: { type: 'direct' } }],
|
||||
tests: [{ vars: { test: 'direct' } }],
|
||||
};
|
||||
|
||||
const globScenarios = [
|
||||
{
|
||||
description: 'Glob scenario 1',
|
||||
config: [{ vars: { type: 'glob' } }],
|
||||
tests: [{ vars: { test: 'glob1' } }],
|
||||
},
|
||||
{
|
||||
description: 'Glob scenario 2',
|
||||
config: [{ vars: { type: 'glob' } }],
|
||||
tests: [{ vars: { test: 'glob2' } }],
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fsPromises.readFile).mockImplementation((filePath) => {
|
||||
if (filePath === 'config.yaml') {
|
||||
return Promise.resolve(
|
||||
yaml.dump({
|
||||
prompts: ['Test prompt'],
|
||||
providers: ['openai:gpt-3.5-turbo'],
|
||||
scenarios: [directScenario, 'file://scenarios/*.yaml'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve('');
|
||||
});
|
||||
|
||||
vi.mocked(globSync).mockReturnValue(['config.yaml']);
|
||||
|
||||
vi.mocked(maybeLoadFromExternalFile).mockImplementation((input) => {
|
||||
if (Array.isArray(input)) {
|
||||
// First element is direct scenario, second is glob pattern
|
||||
return [directScenario, globScenarios];
|
||||
}
|
||||
return input;
|
||||
});
|
||||
|
||||
const cmdObj = { config: ['config.yaml'] };
|
||||
const defaultConfig = {};
|
||||
|
||||
const { testSuite } = await resolveConfigs(cmdObj, defaultConfig);
|
||||
|
||||
// Verify mixed scenarios are flattened correctly
|
||||
expect(testSuite.scenarios).toHaveLength(3);
|
||||
expect(testSuite.scenarios![0]).toEqual(directScenario);
|
||||
expect(testSuite.scenarios![1]).toEqual(globScenarios[0]);
|
||||
expect(testSuite.scenarios![2]).toEqual(globScenarios[1]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
extractBase64FromDataUrl,
|
||||
isDataUrl,
|
||||
parseDataUrl,
|
||||
toDataUri,
|
||||
} from '../../src/util/dataUrl';
|
||||
|
||||
describe('dataUrl utilities', () => {
|
||||
describe('isDataUrl', () => {
|
||||
it('should return true for valid data URLs', () => {
|
||||
expect(isDataUrl('data:image/jpeg;base64,/9j/4AAQSkZJRg')).toBe(true);
|
||||
expect(isDataUrl('data:image/png;base64,iVBORw0KGgo')).toBe(true);
|
||||
expect(isDataUrl('data:image/gif;base64,R0lGODlh')).toBe(true);
|
||||
expect(isDataUrl('data:image/webp;base64,UklGR')).toBe(true);
|
||||
expect(isDataUrl('data:text/plain;base64,SGVsbG8=')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for raw base64 strings', () => {
|
||||
expect(isDataUrl('/9j/4AAQSkZJRg')).toBe(false);
|
||||
expect(isDataUrl('iVBORw0KGgo')).toBe(false);
|
||||
expect(isDataUrl('R0lGODlh')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for HTTP URLs', () => {
|
||||
expect(isDataUrl('https://example.com/image.jpg')).toBe(false);
|
||||
expect(isDataUrl('http://example.com/image.png')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for file URLs', () => {
|
||||
expect(isDataUrl('file://path/to/image.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty or invalid strings', () => {
|
||||
expect(isDataUrl('')).toBe(false);
|
||||
expect(isDataUrl('not a url')).toBe(false);
|
||||
expect(isDataUrl('data')).toBe(false);
|
||||
expect(isDataUrl('data:')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-string values', () => {
|
||||
expect(isDataUrl(null as any)).toBe(false);
|
||||
expect(isDataUrl(undefined as any)).toBe(false);
|
||||
expect(isDataUrl(123 as any)).toBe(false);
|
||||
expect(isDataUrl({} as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDataUrl', () => {
|
||||
it('should parse valid JPEG data URL', () => {
|
||||
const result = parseDataUrl('data:image/jpeg;base64,/9j/4AAQSkZJRg');
|
||||
expect(result).toEqual({
|
||||
mimeType: 'image/jpeg',
|
||||
base64Data: '/9j/4AAQSkZJRg',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse valid PNG data URL', () => {
|
||||
const result = parseDataUrl('data:image/png;base64,iVBORw0KGgo');
|
||||
expect(result).toEqual({
|
||||
mimeType: 'image/png',
|
||||
base64Data: 'iVBORw0KGgo',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse valid GIF data URL', () => {
|
||||
const result = parseDataUrl('data:image/gif;base64,R0lGODlh');
|
||||
expect(result).toEqual({
|
||||
mimeType: 'image/gif',
|
||||
base64Data: 'R0lGODlh',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse valid WebP data URL', () => {
|
||||
const result = parseDataUrl('data:image/webp;base64,UklGR');
|
||||
expect(result).toEqual({
|
||||
mimeType: 'image/webp',
|
||||
base64Data: 'UklGR',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse data URL with long base64 data', () => {
|
||||
const longBase64 =
|
||||
'/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wgARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGgP//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAQUCf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Bf//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEABj8Cf//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAT8hf//aAAwDAQACAAMAAAAQ/wD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/EH//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/EH//xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAE/EH//2Q==';
|
||||
const result = parseDataUrl(`data:image/jpeg;base64,${longBase64}`);
|
||||
expect(result).toEqual({
|
||||
mimeType: 'image/jpeg',
|
||||
base64Data: longBase64,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for raw base64 strings', () => {
|
||||
expect(parseDataUrl('/9j/4AAQSkZJRg')).toBeNull();
|
||||
expect(parseDataUrl('iVBORw0KGgo')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for HTTP URLs', () => {
|
||||
expect(parseDataUrl('https://example.com/image.jpg')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for malformed data URLs', () => {
|
||||
expect(parseDataUrl('data:image/jpeg')).toBeNull();
|
||||
expect(parseDataUrl('data:image/jpeg;base64')).toBeNull();
|
||||
expect(parseDataUrl('data:image/jpeg;base64,')).toBeNull();
|
||||
expect(parseDataUrl('data:;base64,/9j/')).toBeNull(); // missing mime type
|
||||
});
|
||||
|
||||
it('should return null for data URLs without base64 encoding', () => {
|
||||
expect(parseDataUrl('data:text/plain,Hello World')).toBeNull();
|
||||
expect(parseDataUrl('data:image/jpeg,not-base64')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
expect(parseDataUrl('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle data URLs with special characters in MIME type', () => {
|
||||
const result = parseDataUrl('data:image/svg+xml;base64,PHN2Zz4=');
|
||||
expect(result).toEqual({
|
||||
mimeType: 'image/svg+xml',
|
||||
base64Data: 'PHN2Zz4=',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle data URLs with charset parameter', () => {
|
||||
const result = parseDataUrl('data:image/jpeg;charset=utf-8;base64,/9j/test');
|
||||
expect(result).toEqual({
|
||||
mimeType: 'image/jpeg',
|
||||
base64Data: '/9j/test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle data URLs with multiple parameters', () => {
|
||||
const result = parseDataUrl('data:image/jpeg;name=photo.jpg;charset=utf-8;base64,/9j/test');
|
||||
expect(result).toEqual({
|
||||
mimeType: 'image/jpeg',
|
||||
base64Data: '/9j/test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should trim whitespace from base64 data', () => {
|
||||
const result = parseDataUrl('data:image/jpeg;base64, /9j/test ');
|
||||
expect(result?.base64Data).toBe('/9j/test');
|
||||
});
|
||||
|
||||
it('should trim whitespace from MIME type', () => {
|
||||
const result = parseDataUrl('data: image/jpeg ;base64,/9j/test');
|
||||
expect(result?.mimeType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should handle empty base64 data gracefully', () => {
|
||||
const result = parseDataUrl('data:image/jpeg;base64,');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject case-insensitive data URLs', () => {
|
||||
expect(isDataUrl('DATA:image/jpeg;base64,test')).toBe(false);
|
||||
expect(isDataUrl('Data:image/jpeg;base64,test')).toBe(false);
|
||||
expect(isDataUrl('dAtA:image/jpeg;base64,test')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject data URLs with newlines in base64', () => {
|
||||
const result = parseDataUrl('data:image/jpeg;base64,/9j/\n4AAQ\ntest');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle very short valid base64 (small GIFs)', () => {
|
||||
// Smallest valid 1x1 GIF is ~35 chars, we support down to 20
|
||||
const smallBase64 = 'R0lGODlhAQABAAAAACw='; // 20 chars
|
||||
const result = parseDataUrl(`data:image/gif;base64,${smallBase64}`);
|
||||
expect(result).toEqual({
|
||||
mimeType: 'image/gif',
|
||||
base64Data: smallBase64,
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract base64 from data URLs with charset parameter', () => {
|
||||
const base64 = '/9j/test';
|
||||
const dataUrl = `data:image/jpeg;charset=utf-8;base64,${base64}`;
|
||||
expect(extractBase64FromDataUrl(dataUrl)).toBe(base64);
|
||||
});
|
||||
|
||||
it('should handle URL-encoded SVG (not supported, should fail gracefully)', () => {
|
||||
const urlEncodedSvg = 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E';
|
||||
expect(parseDataUrl(urlEncodedSvg)).toBeNull();
|
||||
// Should pass through unchanged since it's not recognized as data URL
|
||||
expect(extractBase64FromDataUrl(urlEncodedSvg)).toBe(urlEncodedSvg);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBase64FromDataUrl', () => {
|
||||
it('should extract base64 from JPEG data URL', () => {
|
||||
const base64 = '/9j/4AAQSkZJRg';
|
||||
const dataUrl = `data:image/jpeg;base64,${base64}`;
|
||||
expect(extractBase64FromDataUrl(dataUrl)).toBe(base64);
|
||||
});
|
||||
|
||||
it('should extract base64 from PNG data URL', () => {
|
||||
const base64 = 'iVBORw0KGgo';
|
||||
const dataUrl = `data:image/png;base64,${base64}`;
|
||||
expect(extractBase64FromDataUrl(dataUrl)).toBe(base64);
|
||||
});
|
||||
|
||||
it('should extract base64 from GIF data URL', () => {
|
||||
const base64 = 'R0lGODlh';
|
||||
const dataUrl = `data:image/gif;base64,${base64}`;
|
||||
expect(extractBase64FromDataUrl(dataUrl)).toBe(base64);
|
||||
});
|
||||
|
||||
it('should return unchanged raw base64 strings', () => {
|
||||
const base64 = '/9j/4AAQSkZJRg';
|
||||
expect(extractBase64FromDataUrl(base64)).toBe(base64);
|
||||
});
|
||||
|
||||
it('should return unchanged HTTP URLs', () => {
|
||||
const url = 'https://example.com/image.jpg';
|
||||
expect(extractBase64FromDataUrl(url)).toBe(url);
|
||||
});
|
||||
|
||||
it('should handle long base64 strings', () => {
|
||||
const longBase64 =
|
||||
'/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wgARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGgP//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAQUCf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Bf//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEABj8Cf//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAT8hf//aAAwDAQACAAMAAAAQ/wD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/EH//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/EH//xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAE/EH//2Q==';
|
||||
const dataUrl = `data:image/jpeg;base64,${longBase64}`;
|
||||
expect(extractBase64FromDataUrl(dataUrl)).toBe(longBase64);
|
||||
});
|
||||
|
||||
it('should be idempotent - extracting from already extracted data returns same value', () => {
|
||||
const base64 = '/9j/4AAQSkZJRg';
|
||||
const extracted1 = extractBase64FromDataUrl(`data:image/jpeg;base64,${base64}`);
|
||||
const extracted2 = extractBase64FromDataUrl(extracted1);
|
||||
expect(extracted1).toBe(base64);
|
||||
expect(extracted2).toBe(base64);
|
||||
});
|
||||
|
||||
it('should handle malformed data URLs by returning them unchanged', () => {
|
||||
const malformed = 'data:image/jpeg';
|
||||
expect(extractBase64FromDataUrl(malformed)).toBe(malformed);
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(extractBase64FromDataUrl('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration scenarios', () => {
|
||||
it('should handle Azure OpenAI use case (data URLs work natively)', () => {
|
||||
const base64 = '/9j/4AAQSkZJRg';
|
||||
const dataUrl = `data:image/jpeg;base64,${base64}`;
|
||||
|
||||
// Azure accepts data URLs directly
|
||||
expect(isDataUrl(dataUrl)).toBe(true);
|
||||
// No conversion needed
|
||||
});
|
||||
|
||||
it('should handle Anthropic use case (needs base64 extraction)', () => {
|
||||
const base64 = '/9j/4AAQSkZJRg';
|
||||
const dataUrl = `data:image/jpeg;base64,${base64}`;
|
||||
|
||||
// Anthropic needs raw base64
|
||||
const extracted = extractBase64FromDataUrl(dataUrl);
|
||||
expect(extracted).toBe(base64);
|
||||
|
||||
// And MIME type
|
||||
const parsed = parseDataUrl(dataUrl);
|
||||
expect(parsed?.mimeType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should handle Google Gemini use case (needs base64 extraction)', () => {
|
||||
const base64 = 'iVBORw0KGgo';
|
||||
const dataUrl = `data:image/png;base64,${base64}`;
|
||||
|
||||
// Google needs raw base64 for inlineData.data
|
||||
const extracted = extractBase64FromDataUrl(dataUrl);
|
||||
expect(extracted).toBe(base64);
|
||||
|
||||
// And MIME type for inlineData.mimeType
|
||||
const parsed = parseDataUrl(dataUrl);
|
||||
expect(parsed?.mimeType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle raw base64 passthrough (backwards compatibility)', () => {
|
||||
const rawBase64 = '/9j/4AAQSkZJRg';
|
||||
|
||||
// Should not be detected as data URL
|
||||
expect(isDataUrl(rawBase64)).toBe(false);
|
||||
|
||||
// Should pass through unchanged
|
||||
expect(extractBase64FromDataUrl(rawBase64)).toBe(rawBase64);
|
||||
});
|
||||
|
||||
it('should handle HTTP URL passthrough', () => {
|
||||
const httpUrl = 'https://example.com/image.jpg';
|
||||
|
||||
// Should not be detected as data URL
|
||||
expect(isDataUrl(httpUrl)).toBe(false);
|
||||
|
||||
// Should pass through unchanged
|
||||
expect(extractBase64FromDataUrl(httpUrl)).toBe(httpUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDataUri', () => {
|
||||
it('should build a data URI from mime type and base64 data', () => {
|
||||
expect(toDataUri('image/png', 'iVBORw0KGgo')).toBe('data:image/png;base64,iVBORw0KGgo');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getDb } from '../../src/database/index';
|
||||
import { updateSignalFileForDeletedEvals } from '../../src/database/signal';
|
||||
import { spansTable, tracesTable } from '../../src/database/tables';
|
||||
import { runDbMigrations } from '../../src/migrate';
|
||||
import Eval from '../../src/models/eval';
|
||||
import { TraceStore } from '../../src/tracing/store';
|
||||
import { deleteAllEvals, deleteEval, deleteEvals } from '../../src/util/database';
|
||||
import EvalFactory from '../factories/evalFactory';
|
||||
|
||||
vi.mock('../../src/database/signal', async () => {
|
||||
const actual = await vi.importActual('../../src/database/signal');
|
||||
return {
|
||||
...actual,
|
||||
updateSignalFile: vi.fn(),
|
||||
updateSignalFileForDeletedEvals: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('database eval deletion', () => {
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const db = await getDb();
|
||||
await db.run('DELETE FROM spans');
|
||||
await db.run('DELETE FROM traces');
|
||||
await db.run('DELETE FROM eval_results');
|
||||
await db.run('DELETE FROM evals_to_datasets');
|
||||
await db.run('DELETE FROM evals_to_prompts');
|
||||
await db.run('DELETE FROM evals_to_tags');
|
||||
await db.run('DELETE FROM evals');
|
||||
});
|
||||
|
||||
async function addTrace(evalId: string, traceId: string) {
|
||||
const traceStore = new TraceStore();
|
||||
await traceStore.createTrace({
|
||||
traceId,
|
||||
evaluationId: evalId,
|
||||
testCaseId: 'test-case-id',
|
||||
});
|
||||
await traceStore.addSpans(traceId, [
|
||||
{
|
||||
spanId: `${traceId}-span`,
|
||||
name: 'test-span',
|
||||
startTime: 1,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
it('deletes traces and spans for a single eval', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
await addTrace(eval_.id, 'trace-single');
|
||||
|
||||
await deleteEval(eval_.id);
|
||||
|
||||
const db = await getDb();
|
||||
expect(await Eval.findById(eval_.id)).toBeUndefined();
|
||||
expect(await db.select().from(tracesTable).all()).toHaveLength(0);
|
||||
expect(await db.select().from(spansTable).all()).toHaveLength(0);
|
||||
expect(updateSignalFileForDeletedEvals).toHaveBeenCalledWith([eval_.id]);
|
||||
});
|
||||
|
||||
it('deletes only traces and spans for selected evals', async () => {
|
||||
const eval1 = await EvalFactory.create();
|
||||
const eval2 = await EvalFactory.create();
|
||||
const eval3 = await EvalFactory.create();
|
||||
await addTrace(eval1.id, 'trace-bulk-1');
|
||||
await addTrace(eval2.id, 'trace-bulk-2');
|
||||
await addTrace(eval3.id, 'trace-retained');
|
||||
|
||||
await deleteEvals([eval1.id, eval2.id]);
|
||||
|
||||
const db = await getDb();
|
||||
expect(await Eval.findById(eval1.id)).toBeUndefined();
|
||||
expect(await Eval.findById(eval2.id)).toBeUndefined();
|
||||
expect(await Eval.findById(eval3.id)).toBeDefined();
|
||||
expect(await db.select({ traceId: tracesTable.traceId }).from(tracesTable).all()).toEqual([
|
||||
{ traceId: 'trace-retained' },
|
||||
]);
|
||||
expect(await db.select({ traceId: spansTable.traceId }).from(spansTable).all()).toEqual([
|
||||
{ traceId: 'trace-retained' },
|
||||
]);
|
||||
expect(updateSignalFileForDeletedEvals).toHaveBeenCalledWith([eval1.id, eval2.id]);
|
||||
});
|
||||
|
||||
it('does not emit a delete signal when called with an empty id list', async () => {
|
||||
// An empty deletedEvalIds list is indistinguishable from "all evals deleted" on the
|
||||
// client, so deleting zero evals must be a no-op rather than a spurious clear.
|
||||
await deleteEvals([]);
|
||||
|
||||
expect(updateSignalFileForDeletedEvals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes traces and spans when deleting all evals', async () => {
|
||||
const eval1 = await EvalFactory.create();
|
||||
const eval2 = await EvalFactory.create();
|
||||
await addTrace(eval1.id, 'trace-all-1');
|
||||
await addTrace(eval2.id, 'trace-all-2');
|
||||
|
||||
await deleteAllEvals();
|
||||
|
||||
const db = await getDb();
|
||||
expect(await Eval.getMany()).toHaveLength(0);
|
||||
expect(await db.select().from(tracesTable).all()).toHaveLength(0);
|
||||
expect(await db.select().from(spansTable).all()).toHaveLength(0);
|
||||
expect(updateSignalFileForDeletedEvals).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('handles evals with no traces without error', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
|
||||
await deleteEval(eval_.id);
|
||||
|
||||
expect(await Eval.findById(eval_.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes every span when a trace has multiple spans', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
await addTrace(eval_.id, 'trace-multi-span');
|
||||
await new TraceStore().addSpans('trace-multi-span', [
|
||||
{ spanId: 'extra-span', name: 'extra', startTime: 2 },
|
||||
]);
|
||||
|
||||
await deleteEval(eval_.id);
|
||||
|
||||
const db = await getDb();
|
||||
expect(await db.select().from(spansTable).all()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getDb } from '../../src/database/index';
|
||||
import { updateSignalFile } from '../../src/database/signal';
|
||||
import { evalsTable } from '../../src/database/tables';
|
||||
import { runDbMigrations } from '../../src/migrate';
|
||||
import Eval from '../../src/models/eval';
|
||||
import EvalResult from '../../src/models/evalResult';
|
||||
import { type CompletedPrompt, type Prompt, ResultFailureReason } from '../../src/types/index';
|
||||
import {
|
||||
clearStandaloneEvalCache,
|
||||
deleteEval,
|
||||
getStandaloneEvals,
|
||||
updateResult,
|
||||
} from '../../src/util/database';
|
||||
import {
|
||||
getCachedStandaloneEvals,
|
||||
getStandaloneEvalCacheKey,
|
||||
} from '../../src/util/standaloneEvalCache';
|
||||
|
||||
vi.mock('../../src/database/signal', async () => {
|
||||
const actual = await vi.importActual('../../src/database/signal');
|
||||
return {
|
||||
...actual,
|
||||
updateSignalFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const completedPrompt: CompletedPrompt = {
|
||||
raw: 'hello',
|
||||
label: 'hello',
|
||||
provider: 'test-provider',
|
||||
metrics: {
|
||||
score: 1,
|
||||
testPassCount: 1,
|
||||
testFailCount: 0,
|
||||
testErrorCount: 0,
|
||||
assertPassCount: 1,
|
||||
assertFailCount: 0,
|
||||
totalLatencyMs: 0,
|
||||
tokenUsage: { total: 0, prompt: 0, completion: 0, cached: 0 },
|
||||
namedScores: {},
|
||||
namedScoresCount: {},
|
||||
cost: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const renderedPrompts: Prompt[] = [{ raw: 'hello', label: 'hello' }];
|
||||
|
||||
// Mirrors the CASE expression in drizzle/0024_repair_eval_redteam_flags.sql.
|
||||
const isRedteamCase = sql`CASE
|
||||
WHEN json_valid(${evalsTable.config}) AND json_type(${evalsTable.config}, '$.redteam') IS NOT NULL THEN 1
|
||||
ELSE 0
|
||||
END`;
|
||||
|
||||
async function createEvalWithPrompts(config: Partial<Parameters<typeof Eval.create>[0]>) {
|
||||
return Eval.create(config as Parameters<typeof Eval.create>[0], renderedPrompts, {
|
||||
completedPrompts: [completedPrompt],
|
||||
});
|
||||
}
|
||||
|
||||
async function resetEvalTables() {
|
||||
const db = await getDb();
|
||||
await db.run('DELETE FROM eval_results');
|
||||
await db.run('DELETE FROM evals_to_datasets');
|
||||
await db.run('DELETE FROM evals_to_prompts');
|
||||
await db.run('DELETE FROM evals_to_tags');
|
||||
await db.run('DELETE FROM evals');
|
||||
clearStandaloneEvalCache();
|
||||
}
|
||||
|
||||
async function setIsRedteam(evalId: string, value: boolean) {
|
||||
const db = await getDb();
|
||||
await db.update(evalsTable).set({ isRedteam: value }).where(eq(evalsTable.id, evalId)).run();
|
||||
}
|
||||
|
||||
function expectStandaloneHistoryCached(options?: Parameters<typeof getStandaloneEvalCacheKey>[0]) {
|
||||
expect(getCachedStandaloneEvals(getStandaloneEvalCacheKey(options))).toBeDefined();
|
||||
}
|
||||
|
||||
describe('getStandaloneEvals', () => {
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
});
|
||||
|
||||
beforeEach(resetEvalTables);
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('returns isRedteam from the materialized column, not raw JSON inspection', async () => {
|
||||
const redteamEval = await createEvalWithPrompts({ redteam: {} as any });
|
||||
const regularEval = await createEvalWithPrompts({});
|
||||
|
||||
// Flip the column away from what the JSON config would imply; the query must trust the column.
|
||||
await setIsRedteam(redteamEval.id, false);
|
||||
await setIsRedteam(regularEval.id, true);
|
||||
|
||||
const rows = await getStandaloneEvals();
|
||||
const byId = new Map(rows.map((row) => [row.evalId, row.isRedteam] as const));
|
||||
|
||||
expect(byId.get(redteamEval.id)).toBe(false);
|
||||
expect(byId.get(regularEval.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('reflects flag transitions after updateResult rewrites config without stale cached history', async () => {
|
||||
const eval_ = await createEvalWithPrompts({});
|
||||
expect((await getStandaloneEvals()).find((row) => row.evalId === eval_.id)?.isRedteam).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
await updateResult(eval_.id, { redteam: {} as any });
|
||||
|
||||
expect((await getStandaloneEvals()).find((row) => row.evalId === eval_.id)?.isRedteam).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('drops deleted evals from cached history immediately', async () => {
|
||||
const keep = await createEvalWithPrompts({});
|
||||
const drop = await createEvalWithPrompts({});
|
||||
|
||||
const beforeIds = (await getStandaloneEvals()).map((row) => row.evalId);
|
||||
expect(beforeIds).toEqual(expect.arrayContaining([keep.id, drop.id]));
|
||||
|
||||
await deleteEval(drop.id);
|
||||
|
||||
const afterIds = (await getStandaloneEvals()).map((row) => row.evalId);
|
||||
expect(afterIds).toContain(keep.id);
|
||||
expect(afterIds).not.toContain(drop.id);
|
||||
});
|
||||
|
||||
it('includes newly created evals after history has been cached', async () => {
|
||||
const first = await createEvalWithPrompts({});
|
||||
const beforeIds = (await getStandaloneEvals()).map((row) => row.evalId);
|
||||
const limitedBeforeIds = (await getStandaloneEvals({ limit: 5 })).map((row) => row.evalId);
|
||||
expect(beforeIds).toContain(first.id);
|
||||
expect(limitedBeforeIds).toContain(first.id);
|
||||
expectStandaloneHistoryCached();
|
||||
expectStandaloneHistoryCached({ limit: 5 });
|
||||
|
||||
const second = await createEvalWithPrompts({});
|
||||
|
||||
const afterIds = (await getStandaloneEvals()).map((row) => row.evalId);
|
||||
const limitedAfterIds = (await getStandaloneEvals({ limit: 5 })).map((row) => row.evalId);
|
||||
expect(afterIds).toContain(first.id);
|
||||
expect(afterIds).toContain(second.id);
|
||||
expect(limitedAfterIds).toContain(first.id);
|
||||
expect(limitedAfterIds).toContain(second.id);
|
||||
});
|
||||
|
||||
it('includes copied evals after history has been cached', async () => {
|
||||
const source = await createEvalWithPrompts({});
|
||||
const beforeIds = (await getStandaloneEvals()).map((row) => row.evalId);
|
||||
expect(beforeIds).toContain(source.id);
|
||||
expectStandaloneHistoryCached();
|
||||
|
||||
const persistedSource = await Eval.findById(source.id);
|
||||
expect(persistedSource).toBeDefined();
|
||||
const copy = await persistedSource!.copy('copy of source');
|
||||
|
||||
const afterIds = (await getStandaloneEvals()).map((row) => row.evalId);
|
||||
expect(afterIds).toContain(source.id);
|
||||
expect(afterIds).toContain(copy.id);
|
||||
expect(updateSignalFile).toHaveBeenCalledWith(copy.id);
|
||||
});
|
||||
|
||||
it('includes direct eval saves after history has been cached', async () => {
|
||||
const eval_ = await createEvalWithPrompts({});
|
||||
const beforeRow = (await getStandaloneEvals()).find((row) => row.evalId === eval_.id);
|
||||
expect(beforeRow?.description).toBeNull();
|
||||
expectStandaloneHistoryCached();
|
||||
|
||||
const persistedEval = await Eval.findById(eval_.id);
|
||||
expect(persistedEval).toBeDefined();
|
||||
persistedEval!.config.description = 'updated description';
|
||||
await persistedEval!.save();
|
||||
|
||||
const afterRow = (await getStandaloneEvals()).find((row) => row.evalId === eval_.id);
|
||||
expect(afterRow?.description).toBe('updated description');
|
||||
expect(updateSignalFile).toHaveBeenCalledWith(eval_.id);
|
||||
});
|
||||
|
||||
it('includes prompt changes after history has been cached', async () => {
|
||||
const eval_ = await createEvalWithPrompts({});
|
||||
const beforeRow = (await getStandaloneEvals()).find((row) => row.evalId === eval_.id);
|
||||
expect(beforeRow?.raw).toBe('hello');
|
||||
expectStandaloneHistoryCached();
|
||||
|
||||
await eval_.addPrompts([
|
||||
{
|
||||
...completedPrompt,
|
||||
raw: 'goodbye',
|
||||
label: 'goodbye',
|
||||
},
|
||||
]);
|
||||
|
||||
const afterRow = (await getStandaloneEvals()).find((row) => row.evalId === eval_.id);
|
||||
expect(afterRow?.raw).toBe('goodbye');
|
||||
});
|
||||
|
||||
it('includes result changes after history has been cached', async () => {
|
||||
const eval_ = await createEvalWithPrompts({});
|
||||
const beforeRow = (await getStandaloneEvals()).find((row) => row.evalId === eval_.id);
|
||||
expect(beforeRow?.pluginFailCount['plugin-a']).toBeUndefined();
|
||||
expectStandaloneHistoryCached();
|
||||
|
||||
await eval_.setResults([
|
||||
new EvalResult({
|
||||
id: 'set-results-history-row',
|
||||
evalId: eval_.id,
|
||||
promptIdx: 0,
|
||||
testIdx: 0,
|
||||
testCase: { vars: {}, metadata: { pluginId: 'plugin-a' } },
|
||||
prompt: renderedPrompts[0],
|
||||
provider: { id: 'test-provider' },
|
||||
response: { output: 'bad' },
|
||||
gradingResult: null,
|
||||
namedScores: {},
|
||||
metadata: {},
|
||||
success: false,
|
||||
score: 0,
|
||||
latencyMs: 1,
|
||||
cost: 0,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
}),
|
||||
]);
|
||||
|
||||
const afterRow = (await getStandaloneEvals()).find((row) => row.evalId === eval_.id);
|
||||
expect(afterRow?.pluginFailCount['plugin-a']).toBe(1);
|
||||
});
|
||||
|
||||
it('includes incrementally added results after history has been cached', async () => {
|
||||
const eval_ = await createEvalWithPrompts({});
|
||||
const beforeRow = (await getStandaloneEvals()).find((row) => row.evalId === eval_.id);
|
||||
expect(beforeRow?.pluginFailCount['plugin-add']).toBeUndefined();
|
||||
expectStandaloneHistoryCached();
|
||||
|
||||
await eval_.addResult({
|
||||
promptIdx: 0,
|
||||
testIdx: 0,
|
||||
testCase: { vars: {}, metadata: { pluginId: 'plugin-add' } },
|
||||
promptId: 'prompt-add',
|
||||
provider: { id: 'test-provider' },
|
||||
prompt: renderedPrompts[0],
|
||||
vars: {},
|
||||
response: { output: 'bad' },
|
||||
error: null,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
success: false,
|
||||
score: 0,
|
||||
latencyMs: 1,
|
||||
gradingResult: null,
|
||||
namedScores: {},
|
||||
cost: 0,
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
const afterRow = (await getStandaloneEvals()).find((row) => row.evalId === eval_.id);
|
||||
expect(afterRow?.pluginFailCount['plugin-add']).toBe(1);
|
||||
});
|
||||
|
||||
it('classifies redteam: null as redteam, matching the runtime predicate', async () => {
|
||||
const eval_ = await createEvalWithPrompts({ redteam: null as any });
|
||||
|
||||
const db = await getDb();
|
||||
const stored = await db
|
||||
.select({ isRedteam: evalsTable.isRedteam })
|
||||
.from(evalsTable)
|
||||
.where(eq(evalsTable.id, eval_.id))
|
||||
.get();
|
||||
expect(stored?.isRedteam).toBe(true);
|
||||
|
||||
const row = (await getStandaloneEvals()).find((r) => r.evalId === eval_.id);
|
||||
expect(row?.isRedteam).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration 0024 repair semantics', () => {
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
});
|
||||
|
||||
beforeEach(resetEvalTables);
|
||||
|
||||
it('classifies {redteam: null} as redteam via json_type (not json_extract)', async () => {
|
||||
const db = await getDb();
|
||||
const evalNullRedteam = await Eval.create({ redteam: null as any }, []);
|
||||
const evalNoRedteam = await Eval.create({}, []);
|
||||
const evalWithRedteam = await Eval.create({ redteam: {} as any }, []);
|
||||
|
||||
const classify = (id: string) =>
|
||||
db
|
||||
.select({
|
||||
fromMigration: sql<number>`${isRedteamCase}`,
|
||||
fromLegacyExtract: sql<number>`CASE
|
||||
WHEN json_valid(${evalsTable.config}) AND json_extract(${evalsTable.config}, '$.redteam') IS NOT NULL THEN 1
|
||||
ELSE 0
|
||||
END`,
|
||||
})
|
||||
.from(evalsTable)
|
||||
.where(eq(evalsTable.id, id))
|
||||
.get();
|
||||
|
||||
const nullRow = await classify(evalNullRedteam.id);
|
||||
const missingRow = await classify(evalNoRedteam.id);
|
||||
const presentRow = await classify(evalWithRedteam.id);
|
||||
|
||||
expect(nullRow?.fromMigration).toBe(1);
|
||||
expect(missingRow?.fromMigration).toBe(0);
|
||||
expect(presentRow?.fromMigration).toBe(1);
|
||||
|
||||
// Legacy json_extract collapses {redteam: null} to non-redteam — the divergence this migration repairs.
|
||||
expect(nullRow?.fromLegacyExtract).toBe(0);
|
||||
expect(missingRow?.fromLegacyExtract).toBe(0);
|
||||
expect(presentRow?.fromLegacyExtract).toBe(1);
|
||||
});
|
||||
|
||||
it('idempotently repairs only stale rows', async () => {
|
||||
const db = await getDb();
|
||||
const evalWithRedteam = await Eval.create({ redteam: {} as any }, []);
|
||||
const evalRegular = await Eval.create({}, []);
|
||||
|
||||
await setIsRedteam(evalWithRedteam.id, false);
|
||||
await setIsRedteam(evalRegular.id, true);
|
||||
|
||||
const repairSql = sql`UPDATE evals SET is_redteam = ${isRedteamCase} WHERE is_redteam != ${isRedteamCase}`;
|
||||
|
||||
expect((await db.run(repairSql)).rowsAffected).toBe(2);
|
||||
expect((await db.run(repairSql)).rowsAffected).toBe(0);
|
||||
|
||||
const rows = await db
|
||||
.select({ id: evalsTable.id, isRedteam: evalsTable.isRedteam })
|
||||
.from(evalsTable)
|
||||
.all();
|
||||
const byId = new Map(rows.map((row) => [row.id, row.isRedteam] as const));
|
||||
expect(byId.get(evalWithRedteam.id)).toBe(true);
|
||||
expect(byId.get(evalRegular.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getDb } from '../../src/database/index';
|
||||
import { updateSignalFile } from '../../src/database/signal';
|
||||
import { datasetsTable, evalsTable, promptsTable, tagsTable } from '../../src/database/tables';
|
||||
import { runDbMigrations } from '../../src/migrate';
|
||||
import {
|
||||
clearStandaloneEvalCache,
|
||||
getStandaloneEvals,
|
||||
writeResultsToDatabase,
|
||||
} from '../../src/util/database';
|
||||
import {
|
||||
getCachedStandaloneEvals,
|
||||
getStandaloneEvalCacheKey,
|
||||
} from '../../src/util/standaloneEvalCache';
|
||||
import { createEvaluateSummaryV2 } from '../factories/eval';
|
||||
|
||||
vi.mock('../../src/database/signal', async () => {
|
||||
const actual = await vi.importActual('../../src/database/signal');
|
||||
return {
|
||||
...actual,
|
||||
updateSignalFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function expectWrittenHistoryCached() {
|
||||
expect(getCachedStandaloneEvals(getStandaloneEvalCacheKey())).toBeDefined();
|
||||
}
|
||||
|
||||
describe('writeResultsToDatabase', () => {
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const db = await getDb();
|
||||
await db.run('DROP TRIGGER IF EXISTS fail_evals_to_tags_insert');
|
||||
await db.run('DELETE FROM evals_to_datasets');
|
||||
await db.run('DELETE FROM evals_to_prompts');
|
||||
await db.run('DELETE FROM evals_to_tags');
|
||||
await db.run('DELETE FROM evals');
|
||||
await db.run('DELETE FROM datasets');
|
||||
await db.run('DELETE FROM prompts');
|
||||
await db.run('DELETE FROM tags');
|
||||
clearStandaloneEvalCache();
|
||||
});
|
||||
|
||||
it('rolls back related rows when a dependent insert fails', async () => {
|
||||
const db = await getDb();
|
||||
await db.run(`
|
||||
CREATE TRIGGER fail_evals_to_tags_insert
|
||||
BEFORE INSERT ON evals_to_tags
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced tag failure');
|
||||
END;
|
||||
`);
|
||||
|
||||
await expect(
|
||||
writeResultsToDatabase(
|
||||
createEvaluateSummaryV2(),
|
||||
{
|
||||
tags: { suite: 'regression' },
|
||||
tests: [],
|
||||
},
|
||||
new Date('2024-01-01T00:00:00.000Z'),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(db.select().from(evalsTable).all()).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(promptsTable).all()).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(datasetsTable).all()).resolves.toHaveLength(0);
|
||||
await expect(db.select().from(tagsTable).all()).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps cached history when a later write rolls back', async () => {
|
||||
const firstEvalId = await writeResultsToDatabase(createEvaluateSummaryV2(), {}, new Date());
|
||||
const beforeIds = (await getStandaloneEvals()).map((row) => row.evalId);
|
||||
expect(beforeIds).toContain(firstEvalId);
|
||||
expectWrittenHistoryCached();
|
||||
const secondDate = new Date(Date.now() + 1000);
|
||||
|
||||
const db = await getDb();
|
||||
await db.run(`
|
||||
CREATE TRIGGER fail_evals_to_tags_insert
|
||||
BEFORE INSERT ON evals_to_tags
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'forced tag failure');
|
||||
END;
|
||||
`);
|
||||
|
||||
await expect(
|
||||
writeResultsToDatabase(
|
||||
createEvaluateSummaryV2(),
|
||||
{
|
||||
tags: { suite: 'regression' },
|
||||
tests: [],
|
||||
},
|
||||
secondDate,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
const cachedIds = getCachedStandaloneEvals(getStandaloneEvalCacheKey())?.map(
|
||||
(row) => row.evalId,
|
||||
);
|
||||
expect(cachedIds).toContain(firstEvalId);
|
||||
});
|
||||
|
||||
it('includes newly persisted evals after history has been cached', async () => {
|
||||
const firstDate = new Date();
|
||||
const firstEvalId = await writeResultsToDatabase(createEvaluateSummaryV2(), {}, firstDate);
|
||||
const beforeIds = (await getStandaloneEvals()).map((row) => row.evalId);
|
||||
expect(beforeIds).toContain(firstEvalId);
|
||||
expectWrittenHistoryCached();
|
||||
|
||||
const secondEvalId = await writeResultsToDatabase(
|
||||
createEvaluateSummaryV2(),
|
||||
{},
|
||||
new Date(firstDate.getTime() + 1000),
|
||||
);
|
||||
|
||||
const afterIds = (await getStandaloneEvals()).map((row) => row.evalId);
|
||||
expect(afterIds).toContain(firstEvalId);
|
||||
expect(afterIds).toContain(secondEvalId);
|
||||
expect(updateSignalFile).toHaveBeenCalledWith(secondEvalId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { updateSignalFileForDeletedEvals } from '../../src/database/signal';
|
||||
import { runDbMigrations } from '../../src/migrate';
|
||||
import Eval from '../../src/models/eval';
|
||||
import { deleteAllEvals } from '../../src/util/database';
|
||||
import EvalFactory from '../factories/evalFactory';
|
||||
|
||||
vi.mock('../../src/database/signal', async () => {
|
||||
const actual = await vi.importActual('../../src/database/signal');
|
||||
return {
|
||||
...actual,
|
||||
updateSignalFile: vi.fn(),
|
||||
updateSignalFileForDeletedEvals: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('delete all evals', () => {
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
it('should delete all evals', async () => {
|
||||
await EvalFactory.create();
|
||||
await EvalFactory.create();
|
||||
await EvalFactory.create();
|
||||
|
||||
const evals = await Eval.getMany();
|
||||
expect(evals).toHaveLength(3);
|
||||
|
||||
await deleteAllEvals();
|
||||
|
||||
const evals2 = await Eval.getMany();
|
||||
expect(evals2).toHaveLength(0);
|
||||
expect(updateSignalFileForDeletedEvals).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest';
|
||||
import logger from '../../src/logger';
|
||||
import * as configManage from '../../src/util/config/manage';
|
||||
import {
|
||||
getConfigDirectoryPath,
|
||||
refreshConfigDirectoryPathFromEnv,
|
||||
setConfigDirectoryPath,
|
||||
} from '../../src/util/config/manage';
|
||||
import { setupEnv } from '../../src/util/env';
|
||||
import { mockProcessEnv } from './utils';
|
||||
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('setupEnv', () => {
|
||||
let originalEnv: typeof process.env;
|
||||
let dotenvConfigSpy: MockInstance<
|
||||
(options?: dotenv.DotenvConfigOptions) => dotenv.DotenvConfigOutput
|
||||
>;
|
||||
let loggerInfoSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
// Ensure NODE_ENV is not set at the start of each test
|
||||
mockProcessEnv({ NODE_ENV: undefined });
|
||||
mockProcessEnv({ PROMPTFOO_CONFIG_DIR: undefined });
|
||||
refreshConfigDirectoryPathFromEnv();
|
||||
setConfigDirectoryPath(undefined);
|
||||
// Spy on dotenv.config to verify it's called with the right parameters
|
||||
dotenvConfigSpy = vi.spyOn(dotenv, 'config').mockImplementation(() => ({ parsed: {} }));
|
||||
loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => logger);
|
||||
// Mock file existence check - default to true for backward compat with existing tests
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockProcessEnv(originalEnv, { clear: true });
|
||||
refreshConfigDirectoryPathFromEnv();
|
||||
setConfigDirectoryPath(undefined);
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should call dotenv.config with quiet=true when envPath is undefined', () => {
|
||||
setupEnv(undefined);
|
||||
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledWith({ quiet: true });
|
||||
});
|
||||
|
||||
it('should call dotenv.config with path, override=true, and quiet=true when envPath is specified', () => {
|
||||
const testEnvPath = '.env.test';
|
||||
|
||||
setupEnv(testEnvPath);
|
||||
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledWith({
|
||||
path: testEnvPath,
|
||||
override: true,
|
||||
quiet: true,
|
||||
});
|
||||
expect(loggerInfoSpy).toHaveBeenCalledWith('Loading environment variables from .env.test');
|
||||
});
|
||||
|
||||
it('should load environment variables with override when specified env file has conflicting values', () => {
|
||||
// Mock dotenv.config to simulate loading variables
|
||||
dotenvConfigSpy.mockImplementation((options?: dotenv.DotenvConfigOptions) => {
|
||||
if (options?.path === '.env.production') {
|
||||
if (options.override) {
|
||||
mockProcessEnv({ NODE_ENV: 'production' });
|
||||
} else if (!process.env.NODE_ENV) {
|
||||
mockProcessEnv({ NODE_ENV: 'production' });
|
||||
}
|
||||
} else {
|
||||
// Default .env file
|
||||
if (!process.env.NODE_ENV) {
|
||||
mockProcessEnv({ NODE_ENV: 'development' });
|
||||
}
|
||||
}
|
||||
return { parsed: {} };
|
||||
});
|
||||
|
||||
// First load the default .env (setting NODE_ENV to 'development')
|
||||
setupEnv(undefined);
|
||||
expect(process.env.NODE_ENV).toBe('development');
|
||||
|
||||
// Then load .env.production with override (should change NODE_ENV to 'production')
|
||||
setupEnv('.env.production');
|
||||
expect(process.env.NODE_ENV).toBe('production');
|
||||
});
|
||||
|
||||
it('should refresh the config directory after early env loading and freeze later changes', () => {
|
||||
const refreshConfigDirectorySpy = vi.spyOn(configManage, 'refreshConfigDirectoryPathFromEnv');
|
||||
dotenvConfigSpy.mockImplementation(() => {
|
||||
mockProcessEnv({ PROMPTFOO_CONFIG_DIR: '/early/config' });
|
||||
return { parsed: {} };
|
||||
});
|
||||
|
||||
setupEnv('.env.early', { refreshConfigDirectory: true });
|
||||
expect(refreshConfigDirectorySpy).toHaveBeenCalledTimes(1);
|
||||
expect(getConfigDirectoryPath()).toBe('/early/config');
|
||||
|
||||
mockProcessEnv({ PROMPTFOO_CONFIG_DIR: '/late/config' });
|
||||
expect(getConfigDirectoryPath()).toBe('/early/config');
|
||||
});
|
||||
|
||||
describe('multi-file support', () => {
|
||||
it('should call dotenv.config with array of paths when multiple files specified', () => {
|
||||
const paths = ['.env', '.env.local'];
|
||||
|
||||
setupEnv(paths);
|
||||
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledWith({
|
||||
path: paths,
|
||||
override: true,
|
||||
quiet: true,
|
||||
});
|
||||
expect(loggerInfoSpy).toHaveBeenCalledWith(
|
||||
'Loading environment variables from: .env, .env.local',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call dotenv.config with single path (not array) when one file specified as array', () => {
|
||||
const paths = ['.env'];
|
||||
|
||||
setupEnv(paths);
|
||||
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledWith({
|
||||
path: '.env',
|
||||
override: true,
|
||||
quiet: true,
|
||||
});
|
||||
expect(loggerInfoSpy).toHaveBeenCalledWith('Loading environment variables from .env');
|
||||
});
|
||||
|
||||
it('should throw error when specified file does not exist', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
expect(() => setupEnv('.env.missing')).toThrow('Environment file not found: .env.missing');
|
||||
});
|
||||
|
||||
it('should throw error when any file in array does not exist', () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => p === '.env');
|
||||
|
||||
expect(() => setupEnv(['.env', '.env.missing'])).toThrow(
|
||||
'Environment file not found: .env.missing',
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate all files exist before calling dotenv.config', () => {
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => p === '.env');
|
||||
|
||||
expect(() => setupEnv(['.env', '.env.missing'])).toThrow();
|
||||
expect(dotenvConfigSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should filter out empty strings from array', () => {
|
||||
setupEnv(['', '.env', ' ']);
|
||||
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledWith({
|
||||
path: '.env',
|
||||
override: true,
|
||||
quiet: true,
|
||||
});
|
||||
expect(loggerInfoSpy).toHaveBeenCalledWith('Loading environment variables from .env');
|
||||
});
|
||||
|
||||
it('should call default dotenv.config when array contains only empty strings', () => {
|
||||
setupEnv(['', ' ', '']);
|
||||
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledWith({ quiet: true });
|
||||
});
|
||||
|
||||
it('should call default dotenv.config when given empty array', () => {
|
||||
setupEnv([]);
|
||||
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledWith({ quiet: true });
|
||||
});
|
||||
|
||||
it('should expand comma-separated values within array elements', () => {
|
||||
// This simulates what Commander passes when using --env-file .env,.env.local
|
||||
setupEnv(['.env,.env.local']);
|
||||
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledTimes(1);
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledWith({
|
||||
path: ['.env', '.env.local'],
|
||||
override: true,
|
||||
quiet: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed array with some comma-separated and some individual paths', () => {
|
||||
setupEnv(['.env,.env.local', '.env.production']);
|
||||
|
||||
expect(dotenvConfigSpy).toHaveBeenCalledWith({
|
||||
path: ['.env', '.env.local', '.env.production'],
|
||||
override: true,
|
||||
quiet: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import fs from 'fs';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest';
|
||||
import logger from '../../../src/logger';
|
||||
import { printErrorInformation } from '../../../src/util/errors';
|
||||
|
||||
vi.mock('fs');
|
||||
|
||||
describe('printErrorInformation', () => {
|
||||
let infoSpy: MockInstance<typeof logger.info>;
|
||||
let debugSpy: MockInstance<typeof logger.debug>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => logger);
|
||||
debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => logger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does nothing when no errorLogFile is provided', () => {
|
||||
printErrorInformation();
|
||||
|
||||
expect(infoSpy).not.toHaveBeenCalled();
|
||||
expect(fs.statSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when errorLogFile is an empty string', () => {
|
||||
printErrorInformation('');
|
||||
|
||||
expect(infoSpy).not.toHaveBeenCalled();
|
||||
expect(fs.statSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the error log file does not exist (ENOENT)', () => {
|
||||
const enoent = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
enoent.code = 'ENOENT';
|
||||
vi.mocked(fs.statSync).mockImplementation(() => {
|
||||
throw enoent;
|
||||
});
|
||||
|
||||
printErrorInformation('/missing/error.log');
|
||||
|
||||
expect(infoSpy).not.toHaveBeenCalled();
|
||||
// ENOENT should not be logged as debug noise
|
||||
expect(debugSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the error log file exists but is empty', () => {
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isFile: () => true,
|
||||
size: 0,
|
||||
} as fs.Stats);
|
||||
|
||||
printErrorInformation('/logs/error.log');
|
||||
|
||||
expect(infoSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the path exists but is not a file', () => {
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isFile: () => false,
|
||||
size: 1024,
|
||||
} as fs.Stats);
|
||||
|
||||
printErrorInformation('/logs');
|
||||
|
||||
expect(infoSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs a debug message for unexpected fs errors (e.g. EACCES)', () => {
|
||||
const eacces = new Error('EACCES') as NodeJS.ErrnoException;
|
||||
eacces.code = 'EACCES';
|
||||
vi.mocked(fs.statSync).mockImplementation(() => {
|
||||
throw eacces;
|
||||
});
|
||||
|
||||
printErrorInformation('/logs/error.log');
|
||||
|
||||
expect(infoSpy).not.toHaveBeenCalled();
|
||||
expect(debugSpy).toHaveBeenCalledTimes(1);
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'[errorFileHasContents] Error checking if file has contents: /logs/error.log',
|
||||
),
|
||||
{ error: eacces },
|
||||
);
|
||||
});
|
||||
|
||||
it('prints only the error log path when no debug log is provided', () => {
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isFile: () => true,
|
||||
size: 42,
|
||||
} as fs.Stats);
|
||||
|
||||
printErrorInformation('/logs/error.log');
|
||||
|
||||
expect(infoSpy).toHaveBeenCalledTimes(1);
|
||||
const message = infoSpy.mock.calls[0][0] as string;
|
||||
expect(message).toContain('There were some errors during the operation');
|
||||
expect(message).toContain('/logs/error.log');
|
||||
expect(message).not.toContain('Debug log:');
|
||||
});
|
||||
|
||||
it('prints both error and debug log paths when both are provided', () => {
|
||||
vi.mocked(fs.statSync).mockReturnValue({
|
||||
isFile: () => true,
|
||||
size: 42,
|
||||
} as fs.Stats);
|
||||
|
||||
printErrorInformation('/logs/error.log', '/logs/debug.log');
|
||||
|
||||
expect(infoSpy).toHaveBeenCalledTimes(1);
|
||||
const message = infoSpy.mock.calls[0][0] as string;
|
||||
expect(message).toContain('Error log:');
|
||||
expect(message).toContain('/logs/error.log');
|
||||
expect(message).toContain('Debug log:');
|
||||
expect(message).toContain('/logs/debug.log');
|
||||
});
|
||||
|
||||
it('ignores debugLogFile when errorLogFile is missing/empty', () => {
|
||||
printErrorInformation(undefined, '/logs/debug.log');
|
||||
|
||||
expect(infoSpy).not.toHaveBeenCalled();
|
||||
expect(fs.statSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,734 @@
|
||||
/**
|
||||
* Test for the --filter-failing bug where test.vars gets mutated
|
||||
* during evaluation, causing mismatches when trying to filter failing tests.
|
||||
*
|
||||
* Bug 1: When evaluation adds runtime vars like _conversation to test.vars,
|
||||
* it mutates the original test object. This mutated test is stored in results.
|
||||
* On subsequent runs with --filter-failing, freshly loaded tests don't have
|
||||
* these runtime vars, so deepEqual comparison fails.
|
||||
*
|
||||
* Bug 2: Multi-turn strategy providers (GOAT, Crescendo, SIMBA) add sessionId
|
||||
* to vars during execution, causing the same mismatch issue.
|
||||
*
|
||||
* Fix: We filter out known runtime vars (_conversation, sessionId) during test
|
||||
* matching in resultIsForTestCase, and create a shallow copy of test.vars in
|
||||
* runEval to prevent mutation by reference.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import Eval from '../../../src/models/eval';
|
||||
import { ResultFailureReason } from '../../../src/types/index';
|
||||
import { filterTests } from '../../../src/util/eval/filterTests';
|
||||
|
||||
import type { TestSuite } from '../../../src/types/index';
|
||||
|
||||
vi.mock('../../../src/models/eval', () => ({
|
||||
default: {
|
||||
findById: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('filterTests - vars mutation bug', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should match tests even when stored results have additional runtime vars', async () => {
|
||||
// Simulate a test suite as it would be loaded from config
|
||||
const mockTestSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [
|
||||
{
|
||||
description: 'test1',
|
||||
vars: { input: 'hello', language: 'en' },
|
||||
assert: [],
|
||||
},
|
||||
{
|
||||
description: 'test2',
|
||||
vars: { input: 'goodbye', language: 'en' },
|
||||
assert: [],
|
||||
},
|
||||
{
|
||||
description: 'test3',
|
||||
vars: { input: 'thanks', language: 'en' },
|
||||
assert: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Simulate stored results where test.vars was mutated with _conversation
|
||||
// This is what happens in the bug - the runtime adds _conversation to vars
|
||||
const mockEval = {
|
||||
id: 'eval-123',
|
||||
createdAt: new Date().getTime(),
|
||||
config: {},
|
||||
results: [],
|
||||
resultsCount: 0,
|
||||
prompts: [],
|
||||
persisted: true,
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
timestamp: new Date().toISOString(),
|
||||
results: [
|
||||
{
|
||||
// This test failed and has the original vars
|
||||
vars: { input: 'hello', language: 'en' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: {
|
||||
description: 'test1',
|
||||
// Bug: stored testCase.vars has _conversation added
|
||||
vars: { input: 'hello', language: 'en', _conversation: [] },
|
||||
assert: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
// This test also failed (assertion failure, not error)
|
||||
vars: { input: 'thanks', language: 'en' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: {
|
||||
description: 'test3',
|
||||
// Bug: stored testCase.vars has _conversation added
|
||||
vars: { input: 'thanks', language: 'en', _conversation: [] },
|
||||
assert: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: {
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
|
||||
|
||||
// When filtering failing tests, it should match based on the original vars
|
||||
// not fail because of the extra _conversation property
|
||||
const result = await filterTests(mockTestSuite, { failing: 'eval-123' });
|
||||
|
||||
// Should find both failing tests (both have ASSERT failure reason)
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t) => t.description)).toEqual(['test1', 'test3']);
|
||||
});
|
||||
|
||||
it('should include both failures and errors when using --filter-failing', async () => {
|
||||
const mockTestSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [
|
||||
{ description: 'test1', vars: { input: 'a' }, assert: [] },
|
||||
{ description: 'test2', vars: { input: 'b' }, assert: [] },
|
||||
{ description: 'test3', vars: { input: 'c' }, assert: [] },
|
||||
],
|
||||
};
|
||||
|
||||
const mockEval = {
|
||||
id: 'eval-errors',
|
||||
createdAt: new Date().getTime(),
|
||||
config: {},
|
||||
results: [],
|
||||
resultsCount: 0,
|
||||
prompts: [],
|
||||
persisted: true,
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
timestamp: new Date().toISOString(),
|
||||
results: [
|
||||
{
|
||||
vars: { input: 'a' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT, // Actual failure
|
||||
testCase: { description: 'test1', vars: { input: 'a' }, assert: [] },
|
||||
},
|
||||
{
|
||||
vars: { input: 'b' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ERROR, // Error
|
||||
testCase: { description: 'test2', vars: { input: 'b' }, assert: [] },
|
||||
},
|
||||
{
|
||||
vars: { input: 'c' },
|
||||
success: true, // Success
|
||||
testCase: { description: 'test3', vars: { input: 'c' }, assert: [] },
|
||||
},
|
||||
],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: {
|
||||
successes: 1,
|
||||
failures: 1,
|
||||
errors: 1,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
|
||||
|
||||
// --filter-failing should return both failures AND errors (all non-successful results)
|
||||
const result = await filterTests(mockTestSuite, { failing: 'eval-errors' });
|
||||
|
||||
// Should find test1 (ASSERT failure) and test2 (ERROR), but not test3 (success)
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t) => t.description).sort()).toEqual(['test1', 'test2']);
|
||||
});
|
||||
|
||||
it('should exclude error results when using --filter-failing-only', async () => {
|
||||
const mockTestSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [
|
||||
{ description: 'test1', vars: { input: 'a' }, assert: [] },
|
||||
{ description: 'test2', vars: { input: 'b' }, assert: [] },
|
||||
{ description: 'test3', vars: { input: 'c' }, assert: [] },
|
||||
],
|
||||
};
|
||||
|
||||
const mockEval = {
|
||||
id: 'eval-errors',
|
||||
createdAt: new Date().getTime(),
|
||||
config: {},
|
||||
results: [],
|
||||
resultsCount: 0,
|
||||
prompts: [],
|
||||
persisted: true,
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
timestamp: new Date().toISOString(),
|
||||
results: [
|
||||
{
|
||||
vars: { input: 'a' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT, // Actual failure
|
||||
testCase: { description: 'test1', vars: { input: 'a' }, assert: [] },
|
||||
},
|
||||
{
|
||||
vars: { input: 'b' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ERROR, // Error, not failure
|
||||
testCase: { description: 'test2', vars: { input: 'b' }, assert: [] },
|
||||
},
|
||||
{
|
||||
vars: { input: 'c' },
|
||||
success: true, // Success
|
||||
testCase: { description: 'test3', vars: { input: 'c' }, assert: [] },
|
||||
},
|
||||
],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: {
|
||||
successes: 1,
|
||||
failures: 1,
|
||||
errors: 1,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
|
||||
|
||||
// --filter-failing-only should only return actual failures, not errors
|
||||
const result = await filterTests(mockTestSuite, { failingOnly: 'eval-errors' });
|
||||
|
||||
// Should only find test1 (the one with ASSERT failure), not test2 (error)
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].description).toBe('test1');
|
||||
});
|
||||
|
||||
it('should match tests even when stored results have sessionId added by GOAT/Crescendo providers', async () => {
|
||||
// Simulate a redteam test suite with GOAT strategy
|
||||
const mockTestSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [
|
||||
{
|
||||
description: 'goat-test-1',
|
||||
vars: { prompt: 'test attack prompt', goal: 'test goal' },
|
||||
assert: [],
|
||||
metadata: { pluginId: 'harmful', strategyId: 'goat' },
|
||||
},
|
||||
{
|
||||
description: 'goat-test-2',
|
||||
vars: { prompt: 'another attack prompt', goal: 'another goal' },
|
||||
assert: [],
|
||||
metadata: { pluginId: 'harmful', strategyId: 'goat' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Simulate stored results where vars was mutated with sessionId by GOAT provider
|
||||
const mockEval = {
|
||||
id: 'eval-456',
|
||||
createdAt: new Date().getTime(),
|
||||
config: {},
|
||||
results: [],
|
||||
resultsCount: 0,
|
||||
prompts: [],
|
||||
persisted: true,
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
timestamp: new Date().toISOString(),
|
||||
results: [
|
||||
{
|
||||
// GOAT provider added sessionId during multi-turn attack
|
||||
vars: {
|
||||
prompt: 'test attack prompt',
|
||||
goal: 'test goal',
|
||||
sessionId: 'goat-session-abc',
|
||||
},
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ERROR,
|
||||
testCase: {
|
||||
description: 'goat-test-1',
|
||||
// Stored testCase.vars has sessionId added by GOAT provider
|
||||
vars: {
|
||||
prompt: 'test attack prompt',
|
||||
goal: 'test goal',
|
||||
sessionId: 'goat-session-abc',
|
||||
},
|
||||
assert: [],
|
||||
metadata: { pluginId: 'harmful', strategyId: 'goat' },
|
||||
},
|
||||
},
|
||||
],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: {
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
errors: 1,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
|
||||
|
||||
// When filtering error tests, it should match based on the original vars
|
||||
// not fail because of the extra sessionId property added by GOAT
|
||||
const result = await filterTests(mockTestSuite, { errorsOnly: 'eval-456' });
|
||||
|
||||
// Should find the error test even though sessionId was added
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].description).toBe('goat-test-1');
|
||||
});
|
||||
|
||||
it('should match tests with both _conversation and sessionId runtime vars', async () => {
|
||||
// Combined scenario with both runtime vars
|
||||
const mockTestSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [
|
||||
{
|
||||
description: 'combined-test',
|
||||
vars: { input: 'hello' },
|
||||
assert: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockEval = {
|
||||
id: 'eval-789',
|
||||
createdAt: new Date().getTime(),
|
||||
config: {},
|
||||
results: [],
|
||||
resultsCount: 0,
|
||||
prompts: [],
|
||||
persisted: true,
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
timestamp: new Date().toISOString(),
|
||||
results: [
|
||||
{
|
||||
// Both runtime vars added during evaluation
|
||||
vars: {
|
||||
input: 'hello',
|
||||
_conversation: [{ role: 'user', content: 'hi' }],
|
||||
sessionId: 'session-xyz',
|
||||
},
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: {
|
||||
description: 'combined-test',
|
||||
vars: {
|
||||
input: 'hello',
|
||||
_conversation: [{ role: 'user', content: 'hi' }],
|
||||
sessionId: 'session-xyz',
|
||||
},
|
||||
assert: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: {
|
||||
successes: 0,
|
||||
failures: 1,
|
||||
errors: 0,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
|
||||
|
||||
const result = await filterTests(mockTestSuite, { failing: 'eval-789' });
|
||||
|
||||
// Should match even with both runtime vars present
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].description).toBe('combined-test');
|
||||
});
|
||||
|
||||
it('should match tests when stored results have defaultTest.vars merged', async () => {
|
||||
// Simulate a test suite with defaultTest.vars (as it would be loaded from config)
|
||||
// Note: Fresh tests don't have defaultTest.vars merged yet - that happens in prepareTests
|
||||
const mockTestSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
defaultTest: {
|
||||
vars: {
|
||||
systemPrompt: 'You are a helpful assistant',
|
||||
},
|
||||
},
|
||||
tests: [
|
||||
{
|
||||
description: 'test-with-defaults',
|
||||
vars: { prompt: 'first test' },
|
||||
assert: [],
|
||||
},
|
||||
{
|
||||
description: 'test-that-passed',
|
||||
vars: { prompt: 'second test' },
|
||||
assert: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Stored results have defaultTest.vars merged (as done by prepareTests in evaluator)
|
||||
const mockEval = {
|
||||
id: 'eval-default-vars',
|
||||
createdAt: new Date().getTime(),
|
||||
config: {},
|
||||
results: [],
|
||||
resultsCount: 0,
|
||||
prompts: [],
|
||||
persisted: true,
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
timestamp: new Date().toISOString(),
|
||||
results: [
|
||||
{
|
||||
// Stored vars have defaultTest.vars merged
|
||||
vars: { systemPrompt: 'You are a helpful assistant', prompt: 'first test' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: {
|
||||
description: 'test-with-defaults',
|
||||
vars: { systemPrompt: 'You are a helpful assistant', prompt: 'first test' },
|
||||
assert: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: {
|
||||
successes: 1,
|
||||
failures: 1,
|
||||
errors: 0,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
|
||||
|
||||
// filterTests should merge defaultTest.vars before comparison
|
||||
const result = await filterTests(mockTestSuite, { failing: 'eval-default-vars' });
|
||||
|
||||
// Should find the failing test even though fresh test.vars doesn't have systemPrompt
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].description).toBe('test-with-defaults');
|
||||
});
|
||||
|
||||
it('should match old results that lack defaultTest.vars via fallback', async () => {
|
||||
// This test verifies the fallback matching for old results:
|
||||
// - Config has defaultTest.vars defined
|
||||
// - But stored results were created without defaultTest.vars merged
|
||||
// - The fallback tries matching without defaults, which succeeds
|
||||
const mockTestSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
defaultTest: {
|
||||
vars: {
|
||||
systemPrompt: 'You are a helpful assistant',
|
||||
context: 'General knowledge',
|
||||
},
|
||||
},
|
||||
tests: [
|
||||
{
|
||||
description: 'emoji-test',
|
||||
vars: { prompt: '😊' + String.fromCodePoint(0xfe00) }, // Emoji encoded
|
||||
assert: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Stored results DON'T have defaultTest.vars (old version or bug)
|
||||
const mockEval = {
|
||||
id: 'eval-missing-defaults',
|
||||
createdAt: new Date().getTime(),
|
||||
config: {},
|
||||
results: [],
|
||||
resultsCount: 0,
|
||||
prompts: [],
|
||||
persisted: true,
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
timestamp: new Date().toISOString(),
|
||||
results: [
|
||||
{
|
||||
// Stored vars only have the test-specific vars, NOT the defaults
|
||||
vars: { prompt: '😊' + String.fromCodePoint(0xfe00) },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ERROR,
|
||||
testCase: {
|
||||
description: 'emoji-test',
|
||||
vars: { prompt: '😊' + String.fromCodePoint(0xfe00) },
|
||||
assert: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: {
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
errors: 1,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
|
||||
|
||||
// With the fallback, this SHOULD match because:
|
||||
// 1. First try: testCase.vars after merge: { systemPrompt, context, prompt } vs result.vars: { prompt } - NO MATCH
|
||||
// 2. Fallback: testCase.vars without merge: { prompt } vs result.vars: { prompt } - MATCH!
|
||||
const result = await filterTests(mockTestSuite, { errorsOnly: 'eval-missing-defaults' });
|
||||
|
||||
// Should find the test via fallback matching
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].description).toBe('emoji-test');
|
||||
});
|
||||
|
||||
it('should match when defaultTest.vars is empty or undefined', async () => {
|
||||
// When defaultTest has no vars, the merge should not affect matching
|
||||
const mockTestSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
defaultTest: {
|
||||
// No vars defined, or vars is empty
|
||||
},
|
||||
tests: [
|
||||
{
|
||||
description: 'basic-test',
|
||||
vars: { prompt: 'test prompt' },
|
||||
assert: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockEval = {
|
||||
id: 'eval-no-defaults',
|
||||
createdAt: new Date().getTime(),
|
||||
config: {},
|
||||
results: [],
|
||||
resultsCount: 0,
|
||||
prompts: [],
|
||||
persisted: true,
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
timestamp: new Date().toISOString(),
|
||||
results: [
|
||||
{
|
||||
vars: { prompt: 'test prompt', sessionId: 'runtime-session' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ERROR,
|
||||
testCase: {
|
||||
description: 'basic-test',
|
||||
vars: { prompt: 'test prompt' },
|
||||
assert: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: {
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
errors: 1,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
|
||||
|
||||
// Should match because:
|
||||
// - testCase.vars after merge (no defaults): { prompt }
|
||||
// - result.vars after filtering sessionId: { prompt }
|
||||
const result = await filterTests(mockTestSuite, { errorsOnly: 'eval-no-defaults' });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].description).toBe('basic-test');
|
||||
});
|
||||
|
||||
it('should combine failingOnly and errorsOnly filters (union) when both are provided', async () => {
|
||||
// When both --filter-failing-only and --filter-errors-only are used, combine results
|
||||
const mockTestSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [
|
||||
{
|
||||
description: 'error-test',
|
||||
vars: { prompt: 'error prompt' },
|
||||
assert: [],
|
||||
},
|
||||
{
|
||||
description: 'failure-test',
|
||||
vars: { prompt: 'failure prompt' },
|
||||
assert: [],
|
||||
},
|
||||
{
|
||||
description: 'passing-test',
|
||||
vars: { prompt: 'passing prompt' },
|
||||
assert: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockEval = {
|
||||
id: 'eval-combined',
|
||||
createdAt: new Date().getTime(),
|
||||
config: {},
|
||||
results: [],
|
||||
resultsCount: 0,
|
||||
prompts: [],
|
||||
persisted: true,
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
timestamp: new Date().toISOString(),
|
||||
results: [
|
||||
{
|
||||
vars: { prompt: 'error prompt' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ERROR,
|
||||
testCase: { description: 'error-test', vars: { prompt: 'error prompt' }, assert: [] },
|
||||
},
|
||||
{
|
||||
vars: { prompt: 'failure prompt' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: {
|
||||
description: 'failure-test',
|
||||
vars: { prompt: 'failure prompt' },
|
||||
assert: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
vars: { prompt: 'passing prompt' },
|
||||
success: true,
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
testCase: {
|
||||
description: 'passing-test',
|
||||
vars: { prompt: 'passing prompt' },
|
||||
assert: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: {
|
||||
successes: 1,
|
||||
failures: 1,
|
||||
errors: 1,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
|
||||
|
||||
// Using both filters should return union of failingOnly + errors (but not passing)
|
||||
const result = await filterTests(mockTestSuite, {
|
||||
failingOnly: 'eval-combined',
|
||||
errorsOnly: 'eval-combined',
|
||||
});
|
||||
|
||||
// Should find both error-test and failure-test (union), but not passing-test
|
||||
expect(result).toHaveLength(2);
|
||||
const descriptions = result.map((t) => t.description).sort();
|
||||
expect(descriptions).toEqual(['error-test', 'failure-test']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterPrompts } from '../../../src/util/eval/filterPrompts';
|
||||
|
||||
import type { Prompt } from '../../../src/types/index';
|
||||
|
||||
describe('filterPrompts', () => {
|
||||
const mockPrompts: Prompt[] = [
|
||||
{
|
||||
id: 'prompt-1',
|
||||
raw: 'Hello {{name}}',
|
||||
label: 'Greeting Prompt',
|
||||
},
|
||||
{
|
||||
id: 'prompt-2',
|
||||
raw: 'Goodbye {{name}}',
|
||||
label: 'Farewell Prompt',
|
||||
},
|
||||
{
|
||||
id: 'prompt-3',
|
||||
raw: 'How are you?',
|
||||
label: 'Question Prompt',
|
||||
},
|
||||
{
|
||||
id: 'prompt-4',
|
||||
raw: 'System message',
|
||||
label: 'System:Admin',
|
||||
},
|
||||
{
|
||||
id: 'prompt-5',
|
||||
raw: 'No label here',
|
||||
label: '', // Empty label
|
||||
},
|
||||
];
|
||||
|
||||
it('should return all prompts if no filter is provided', () => {
|
||||
const result = filterPrompts(mockPrompts);
|
||||
expect(result).toEqual(mockPrompts);
|
||||
});
|
||||
|
||||
it('should filter prompts by exact label match', () => {
|
||||
const result = filterPrompts(mockPrompts, 'Greeting Prompt');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].label).toBe('Greeting Prompt');
|
||||
});
|
||||
|
||||
it('should filter prompts by partial label match', () => {
|
||||
const result = filterPrompts(mockPrompts, 'Prompt');
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((p) => p.label)).toEqual([
|
||||
'Greeting Prompt',
|
||||
'Farewell Prompt',
|
||||
'Question Prompt',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter prompts by id', () => {
|
||||
const result = filterPrompts(mockPrompts, 'prompt-1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('prompt-1');
|
||||
});
|
||||
|
||||
it('should filter prompts by id pattern', () => {
|
||||
const result = filterPrompts(mockPrompts, 'prompt-[12]');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((p) => p.id)).toEqual(['prompt-1', 'prompt-2']);
|
||||
});
|
||||
|
||||
it('should match on either id or label', () => {
|
||||
const mixedPrompts: Prompt[] = [
|
||||
{ id: 'greeting-v1', raw: 'Hello', label: 'Greeting' },
|
||||
{ id: 'farewell-v1', raw: 'Goodbye', label: 'Farewell' },
|
||||
];
|
||||
const result1 = filterPrompts(mixedPrompts, 'v1');
|
||||
expect(result1).toHaveLength(2);
|
||||
const result2 = filterPrompts(mixedPrompts, 'Greeting');
|
||||
expect(result2).toHaveLength(1);
|
||||
expect(result2[0].label).toBe('Greeting');
|
||||
});
|
||||
|
||||
it('should handle regex patterns', () => {
|
||||
const result = filterPrompts(mockPrompts, '(Greeting|Farewell)');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((p) => p.label)).toEqual(['Greeting Prompt', 'Farewell Prompt']);
|
||||
});
|
||||
|
||||
it('should handle case-sensitive regex', () => {
|
||||
const result = filterPrompts(mockPrompts, 'greeting');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle case-sensitive matching (default behavior)', () => {
|
||||
// JavaScript RegExp is case-sensitive by default
|
||||
// Users can use [Gg] or similar patterns for case-insensitive matching
|
||||
const result = filterPrompts(mockPrompts, '[Gg]reeting');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].label).toBe('Greeting Prompt');
|
||||
});
|
||||
|
||||
it('should return empty array if no prompts match filter', () => {
|
||||
const result = filterPrompts(mockPrompts, 'nonexistent');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle prompts with empty labels', () => {
|
||||
const result = filterPrompts(mockPrompts, 'prompt-');
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should handle prompts without label property', () => {
|
||||
const promptsWithoutLabel: Prompt[] = [
|
||||
{
|
||||
id: 'test-1',
|
||||
raw: 'Test',
|
||||
label: 'Valid Label',
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
raw: 'Test 2',
|
||||
label: undefined as any, // Simulate missing label
|
||||
},
|
||||
];
|
||||
const result1 = filterPrompts(promptsWithoutLabel, 'Valid');
|
||||
expect(result1).toHaveLength(1);
|
||||
expect(result1[0].label).toBe('Valid Label');
|
||||
const result2 = filterPrompts(promptsWithoutLabel, 'test-');
|
||||
expect(result2).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle special regex characters in labels', () => {
|
||||
const result = filterPrompts(mockPrompts, 'System:Admin');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].label).toBe('System:Admin');
|
||||
});
|
||||
|
||||
it('should handle complex regex patterns', () => {
|
||||
const result = filterPrompts(mockPrompts, '^(Greeting|Question).*');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((p) => p.label)).toEqual(['Greeting Prompt', 'Question Prompt']);
|
||||
});
|
||||
|
||||
it('should handle empty prompts array', () => {
|
||||
const result = filterPrompts([], 'anything');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw on invalid regex with helpful error message', () => {
|
||||
expect(() => filterPrompts(mockPrompts, '[invalid')).toThrow(
|
||||
'Invalid regex pattern for --filter-prompts: "[invalid"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on invalid regex with multiple patterns', () => {
|
||||
expect(() => filterPrompts(mockPrompts, '((')).toThrow(
|
||||
'Invalid regex pattern for --filter-prompts',
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter with word boundaries', () => {
|
||||
const result = filterPrompts(mockPrompts, '\\bPrompt\\b');
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
filterProviderConfigs,
|
||||
filterProviders,
|
||||
getPersistedProviderFilterOptions,
|
||||
getProviderFilterRegexError,
|
||||
} from '../../../src/util/eval/filterProviders';
|
||||
|
||||
import type { ApiProvider, TestSuiteConfig } from '../../../src/types/index';
|
||||
import type { ProviderOptions, ProviderOptionsMap } from '../../../src/types/providers';
|
||||
|
||||
describe('getPersistedProviderFilterOptions', () => {
|
||||
it('converts a persisted filter into config resolution options', () => {
|
||||
expect(getPersistedProviderFilterOptions('selected-target')).toEqual({
|
||||
filterProviders: 'selected-target',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, null])('treats a missing persisted filter as no filter: %j', (value) => {
|
||||
expect(getPersistedProviderFilterOptions(value)).toEqual({});
|
||||
});
|
||||
|
||||
it.each(['', 42, {}, ['echo']])('fails closed on a tampered persisted filter: %j', (value) => {
|
||||
expect(() => getPersistedProviderFilterOptions(value)).toThrow(
|
||||
'Stored provider filter is invalid',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderFilterRegexError', () => {
|
||||
it('returns undefined for a valid pattern', () => {
|
||||
expect(getProviderFilterRegexError('selected-.*')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the reason for an invalid pattern', () => {
|
||||
expect(getProviderFilterRegexError('[')).toContain('Invalid regular expression');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterProviders', () => {
|
||||
const mockProviders: ApiProvider[] = [
|
||||
{
|
||||
id: () => 'openai:gpt-4',
|
||||
label: 'GPT-4',
|
||||
callApi: async () => ({ output: '' }),
|
||||
},
|
||||
{
|
||||
id: () => 'openai:gpt-3.5-turbo',
|
||||
label: 'GPT-3.5',
|
||||
callApi: async () => ({ output: '' }),
|
||||
},
|
||||
{
|
||||
id: () => 'anthropic:claude-2',
|
||||
label: 'Claude',
|
||||
callApi: async () => ({ output: '' }),
|
||||
},
|
||||
{
|
||||
id: () => 'custom:provider',
|
||||
callApi: async () => ({ output: '' }),
|
||||
// No label
|
||||
},
|
||||
];
|
||||
|
||||
it('should return all providers if no filter is provided', () => {
|
||||
const result = filterProviders(mockProviders);
|
||||
expect(result).toEqual(mockProviders);
|
||||
});
|
||||
|
||||
it('should filter providers by ID', () => {
|
||||
const result = filterProviders(mockProviders, 'openai');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((p) => p.id())).toEqual(['openai:gpt-4', 'openai:gpt-3.5-turbo']);
|
||||
});
|
||||
|
||||
it('should filter providers by label', () => {
|
||||
const result = filterProviders(mockProviders, 'GPT');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((p) => p.label)).toEqual(['GPT-4', 'GPT-3.5']);
|
||||
});
|
||||
|
||||
it('should handle providers without labels', () => {
|
||||
const result = filterProviders(mockProviders, 'custom');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id()).toBe('custom:provider');
|
||||
});
|
||||
|
||||
it('should handle regex patterns', () => {
|
||||
const result = filterProviders(mockProviders, '(gpt|claude)');
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((p) => p.id())).toEqual([
|
||||
'openai:gpt-4',
|
||||
'openai:gpt-3.5-turbo',
|
||||
'anthropic:claude-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array if no providers match filter', () => {
|
||||
const result = filterProviders(mockProviders, 'nonexistent');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle case sensitivity', () => {
|
||||
const result = filterProviders(mockProviders, 'GPT');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterProviderConfigs', () => {
|
||||
describe('string providers', () => {
|
||||
it('should return the string if it matches the filter', () => {
|
||||
const result = filterProviderConfigs('openai:gpt-4', 'openai');
|
||||
expect(result).toBe('openai:gpt-4');
|
||||
});
|
||||
|
||||
it('should return empty array if string does not match', () => {
|
||||
const result = filterProviderConfigs('openai:gpt-4', 'anthropic');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle regex patterns for strings', () => {
|
||||
const result = filterProviderConfigs('openai:gpt-4', 'gpt-[0-9]');
|
||||
expect(result).toBe('openai:gpt-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('function providers', () => {
|
||||
it('should filter function provider by label', () => {
|
||||
const fn = Object.assign(async () => ({ output: '' }), { label: 'MyFunction' });
|
||||
const result = filterProviderConfigs(fn, 'MyFunction');
|
||||
expect(result).toBe(fn);
|
||||
});
|
||||
|
||||
it('should filter function provider without label by default id', () => {
|
||||
const fn = async () => ({ output: '' });
|
||||
const result = filterProviderConfigs(fn, 'custom-function');
|
||||
expect(result).toBe(fn);
|
||||
});
|
||||
|
||||
it('should return empty array if function label does not match', () => {
|
||||
const fn = Object.assign(async () => ({ output: '' }), { label: 'MyFunction' });
|
||||
const result = filterProviderConfigs(fn, 'OtherFunction');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('array of providers', () => {
|
||||
it('should return all providers if no filter is provided', () => {
|
||||
const providers = ['openai:gpt-4', 'anthropic:claude-2'];
|
||||
const result = filterProviderConfigs(providers);
|
||||
expect(result).toEqual(providers);
|
||||
});
|
||||
|
||||
it('should filter string providers in array', () => {
|
||||
const providers = ['openai:gpt-4', 'openai:gpt-3.5-turbo', 'anthropic:claude-2'];
|
||||
const result = filterProviderConfigs(providers, 'openai');
|
||||
expect(result).toEqual(['openai:gpt-4', 'openai:gpt-3.5-turbo']);
|
||||
});
|
||||
|
||||
it('should filter ProviderOptions by id', () => {
|
||||
const providers: ProviderOptions[] = [
|
||||
{ id: 'openai:gpt-4', label: 'GPT-4' },
|
||||
{ id: 'anthropic:claude-2', label: 'Claude' },
|
||||
];
|
||||
const result = filterProviderConfigs(providers, 'openai');
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result as ProviderOptions[])[0].id).toBe('openai:gpt-4');
|
||||
});
|
||||
|
||||
it('should filter ProviderOptions by label', () => {
|
||||
const providers: ProviderOptions[] = [
|
||||
{ id: 'openai:gpt-4', label: 'Dev' },
|
||||
{ id: 'openai:gpt-4', label: 'Stage' },
|
||||
{ id: 'openai:gpt-4', label: 'Prod' },
|
||||
];
|
||||
const result = filterProviderConfigs(providers, 'Dev');
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result as ProviderOptions[])[0].label).toBe('Dev');
|
||||
});
|
||||
|
||||
it('should filter ProviderOptionsMap by key (provider id)', () => {
|
||||
const providers: ProviderOptionsMap[] = [
|
||||
{ 'openai:gpt-4': { label: 'Dev' } },
|
||||
{ 'anthropic:claude-2': { label: 'Stage' } },
|
||||
];
|
||||
const result = filterProviderConfigs(providers, 'openai');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should filter ProviderOptionsMap by nested label', () => {
|
||||
const providers: ProviderOptionsMap[] = [
|
||||
{ 'openai:gpt-4': { label: 'Dev' } },
|
||||
{ 'openai:gpt-4': { label: 'Stage' } },
|
||||
];
|
||||
const result = filterProviderConfigs(providers, 'Stage');
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result as ProviderOptionsMap[])[0]['openai:gpt-4'].label).toBe('Stage');
|
||||
});
|
||||
|
||||
it('should handle mixed array of provider types', () => {
|
||||
const fn = Object.assign(async () => ({ output: '' }), { label: 'CustomDev' });
|
||||
const providers: TestSuiteConfig['providers'] = [
|
||||
'openai:gpt-4',
|
||||
{ id: 'anthropic:claude-2', label: 'ClaudeDev' },
|
||||
{ 'google:gemini': { label: 'GeminiDev' } } as ProviderOptionsMap,
|
||||
fn,
|
||||
];
|
||||
const result = filterProviderConfigs(providers, 'Dev');
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return empty array if no providers match', () => {
|
||||
const providers = ['openai:gpt-4', 'anthropic:claude-2'];
|
||||
const result = filterProviderConfigs(providers, 'nonexistent');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex patterns', () => {
|
||||
it('should support complex regex patterns', () => {
|
||||
const providers: ProviderOptions[] = [
|
||||
{ id: 'openai:gpt-4', label: 'Dev-US' },
|
||||
{ id: 'openai:gpt-4', label: 'Dev-EU' },
|
||||
{ id: 'openai:gpt-4', label: 'Prod-US' },
|
||||
];
|
||||
const result = filterProviderConfigs(providers, 'Dev-.*');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should match either id or label with regex', () => {
|
||||
const providers: ProviderOptions[] = [
|
||||
{ id: 'openai:gpt-4', label: 'Production' },
|
||||
{ id: 'anthropic:claude-sonnet', label: 'Dev' },
|
||||
];
|
||||
const result = filterProviderConfigs(providers, '(gpt|sonnet)');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty array', () => {
|
||||
const result = filterProviderConfigs([], 'anything');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle provider without label filtering by id only', () => {
|
||||
const providers: ProviderOptions[] = [{ id: 'openai:gpt-4' }];
|
||||
const result = filterProviderConfigs(providers, 'gpt-4');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle ProviderOptionsMap with overridden id', () => {
|
||||
const providers: ProviderOptionsMap[] = [
|
||||
{ 'file://custom.js': { id: 'my-custom-id', label: 'Dev' } },
|
||||
];
|
||||
// Should match the overridden id, not the key
|
||||
const result = filterProviderConfigs(providers, 'my-custom-id');
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should filter HTTP providers with config by label (bug report case)', () => {
|
||||
const providers: ProviderOptions[] = [
|
||||
{
|
||||
id: 'https',
|
||||
label: 'direct-haiku-4.5',
|
||||
config: {
|
||||
url: 'http://localhost:3333/assistant',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: '{{acceptHeader}}',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Assistant-Model': 'anthropic:claude-haiku-4-5-20251001',
|
||||
'X-Conversation-ID': '{{conversationId}}',
|
||||
'X-System-ID': '{{systemId}}',
|
||||
},
|
||||
body: {
|
||||
prompt: '{{prompt}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'https',
|
||||
label: 'haiku-4.5',
|
||||
config: {
|
||||
url: 'http://localhost:3333/assistant',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: '{{acceptHeader}}',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Assistant-Model': 'openrouter:anthropic/claude-haiku-4.5',
|
||||
'X-Conversation-ID': '{{conversationId}}',
|
||||
'X-System-ID': '{{systemId}}',
|
||||
},
|
||||
body: {
|
||||
prompt: '{{prompt}}',
|
||||
},
|
||||
transformResponse: 'json',
|
||||
},
|
||||
},
|
||||
];
|
||||
const result = filterProviderConfigs(providers, 'direct-haiku-4.5');
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result as ProviderOptions[])[0].label).toBe('direct-haiku-4.5');
|
||||
});
|
||||
|
||||
it('should handle provider with null or empty id', () => {
|
||||
const providers: Array<Partial<ProviderOptions>> = [
|
||||
{ id: null as any, label: 'Provider1', config: {} },
|
||||
{ id: '', label: 'Provider2', config: {} },
|
||||
{ id: undefined, label: 'Provider3', config: {} },
|
||||
];
|
||||
const result = filterProviderConfigs(providers as any, 'Provider2');
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result as Array<Partial<ProviderOptions>>)[0].label).toBe('Provider2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,797 @@
|
||||
import path from 'path';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import logger from '../../../src/logger';
|
||||
import Eval from '../../../src/models/eval';
|
||||
import { ResultFailureReason } from '../../../src/types/index';
|
||||
import { filterTests } from '../../../src/util/eval/filterTests';
|
||||
|
||||
import type { TestCase, TestSuite } from '../../../src/types/index';
|
||||
|
||||
vi.mock('../../../src/models/eval', () => ({
|
||||
default: {
|
||||
findById: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('filterTests', () => {
|
||||
const mockTestSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [
|
||||
{
|
||||
description: 'test1',
|
||||
vars: { var1: 'test1' },
|
||||
assert: [],
|
||||
metadata: { type: 'unit' },
|
||||
},
|
||||
{
|
||||
description: 'test2',
|
||||
vars: { var1: 'test2' },
|
||||
assert: [],
|
||||
metadata: { type: 'integration' },
|
||||
},
|
||||
{
|
||||
description: 'test3',
|
||||
vars: { var1: 'test3' },
|
||||
assert: [],
|
||||
metadata: { type: 'unit' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
const mockEval = {
|
||||
id: 'eval-123',
|
||||
createdAt: new Date().getTime(),
|
||||
config: {},
|
||||
results: [],
|
||||
resultsCount: 0,
|
||||
prompts: [],
|
||||
persisted: true,
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
timestamp: new Date().toISOString(),
|
||||
results: [
|
||||
{
|
||||
vars: { var1: 'test1' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: mockTestSuite.tests![0],
|
||||
},
|
||||
{
|
||||
vars: { var1: 'test2' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ERROR,
|
||||
testCase: mockTestSuite.tests![1],
|
||||
},
|
||||
{
|
||||
vars: { var1: 'test3' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: mockTestSuite.tests![2],
|
||||
},
|
||||
],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: {
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: {
|
||||
reasoning: 0,
|
||||
acceptedPrediction: 0,
|
||||
rejectedPrediction: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
|
||||
});
|
||||
|
||||
it('should return all tests if no options provided', async () => {
|
||||
const result = await filterTests(mockTestSuite, {});
|
||||
expect(result).toEqual(mockTestSuite.tests);
|
||||
});
|
||||
|
||||
it('should return empty array if testSuite has no tests', async () => {
|
||||
const result = await filterTests({ prompts: [], providers: [] }, {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
describe('metadata filter', () => {
|
||||
it('should filter tests by metadata key-value pair', async () => {
|
||||
const result = await filterTests(mockTestSuite, { metadata: 'type=unit' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t: TestCase) => t.vars?.var1)).toEqual(['test1', 'test3']);
|
||||
});
|
||||
|
||||
it('should throw error if metadata filter format is invalid', async () => {
|
||||
await expect(filterTests(mockTestSuite, { metadata: 'invalid' })).rejects.toThrow(
|
||||
'--filter-metadata must be specified in key=value format',
|
||||
);
|
||||
});
|
||||
|
||||
it('should exclude tests without metadata', async () => {
|
||||
const testSuite = {
|
||||
...mockTestSuite,
|
||||
tests: [...mockTestSuite.tests!, { description: 'no-metadata', vars: {}, assert: [] }],
|
||||
};
|
||||
const result = await filterTests(testSuite, { metadata: 'type=unit' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t: TestCase) => t.vars?.var1)).toEqual(['test1', 'test3']);
|
||||
});
|
||||
|
||||
describe('multiple metadata filters', () => {
|
||||
const multiMetadataTestSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [
|
||||
{
|
||||
description: 'test1',
|
||||
vars: { var1: 'test1' },
|
||||
assert: [],
|
||||
metadata: { type: 'unit', env: 'dev', priority: 'high' },
|
||||
},
|
||||
{
|
||||
description: 'test2',
|
||||
vars: { var1: 'test2' },
|
||||
assert: [],
|
||||
metadata: { type: 'unit', env: 'prod', priority: 'low' },
|
||||
},
|
||||
{
|
||||
description: 'test3',
|
||||
vars: { var1: 'test3' },
|
||||
assert: [],
|
||||
metadata: { type: 'integration', env: 'dev', priority: 'high' },
|
||||
},
|
||||
{
|
||||
description: 'test4',
|
||||
vars: { var1: 'test4' },
|
||||
assert: [],
|
||||
metadata: { type: 'integration', env: 'prod', priority: 'medium' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('should filter tests matching ALL metadata conditions (AND logic)', async () => {
|
||||
const result = await filterTests(multiMetadataTestSuite, {
|
||||
metadata: ['type=unit', 'env=dev'],
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.vars?.var1).toBe('test1');
|
||||
});
|
||||
|
||||
it('should return empty when no tests match all conditions', async () => {
|
||||
const result = await filterTests(multiMetadataTestSuite, {
|
||||
metadata: ['type=unit', 'env=staging'],
|
||||
});
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle single string value (backward compatibility)', async () => {
|
||||
const result = await filterTests(multiMetadataTestSuite, {
|
||||
metadata: 'type=unit',
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t: TestCase) => t.vars?.var1)).toEqual(['test1', 'test2']);
|
||||
});
|
||||
|
||||
it('should handle array with single element', async () => {
|
||||
const result = await filterTests(multiMetadataTestSuite, {
|
||||
metadata: ['type=unit'],
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t: TestCase) => t.vars?.var1)).toEqual(['test1', 'test2']);
|
||||
});
|
||||
|
||||
it('should filter with three or more conditions', async () => {
|
||||
const result = await filterTests(multiMetadataTestSuite, {
|
||||
metadata: ['type=unit', 'env=dev', 'priority=high'],
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.vars?.var1).toBe('test1');
|
||||
});
|
||||
|
||||
it('should handle values containing equals sign', async () => {
|
||||
const testSuiteWithEquals: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [
|
||||
{
|
||||
description: 'test-with-equals',
|
||||
vars: { var1: 'test1' },
|
||||
assert: [],
|
||||
metadata: { query: 'a=1&b=2', type: 'special' },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await filterTests(testSuiteWithEquals, {
|
||||
metadata: ['query=a=1&b=2'],
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should throw error if any filter in array is invalid', async () => {
|
||||
await expect(
|
||||
filterTests(multiMetadataTestSuite, {
|
||||
metadata: ['type=unit', 'invalid'],
|
||||
}),
|
||||
).rejects.toThrow('--filter-metadata must be specified in key=value format');
|
||||
});
|
||||
|
||||
it('should throw error for empty value', async () => {
|
||||
await expect(
|
||||
filterTests(multiMetadataTestSuite, {
|
||||
metadata: ['type='],
|
||||
}),
|
||||
).rejects.toThrow('--filter-metadata must be specified in key=value format');
|
||||
});
|
||||
|
||||
it('should exclude tests missing any of the required metadata keys', async () => {
|
||||
const testSuitePartialMetadata: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [
|
||||
{
|
||||
description: 'has-both',
|
||||
vars: { var1: 'test1' },
|
||||
assert: [],
|
||||
metadata: { type: 'unit', env: 'dev' },
|
||||
},
|
||||
{
|
||||
description: 'missing-env',
|
||||
vars: { var1: 'test2' },
|
||||
assert: [],
|
||||
metadata: { type: 'unit' },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await filterTests(testSuitePartialMetadata, {
|
||||
metadata: ['type=unit', 'env=dev'],
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.vars?.var1).toBe('test1');
|
||||
});
|
||||
|
||||
it('should work with array metadata values', async () => {
|
||||
const testSuiteArrayMetadata: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [
|
||||
{
|
||||
description: 'has-security-tag',
|
||||
vars: { var1: 'test1' },
|
||||
assert: [],
|
||||
metadata: { type: 'unit', tags: ['security', 'auth'] },
|
||||
},
|
||||
{
|
||||
description: 'no-security-tag',
|
||||
vars: { var1: 'test2' },
|
||||
assert: [],
|
||||
metadata: { type: 'unit', tags: ['performance'] },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await filterTests(testSuiteArrayMetadata, {
|
||||
metadata: ['type=unit', 'tags=security'],
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.vars?.var1).toBe('test1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('failing filter', () => {
|
||||
it('should filter failing tests when failing option is provided', async () => {
|
||||
// --filter-failing returns all non-successful results (failures + errors)
|
||||
const result = await filterTests(mockTestSuite, { failing: 'eval-123' });
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((t: TestCase) => t.vars?.var1)).toEqual(['test1', 'test2', 'test3']);
|
||||
});
|
||||
|
||||
it('should match failing tests when provider paths differ', async () => {
|
||||
vi.resetAllMocks();
|
||||
const absPath = path.join(process.cwd(), 'provider.js');
|
||||
const mockEval = {
|
||||
id: 'eval-123',
|
||||
createdAt: new Date().getTime(),
|
||||
config: {},
|
||||
results: [],
|
||||
resultsCount: 0,
|
||||
prompts: [],
|
||||
persisted: true,
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
timestamp: new Date().toISOString(),
|
||||
results: [
|
||||
{
|
||||
vars: { var1: 'test1' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
provider: { id: `file://${absPath}` },
|
||||
testCase: mockTestSuite.tests![0],
|
||||
},
|
||||
],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: {
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
|
||||
|
||||
const testSuite = {
|
||||
...mockTestSuite,
|
||||
tests: [
|
||||
{
|
||||
...mockTestSuite.tests![0],
|
||||
provider: `file://./provider.js`,
|
||||
},
|
||||
],
|
||||
} as TestSuite;
|
||||
|
||||
const result = await filterTests(testSuite, { failing: 'eval-123' });
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errors only filter', () => {
|
||||
it('should filter error tests when errorsOnly option is provided', async () => {
|
||||
const result = await filterTests(mockTestSuite, { errorsOnly: 'eval-123' });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.vars?.var1).toBe('test2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('failing only filter', () => {
|
||||
it('should filter assertion failures only, excluding errors', async () => {
|
||||
// --filter-failing-only returns only assertion failures, not errors
|
||||
const result = await filterTests(mockTestSuite, { failingOnly: 'eval-123' });
|
||||
expect(result).toHaveLength(2);
|
||||
// test1 and test3 have ASSERT failures, test2 has ERROR
|
||||
expect(result.map((t: TestCase) => t.vars?.var1)).toEqual(['test1', 'test3']);
|
||||
});
|
||||
|
||||
it('should return empty when all failures are errors', async () => {
|
||||
vi.resetAllMocks();
|
||||
const mockEval = {
|
||||
id: 'eval-456',
|
||||
createdAt: new Date().getTime(),
|
||||
config: {},
|
||||
results: [],
|
||||
resultsCount: 0,
|
||||
prompts: [],
|
||||
persisted: true,
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
version: 2,
|
||||
timestamp: new Date().toISOString(),
|
||||
results: [
|
||||
{
|
||||
vars: { var1: 'test1' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ERROR,
|
||||
testCase: mockTestSuite.tests![0],
|
||||
},
|
||||
{
|
||||
vars: { var1: 'test2' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ERROR,
|
||||
testCase: mockTestSuite.tests![1],
|
||||
},
|
||||
],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: {
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
errors: 2,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: { reasoning: 0, acceptedPrediction: 0, rejectedPrediction: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(Eval.findById).mockResolvedValue(mockEval as any);
|
||||
|
||||
const result = await filterTests(mockTestSuite, { failingOnly: 'eval-456' });
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should combine failingOnly and errorsOnly when both provided', async () => {
|
||||
// When both are provided, it should be a union of assertion failures and errors
|
||||
const result = await filterTests(mockTestSuite, {
|
||||
failingOnly: 'eval-123',
|
||||
errorsOnly: 'eval-123',
|
||||
});
|
||||
// Should include all 3 tests: test1 (ASSERT), test2 (ERROR), test3 (ASSERT)
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((t: TestCase) => t.vars?.var1)).toEqual(
|
||||
expect.arrayContaining(['test1', 'test2', 'test3']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pattern filter', () => {
|
||||
it('should filter tests by description pattern', async () => {
|
||||
const result = await filterTests(mockTestSuite, { pattern: 'test[12]' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t: TestCase) => t.vars?.var1)).toEqual(['test1', 'test2']);
|
||||
});
|
||||
|
||||
it('should handle tests without description', async () => {
|
||||
const testSuite = {
|
||||
...mockTestSuite,
|
||||
tests: [...mockTestSuite.tests!, { vars: {}, assert: [] }],
|
||||
};
|
||||
const result = await filterTests(testSuite, { pattern: 'test' });
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should throw error for invalid regex pattern', async () => {
|
||||
await expect(filterTests(mockTestSuite, { pattern: '[invalid' })).rejects.toThrow(
|
||||
/Invalid regex pattern "\[invalid"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('range filter', () => {
|
||||
it('should slice tests by zero-based start-inclusive, end-exclusive range', async () => {
|
||||
const result = await filterTests(mockTestSuite, { range: '1:3' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t: TestCase) => t.vars?.var1)).toEqual(['test2', 'test3']);
|
||||
});
|
||||
|
||||
it('should support omitted start and omitted end', async () => {
|
||||
const fromStart = await filterTests(mockTestSuite, { range: ':2' });
|
||||
expect(fromStart.map((t: TestCase) => t.vars?.var1)).toEqual(['test1', 'test2']);
|
||||
|
||||
const toEnd = await filterTests(mockTestSuite, { range: '1:' });
|
||||
expect(toEnd.map((t: TestCase) => t.vars?.var1)).toEqual(['test2', 'test3']);
|
||||
});
|
||||
|
||||
it('should allow range ends beyond the test count', async () => {
|
||||
const result = await filterTests(mockTestSuite, { range: '2:100' });
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.vars?.var1).toBe('test3');
|
||||
});
|
||||
|
||||
it('should apply after pattern and before firstN', async () => {
|
||||
const result = await filterTests(mockTestSuite, {
|
||||
pattern: 'test',
|
||||
range: '1:3',
|
||||
firstN: 1,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.vars?.var1).toBe('test2');
|
||||
});
|
||||
|
||||
it('should throw error for invalid range format', async () => {
|
||||
await expect(filterTests(mockTestSuite, { range: '1' })).rejects.toThrow(
|
||||
/--filter-range must be specified in start:end format/,
|
||||
);
|
||||
await expect(filterTests(mockTestSuite, { range: ':' })).rejects.toThrow(
|
||||
/--filter-range must be specified in start:end format/,
|
||||
);
|
||||
await expect(filterTests(mockTestSuite, { range: 'a:b' })).rejects.toThrow(
|
||||
/--filter-range must be specified in start:end format/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid whitespace-heavy ranges without excessive backtracking', async () => {
|
||||
await expect(
|
||||
filterTests(mockTestSuite, { range: `${'\t'.repeat(10_000)}:${'\t'.repeat(10_000)}x` }),
|
||||
).rejects.toThrow(/--filter-range must be specified in start:end format/);
|
||||
});
|
||||
|
||||
it('should throw error when start is greater than end', async () => {
|
||||
await expect(filterTests(mockTestSuite, { range: '3:1' })).rejects.toThrow(
|
||||
'--filter-range start must be less than or equal to end, got: 3:1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject negative range bounds', async () => {
|
||||
await expect(filterTests(mockTestSuite, { range: '-1:5' })).rejects.toThrow(
|
||||
/--filter-range must be specified in start:end format/,
|
||||
);
|
||||
await expect(filterTests(mockTestSuite, { range: '0:-3' })).rejects.toThrow(
|
||||
/--filter-range must be specified in start:end format/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn when range slices to an empty set on a non-empty suite', async () => {
|
||||
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined as any);
|
||||
try {
|
||||
const result = await filterTests(mockTestSuite, { range: '100:200' });
|
||||
expect(result).toHaveLength(0);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--filter-range 100:200 selected 0 tests'),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('firstN filter', () => {
|
||||
it('should take first N tests', async () => {
|
||||
const result = await filterTests(mockTestSuite, { firstN: 2 });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t: TestCase) => t.vars?.var1)).toEqual(['test1', 'test2']);
|
||||
});
|
||||
|
||||
it('should handle string input for firstN', async () => {
|
||||
const result = await filterTests(mockTestSuite, { firstN: '2' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t: TestCase) => t.vars?.var1)).toEqual(['test1', 'test2']);
|
||||
});
|
||||
|
||||
it('should throw error if firstN is not a number', async () => {
|
||||
await expect(filterTests(mockTestSuite, { firstN: 'invalid' })).rejects.toThrow(
|
||||
'firstN must be a number, got: invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined firstN', async () => {
|
||||
const result = await filterTests(mockTestSuite, { firstN: undefined });
|
||||
expect(result).toEqual(mockTestSuite.tests);
|
||||
});
|
||||
|
||||
it('should throw error if firstN is null', async () => {
|
||||
await expect(filterTests(mockTestSuite, { firstN: null as any })).rejects.toThrow(
|
||||
'firstN must be a number, got: null',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if firstN is NaN', async () => {
|
||||
await expect(filterTests(mockTestSuite, { firstN: Number.NaN })).rejects.toThrow(
|
||||
'firstN must be a number, got: NaN',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sample filter', () => {
|
||||
it('should take N random tests', async () => {
|
||||
const result = await filterTests(mockTestSuite, { sample: 2 });
|
||||
expect(result).toHaveLength(2);
|
||||
// Can't test exact values since it's random
|
||||
expect(result.every((t: TestCase) => mockTestSuite.tests!.includes(t))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle string input for sample', async () => {
|
||||
const result = await filterTests(mockTestSuite, { sample: '2' });
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.every((t: TestCase) => mockTestSuite.tests!.includes(t))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return the same sample when given the same seed', async () => {
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||
throw new Error('seeded sampling should not use Math.random');
|
||||
});
|
||||
try {
|
||||
const first = await filterTests(mockTestSuite, { sample: 2, sampleSeed: 42 });
|
||||
const second = await filterTests(mockTestSuite, { sample: 2, sampleSeed: 42 });
|
||||
|
||||
expect(first.map((test) => test.vars?.var1)).toEqual(second.map((test) => test.vars?.var1));
|
||||
expect(randomSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
randomSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw error if sample is not a number', async () => {
|
||||
await expect(filterTests(mockTestSuite, { sample: 'invalid' })).rejects.toThrow(
|
||||
'sample must be a number, got: invalid',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined sample', async () => {
|
||||
const result = await filterTests(mockTestSuite, { sample: undefined });
|
||||
expect(result).toEqual(mockTestSuite.tests);
|
||||
});
|
||||
|
||||
it('should throw error if sample is null', async () => {
|
||||
await expect(filterTests(mockTestSuite, { sample: null as any })).rejects.toThrow(
|
||||
'sample must be a number, got: null',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if sample is NaN', async () => {
|
||||
await expect(filterTests(mockTestSuite, { sample: Number.NaN })).rejects.toThrow(
|
||||
'sample must be a number, got: NaN',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple filters', () => {
|
||||
it('should compose metadata and failing filters', async () => {
|
||||
vi.mocked(Eval.findById).mockResolvedValue({
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
vars: { var1: 'test1' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: mockTestSuite.tests![0],
|
||||
},
|
||||
{
|
||||
vars: { var1: 'test2' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: mockTestSuite.tests![1],
|
||||
},
|
||||
{
|
||||
vars: { var1: 'test3' },
|
||||
success: true,
|
||||
testCase: mockTestSuite.tests![2],
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await filterTests(mockTestSuite, {
|
||||
metadata: 'type=unit',
|
||||
failing: 'eval-123',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.vars?.var1).toBe('test1');
|
||||
});
|
||||
|
||||
it('should not reintroduce metadata-excluded tests with matching vars', async () => {
|
||||
const excludedTest: TestCase = {
|
||||
description: 'integration test',
|
||||
vars: { prompt: 'shared input' },
|
||||
metadata: { type: 'integration' },
|
||||
provider: 'provider-integration',
|
||||
};
|
||||
const selectedTest: TestCase = {
|
||||
description: 'unit test',
|
||||
vars: { prompt: 'shared input' },
|
||||
metadata: { type: 'unit' },
|
||||
provider: 'provider-unit',
|
||||
};
|
||||
const testSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [excludedTest, selectedTest],
|
||||
};
|
||||
vi.mocked(Eval.findById).mockResolvedValue({
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
vars: excludedTest.vars,
|
||||
provider: { id: 'provider-integration' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: excludedTest,
|
||||
},
|
||||
{
|
||||
vars: selectedTest.vars,
|
||||
provider: { id: 'provider-unit' },
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: selectedTest,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await filterTests(testSuite, {
|
||||
metadata: 'type=unit',
|
||||
failing: 'eval-123',
|
||||
});
|
||||
|
||||
expect(result).toEqual([selectedTest]);
|
||||
});
|
||||
|
||||
it('should preserve generated result tests when metadata matches all config tests', async () => {
|
||||
const configTest: TestCase = {
|
||||
description: 'configured test',
|
||||
vars: { prompt: 'configured' },
|
||||
metadata: { type: 'unit' },
|
||||
};
|
||||
const generatedTest: TestCase = {
|
||||
description: 'generated test',
|
||||
vars: { prompt: 'generated' },
|
||||
metadata: { type: 'unit', pluginId: 'cipher-code' },
|
||||
};
|
||||
const testSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [configTest],
|
||||
};
|
||||
vi.mocked(Eval.findById).mockResolvedValue({
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
vars: configTest.vars,
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: configTest,
|
||||
},
|
||||
{
|
||||
vars: generatedTest.vars,
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: generatedTest,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await filterTests(testSuite, {
|
||||
metadata: 'type=unit',
|
||||
failing: 'eval-123',
|
||||
});
|
||||
|
||||
expect(result.map((test) => test.description)).toEqual(['configured test', 'generated test']);
|
||||
});
|
||||
|
||||
it('should filter generated result tests by metadata before deduplication', async () => {
|
||||
const excludedGeneratedTest: TestCase = {
|
||||
description: 'excluded generated test',
|
||||
vars: { prompt: 'shared generated input' },
|
||||
metadata: { type: 'integration' },
|
||||
};
|
||||
const selectedGeneratedTest: TestCase = {
|
||||
description: 'selected generated test',
|
||||
vars: { prompt: 'shared generated input' },
|
||||
metadata: { type: 'unit' },
|
||||
};
|
||||
const testSuite: TestSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [],
|
||||
};
|
||||
vi.mocked(Eval.findById).mockResolvedValue({
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
vars: excludedGeneratedTest.vars,
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: excludedGeneratedTest,
|
||||
},
|
||||
{
|
||||
vars: selectedGeneratedTest.vars,
|
||||
success: false,
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
testCase: selectedGeneratedTest,
|
||||
},
|
||||
],
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const result = await filterTests(testSuite, {
|
||||
metadata: 'type=unit',
|
||||
failing: 'eval-123',
|
||||
});
|
||||
|
||||
expect(result).toEqual([selectedGeneratedTest]);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import logger from '../../../src/logger';
|
||||
import { warnIfRedteamConfigHasNoTests } from '../../../src/util/eval/redteamWarning';
|
||||
|
||||
import type { TestSuite, UnifiedConfig } from '../../../src/types/index';
|
||||
|
||||
vi.mock('../../../src/logger', () => {
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: mockLogger,
|
||||
};
|
||||
});
|
||||
|
||||
describe('redteam warning in eval command', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should warn when config has redteam section but no test cases', () => {
|
||||
const config = {
|
||||
redteam: {
|
||||
purpose: 'Test red team purpose',
|
||||
},
|
||||
} as Partial<UnifiedConfig>;
|
||||
const testSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [],
|
||||
} as TestSuite;
|
||||
|
||||
warnIfRedteamConfigHasNoTests(config, testSuite);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('redteam section but no test cases'),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('promptfoo redteam generate'));
|
||||
});
|
||||
|
||||
it('should not warn when scenarios are present even if tests is empty', () => {
|
||||
const config = {
|
||||
redteam: {
|
||||
purpose: 'Test red team purpose',
|
||||
},
|
||||
} as Partial<UnifiedConfig>;
|
||||
const testSuite = {
|
||||
prompts: [],
|
||||
providers: [],
|
||||
tests: [],
|
||||
scenarios: [{ config: [], tests: [] }],
|
||||
} as TestSuite;
|
||||
|
||||
warnIfRedteamConfigHasNoTests(config, testSuite);
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,891 @@
|
||||
import chalk from 'chalk';
|
||||
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
|
||||
import { generateEvalSummary } from '../../../src/util/eval/summary';
|
||||
import { stripAnsi } from '../../util/utils';
|
||||
|
||||
import type { EvalSummaryParams } from '../../../src/util/eval/summary';
|
||||
import type { TokenUsageTracker } from '../../../src/util/tokenUsage';
|
||||
|
||||
type MockTracker = {
|
||||
getProviderIds: Mock;
|
||||
getProviderUsage: Mock;
|
||||
trackUsage: Mock;
|
||||
resetAllUsage: Mock;
|
||||
resetProviderUsage: Mock;
|
||||
getTotalUsage: Mock;
|
||||
cleanup: Mock;
|
||||
};
|
||||
|
||||
function createMockTracker(): TokenUsageTracker {
|
||||
return {
|
||||
getProviderIds: vi.fn().mockReturnValue([]),
|
||||
getProviderUsage: vi.fn(),
|
||||
trackUsage: vi.fn(),
|
||||
resetAllUsage: vi.fn(),
|
||||
resetProviderUsage: vi.fn(),
|
||||
getTotalUsage: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
} as unknown as TokenUsageTracker;
|
||||
}
|
||||
|
||||
describe('generateEvalSummary', () => {
|
||||
let mockTracker: MockTracker & TokenUsageTracker;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockTracker = createMockTracker() as MockTracker & TokenUsageTracker;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('completion message', () => {
|
||||
it('should show basic completion message when not writing to database', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-123',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('✓ Eval complete');
|
||||
expect(output).not.toContain('eval-123');
|
||||
});
|
||||
|
||||
it('should show eval ID when writing to database without shareable URL', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-456',
|
||||
isRedteam: false,
|
||||
writeToDatabase: true,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('✓ Eval complete (ID: eval-456)');
|
||||
});
|
||||
|
||||
it('should show shareable URL when available', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-789',
|
||||
isRedteam: false,
|
||||
writeToDatabase: true,
|
||||
shareableUrl: 'https://promptfoo.app/eval/abc123',
|
||||
wantsToShare: true,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('✓ Eval complete: https://promptfoo.app/eval/abc123');
|
||||
expect(output).not.toContain('eval-789');
|
||||
});
|
||||
|
||||
it('should say "Red team complete" for red team evals', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-rt-1',
|
||||
isRedteam: true,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 10,
|
||||
failures: 2,
|
||||
errors: 0,
|
||||
duration: 8000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('✓ Red team complete');
|
||||
expect(output).not.toContain('Eval complete');
|
||||
});
|
||||
|
||||
it('should explain non-retryable target errors when an eval is aborted', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-aborted',
|
||||
isRedteam: false,
|
||||
writeToDatabase: true,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 0,
|
||||
failures: 0,
|
||||
errors: 1,
|
||||
duration: 1000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
targetErrorStatus: 401,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('✗ Eval aborted (ID: eval-aborted)');
|
||||
expect(output).toContain(
|
||||
'Scan stopped: Target is unavailable and will not recover on retry.',
|
||||
);
|
||||
expect(output).toContain('Target returned HTTP 401');
|
||||
expect(output).not.toContain('Server error (500)');
|
||||
expect(output).toContain('To fix: Check your target configuration and credentials.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('token usage', () => {
|
||||
it('should display eval tokens correctly', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-123',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: {
|
||||
total: 1000,
|
||||
prompt: 400,
|
||||
completion: 600,
|
||||
cached: 0,
|
||||
},
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('Tokens:');
|
||||
expect(output).toContain('Eval: 1,000 (400 prompt, 600 completion)');
|
||||
});
|
||||
|
||||
it('should display grading tokens only when no eval tokens (critical bug fix)', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-grading-only',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: {
|
||||
total: 0,
|
||||
assertions: {
|
||||
total: 500,
|
||||
prompt: 200,
|
||||
completion: 300,
|
||||
cached: 0,
|
||||
},
|
||||
},
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('Tokens:');
|
||||
expect(output).toContain('Grading: 500 (200 prompt, 300 completion)');
|
||||
expect(output).not.toContain('Eval:');
|
||||
});
|
||||
|
||||
it('should display both eval and grading tokens', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-both',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: {
|
||||
total: 1000,
|
||||
prompt: 400,
|
||||
completion: 600,
|
||||
assertions: {
|
||||
total: 500,
|
||||
prompt: 200,
|
||||
completion: 300,
|
||||
},
|
||||
},
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('Tokens:');
|
||||
expect(output).toContain('Eval: 1,000 (400 prompt, 600 completion)');
|
||||
expect(output).toContain('Grading: 500 (200 prompt, 300 completion)');
|
||||
});
|
||||
|
||||
it('should show 100% cached correctly', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-cached',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: {
|
||||
total: 1000,
|
||||
cached: 1000,
|
||||
},
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('Tokens:');
|
||||
expect(output).toContain('Eval: 1,000 (cached)');
|
||||
});
|
||||
|
||||
it('should show partial cached tokens', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-partial-cache',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: {
|
||||
total: 1000,
|
||||
prompt: 400,
|
||||
completion: 600,
|
||||
cached: 200,
|
||||
},
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('Eval: 1,000 (400 prompt, 600 completion, 200 cached)');
|
||||
});
|
||||
|
||||
it('should not show token section when no tokens', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-no-tokens',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).not.toContain('Tokens:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider breakdown', () => {
|
||||
it('should show provider breakdown with request counts', () => {
|
||||
mockTracker.getProviderIds.mockReturnValue(['openai:gpt-4', 'anthropic:claude-3']);
|
||||
mockTracker.getProviderUsage.mockImplementation((id: string) => {
|
||||
if (id === 'openai:gpt-4') {
|
||||
return {
|
||||
total: 1500,
|
||||
prompt: 600,
|
||||
completion: 900,
|
||||
cached: 0,
|
||||
numRequests: 5,
|
||||
};
|
||||
}
|
||||
if (id === 'anthropic:claude-3') {
|
||||
return {
|
||||
total: 800,
|
||||
prompt: 300,
|
||||
completion: 500,
|
||||
cached: 0,
|
||||
numRequests: 3,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-providers',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 2300 },
|
||||
successes: 8,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('Providers:');
|
||||
expect(output).toContain('openai:gpt-4');
|
||||
expect(output).toContain('1,500 (5 requests; 600 prompt, 900 completion)');
|
||||
expect(output).toContain('anthropic:claude-3');
|
||||
expect(output).toContain('800 (3 requests; 300 prompt, 500 completion)');
|
||||
});
|
||||
|
||||
it('should always show request count even when 0', () => {
|
||||
mockTracker.getProviderIds.mockReturnValue(['openai:gpt-4', 'anthropic:claude-3']);
|
||||
mockTracker.getProviderUsage.mockImplementation((id: string) => {
|
||||
if (id === 'openai:gpt-4') {
|
||||
return {
|
||||
total: 1000,
|
||||
cached: 1000,
|
||||
numRequests: 0,
|
||||
};
|
||||
}
|
||||
if (id === 'anthropic:claude-3') {
|
||||
return {
|
||||
total: 500,
|
||||
prompt: 200,
|
||||
completion: 300,
|
||||
numRequests: 2,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-zero-requests',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 1500 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('Providers:');
|
||||
expect(output).toContain('openai:gpt-4: 1,000 (0 requests; cached)');
|
||||
expect(output).toContain('anthropic:claude-3: 500 (2 requests; 200 prompt, 300 completion)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pass rate and results', () => {
|
||||
it('should show percentages for each result line at 100% pass rate', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-100',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 10,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const plainOutput = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(plainOutput).toContain('Results:');
|
||||
expect(plainOutput).toContain('10 passed (100%)');
|
||||
expect(plainOutput).toContain('0 failed (0%)');
|
||||
expect(plainOutput).toContain('0 errors (0%)');
|
||||
});
|
||||
|
||||
it('should show percentages for each result line when some tests fail', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-85',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 17,
|
||||
failures: 3,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const plainOutput = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(plainOutput).toContain('Results:');
|
||||
expect(plainOutput).toContain('17 passed (85.00%)');
|
||||
expect(plainOutput).toContain('3 failed (15.00%)');
|
||||
expect(plainOutput).toContain('0 errors (0%)');
|
||||
});
|
||||
|
||||
it('should show percentages for each result line when passed and failed are split evenly', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-50',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 5,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const plainOutput = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(plainOutput).toContain('Results:');
|
||||
expect(plainOutput).toContain('5 passed (50.00%)');
|
||||
expect(plainOutput).toContain('5 failed (50.00%)');
|
||||
expect(plainOutput).toContain('0 errors (0%)');
|
||||
});
|
||||
|
||||
it('should include errors in results', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-errors',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 8,
|
||||
failures: 1,
|
||||
errors: 1,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const plainOutput = stripAnsi(lines.join('\n'));
|
||||
const outputLines = plainOutput.split('\n');
|
||||
const hasLineMatching = (pattern: RegExp) => outputLines.some((line) => pattern.test(line));
|
||||
|
||||
expect(plainOutput).toContain('Results:');
|
||||
expect(hasLineMatching(/^\s*(✓\s+)?8 passed \(80\.00%\)$/)).toBe(true);
|
||||
expect(hasLineMatching(/^\s*(✗\s+)?1 failed \(10\.00%\)$/)).toBe(true);
|
||||
expect(hasLineMatching(/^\s*(✗\s+)?1 error \(10\.00%\)$/)).toBe(true);
|
||||
expect(plainOutput).not.toContain('1 errors');
|
||||
});
|
||||
|
||||
it('should render colored icons with muted percentages', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-styling',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 8,
|
||||
failures: 1,
|
||||
errors: 1,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
|
||||
expect(lines).toContain(
|
||||
` ${chalk.green('✓')} ${chalk.white.bold('8')} ${chalk.white('passed')} ${chalk.gray('(80.00%)')}`,
|
||||
);
|
||||
expect(lines).toContain(
|
||||
` ${chalk.red('✗')} ${chalk.white.bold('1')} ${chalk.white('failed')} ${chalk.gray('(10.00%)')}`,
|
||||
);
|
||||
expect(lines).toContain(
|
||||
` ${chalk.red('✗')} ${chalk.white.bold('1')} ${chalk.white('error')} ${chalk.gray('(10.00%)')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guidance messages', () => {
|
||||
it('should show guidance when writing to database without shareable URL', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-view',
|
||||
isRedteam: false,
|
||||
writeToDatabase: true,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('» View results: promptfoo view');
|
||||
expect(output).toContain('» Share with your team: https://promptfoo.app');
|
||||
expect(output).toContain('» Feedback: https://promptfoo.dev/feedback');
|
||||
});
|
||||
|
||||
it('should show share guidance with cloud enabled', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-share-cloud',
|
||||
isRedteam: false,
|
||||
writeToDatabase: true,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: true,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('» View results: promptfoo view');
|
||||
expect(output).toContain('» Create shareable URL: promptfoo share');
|
||||
expect(output).not.toContain('https://promptfoo.app');
|
||||
});
|
||||
|
||||
it('should NOT show share guidance when explicitly disabled (--no-share)', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-no-share',
|
||||
isRedteam: false,
|
||||
writeToDatabase: true,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: true,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('» View results: promptfoo view');
|
||||
expect(output).not.toContain('» Share with your team');
|
||||
expect(output).not.toContain('» Create shareable URL');
|
||||
});
|
||||
|
||||
it('should NOT show guidance when not writing to database', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-no-write',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).not.toContain('» View results:');
|
||||
expect(output).not.toContain('» Share');
|
||||
expect(output).not.toContain('» Feedback:');
|
||||
});
|
||||
|
||||
it('should NOT show guidance when shareable URL is present', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-with-url',
|
||||
isRedteam: false,
|
||||
writeToDatabase: true,
|
||||
shareableUrl: 'https://promptfoo.app/eval/abc123',
|
||||
wantsToShare: true,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: true,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).not.toContain('» View results:');
|
||||
expect(output).not.toContain('» Share');
|
||||
expect(output).not.toContain('» Feedback:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance metrics', () => {
|
||||
it('should show duration and concurrency', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-perf',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 125, // 125 seconds = 2m 5s
|
||||
maxConcurrency: 8,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(output).toContain('Duration:');
|
||||
expect(output).toContain('(concurrency: 8)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should use singular "error" when there is exactly 1 error', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-singular-error',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 1,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const plainOutput = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(plainOutput).toContain('1 error');
|
||||
expect(plainOutput).not.toContain('1 errors');
|
||||
});
|
||||
|
||||
it('should use plural "errors" when there are multiple errors', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-plural-errors',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 3,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const plainOutput = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(plainOutput).toContain('3 errors');
|
||||
});
|
||||
|
||||
it('should use plural "errors" when there are 0 errors', () => {
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-zero-errors',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 0 },
|
||||
successes: 5,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
const lines = generateEvalSummary(params);
|
||||
const plainOutput = stripAnsi(lines.join('\n'));
|
||||
|
||||
expect(plainOutput).toContain('0 errors');
|
||||
});
|
||||
|
||||
it('should handle provider returning undefined usage gracefully', () => {
|
||||
mockTracker.getProviderIds.mockReturnValue([
|
||||
'openai:gpt-4',
|
||||
'missing-provider',
|
||||
'anthropic:claude-3',
|
||||
]);
|
||||
mockTracker.getProviderUsage.mockImplementation((id: string) => {
|
||||
if (id === 'openai:gpt-4') {
|
||||
return {
|
||||
total: 1000,
|
||||
prompt: 400,
|
||||
completion: 600,
|
||||
numRequests: 5,
|
||||
};
|
||||
}
|
||||
if (id === 'missing-provider') {
|
||||
return undefined; // Simulates a provider that returns undefined
|
||||
}
|
||||
if (id === 'anthropic:claude-3') {
|
||||
return {
|
||||
total: 500,
|
||||
prompt: 200,
|
||||
completion: 300,
|
||||
numRequests: 3,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const params: EvalSummaryParams = {
|
||||
evalId: 'eval-undefined-provider',
|
||||
isRedteam: false,
|
||||
writeToDatabase: false,
|
||||
shareableUrl: null,
|
||||
wantsToShare: false,
|
||||
hasExplicitDisable: false,
|
||||
cloudEnabled: false,
|
||||
tokenUsage: { total: 1500 },
|
||||
successes: 8,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
duration: 5000,
|
||||
maxConcurrency: 4,
|
||||
tracker: mockTracker,
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
const lines = generateEvalSummary(params);
|
||||
const output = stripAnsi(lines.join('\n'));
|
||||
|
||||
// Should show the providers that have valid usage
|
||||
expect(output).toContain('Providers:');
|
||||
expect(output).toContain('openai:gpt-4');
|
||||
expect(output).toContain('anthropic:claude-3');
|
||||
// Should NOT show the missing provider
|
||||
expect(output).not.toContain('missing-provider');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { convertTestResultsToTableRow } from '../../../src/util/exportToFile/index';
|
||||
|
||||
import type EvalResult from '../../../src/models/evalResult';
|
||||
|
||||
/**
|
||||
* Creates a minimal mock EvalResult for testing.
|
||||
* We use a partial type and cast to avoid needing the full EvalResult class.
|
||||
*/
|
||||
function createMockEvalResult(overrides: Partial<EvalResult> = {}): EvalResult {
|
||||
const defaultResult = {
|
||||
id: 'test-id',
|
||||
testIdx: 0,
|
||||
promptIdx: 0,
|
||||
description: 'Test description',
|
||||
testCase: {
|
||||
vars: {},
|
||||
},
|
||||
prompt: {
|
||||
raw: 'test prompt',
|
||||
},
|
||||
response: {
|
||||
output: 'test output',
|
||||
},
|
||||
provider: {
|
||||
id: 'test-provider',
|
||||
label: 'Test Provider',
|
||||
},
|
||||
success: true,
|
||||
score: 1,
|
||||
latencyMs: 100,
|
||||
error: undefined,
|
||||
metadata: undefined,
|
||||
namedScores: {},
|
||||
gradingResult: undefined,
|
||||
cost: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
...defaultResult,
|
||||
...overrides,
|
||||
testCase: {
|
||||
...defaultResult.testCase,
|
||||
...(overrides.testCase || {}),
|
||||
},
|
||||
prompt: {
|
||||
...defaultResult.prompt,
|
||||
...(overrides.prompt || {}),
|
||||
},
|
||||
response: {
|
||||
...defaultResult.response,
|
||||
...(overrides.response || {}),
|
||||
},
|
||||
provider: {
|
||||
...defaultResult.provider,
|
||||
...(overrides.provider || {}),
|
||||
},
|
||||
} as EvalResult;
|
||||
}
|
||||
|
||||
describe('convertTestResultsToTableRow', () => {
|
||||
describe('basic functionality', () => {
|
||||
it('should convert results to table row format', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: { question: 'What is AI?' } },
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['question'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.description).toBe('Test description');
|
||||
expect(row.vars).toEqual(['What is AI?']);
|
||||
expect(row.testIdx).toBe(0);
|
||||
expect(row.outputs).toHaveLength(1);
|
||||
expect(row.outputs[0].text).toBe('test output');
|
||||
});
|
||||
|
||||
it('should handle multiple variables', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: {
|
||||
vars: {
|
||||
question: 'What is AI?',
|
||||
context: 'Some context',
|
||||
temperature: 0.5,
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['context', 'question', 'temperature'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['Some context', 'What is AI?', '0.5']);
|
||||
});
|
||||
|
||||
it('should handle missing variables', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: { question: 'What is AI?' } },
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['question', 'missingVar'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['What is AI?', '']);
|
||||
});
|
||||
|
||||
it('should handle object variables as JSON', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: {
|
||||
vars: {
|
||||
config: { nested: 'value', count: 42 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['config'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars[0]).toBe('{"nested":"value","count":42}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionId handling', () => {
|
||||
it('should use sessionId from testCase.vars when present', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: { sessionId: 'user-session-123' } },
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['user-session-123']);
|
||||
});
|
||||
|
||||
it('should use metadata.sessionId when vars.sessionId is not present', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: {} },
|
||||
metadata: { sessionId: 'metadata-session-456' },
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['metadata-session-456']);
|
||||
});
|
||||
|
||||
it('should use metadata.sessionId when vars.sessionId is empty string', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: { sessionId: '' } },
|
||||
metadata: { sessionId: 'metadata-session-789' },
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['metadata-session-789']);
|
||||
});
|
||||
|
||||
it('should not overwrite non-empty vars.sessionId with metadata.sessionId', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: { sessionId: 'user-provided-session' } },
|
||||
metadata: { sessionId: 'should-be-ignored' },
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['user-provided-session']);
|
||||
});
|
||||
|
||||
it('should return empty string when no sessionId is available', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: {} },
|
||||
metadata: {},
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionIds array handling (multi-turn strategies)', () => {
|
||||
it('should join sessionIds array with newlines', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: {} },
|
||||
metadata: { sessionIds: ['session-1', 'session-2', 'session-3'] },
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['session-1\nsession-2\nsession-3']);
|
||||
});
|
||||
|
||||
it('should handle single-element sessionIds array', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: {} },
|
||||
metadata: { sessionIds: ['only-session'] },
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['only-session']);
|
||||
});
|
||||
|
||||
it('should prefer sessionIds array over sessionId when both exist', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: {} },
|
||||
metadata: {
|
||||
sessionId: 'single-session',
|
||||
sessionIds: ['multi-1', 'multi-2'],
|
||||
},
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['multi-1\nmulti-2']);
|
||||
});
|
||||
|
||||
it('should fall back to sessionId when sessionIds is empty array', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: {} },
|
||||
metadata: {
|
||||
sessionId: 'fallback-session',
|
||||
sessionIds: [],
|
||||
},
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['fallback-session']);
|
||||
});
|
||||
|
||||
it('should ignore non-array sessionIds and use sessionId instead', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: {} },
|
||||
metadata: {
|
||||
sessionId: 'fallback-session',
|
||||
sessionIds: 'not-an-array' as unknown as string[],
|
||||
},
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['fallback-session']);
|
||||
});
|
||||
|
||||
it('should not use sessionIds array if vars.sessionId is provided', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: { sessionId: 'user-provided' } },
|
||||
metadata: { sessionIds: ['should-be-ignored-1', 'should-be-ignored-2'] },
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['user-provided']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined scenarios', () => {
|
||||
it('should handle sessionId with other variables', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: {
|
||||
vars: {
|
||||
question: 'What is AI?',
|
||||
context: 'Some context',
|
||||
},
|
||||
},
|
||||
metadata: { sessionIds: ['session-a', 'session-b'] },
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['context', 'question', 'sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['Some context', 'What is AI?', 'session-a\nsession-b']);
|
||||
});
|
||||
|
||||
it('should handle edge case with undefined metadata', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: { question: 'What is AI?' } },
|
||||
metadata: undefined,
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['question', 'sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.vars).toEqual(['What is AI?', '']);
|
||||
});
|
||||
|
||||
it('should handle non-string sessionId values in metadata', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: {} },
|
||||
metadata: { sessionId: { complex: 'object' } as unknown as string },
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
// Should be JSON stringified
|
||||
expect(row.vars[0]).toBe('{"complex":"object"}');
|
||||
});
|
||||
|
||||
it('should handle non-string sessionId values in vars', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
testCase: { vars: { sessionId: { complex: 'object' } as unknown as string } },
|
||||
}),
|
||||
];
|
||||
const varsForHeader = ['sessionId'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
// Should be JSON stringified
|
||||
expect(row.vars[0]).toBe('{"complex":"object"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('output formatting', () => {
|
||||
it('should format successful output correctly', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
success: true,
|
||||
response: { output: 'The answer is 42' },
|
||||
}),
|
||||
];
|
||||
const varsForHeader: string[] = [];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.outputs[0].text).toBe('The answer is 42');
|
||||
expect(row.outputs[0].pass).toBe(true);
|
||||
});
|
||||
|
||||
it('should format error output correctly', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
success: false,
|
||||
error: 'Provider error occurred',
|
||||
response: { output: '' },
|
||||
}),
|
||||
];
|
||||
const varsForHeader: string[] = [];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.outputs[0].text).toBe('Provider error occurred');
|
||||
expect(row.outputs[0].pass).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null output', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
response: { output: null },
|
||||
error: 'Null response',
|
||||
}),
|
||||
];
|
||||
const varsForHeader: string[] = [];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.outputs[0].text).toBe('Null response');
|
||||
});
|
||||
|
||||
it('should handle object output', () => {
|
||||
const results = [
|
||||
createMockEvalResult({
|
||||
response: { output: { key: 'value', nested: { data: 123 } } },
|
||||
}),
|
||||
];
|
||||
const varsForHeader: string[] = [];
|
||||
|
||||
const row = convertTestResultsToTableRow(results, varsForHeader);
|
||||
|
||||
expect(row.outputs[0].text).toBe('{"key":"value","nested":{"data":123}}');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getHeaderForTable } from '../../../src/util/exportToFile/getHeaderForTable';
|
||||
|
||||
import type Eval from '../../../src/models/eval';
|
||||
import type { TestCase } from '../../../src/types/index';
|
||||
|
||||
describe('getHeaderForTable', () => {
|
||||
let mockEval: Eval;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEval = {
|
||||
config: {
|
||||
defaultTest: undefined,
|
||||
},
|
||||
results: [],
|
||||
} as any;
|
||||
});
|
||||
|
||||
it('should handle object defaultTest with vars', () => {
|
||||
mockEval.config.defaultTest = {
|
||||
vars: {
|
||||
defaultVar1: 'value1',
|
||||
defaultVar2: 'value2',
|
||||
},
|
||||
};
|
||||
|
||||
mockEval.results = [
|
||||
{
|
||||
testCase: {
|
||||
vars: {
|
||||
testVar1: 'test1',
|
||||
defaultVar1: 'override1',
|
||||
},
|
||||
} as TestCase,
|
||||
},
|
||||
{
|
||||
testCase: {
|
||||
vars: {
|
||||
testVar2: 'test2',
|
||||
testVar3: 'test3',
|
||||
},
|
||||
} as TestCase,
|
||||
},
|
||||
] as any;
|
||||
|
||||
const header = getHeaderForTable(mockEval);
|
||||
|
||||
// Should include all unique var names from defaultTest and test cases
|
||||
expect(header.vars).toContain('defaultVar1');
|
||||
expect(header.vars).toContain('defaultVar2');
|
||||
expect(header.vars).toContain('testVar1');
|
||||
expect(header.vars).toContain('testVar2');
|
||||
expect(header.vars).toContain('testVar3');
|
||||
});
|
||||
|
||||
it('should handle string defaultTest', () => {
|
||||
mockEval.config.defaultTest = 'file://path/to/defaultTest.yaml';
|
||||
|
||||
mockEval.results = [
|
||||
{
|
||||
testCase: {
|
||||
vars: {
|
||||
var1: 'value1',
|
||||
var2: 'value2',
|
||||
},
|
||||
} as TestCase,
|
||||
},
|
||||
] as any;
|
||||
|
||||
const header = getHeaderForTable(mockEval);
|
||||
|
||||
// Should only include vars from test cases when defaultTest is a string
|
||||
expect(header.vars).toContain('var1');
|
||||
expect(header.vars).toContain('var2');
|
||||
});
|
||||
|
||||
it('should handle undefined defaultTest', () => {
|
||||
mockEval.config.defaultTest = undefined;
|
||||
|
||||
mockEval.results = [
|
||||
{
|
||||
testCase: {
|
||||
vars: {
|
||||
var1: 'value1',
|
||||
var2: 'value2',
|
||||
},
|
||||
} as TestCase,
|
||||
},
|
||||
] as any;
|
||||
|
||||
const header = getHeaderForTable(mockEval);
|
||||
|
||||
expect(header.vars).toContain('var1');
|
||||
expect(header.vars).toContain('var2');
|
||||
});
|
||||
|
||||
it('should handle empty vars in defaultTest', () => {
|
||||
mockEval.config.defaultTest = {
|
||||
vars: {},
|
||||
assert: [{ type: 'equals', value: 'test' }],
|
||||
};
|
||||
|
||||
mockEval.results = [
|
||||
{
|
||||
testCase: {
|
||||
vars: {
|
||||
var1: 'value1',
|
||||
},
|
||||
} as TestCase,
|
||||
},
|
||||
] as any;
|
||||
|
||||
const header = getHeaderForTable(mockEval);
|
||||
|
||||
expect(header.vars).toContain('var1');
|
||||
});
|
||||
|
||||
it('should handle defaultTest with no vars property', () => {
|
||||
mockEval.config.defaultTest = {
|
||||
assert: [{ type: 'equals', value: 'test' }],
|
||||
options: { provider: 'openai:gpt-4' },
|
||||
};
|
||||
|
||||
mockEval.results = [
|
||||
{
|
||||
testCase: {
|
||||
vars: {
|
||||
var1: 'value1',
|
||||
},
|
||||
} as TestCase,
|
||||
},
|
||||
] as any;
|
||||
|
||||
const header = getHeaderForTable(mockEval);
|
||||
|
||||
expect(header.vars).toContain('var1');
|
||||
});
|
||||
|
||||
it('should deduplicate var names', () => {
|
||||
mockEval.config.defaultTest = {
|
||||
vars: {
|
||||
commonVar: 'default',
|
||||
defaultOnly: 'value',
|
||||
},
|
||||
};
|
||||
|
||||
mockEval.results = [
|
||||
{
|
||||
testCase: {
|
||||
vars: {
|
||||
commonVar: 'override1',
|
||||
test1Var: 'value1',
|
||||
},
|
||||
} as TestCase,
|
||||
},
|
||||
{
|
||||
testCase: {
|
||||
vars: {
|
||||
commonVar: 'override2',
|
||||
test2Var: 'value2',
|
||||
},
|
||||
} as TestCase,
|
||||
},
|
||||
] as any;
|
||||
|
||||
const header = getHeaderForTable(mockEval);
|
||||
|
||||
// Count occurrences of commonVar
|
||||
const commonVarCount = header.vars.filter((name) => name === 'commonVar').length;
|
||||
expect(commonVarCount).toBe(1);
|
||||
|
||||
// All vars should be present
|
||||
expect(header.vars).toContain('commonVar');
|
||||
expect(header.vars).toContain('defaultOnly');
|
||||
expect(header.vars).toContain('test1Var');
|
||||
expect(header.vars).toContain('test2Var');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ResultFailureReason } from '../../../src/types/index';
|
||||
import { getHeaderForTable } from '../../../src/util/exportToFile/getHeaderForTable';
|
||||
import {
|
||||
convertEvalResultToTableCell,
|
||||
convertTestResultsToTableRow,
|
||||
} from '../../../src/util/exportToFile/index';
|
||||
|
||||
import type Eval from '../../../src/models/eval';
|
||||
import type EvalResult from '../../../src/models/evalResult';
|
||||
|
||||
describe('exportToFile utils', () => {
|
||||
describe('getHeaderForTable', () => {
|
||||
it('should extract vars from defaultTest', () => {
|
||||
const eval_: Partial<Eval> = {
|
||||
id: 'test-id',
|
||||
createdAt: Date.now(),
|
||||
config: {
|
||||
defaultTest: {
|
||||
vars: {
|
||||
var1: 'value1',
|
||||
var2: 'value2',
|
||||
},
|
||||
},
|
||||
},
|
||||
prompts: [],
|
||||
results: [],
|
||||
persisted: false,
|
||||
};
|
||||
|
||||
const result = getHeaderForTable(eval_ as Eval);
|
||||
expect(result.vars).toEqual(['var1', 'var2']);
|
||||
});
|
||||
|
||||
it('should extract vars from tests array', () => {
|
||||
const eval_: Partial<Eval> = {
|
||||
id: 'test-id',
|
||||
createdAt: Date.now(),
|
||||
config: {
|
||||
tests: [
|
||||
{
|
||||
vars: {
|
||||
var3: 'value3',
|
||||
var4: 'value4',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
prompts: [],
|
||||
results: [],
|
||||
persisted: false,
|
||||
};
|
||||
|
||||
const result = getHeaderForTable(eval_ as Eval);
|
||||
expect(result.vars).toEqual(['var3', 'var4']);
|
||||
});
|
||||
|
||||
it('should extract vars from scenarios', () => {
|
||||
const eval_: Partial<Eval> = {
|
||||
id: 'test-id',
|
||||
createdAt: Date.now(),
|
||||
config: {
|
||||
scenarios: [
|
||||
{
|
||||
config: [
|
||||
{
|
||||
vars: {
|
||||
var5: 'value5',
|
||||
},
|
||||
},
|
||||
],
|
||||
tests: [
|
||||
{
|
||||
vars: {
|
||||
var6: 'value6',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
prompts: [],
|
||||
results: [],
|
||||
persisted: false,
|
||||
};
|
||||
|
||||
const result = getHeaderForTable(eval_ as Eval);
|
||||
expect(result.vars).toEqual(['var5', 'var6']);
|
||||
});
|
||||
|
||||
it('should handle empty config', () => {
|
||||
const eval_: Partial<Eval> = {
|
||||
id: 'test-id',
|
||||
createdAt: Date.now(),
|
||||
config: {},
|
||||
prompts: [],
|
||||
results: [],
|
||||
persisted: false,
|
||||
};
|
||||
|
||||
const result = getHeaderForTable(eval_ as Eval);
|
||||
expect(result.vars).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertEvalResultToTableCell', () => {
|
||||
it('should handle successful assertion result', () => {
|
||||
const result: Partial<EvalResult> = {
|
||||
id: 'test-1',
|
||||
evalId: 'eval-1',
|
||||
testCase: {
|
||||
assert: [
|
||||
{
|
||||
type: 'contains',
|
||||
value: 'test',
|
||||
},
|
||||
],
|
||||
},
|
||||
success: true,
|
||||
response: {
|
||||
output: 'test output',
|
||||
},
|
||||
prompt: {
|
||||
raw: 'test prompt',
|
||||
label: 'test',
|
||||
},
|
||||
provider: {
|
||||
id: 'test-provider',
|
||||
},
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
};
|
||||
|
||||
const output = convertEvalResultToTableCell(result as EvalResult);
|
||||
expect(output.text).toBe('test output');
|
||||
expect(output.pass).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle failed assertion result', () => {
|
||||
const result: Partial<EvalResult> = {
|
||||
id: 'test-1',
|
||||
evalId: 'eval-1',
|
||||
testCase: {
|
||||
assert: [
|
||||
{
|
||||
type: 'contains',
|
||||
value: 'test',
|
||||
},
|
||||
],
|
||||
},
|
||||
success: false,
|
||||
error: 'test error',
|
||||
response: {
|
||||
output: 'test output',
|
||||
},
|
||||
prompt: {
|
||||
raw: 'test prompt',
|
||||
label: 'test',
|
||||
},
|
||||
provider: {
|
||||
id: 'test-provider',
|
||||
},
|
||||
gradingResult: {
|
||||
pass: false,
|
||||
score: 0,
|
||||
reason: 'test failure',
|
||||
componentResults: [
|
||||
{
|
||||
pass: false,
|
||||
score: 0,
|
||||
reason: 'test failure',
|
||||
},
|
||||
],
|
||||
},
|
||||
failureReason: ResultFailureReason.ASSERT,
|
||||
};
|
||||
|
||||
const output = convertEvalResultToTableCell(result as EvalResult);
|
||||
expect(output.text).toBe('test output');
|
||||
expect(output.pass).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle error without assertion', () => {
|
||||
const result: Partial<EvalResult> = {
|
||||
id: 'test-1',
|
||||
evalId: 'eval-1',
|
||||
testCase: {},
|
||||
error: 'test error',
|
||||
prompt: {
|
||||
raw: 'test prompt',
|
||||
label: 'test',
|
||||
},
|
||||
provider: {
|
||||
id: 'test-provider',
|
||||
},
|
||||
failureReason: ResultFailureReason.ERROR,
|
||||
};
|
||||
|
||||
const output = convertEvalResultToTableCell(result as EvalResult);
|
||||
expect(output.text).toBe('test error');
|
||||
});
|
||||
|
||||
it('should preserve falsy outputs like 0 and false', () => {
|
||||
const numericResult: Partial<EvalResult> = {
|
||||
id: 'test-1',
|
||||
evalId: 'eval-1',
|
||||
testCase: {},
|
||||
response: {
|
||||
output: 0,
|
||||
},
|
||||
prompt: {
|
||||
raw: 'test prompt',
|
||||
label: 'test',
|
||||
},
|
||||
provider: {
|
||||
id: 'test-provider',
|
||||
},
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
};
|
||||
|
||||
const booleanResult: Partial<EvalResult> = {
|
||||
id: 'test-2',
|
||||
evalId: 'eval-1',
|
||||
testCase: {},
|
||||
response: {
|
||||
output: false,
|
||||
},
|
||||
prompt: {
|
||||
raw: 'test prompt',
|
||||
label: 'test',
|
||||
},
|
||||
provider: {
|
||||
id: 'test-provider',
|
||||
},
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
};
|
||||
|
||||
const numericOutput = convertEvalResultToTableCell(numericResult as EvalResult);
|
||||
const booleanOutput = convertEvalResultToTableCell(booleanResult as EvalResult);
|
||||
|
||||
expect(numericOutput.text).toBe('0');
|
||||
expect(booleanOutput.text).toBe('false');
|
||||
});
|
||||
|
||||
it('should handle null output by falling back to error', () => {
|
||||
const resultWithError: Partial<EvalResult> = {
|
||||
id: 'test-1',
|
||||
evalId: 'eval-1',
|
||||
testCase: {},
|
||||
response: {
|
||||
output: null,
|
||||
},
|
||||
error: 'Provider returned null',
|
||||
prompt: {
|
||||
raw: 'test prompt',
|
||||
label: 'test',
|
||||
},
|
||||
provider: {
|
||||
id: 'test-provider',
|
||||
},
|
||||
failureReason: ResultFailureReason.ERROR,
|
||||
};
|
||||
|
||||
const resultWithoutError: Partial<EvalResult> = {
|
||||
id: 'test-2',
|
||||
evalId: 'eval-1',
|
||||
testCase: {},
|
||||
response: {
|
||||
output: null,
|
||||
},
|
||||
prompt: {
|
||||
raw: 'test prompt',
|
||||
label: 'test',
|
||||
},
|
||||
provider: {
|
||||
id: 'test-provider',
|
||||
},
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
};
|
||||
|
||||
const outputWithError = convertEvalResultToTableCell(resultWithError as EvalResult);
|
||||
const outputWithoutError = convertEvalResultToTableCell(resultWithoutError as EvalResult);
|
||||
|
||||
expect(outputWithError.text).toBe('Provider returned null');
|
||||
expect(outputWithoutError.text).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertTestResultsToTableRow', () => {
|
||||
it('should convert results to table row', () => {
|
||||
const results: Partial<EvalResult>[] = [
|
||||
{
|
||||
id: 'test-1',
|
||||
evalId: 'eval-1',
|
||||
description: 'test description',
|
||||
promptIdx: 0,
|
||||
testCase: {
|
||||
vars: {
|
||||
var1: 'value1',
|
||||
var2: 'value2',
|
||||
},
|
||||
},
|
||||
response: {
|
||||
output: 'test output',
|
||||
},
|
||||
prompt: {
|
||||
raw: 'test prompt',
|
||||
label: 'test',
|
||||
},
|
||||
provider: {
|
||||
id: 'test-provider',
|
||||
},
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
},
|
||||
];
|
||||
|
||||
const varsForHeader = ['var1', 'var2'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results as EvalResult[], varsForHeader);
|
||||
|
||||
expect(row.description).toBe('test description');
|
||||
expect(row.vars).toEqual(['value1', 'value2']);
|
||||
expect(row.outputs[0].text).toBe('test output');
|
||||
});
|
||||
|
||||
it('should handle complex var values', () => {
|
||||
const results: Partial<EvalResult>[] = [
|
||||
{
|
||||
id: 'test-1',
|
||||
evalId: 'eval-1',
|
||||
promptIdx: 0,
|
||||
testCase: {
|
||||
vars: {
|
||||
var1: { complex: 'object' },
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
raw: 'test prompt',
|
||||
label: 'test',
|
||||
},
|
||||
provider: {
|
||||
id: 'test-provider',
|
||||
},
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
},
|
||||
];
|
||||
|
||||
const varsForHeader = ['var1'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results as EvalResult[], varsForHeader);
|
||||
expect(row.vars).toEqual(['{"complex":"object"}']);
|
||||
});
|
||||
|
||||
it('should preserve falsy var values like 0 and false', () => {
|
||||
const results: Partial<EvalResult>[] = [
|
||||
{
|
||||
id: 'test-1',
|
||||
evalId: 'eval-1',
|
||||
promptIdx: 0,
|
||||
testCase: {
|
||||
vars: {
|
||||
var1: 0,
|
||||
var2: false,
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
raw: 'test prompt',
|
||||
label: 'test',
|
||||
},
|
||||
provider: {
|
||||
id: 'test-provider',
|
||||
},
|
||||
failureReason: ResultFailureReason.NONE,
|
||||
},
|
||||
];
|
||||
|
||||
const varsForHeader = ['var1', 'var2'];
|
||||
|
||||
const row = convertTestResultsToTableRow(results as EvalResult[], varsForHeader);
|
||||
expect(row.vars).toEqual(['0', 'false']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,533 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
extractRateLimitErrorCode,
|
||||
findTargetErrorStatus,
|
||||
formatRateLimitDetail,
|
||||
formatRateLimitErrorMessage,
|
||||
HttpRateLimitError,
|
||||
isAbortError,
|
||||
isHardQuotaCode,
|
||||
isHttpRateLimitError,
|
||||
isNonTransientHttpStatus,
|
||||
isTransientConnectionError,
|
||||
} from '../../../src/util/fetch/errors';
|
||||
|
||||
describe('isAbortError', () => {
|
||||
it('returns true for AbortError and AbortException', () => {
|
||||
const abortError = new Error('aborted');
|
||||
abortError.name = 'AbortError';
|
||||
const abortException = new Error('aborted');
|
||||
abortException.name = 'AbortException';
|
||||
expect(isAbortError(abortError)).toBe(true);
|
||||
expect(isAbortError(abortException)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for other errors and non-errors', () => {
|
||||
expect(isAbortError(new TypeError('terminated'))).toBe(false);
|
||||
expect(isAbortError(new Error('boom'))).toBe(false);
|
||||
expect(isAbortError({ name: 'AbortError' })).toBe(false);
|
||||
expect(isAbortError('AbortError')).toBe(false);
|
||||
expect(isAbortError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNonTransientHttpStatus', () => {
|
||||
it('returns true for 401 Unauthorized', () => {
|
||||
expect(isNonTransientHttpStatus(401)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for 403 Forbidden', () => {
|
||||
expect(isNonTransientHttpStatus(403)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for 404 Not Found', () => {
|
||||
expect(isNonTransientHttpStatus(404)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for 500 Internal Server Error (transient)', () => {
|
||||
expect(isNonTransientHttpStatus(500)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for 501 Not Implemented', () => {
|
||||
expect(isNonTransientHttpStatus(501)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for 200 OK', () => {
|
||||
expect(isNonTransientHttpStatus(200)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for 201 Created', () => {
|
||||
expect(isNonTransientHttpStatus(201)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for 429 Too Many Requests (transient)', () => {
|
||||
expect(isNonTransientHttpStatus(429)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for 502 Bad Gateway (transient)', () => {
|
||||
expect(isNonTransientHttpStatus(502)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for 503 Service Unavailable (transient)', () => {
|
||||
expect(isNonTransientHttpStatus(503)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for 504 Gateway Timeout (transient)', () => {
|
||||
expect(isNonTransientHttpStatus(504)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTargetErrorStatus', () => {
|
||||
it('returns undefined for empty results', () => {
|
||||
expect(findTargetErrorStatus([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no HTTP status in results', () => {
|
||||
const results = [{ response: {} }, { response: { metadata: {} } }];
|
||||
expect(findTargetErrorStatus(results)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for successful HTTP status', () => {
|
||||
const results = [{ response: { metadata: { http: { status: 200 } } } }];
|
||||
expect(findTargetErrorStatus(results)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for transient errors (429, 502, 503, 504)', () => {
|
||||
const results = [
|
||||
{ response: { metadata: { http: { status: 429 } } } },
|
||||
{ response: { metadata: { http: { status: 502 } } } },
|
||||
{ response: { metadata: { http: { status: 503 } } } },
|
||||
{ response: { metadata: { http: { status: 504 } } } },
|
||||
];
|
||||
expect(findTargetErrorStatus(results)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 401 for unauthorized error', () => {
|
||||
const results = [
|
||||
{ response: { metadata: { http: { status: 200 } } } },
|
||||
{ response: { metadata: { http: { status: 401 } } } },
|
||||
];
|
||||
expect(findTargetErrorStatus(results)).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for forbidden error', () => {
|
||||
const results = [{ response: { metadata: { http: { status: 403 } } } }];
|
||||
expect(findTargetErrorStatus(results)).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 404 for not found error', () => {
|
||||
const results = [{ response: { metadata: { http: { status: 404 } } } }];
|
||||
expect(findTargetErrorStatus(results)).toBe(404);
|
||||
});
|
||||
|
||||
it('returns undefined for 500 Internal Server Error (transient)', () => {
|
||||
const results = [{ response: { metadata: { http: { status: 500 } } } }];
|
||||
expect(findTargetErrorStatus(results)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns 501 for not implemented error', () => {
|
||||
const results = [{ response: { metadata: { http: { status: 501 } } } }];
|
||||
expect(findTargetErrorStatus(results)).toBe(501);
|
||||
});
|
||||
|
||||
it('returns first non-transient error found', () => {
|
||||
const results = [
|
||||
{ response: { metadata: { http: { status: 200 } } } },
|
||||
{ response: { metadata: { http: { status: 403 } } } },
|
||||
{ response: { metadata: { http: { status: 404 } } } },
|
||||
];
|
||||
expect(findTargetErrorStatus(results)).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTransientConnectionError', () => {
|
||||
it('returns false for undefined error', () => {
|
||||
expect(isTransientConnectionError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for ECONNRESET errors', () => {
|
||||
const error = new Error('Connection reset') as Error & { code?: string };
|
||||
error.code = 'ECONNRESET';
|
||||
expect(isTransientConnectionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for mixed-case ECONNRESET messages', () => {
|
||||
const error = new Error('EConnReset');
|
||||
expect(isTransientConnectionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for EPIPE errors', () => {
|
||||
const error = new Error('Broken pipe') as Error & { code?: string };
|
||||
error.code = 'EPIPE';
|
||||
expect(isTransientConnectionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for socket hang up errors', () => {
|
||||
const error = new Error('socket hang up');
|
||||
expect(isTransientConnectionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for mixed-case socket hang up errors', () => {
|
||||
const error = new Error('Socket Hang Up');
|
||||
expect(isTransientConnectionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for bad record mac errors', () => {
|
||||
const error = new Error('bad record mac');
|
||||
expect(isTransientConnectionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for mixed-case bad record mac errors', () => {
|
||||
const error = new Error('Bad Record MAC');
|
||||
expect(isTransientConnectionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for standalone eproto errors', () => {
|
||||
const error = new Error('eproto');
|
||||
expect(isTransientConnectionError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for permanent TLS config errors', () => {
|
||||
const error = new Error('eproto self signed certificate');
|
||||
expect(isTransientConnectionError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for eproto unable-to-verify TLS errors', () => {
|
||||
// Must include `eproto` to enter the permanent-error exclusion; otherwise the
|
||||
// message has no transient marker and would return false trivially.
|
||||
const error = new Error('write EPROTO unable to verify the first certificate');
|
||||
expect(isTransientConnectionError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for eproto unknown ca TLS errors', () => {
|
||||
const error = new Error('write EPROTO tlsv1 alert unknown ca');
|
||||
expect(isTransientConnectionError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for eproto certificate verify TLS errors', () => {
|
||||
const error = new Error('write EPROTO certificate verify failed');
|
||||
expect(isTransientConnectionError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for wrong version number errors', () => {
|
||||
const error = new Error('eproto wrong version number');
|
||||
expect(isTransientConnectionError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHardQuotaCode', () => {
|
||||
it.each([
|
||||
['insufficient_quota', true],
|
||||
['billing_hard_limit_reached', true],
|
||||
['billing_not_active', true],
|
||||
['access_terminated', true],
|
||||
['quota_exceeded', true],
|
||||
['rate_limit_exceeded', false],
|
||||
['tokens_per_min', false],
|
||||
['', false],
|
||||
])('isHardQuotaCode(%s) === %s', (code, expected) => {
|
||||
expect(isHardQuotaCode(code as string)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isHardQuotaCode(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRateLimitErrorCode', () => {
|
||||
it('extracts OpenAI / Azure shape: { error: { code } }', () => {
|
||||
expect(
|
||||
extractRateLimitErrorCode({
|
||||
error: { code: 'insufficient_quota', message: 'You exceeded your current quota' },
|
||||
}),
|
||||
).toBe('insufficient_quota');
|
||||
});
|
||||
|
||||
it('falls back to error.type when error.code is missing', () => {
|
||||
expect(extractRateLimitErrorCode({ error: { type: 'rate_limit_error' } })).toBe(
|
||||
'rate_limit_error',
|
||||
);
|
||||
});
|
||||
|
||||
it('reads top-level code', () => {
|
||||
expect(extractRateLimitErrorCode({ code: 'tokens_per_min' })).toBe('tokens_per_min');
|
||||
});
|
||||
|
||||
it('reads top-level type', () => {
|
||||
expect(extractRateLimitErrorCode({ type: 'rate_limit_error' })).toBe('rate_limit_error');
|
||||
});
|
||||
|
||||
it('returns undefined for non-object', () => {
|
||||
expect(extractRateLimitErrorCode('plain text')).toBeUndefined();
|
||||
expect(extractRateLimitErrorCode(null)).toBeUndefined();
|
||||
expect(extractRateLimitErrorCode(undefined)).toBeUndefined();
|
||||
expect(extractRateLimitErrorCode(123)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no code-like field is present', () => {
|
||||
expect(extractRateLimitErrorCode({ error: {} })).toBeUndefined();
|
||||
expect(extractRateLimitErrorCode({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores empty string code', () => {
|
||||
expect(extractRateLimitErrorCode({ error: { code: '' } })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HttpRateLimitError', () => {
|
||||
it('classifies known quota codes as kind="quota"', () => {
|
||||
const err = new HttpRateLimitError({ status: 429, code: 'insufficient_quota' });
|
||||
expect(err.kind).toBe('quota');
|
||||
expect(err.message).toContain('Quota exceeded');
|
||||
expect(err.message).toContain('429');
|
||||
expect(err.message).toContain('insufficient_quota');
|
||||
});
|
||||
|
||||
it('classifies unknown / per-window codes as kind="rate_limit"', () => {
|
||||
const err = new HttpRateLimitError({ status: 429, code: 'rate_limit_exceeded' });
|
||||
expect(err.kind).toBe('rate_limit');
|
||||
expect(err.message).toContain('Rate limit exceeded');
|
||||
expect(err.message).toContain('429');
|
||||
});
|
||||
|
||||
it('preserves status, retryAfterMs, resetAt, headers, body', () => {
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
retryAfterMs: 5000,
|
||||
resetAt: 1_700_000_000_000,
|
||||
headers: { 'retry-after': '5' },
|
||||
body: { error: { code: 'rate_limit_exceeded' } },
|
||||
code: 'rate_limit_exceeded',
|
||||
});
|
||||
expect(err.status).toBe(429);
|
||||
expect(err.statusText).toBe('Too Many Requests');
|
||||
expect(err.retryAfterMs).toBe(5000);
|
||||
expect(err.resetAt).toBe(1_700_000_000_000);
|
||||
expect(err.headers?.['retry-after']).toBe('5');
|
||||
expect(err.body).toEqual({ error: { code: 'rate_limit_exceeded' } });
|
||||
expect(err.code).toBe('rate_limit_exceeded');
|
||||
});
|
||||
|
||||
it('defaults statusText to "Too Many Requests"', () => {
|
||||
const err = new HttpRateLimitError({ status: 429 });
|
||||
expect(err.statusText).toBe('Too Many Requests');
|
||||
});
|
||||
|
||||
it('defaults empty statusText to "Too Many Requests"', () => {
|
||||
const err = new HttpRateLimitError({ status: 429, statusText: '' });
|
||||
expect(err.statusText).toBe('Too Many Requests');
|
||||
expect(formatRateLimitErrorMessage(err)).toContain('Too Many Requests');
|
||||
});
|
||||
|
||||
it('produces a message containing the substrings legacy classifiers match on', () => {
|
||||
const err = new HttpRateLimitError({ status: 429, code: 'rate_limit_exceeded' });
|
||||
const lowered = err.message.toLowerCase();
|
||||
// Back-compat: substring matchers across the codebase look for these tokens
|
||||
expect(err.message).toContain('429');
|
||||
expect(lowered.includes('rate limit') || lowered.includes('too many requests')).toBe(true);
|
||||
});
|
||||
|
||||
it('isHttpRateLimitError type guard works', () => {
|
||||
expect(isHttpRateLimitError(new HttpRateLimitError({ status: 429 }))).toBe(true);
|
||||
expect(isHttpRateLimitError(new Error('rate limit'))).toBe(false);
|
||||
expect(isHttpRateLimitError(undefined)).toBe(false);
|
||||
expect(isHttpRateLimitError(null)).toBe(false);
|
||||
expect(isHttpRateLimitError({ name: 'HttpRateLimitError' })).toBe(false);
|
||||
});
|
||||
|
||||
it('shallow-copies headers so post-construction mutation does not leak in', () => {
|
||||
const headers = { 'retry-after': '5' };
|
||||
const err = new HttpRateLimitError({ status: 429, headers });
|
||||
headers['retry-after'] = '999';
|
||||
expect(err.headers?.['retry-after']).toBe('5');
|
||||
});
|
||||
|
||||
it('rejects negative retryAfterMs', () => {
|
||||
const err = new HttpRateLimitError({ status: 429, retryAfterMs: -100 });
|
||||
expect(err.retryAfterMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a valid resetAt even when retryAfterMs is negative (independent validation)', () => {
|
||||
const resetAt = Date.now() + 30_000;
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
retryAfterMs: -100,
|
||||
resetAt,
|
||||
});
|
||||
expect(err.retryAfterMs).toBeUndefined();
|
||||
expect(err.resetAt).toBe(resetAt);
|
||||
});
|
||||
|
||||
it('drops a negative resetAt when retryAfterMs is valid', () => {
|
||||
const err = new HttpRateLimitError({ status: 429, retryAfterMs: 5000, resetAt: -1 });
|
||||
expect(err.retryAfterMs).toBe(5000);
|
||||
expect(err.resetAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops a non-number resetAt (e.g. string) regardless of retryAfterMs', () => {
|
||||
// Exercises the runtime typeof guard for untyped / `any` callers.
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
resetAt: '1700000000000' as unknown as number,
|
||||
});
|
||||
expect(err.resetAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops non-finite retry metadata', () => {
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
retryAfterMs: Number.POSITIVE_INFINITY,
|
||||
resetAt: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
expect(err.retryAfterMs).toBeUndefined();
|
||||
expect(err.resetAt).toBeUndefined();
|
||||
expect(formatRateLimitDetail(err)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRateLimitDetail', () => {
|
||||
it('renders retry-after seconds', () => {
|
||||
const err = new HttpRateLimitError({ status: 429, retryAfterMs: 12_000 });
|
||||
expect(formatRateLimitDetail(err)).toBe(' [retry after 12s]');
|
||||
});
|
||||
|
||||
it('renders resetAt fallback when retryAfterMs is missing', () => {
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
expect(formatRateLimitDetail(err)).toMatch(/resets in \d+s/);
|
||||
});
|
||||
|
||||
it('prefers retry-after over resetAt when both are present', () => {
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
retryAfterMs: 12_000,
|
||||
resetAt: Date.now() + 999_000,
|
||||
});
|
||||
expect(formatRateLimitDetail(err)).toBe(' [retry after 12s]');
|
||||
});
|
||||
|
||||
it('returns empty string when no metadata is present', () => {
|
||||
const err = new HttpRateLimitError({ status: 429 });
|
||||
expect(formatRateLimitDetail(err)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for kind=quota even when retry metadata is present', () => {
|
||||
// Quota errors should not advertise a "retry after Xs" hint — that
|
||||
// would contradict the "Retries will not help" message the providers
|
||||
// surface to the operator. Use a 2-hour retryAfterMs so the constructor's
|
||||
// "small Retry-After downgrades quota to rate_limit" heuristic does not
|
||||
// fire here.
|
||||
const twoHoursMs = 2 * 60 * 60 * 1000;
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
code: 'insufficient_quota',
|
||||
retryAfterMs: twoHoursMs,
|
||||
resetAt: Date.now() + twoHoursMs,
|
||||
});
|
||||
expect(err.kind).toBe('quota');
|
||||
expect(formatRateLimitDetail(err)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HttpRateLimitError: small Retry-After downgrades quota to rate_limit', () => {
|
||||
// Azure OpenAI returns `insufficient_quota` for both billing exhaustion and
|
||||
// per-minute deployment saturation. A small Retry-After is the server
|
||||
// hinting at recovery — billing quotas don't recover in seconds.
|
||||
it('downgrades insufficient_quota with small retryAfterMs to rate_limit', () => {
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
code: 'insufficient_quota',
|
||||
retryAfterMs: 30_000,
|
||||
});
|
||||
expect(err.kind).toBe('rate_limit');
|
||||
expect(err.code).toBe('insufficient_quota');
|
||||
expect(err.message).toContain('Rate limit exceeded');
|
||||
});
|
||||
|
||||
it('keeps kind=quota when no Retry-After is present', () => {
|
||||
const err = new HttpRateLimitError({ status: 429, code: 'insufficient_quota' });
|
||||
expect(err.kind).toBe('quota');
|
||||
});
|
||||
|
||||
it('keeps kind=quota when Retry-After is large (> 1h)', () => {
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
code: 'insufficient_quota',
|
||||
retryAfterMs: 90 * 60 * 1000,
|
||||
});
|
||||
expect(err.kind).toBe('quota');
|
||||
});
|
||||
|
||||
it('downgrades all hard-quota codes when Retry-After is small', () => {
|
||||
for (const code of ['quota_exceeded', 'billing_hard_limit_reached', 'insufficient_quota']) {
|
||||
const err = new HttpRateLimitError({ status: 429, code, retryAfterMs: 5000 });
|
||||
expect(err.kind).toBe('rate_limit');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRateLimitErrorMessage', () => {
|
||||
it('formats a per-window rate limit with status, code, and retry-after', () => {
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
code: 'rate_limit_exceeded',
|
||||
retryAfterMs: 7000,
|
||||
});
|
||||
expect(formatRateLimitErrorMessage(err)).toBe(
|
||||
'Rate limit exceeded: HTTP 429 Too Many Requests (code: rate_limit_exceeded) [retry after 7s]',
|
||||
);
|
||||
});
|
||||
|
||||
it('formats a hard quota with the non-retryable hint', () => {
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
code: 'insufficient_quota',
|
||||
});
|
||||
expect(formatRateLimitErrorMessage(err)).toBe(
|
||||
'Quota exceeded: HTTP 429 Too Many Requests (code: insufficient_quota). Retries will not help — check your billing or daily quota.',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the code segment when no code is set', () => {
|
||||
const err = new HttpRateLimitError({ status: 429 });
|
||||
expect(formatRateLimitErrorMessage(err)).toBe(
|
||||
'Rate limit exceeded: HTTP 429 Too Many Requests',
|
||||
);
|
||||
});
|
||||
|
||||
it('appends `details` for upstream-supplied context', () => {
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
code: 'rate_limit_exceeded',
|
||||
retryAfterMs: 12_000,
|
||||
});
|
||||
const out = formatRateLimitErrorMessage(
|
||||
err,
|
||||
'Rate limit reached for gpt-4o (current: 1000 TPM)',
|
||||
);
|
||||
expect(out).toBe(
|
||||
'Rate limit exceeded: HTTP 429 Too Many Requests (code: rate_limit_exceeded) Rate limit reached for gpt-4o (current: 1000 TPM) [retry after 12s]',
|
||||
);
|
||||
});
|
||||
|
||||
it('appends `details` before the non-retryable hint on quota errors', () => {
|
||||
const err = new HttpRateLimitError({
|
||||
status: 429,
|
||||
code: 'insufficient_quota',
|
||||
});
|
||||
expect(formatRateLimitErrorMessage(err, 'Quota exhausted for asst_xyz')).toBe(
|
||||
'Quota exceeded: HTTP 429 Too Many Requests (code: insufficient_quota) Quota exhausted for asst_xyz. Retries will not help — check your billing or daily quota.',
|
||||
);
|
||||
});
|
||||
|
||||
it('produces a single non-redundant prefix (no double "Rate limit exceeded")', () => {
|
||||
const err = new HttpRateLimitError({ status: 429, code: 'rate_limit_exceeded' });
|
||||
const out = formatRateLimitErrorMessage(err);
|
||||
expect(out.match(/Rate limit exceeded/g)?.length).toBe(1);
|
||||
expect(out.match(/HTTP 429/g)?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { stripDecompressionHeaders } from '../../../src/util/fetch/stripDecompressionHeaders';
|
||||
import type { Dispatcher } from 'undici';
|
||||
|
||||
type RawHeaderPairs = [string, string][];
|
||||
type ResponseCallbackMode = 'started' | 'start' | 'both';
|
||||
type OnResponseStartArgs = Parameters<NonNullable<Dispatcher.DispatchHandler['onResponseStart']>>;
|
||||
type ParsedHeaders = OnResponseStartArgs[2];
|
||||
type TrackedEvent =
|
||||
| {
|
||||
method: 'onResponseStart';
|
||||
args: OnResponseStartArgs;
|
||||
}
|
||||
| {
|
||||
method: 'onResponseStarted';
|
||||
args: [];
|
||||
};
|
||||
|
||||
interface DispatchResponseStartOptions {
|
||||
rawHeaders?: Buffer[] | null;
|
||||
parsedHeaders: ParsedHeaders;
|
||||
statusCode?: number;
|
||||
dispatchResult?: boolean;
|
||||
callbackMode?: ResponseCallbackMode;
|
||||
}
|
||||
|
||||
interface DispatchResponseStartResult {
|
||||
controller: Dispatcher.DispatchController & { rawHeaders?: Buffer[] | null };
|
||||
events: TrackedEvent[];
|
||||
dispatched: boolean;
|
||||
}
|
||||
|
||||
function makeRawHeaders(pairs: RawHeaderPairs): Buffer[] {
|
||||
return pairs.flatMap(([name, value]) => [Buffer.from(name), Buffer.from(value)]);
|
||||
}
|
||||
|
||||
function rawHeadersToPairs(rawHeaders: Buffer[]): RawHeaderPairs {
|
||||
const pairs: RawHeaderPairs = [];
|
||||
for (let i = 0; i + 1 < rawHeaders.length; i += 2) {
|
||||
pairs.push([rawHeaders[i].toString(), rawHeaders[i + 1].toString()]);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the strip interceptor with a synthetic controller the way undici's
|
||||
* v7→v8 bridge does on Node 26: the controller carries the original raw
|
||||
* headers while the parsed headers reflect any rewriting the decompress
|
||||
* interceptor performed. This exercises the strip logic on every Node
|
||||
* version, without needing a Node 26 fetch.
|
||||
*/
|
||||
function dispatchResponseStart({
|
||||
rawHeaders,
|
||||
parsedHeaders,
|
||||
statusCode = 200,
|
||||
dispatchResult = true,
|
||||
callbackMode = 'both',
|
||||
}: DispatchResponseStartOptions): DispatchResponseStartResult {
|
||||
const events: TrackedEvent[] = [];
|
||||
const innerHandler: Dispatcher.DispatchHandler = {
|
||||
onResponseStart(controller, status, headers, statusMessage) {
|
||||
events.push({
|
||||
method: 'onResponseStart',
|
||||
args: [controller, status, headers, statusMessage],
|
||||
});
|
||||
},
|
||||
onResponseStarted() {
|
||||
events.push({ method: 'onResponseStarted', args: [] });
|
||||
},
|
||||
};
|
||||
const controller = { rawHeaders } as unknown as Dispatcher.DispatchController & {
|
||||
rawHeaders?: Buffer[] | null;
|
||||
};
|
||||
// Real undici handlers use one callback generation. Tests can select either
|
||||
// generation explicitly; "both" is only a compact harness mode.
|
||||
const dispatch: Parameters<Dispatcher.DispatchInterceptor>[0] = (_opts, handler) => {
|
||||
if (callbackMode === 'both' || callbackMode === 'started') {
|
||||
handler.onResponseStarted?.();
|
||||
}
|
||||
if (callbackMode === 'both' || callbackMode === 'start') {
|
||||
handler.onResponseStart?.(controller, statusCode, parsedHeaders, 'OK');
|
||||
}
|
||||
return dispatchResult;
|
||||
};
|
||||
const composed = stripDecompressionHeaders()(dispatch);
|
||||
const dispatched = composed(
|
||||
{ path: '/', method: 'GET' } as Dispatcher.DispatchOptions,
|
||||
innerHandler,
|
||||
);
|
||||
return { controller, events, dispatched };
|
||||
}
|
||||
|
||||
describe('stripDecompressionHeaders', () => {
|
||||
it('strips content-encoding and content-length from rawHeaders when decompress decoded the body', () => {
|
||||
// decompress removed both headers from the parsed headers => body is decoded.
|
||||
const { controller, events } = dispatchResponseStart({
|
||||
rawHeaders: makeRawHeaders([
|
||||
['content-type', 'application/json'],
|
||||
['content-encoding', 'gzip'],
|
||||
['content-length', '123'],
|
||||
['x-request-id', 'abc'],
|
||||
]),
|
||||
parsedHeaders: { 'content-type': 'application/json', 'x-request-id': 'abc' },
|
||||
});
|
||||
|
||||
expect(rawHeadersToPairs(controller.rawHeaders as Buffer[])).toEqual([
|
||||
['content-type', 'application/json'],
|
||||
['x-request-id', 'abc'],
|
||||
]);
|
||||
expect(events.map((e) => e.method)).toEqual(['onResponseStarted', 'onResponseStart']);
|
||||
});
|
||||
|
||||
it('strips mixed-case and repeated content-encoding entries', () => {
|
||||
// Defensive: undici's parseHeaders merges repeated names into one array
|
||||
// value and decompress rejects array-valued content-encoding, so this
|
||||
// exact state is synthetic — the strip should still handle it sanely.
|
||||
const { controller } = dispatchResponseStart({
|
||||
rawHeaders: makeRawHeaders([
|
||||
['Content-Encoding', 'gzip'],
|
||||
['content-encoding', 'br'],
|
||||
['Content-Length', '99'],
|
||||
['Content-Type', 'text/plain'],
|
||||
]),
|
||||
parsedHeaders: { 'content-type': 'text/plain' },
|
||||
});
|
||||
|
||||
expect(rawHeadersToPairs(controller.rawHeaders as Buffer[])).toEqual([
|
||||
['Content-Type', 'text/plain'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves rawHeaders untouched when the parsed headers still carry content-encoding (decompress skipped)', () => {
|
||||
// An unsupported encoding makes the decompress interceptor pass the
|
||||
// response through with its original headers. The raw content-encoding
|
||||
// must survive so callers can tell the body is still encoded.
|
||||
const rawHeaders = makeRawHeaders([
|
||||
['content-encoding', 'x-snappy'],
|
||||
['content-length', '40'],
|
||||
]);
|
||||
const { controller } = dispatchResponseStart({
|
||||
rawHeaders,
|
||||
parsedHeaders: { 'content-encoding': 'x-snappy', 'content-length': '40' },
|
||||
});
|
||||
|
||||
expect(controller.rawHeaders).toBe(rawHeaders);
|
||||
expect(rawHeadersToPairs(controller.rawHeaders as Buffer[])).toEqual([
|
||||
['content-encoding', 'x-snappy'],
|
||||
['content-length', '40'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves content-length untouched on responses that were never compressed', () => {
|
||||
const rawHeaders = makeRawHeaders([
|
||||
['content-type', 'application/json'],
|
||||
['content-length', '123'],
|
||||
]);
|
||||
const { controller } = dispatchResponseStart({
|
||||
rawHeaders,
|
||||
parsedHeaders: { 'content-type': 'application/json', 'content-length': '123' },
|
||||
});
|
||||
|
||||
expect(controller.rawHeaders).toBe(rawHeaders);
|
||||
expect(rawHeadersToPairs(controller.rawHeaders as Buffer[])).toEqual([
|
||||
['content-type', 'application/json'],
|
||||
['content-length', '123'],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
])('passes through when controller.rawHeaders is %s (not an array)', (rawHeaders) => {
|
||||
const { controller, events } = dispatchResponseStart({
|
||||
rawHeaders,
|
||||
parsedHeaders: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(controller.rawHeaders).toBe(rawHeaders);
|
||||
expect(events.map((e) => e.method)).toEqual(['onResponseStarted', 'onResponseStart']);
|
||||
});
|
||||
|
||||
it('blocks the strip when the parsed content-encoding key is not lowercase', () => {
|
||||
// Decompress only decodes on the exact lowercase key, so a differently
|
||||
// cased parsed key means the body was not decoded — the case-insensitive
|
||||
// fallback in the gate must block the strip.
|
||||
const rawHeaders = makeRawHeaders([
|
||||
['content-encoding', 'x-snappy'],
|
||||
['content-length', '40'],
|
||||
]);
|
||||
const { controller } = dispatchResponseStart({
|
||||
rawHeaders,
|
||||
parsedHeaders: { 'Content-Encoding': 'x-snappy' },
|
||||
});
|
||||
|
||||
expect(controller.rawHeaders).toBe(rawHeaders);
|
||||
});
|
||||
|
||||
it.each([true, false])('propagates the dispatch backpressure result (%s)', (dispatchResult) => {
|
||||
const { dispatched } = dispatchResponseStart({
|
||||
rawHeaders: makeRawHeaders([['content-type', 'text/plain']]),
|
||||
parsedHeaders: { 'content-type': 'text/plain' },
|
||||
dispatchResult,
|
||||
});
|
||||
|
||||
expect(dispatched).toBe(dispatchResult);
|
||||
});
|
||||
|
||||
it('preserves a trailing unpaired rawHeaders element when stripping', () => {
|
||||
const rawHeaders = [
|
||||
...makeRawHeaders([
|
||||
['content-encoding', 'gzip'],
|
||||
['x-request-id', 'abc'],
|
||||
]),
|
||||
Buffer.from('dangling'),
|
||||
];
|
||||
const { controller } = dispatchResponseStart({
|
||||
rawHeaders,
|
||||
parsedHeaders: { 'x-request-id': 'abc' },
|
||||
});
|
||||
|
||||
const result = controller.rawHeaders as Buffer[];
|
||||
expect(rawHeadersToPairs(result)).toEqual([['x-request-id', 'abc']]);
|
||||
expect(result[result.length - 1].toString()).toBe('dangling');
|
||||
});
|
||||
|
||||
it('forwards response events and arguments to the inner handler unchanged', () => {
|
||||
const parsedHeaders = { 'content-type': 'application/json' };
|
||||
const { controller, events } = dispatchResponseStart({
|
||||
rawHeaders: makeRawHeaders([['content-type', 'application/json']]),
|
||||
parsedHeaders,
|
||||
callbackMode: 'start',
|
||||
});
|
||||
|
||||
const start = events.find((e) => e.method === 'onResponseStart');
|
||||
expect(start?.args[0]).toBe(controller);
|
||||
expect(start?.args[1]).toBe(200);
|
||||
expect(start?.args[2]).toBe(parsedHeaders);
|
||||
expect(start?.args[3]).toBe('OK');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,335 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as esmModule from '../../src/esm';
|
||||
import logger from '../../src/logger';
|
||||
import * as pythonUtils from '../../src/python/pythonUtils';
|
||||
import { isJavascriptFile } from '../../src/util/fileExtensions';
|
||||
import { loadFileReference, processConfigFileReferences } from '../../src/util/fileReference';
|
||||
import { loadYaml } from '../../src/util/yamlLoad';
|
||||
import type { Logger } from 'winston';
|
||||
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof import('fs')>('fs');
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
promises: {
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
},
|
||||
promises: {
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('path');
|
||||
vi.mock('../../src/util/yamlLoad');
|
||||
vi.mock('../../src/esm', () => ({
|
||||
importModule: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../src/python/pythonUtils', () => ({
|
||||
runPython: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../src/util/fileExtensions');
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const importModule = vi.mocked(esmModule.importModule);
|
||||
const runPython = vi.mocked(pythonUtils.runPython);
|
||||
const readFileMock = vi.mocked(fs.promises.readFile);
|
||||
|
||||
describe('fileReference utility functions', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
vi.mocked(logger.debug).mockImplementation((_message: string) => {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
});
|
||||
|
||||
vi.mocked(logger.error).mockImplementation((_message: string) => {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
});
|
||||
|
||||
vi.mocked(path.resolve).mockImplementation((basePath, filePath) =>
|
||||
filePath?.startsWith('/') ? filePath : path.join(basePath || '', filePath || ''),
|
||||
);
|
||||
|
||||
vi.mocked(path.join).mockImplementation((...parts) => parts.filter(Boolean).join('/'));
|
||||
|
||||
vi.mocked(path.extname).mockImplementation((filePath) => {
|
||||
if (!filePath) {
|
||||
return '';
|
||||
}
|
||||
const parts = filePath.split('.');
|
||||
return parts.length > 1 ? `.${parts[parts.length - 1]}` : '';
|
||||
});
|
||||
|
||||
vi.mocked(isJavascriptFile).mockImplementation((filePath) => {
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
return ['.js', '.mjs', '.ts', '.cjs'].some((ext) => filePath.endsWith(ext));
|
||||
});
|
||||
|
||||
importModule.mockResolvedValue({});
|
||||
runPython.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe('loadFileReference', () => {
|
||||
it('should load JSON files correctly', async () => {
|
||||
const fileRef = 'file:///path/to/config.json';
|
||||
const fileContent = '{"name": "test", "value": 42}';
|
||||
const parsedContent = { name: 'test', value: 42 };
|
||||
|
||||
readFileMock.mockResolvedValue(fileContent);
|
||||
vi.spyOn(JSON, 'parse').mockReturnValue(parsedContent);
|
||||
|
||||
const result = await loadFileReference(fileRef);
|
||||
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith('/path/to/config.json', 'utf8');
|
||||
expect(result).toEqual(parsedContent);
|
||||
});
|
||||
|
||||
it('should load YAML files correctly', async () => {
|
||||
const fileRef = 'file:///path/to/config.yaml';
|
||||
const fileContent = 'name: test\nvalue: 42';
|
||||
const parsedContent = { name: 'test', value: 42 };
|
||||
|
||||
readFileMock.mockResolvedValue(fileContent);
|
||||
vi.mocked(loadYaml).mockReturnValue(parsedContent);
|
||||
|
||||
const result = await loadFileReference(fileRef);
|
||||
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith('/path/to/config.yaml', 'utf8');
|
||||
expect(loadYaml).toHaveBeenCalledWith(fileContent);
|
||||
expect(result).toEqual(parsedContent);
|
||||
});
|
||||
|
||||
it('should load JavaScript files correctly', async () => {
|
||||
const fileRef = 'file:///path/to/config.js';
|
||||
const moduleOutput = { settings: { temperature: 0.7 } };
|
||||
|
||||
vi.mocked(isJavascriptFile).mockReturnValue(true);
|
||||
importModule.mockResolvedValue(moduleOutput);
|
||||
|
||||
const result = await loadFileReference(fileRef);
|
||||
|
||||
expect(importModule).toHaveBeenCalledWith('/path/to/config.js', undefined);
|
||||
expect(result).toEqual(moduleOutput);
|
||||
});
|
||||
|
||||
it('should load JavaScript files with function name correctly', async () => {
|
||||
const fileRef = 'file:///path/to/config.js:getConfig';
|
||||
const moduleOutput = { getConfig: 'success' };
|
||||
|
||||
vi.mocked(isJavascriptFile).mockReturnValue(true);
|
||||
importModule.mockResolvedValue(moduleOutput);
|
||||
|
||||
const result = await loadFileReference(fileRef);
|
||||
|
||||
expect(importModule).toHaveBeenCalledWith('/path/to/config.js', 'getConfig');
|
||||
expect(result).toEqual(moduleOutput);
|
||||
});
|
||||
|
||||
it('should load Python files correctly', async () => {
|
||||
const fileRef = 'file:///path/to/config.py';
|
||||
const pythonOutput = { message: 'Hello from Python' };
|
||||
|
||||
runPython.mockResolvedValue(pythonOutput);
|
||||
|
||||
const result = await loadFileReference(fileRef);
|
||||
|
||||
expect(runPython).toHaveBeenCalledWith('/path/to/config.py', 'get_config', []);
|
||||
expect(result).toEqual(pythonOutput);
|
||||
});
|
||||
|
||||
it('should load Python files with function name correctly', async () => {
|
||||
const fileRef = 'file:///path/to/config.py:custom_func';
|
||||
const pythonOutput = { custom: true };
|
||||
|
||||
runPython.mockResolvedValue(pythonOutput);
|
||||
|
||||
const result = await loadFileReference(fileRef);
|
||||
|
||||
expect(runPython).toHaveBeenCalledWith('/path/to/config.py', 'custom_func', []);
|
||||
expect(result).toEqual(pythonOutput);
|
||||
});
|
||||
|
||||
it('should load text files correctly', async () => {
|
||||
const fileRef = 'file:///path/to/config.txt';
|
||||
const fileContent = 'This is a text file';
|
||||
|
||||
readFileMock.mockResolvedValue(fileContent);
|
||||
|
||||
const result = await loadFileReference(fileRef);
|
||||
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith('/path/to/config.txt', 'utf8');
|
||||
expect(result).toEqual(fileContent);
|
||||
});
|
||||
|
||||
it('should resolve file paths relative to the basePath', async () => {
|
||||
const fileRef = 'file://config.json';
|
||||
const basePath = '/base/path';
|
||||
const fileContent = '{"name": "test"}';
|
||||
const parsedContent = { name: 'test' };
|
||||
|
||||
vi.mocked(path.resolve).mockReturnValue('/base/path/config.json');
|
||||
readFileMock.mockResolvedValue(fileContent);
|
||||
vi.spyOn(JSON, 'parse').mockReturnValue(parsedContent);
|
||||
|
||||
const result = await loadFileReference(fileRef, basePath);
|
||||
|
||||
expect(path.resolve).toHaveBeenCalledWith('/base/path', 'config.json');
|
||||
expect(fs.promises.readFile).toHaveBeenCalledWith('/base/path/config.json', 'utf8');
|
||||
expect(result).toEqual(parsedContent);
|
||||
});
|
||||
|
||||
it('should throw an error for unsupported file types', async () => {
|
||||
const fileRef = 'file:///path/to/file.xyz';
|
||||
|
||||
vi.mocked(path.extname).mockReturnValue('.xyz');
|
||||
vi.mocked(isJavascriptFile).mockReturnValue(false);
|
||||
|
||||
await expect(loadFileReference(fileRef)).rejects.toThrow('Unsupported file extension: .xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processConfigFileReferences', () => {
|
||||
it('should return primitive values as is', async () => {
|
||||
await expect(processConfigFileReferences(42)).resolves.toBe(42);
|
||||
await expect(processConfigFileReferences('test')).resolves.toBe('test');
|
||||
await expect(processConfigFileReferences(true)).resolves.toBe(true);
|
||||
await expect(processConfigFileReferences(null)).resolves.toBeNull();
|
||||
await expect(processConfigFileReferences(undefined)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should process a simple file reference string', async () => {
|
||||
const config = 'file:///path/to/config.json';
|
||||
const parsedContent = { name: 'test', value: 42 };
|
||||
|
||||
readFileMock.mockResolvedValue(JSON.stringify(parsedContent));
|
||||
vi.spyOn(JSON, 'parse').mockReturnValue(parsedContent);
|
||||
|
||||
const result = await processConfigFileReferences(config);
|
||||
|
||||
expect(result).toEqual(parsedContent);
|
||||
});
|
||||
|
||||
it('should process nested file references in objects', async () => {
|
||||
const config = {
|
||||
setting1: 'value1',
|
||||
setting2: 'file:///path/to/setting2.json',
|
||||
nested: {
|
||||
setting3: 'file:///path/to/setting3.yaml',
|
||||
},
|
||||
};
|
||||
|
||||
readFileMock.mockImplementation((path) => {
|
||||
if (path === '/path/to/setting2.json') {
|
||||
return Promise.resolve('{"key": "value2"}');
|
||||
}
|
||||
if (path === '/path/to/setting3.yaml') {
|
||||
return Promise.resolve('key: value3');
|
||||
}
|
||||
return Promise.resolve('');
|
||||
});
|
||||
|
||||
vi.spyOn(JSON, 'parse').mockImplementation((content) => {
|
||||
if (content === '{"key": "value2"}') {
|
||||
return { key: 'value2' };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mocked(loadYaml).mockImplementation((content) => {
|
||||
if (content === 'key: value3') {
|
||||
return { key: 'value3' };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const result = await processConfigFileReferences(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
setting1: 'value1',
|
||||
setting2: { key: 'value2' },
|
||||
nested: {
|
||||
setting3: { key: 'value3' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should process file references in arrays', async () => {
|
||||
const config = [
|
||||
'regular string',
|
||||
'file:///path/to/item1.json',
|
||||
'file:///path/to/item2.yaml',
|
||||
42,
|
||||
];
|
||||
|
||||
readFileMock.mockImplementation((path) => {
|
||||
if (path === '/path/to/item1.json') {
|
||||
return Promise.resolve('{"name": "item1"}');
|
||||
}
|
||||
if (path === '/path/to/item2.yaml') {
|
||||
return Promise.resolve('name: item2');
|
||||
}
|
||||
return Promise.resolve('');
|
||||
});
|
||||
|
||||
vi.spyOn(JSON, 'parse').mockImplementation((content) => {
|
||||
if (content === '{"name": "item1"}') {
|
||||
return { name: 'item1' };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
vi.mocked(loadYaml).mockImplementation((content) => {
|
||||
if (content === 'name: item2') {
|
||||
return { name: 'item2' };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const result = await processConfigFileReferences(config);
|
||||
|
||||
expect(result).toEqual(['regular string', { name: 'item1' }, { name: 'item2' }, 42]);
|
||||
});
|
||||
|
||||
it('should handle errors when processing file references', async () => {
|
||||
const config = {
|
||||
valid: 'regular value',
|
||||
invalid: 'file:///path/to/nonexistent.json',
|
||||
};
|
||||
|
||||
readFileMock.mockImplementation((path) => {
|
||||
if (path === '/path/to/nonexistent.json') {
|
||||
return Promise.reject(new Error('File not found'));
|
||||
}
|
||||
return Promise.resolve('');
|
||||
});
|
||||
|
||||
await expect(processConfigFileReferences(config)).rejects.toThrow('File not found');
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('File not found'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeFinishReason } from '../../src/util/finishReason';
|
||||
|
||||
describe('normalizeFinishReason', () => {
|
||||
describe('OpenAI mappings', () => {
|
||||
it('should pass through OpenAI standard reasons unchanged', () => {
|
||||
expect(normalizeFinishReason('stop')).toBe('stop');
|
||||
expect(normalizeFinishReason('length')).toBe('length');
|
||||
expect(normalizeFinishReason('content_filter')).toBe('content_filter');
|
||||
expect(normalizeFinishReason('tool_calls')).toBe('tool_calls');
|
||||
});
|
||||
|
||||
it('should map function_call to tool_calls', () => {
|
||||
expect(normalizeFinishReason('function_call')).toBe('tool_calls');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anthropic mappings', () => {
|
||||
it('should normalize Anthropic reasons to standard values', () => {
|
||||
expect(normalizeFinishReason('end_turn')).toBe('stop');
|
||||
expect(normalizeFinishReason('stop_sequence')).toBe('stop');
|
||||
expect(normalizeFinishReason('max_tokens')).toBe('length');
|
||||
expect(normalizeFinishReason('tool_use')).toBe('tool_calls');
|
||||
expect(normalizeFinishReason('refusal')).toBe('content_filter');
|
||||
});
|
||||
|
||||
it('should preserve pause_turn as-is', () => {
|
||||
expect(normalizeFinishReason('pause_turn')).toBe('pause_turn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('case normalization', () => {
|
||||
it('should handle uppercase input', () => {
|
||||
expect(normalizeFinishReason('STOP')).toBe('stop');
|
||||
expect(normalizeFinishReason('LENGTH')).toBe('length');
|
||||
expect(normalizeFinishReason('END_TURN')).toBe('stop');
|
||||
});
|
||||
|
||||
it('should handle mixed case input', () => {
|
||||
expect(normalizeFinishReason('Stop')).toBe('stop');
|
||||
expect(normalizeFinishReason('Length')).toBe('length');
|
||||
expect(normalizeFinishReason('End_Turn')).toBe('stop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null and undefined', () => {
|
||||
expect(normalizeFinishReason(null)).toBeUndefined();
|
||||
expect(normalizeFinishReason(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty and whitespace strings', () => {
|
||||
expect(normalizeFinishReason('')).toBeUndefined();
|
||||
expect(normalizeFinishReason(' ')).toBeUndefined();
|
||||
expect(normalizeFinishReason('\t\n')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle non-string input', () => {
|
||||
expect(normalizeFinishReason(123 as any)).toBeUndefined();
|
||||
expect(normalizeFinishReason({} as any)).toBeUndefined();
|
||||
expect(normalizeFinishReason([] as any)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should trim whitespace', () => {
|
||||
expect(normalizeFinishReason(' stop ')).toBe('stop');
|
||||
expect(normalizeFinishReason('\tlength\n')).toBe('length');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unmapped reasons', () => {
|
||||
it('should pass through unknown reasons unchanged', () => {
|
||||
expect(normalizeFinishReason('unknown_reason')).toBe('unknown_reason');
|
||||
expect(normalizeFinishReason('custom_stop')).toBe('custom_stop');
|
||||
});
|
||||
|
||||
it('should preserve case for unknown reasons after normalization', () => {
|
||||
expect(normalizeFinishReason('CUSTOM_REASON')).toBe('custom_reason');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatDuration } from '../../src/util/formatDuration';
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('should format seconds only', () => {
|
||||
expect(formatDuration(45)).toBe('45s');
|
||||
expect(formatDuration(0)).toBe('0s');
|
||||
expect(formatDuration(59)).toBe('59s');
|
||||
});
|
||||
|
||||
it('should format minutes and seconds', () => {
|
||||
expect(formatDuration(60)).toBe('1m 0s');
|
||||
expect(formatDuration(65)).toBe('1m 5s');
|
||||
expect(formatDuration(119)).toBe('1m 59s');
|
||||
expect(formatDuration(600)).toBe('10m 0s');
|
||||
});
|
||||
|
||||
it('should format hours, minutes, and seconds', () => {
|
||||
expect(formatDuration(3600)).toBe('1h 0m 0s');
|
||||
expect(formatDuration(3661)).toBe('1h 1m 1s');
|
||||
expect(formatDuration(7382)).toBe('2h 3m 2s');
|
||||
});
|
||||
|
||||
it('should handle edge cases correctly', () => {
|
||||
// Test with decimal values - should round down
|
||||
expect(formatDuration(45.9)).toBe('45s');
|
||||
|
||||
// Test with very large values
|
||||
expect(formatDuration(86400)).toBe('24h 0m 0s'); // 1 day
|
||||
expect(formatDuration(90061)).toBe('25h 1m 1s'); // 1 day, 1 hour, 1 minute, 1 second
|
||||
|
||||
// Test with string numbers (TypeScript should handle this implicitly)
|
||||
expect(formatDuration(Number('120'))).toBe('2m 0s');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { importModule } from '../../../src/esm';
|
||||
import { runPython } from '../../../src/python/pythonUtils';
|
||||
import {
|
||||
functionCache,
|
||||
loadFunction,
|
||||
parseFileUrl,
|
||||
} from '../../../src/util/functions/loadFunction';
|
||||
|
||||
vi.mock('../../../src/esm', () => ({
|
||||
importModule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/python/pythonUtils', () => ({
|
||||
runPython: vi.fn(),
|
||||
}));
|
||||
|
||||
// Use hoisted mock values that work on all platforms
|
||||
const { TEST_JS_PATH, TEST_PY_PATH, TEST_TXT_PATH, mockResolve } = vi.hoisted(() => {
|
||||
const TEST_JS_PATH = '/test/resolved/function.js';
|
||||
const TEST_PY_PATH = '/test/resolved/function.py';
|
||||
const TEST_TXT_PATH = '/test/resolved/function.txt';
|
||||
const mockResolve = vi.fn((...args: string[]): string => {
|
||||
const lastArg = args[args.length - 1] || '';
|
||||
if (lastArg.endsWith('.py')) {
|
||||
return TEST_PY_PATH;
|
||||
}
|
||||
if (lastArg.endsWith('.txt')) {
|
||||
return TEST_TXT_PATH;
|
||||
}
|
||||
return TEST_JS_PATH;
|
||||
});
|
||||
return { TEST_JS_PATH, TEST_PY_PATH, TEST_TXT_PATH, mockResolve };
|
||||
});
|
||||
|
||||
vi.mock('path', async () => {
|
||||
const actual = await vi.importActual<typeof import('path')>('path');
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
resolve: mockResolve,
|
||||
},
|
||||
resolve: mockResolve,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../src/cliState', () => ({
|
||||
default: {
|
||||
basePath: '/base/path',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('loadFunction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolve.mockReset();
|
||||
mockResolve.mockImplementation((...args: string[]) => {
|
||||
const lastArg = args[args.length - 1] || '';
|
||||
if (lastArg.endsWith('.py')) {
|
||||
return TEST_PY_PATH;
|
||||
}
|
||||
if (lastArg.endsWith('.txt')) {
|
||||
return TEST_TXT_PATH;
|
||||
}
|
||||
return TEST_JS_PATH;
|
||||
});
|
||||
// Clear the function cache
|
||||
Object.keys(functionCache).forEach((key) => delete functionCache[key]);
|
||||
});
|
||||
|
||||
describe('JavaScript functions', () => {
|
||||
it('should load a JavaScript function with explicit function name', async () => {
|
||||
const mockFn = vi.fn();
|
||||
vi.mocked(importModule).mockResolvedValue(mockFn);
|
||||
|
||||
const result = await loadFunction({
|
||||
filePath: 'function.js',
|
||||
functionName: 'customFunction',
|
||||
});
|
||||
|
||||
expect(importModule).toHaveBeenCalledWith(TEST_JS_PATH, 'customFunction');
|
||||
expect(result).toBe(mockFn);
|
||||
});
|
||||
|
||||
it('should load a JavaScript function with default export', async () => {
|
||||
const mockFn = vi.fn();
|
||||
vi.mocked(importModule).mockResolvedValue(mockFn);
|
||||
|
||||
const result = await loadFunction({
|
||||
filePath: 'function.js',
|
||||
});
|
||||
|
||||
expect(importModule).toHaveBeenCalledWith(TEST_JS_PATH, undefined);
|
||||
expect(result).toBe(mockFn);
|
||||
});
|
||||
|
||||
it('should load a JavaScript function from default.default export', async () => {
|
||||
const mockFn = vi.fn();
|
||||
vi.mocked(importModule).mockResolvedValue({ default: { default: mockFn } });
|
||||
|
||||
const result = await loadFunction({
|
||||
filePath: 'function.js',
|
||||
});
|
||||
|
||||
expect(importModule).toHaveBeenCalledWith(TEST_JS_PATH, undefined);
|
||||
expect(result).toBe(mockFn);
|
||||
});
|
||||
|
||||
it('should throw error if JavaScript file does not export a function', async () => {
|
||||
vi.mocked(importModule).mockResolvedValue({ notAFunction: 'string' });
|
||||
|
||||
await expect(
|
||||
loadFunction({
|
||||
filePath: 'function.js',
|
||||
functionName: 'customFunction',
|
||||
}),
|
||||
).rejects.toThrow('JavaScript file must export a "customFunction" function');
|
||||
});
|
||||
|
||||
it('should use function cache when enabled', async () => {
|
||||
const mockFn = vi.fn();
|
||||
vi.mocked(importModule).mockResolvedValue(mockFn);
|
||||
|
||||
// First call
|
||||
const result1 = await loadFunction({
|
||||
filePath: 'function.js',
|
||||
useCache: true,
|
||||
});
|
||||
|
||||
// Second call - should use cache
|
||||
const result2 = await loadFunction({
|
||||
filePath: 'function.js',
|
||||
useCache: true,
|
||||
});
|
||||
|
||||
expect(importModule).toHaveBeenCalledTimes(1);
|
||||
expect(result1).toBe(result2);
|
||||
expect(result1).toBe(mockFn);
|
||||
});
|
||||
|
||||
it('should keep cached functions isolated by resolved path', async () => {
|
||||
const mockFn1 = vi.fn();
|
||||
const mockFn2 = vi.fn();
|
||||
mockResolve.mockImplementation((basePath, filePath) => `${basePath}/${filePath}`);
|
||||
vi.mocked(importModule).mockResolvedValueOnce(mockFn1).mockResolvedValueOnce(mockFn2);
|
||||
|
||||
const result1 = await loadFunction({
|
||||
filePath: 'function.js',
|
||||
basePath: '/base/one',
|
||||
useCache: true,
|
||||
});
|
||||
const result2 = await loadFunction({
|
||||
filePath: 'function.js',
|
||||
basePath: '/base/two',
|
||||
useCache: true,
|
||||
});
|
||||
|
||||
expect(importModule).toHaveBeenCalledTimes(2);
|
||||
expect(importModule).toHaveBeenNthCalledWith(1, '/base/one/function.js', undefined);
|
||||
expect(importModule).toHaveBeenNthCalledWith(2, '/base/two/function.js', undefined);
|
||||
expect(result1).toBe(mockFn1);
|
||||
expect(result2).toBe(mockFn2);
|
||||
});
|
||||
|
||||
it('should keep cached functions isolated by default function name', async () => {
|
||||
const mockFn1 = vi.fn();
|
||||
const mockFn2 = vi.fn();
|
||||
vi.mocked(importModule).mockResolvedValue({ first: mockFn1, second: mockFn2 });
|
||||
|
||||
const result1 = await loadFunction({
|
||||
filePath: 'function.js',
|
||||
defaultFunctionName: 'first',
|
||||
useCache: true,
|
||||
});
|
||||
const result2 = await loadFunction({
|
||||
filePath: 'function.js',
|
||||
defaultFunctionName: 'second',
|
||||
useCache: true,
|
||||
});
|
||||
|
||||
expect(importModule).toHaveBeenCalledTimes(2);
|
||||
expect(result1).toBe(mockFn1);
|
||||
expect(result2).toBe(mockFn2);
|
||||
});
|
||||
|
||||
it('should not use cache when disabled', async () => {
|
||||
const mockFn1 = vi.fn();
|
||||
const mockFn2 = vi.fn();
|
||||
vi.mocked(importModule).mockResolvedValueOnce(mockFn1).mockResolvedValueOnce(mockFn2);
|
||||
|
||||
// First call
|
||||
const result1 = await loadFunction({
|
||||
filePath: 'function.js',
|
||||
useCache: false,
|
||||
});
|
||||
|
||||
// Second call
|
||||
const result2 = await loadFunction({
|
||||
filePath: 'function.js',
|
||||
useCache: false,
|
||||
});
|
||||
|
||||
expect(importModule).toHaveBeenCalledTimes(2);
|
||||
expect(result1).not.toBe(result2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Python functions', () => {
|
||||
it('should load a Python function with explicit function name', async () => {
|
||||
const mockPythonResult = vi.fn();
|
||||
vi.mocked(runPython).mockImplementation((...args) => mockPythonResult(...args));
|
||||
|
||||
const result = await loadFunction({
|
||||
filePath: 'function.py',
|
||||
functionName: 'custom_function',
|
||||
});
|
||||
|
||||
expect(typeof result).toBe('function');
|
||||
await result('test input');
|
||||
expect(runPython).toHaveBeenCalledWith(TEST_PY_PATH, 'custom_function', ['test input']);
|
||||
});
|
||||
|
||||
it('should use default function name for Python when none specified', async () => {
|
||||
const mockPythonResult = vi.fn();
|
||||
vi.mocked(runPython).mockImplementation((...args) => mockPythonResult(...args));
|
||||
|
||||
const result = await loadFunction({
|
||||
filePath: 'function.py',
|
||||
});
|
||||
|
||||
expect(typeof result).toBe('function');
|
||||
await result('test input');
|
||||
expect(runPython).toHaveBeenCalledWith(TEST_PY_PATH, 'func', ['test input']);
|
||||
});
|
||||
|
||||
it('should keep cached Python functions isolated by default function name', async () => {
|
||||
const result1 = await loadFunction({
|
||||
filePath: 'function.py',
|
||||
defaultFunctionName: 'first',
|
||||
useCache: true,
|
||||
});
|
||||
const result2 = await loadFunction({
|
||||
filePath: 'function.py',
|
||||
defaultFunctionName: 'second',
|
||||
useCache: true,
|
||||
});
|
||||
|
||||
expect(result1).not.toBe(result2);
|
||||
|
||||
await result1('first input');
|
||||
await result2('second input');
|
||||
|
||||
expect(runPython).toHaveBeenNthCalledWith(1, TEST_PY_PATH, 'first', ['first input']);
|
||||
expect(runPython).toHaveBeenNthCalledWith(2, TEST_PY_PATH, 'second', ['second input']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should throw error for unsupported file types', async () => {
|
||||
await expect(
|
||||
loadFunction({
|
||||
filePath: 'function.txt',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'File must be a JavaScript (js, cjs, mjs, ts, cts, mts) or Python (.py) file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle import errors', async () => {
|
||||
const error = new Error('Import failed');
|
||||
vi.mocked(importModule).mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
loadFunction({
|
||||
filePath: 'function.js',
|
||||
}),
|
||||
).rejects.toThrow('Import failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFileUrl', () => {
|
||||
it('should parse file URL with function name', () => {
|
||||
const result = parseFileUrl('file:///path/to/file.js:functionName');
|
||||
expect(result).toEqual({
|
||||
filePath: '/path/to/file.js',
|
||||
functionName: 'functionName',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse file URL without function name', () => {
|
||||
const result = parseFileUrl('file:///path/to/file.js');
|
||||
expect(result).toEqual({
|
||||
filePath: '/path/to/file.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid file URL', () => {
|
||||
expect(() => parseFileUrl('/path/to/file.js')).toThrow('URL must start with file://');
|
||||
});
|
||||
|
||||
it('should handle Windows-style paths', () => {
|
||||
const result = parseFileUrl('file://C:/path/to/file.js:functionName');
|
||||
expect(result).toEqual({
|
||||
filePath: 'C:/path/to/file.js',
|
||||
functionName: 'functionName',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle standard Windows file URLs on Windows', () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
|
||||
try {
|
||||
expect(parseFileUrl('file:///C:/path/to/file.js')).toEqual({
|
||||
filePath: 'C:/path/to/file.js',
|
||||
});
|
||||
expect(parseFileUrl('file:///C:/path/to/file.js:functionName')).toEqual({
|
||||
filePath: 'C:/path/to/file.js',
|
||||
functionName: 'functionName',
|
||||
});
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve standard Windows-looking file URLs as POSIX paths on POSIX', () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
||||
|
||||
try {
|
||||
expect(parseFileUrl('file:///C:/path/to/file.js')).toEqual({
|
||||
filePath: '/C:/path/to/file.js',
|
||||
});
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle relative paths', () => {
|
||||
const result = parseFileUrl('file://./path/to/file.js:functionName');
|
||||
expect(result).toEqual({
|
||||
filePath: './path/to/file.js',
|
||||
functionName: 'functionName',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse Python file URLs with function names', () => {
|
||||
const result = parseFileUrl('file://./path/to/file.py:function_name');
|
||||
expect(result).toEqual({
|
||||
filePath: './path/to/file.py',
|
||||
functionName: 'function_name',
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve colons in default-export file paths', () => {
|
||||
const result = parseFileUrl('file://./path/to/file:default.js');
|
||||
expect(result).toEqual({
|
||||
filePath: './path/to/file:default.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse named exports from file paths that contain colons', () => {
|
||||
const result = parseFileUrl('file://./path/to/file:default.js:functionName');
|
||||
expect(result).toEqual({
|
||||
filePath: './path/to/file:default.js',
|
||||
functionName: 'functionName',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse named exports from directory paths that contain colons', () => {
|
||||
const result = parseFileUrl('file://path:with:colons/hooks.js:functionName');
|
||||
expect(result).toEqual({
|
||||
filePath: 'path:with:colons/hooks.js',
|
||||
functionName: 'functionName',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { retryWithDeduplication, sampleArray } from '../../src/util/generation';
|
||||
|
||||
describe('retryWithDeduplication', () => {
|
||||
it('should collect unique items until target count is reached', async () => {
|
||||
const operation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([1, 2, 3])
|
||||
.mockResolvedValueOnce([3, 4, 5])
|
||||
.mockResolvedValueOnce([5, 6, 7]);
|
||||
|
||||
const result = await retryWithDeduplication(operation, 5);
|
||||
|
||||
expect(result).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should stop after max consecutive retries', async () => {
|
||||
const operation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([1, 2])
|
||||
.mockResolvedValueOnce([1, 2])
|
||||
.mockResolvedValueOnce([1, 2]);
|
||||
|
||||
const result = await retryWithDeduplication(operation, 5, 2);
|
||||
|
||||
expect(result).toEqual([1, 2]);
|
||||
expect(operation).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('should use custom deduplication function', async () => {
|
||||
const operation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: 1 }, { id: 2 }])
|
||||
.mockResolvedValueOnce([{ id: 2 }, { id: 3 }]);
|
||||
|
||||
const customDedupFn = (items: { id: number }[]) =>
|
||||
Array.from(new Set(items.map((item) => item.id))).map((id) => ({ id }));
|
||||
|
||||
const result = await retryWithDeduplication(operation, 3, 2, customDedupFn);
|
||||
|
||||
expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle empty results from operation', async () => {
|
||||
const operation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([1, 2])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([3]);
|
||||
|
||||
const result = await retryWithDeduplication(operation, 3);
|
||||
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
expect(operation).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should return all unique items even if target count is not reached', async () => {
|
||||
const operation = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([1, 2])
|
||||
.mockResolvedValueOnce([2, 3])
|
||||
.mockResolvedValueOnce([3, 4]);
|
||||
|
||||
const result = await retryWithDeduplication(operation, 10, 2);
|
||||
|
||||
expect(result).toEqual([1, 2, 3, 4]);
|
||||
expect(operation).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sampleArray', () => {
|
||||
it('should return n random items when n is less than array length', () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const result = sampleArray(array, 3);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(new Set(result).size).toBe(3); // All items are unique
|
||||
result.forEach((item) => expect(array).toContain(item));
|
||||
});
|
||||
|
||||
it('should return all items when n is equal to array length', () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const result = sampleArray(array, 5);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(new Set(result).size).toBe(5);
|
||||
expect(result).toEqual(expect.arrayContaining(array));
|
||||
});
|
||||
|
||||
it('should return all items when n is greater than array length', () => {
|
||||
const array = [1, 2, 3];
|
||||
const result = sampleArray(array, 5);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toEqual(expect.arrayContaining(array));
|
||||
});
|
||||
|
||||
it('should return an empty array when input array is empty', () => {
|
||||
const result = sampleArray([], 3);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return a new array, not modifying the original', () => {
|
||||
const array = [1, 2, 3, 4, 5];
|
||||
const originalArray = [...array];
|
||||
sampleArray(array, 3);
|
||||
|
||||
expect(array).toEqual(originalArray);
|
||||
});
|
||||
|
||||
it('should return random samples across multiple calls', () => {
|
||||
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const samples = new Set();
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const result = sampleArray(array, 5);
|
||||
samples.add(result.join(','));
|
||||
}
|
||||
|
||||
// With 100 samples, it's extremely unlikely to get the same sample every time
|
||||
// unless the randomization is not working
|
||||
expect(samples.size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildConfiguredProviderMap,
|
||||
GRADING_PROVIDER_TYPE_KEYS,
|
||||
isProviderTypeMap,
|
||||
resolveConfiguredProviderReference,
|
||||
} from '../../src/util/gradingProvider';
|
||||
|
||||
import type { ApiProvider } from '../../src/types/providers';
|
||||
|
||||
function makeProvider(id: string, label?: string): ApiProvider {
|
||||
return {
|
||||
id: vi.fn().mockReturnValue(id),
|
||||
label,
|
||||
callApi: vi.fn().mockResolvedValue({ output: '' }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('GRADING_PROVIDER_TYPE_KEYS', () => {
|
||||
it('lists the four supported grading-provider types', () => {
|
||||
expect([...GRADING_PROVIDER_TYPE_KEYS]).toEqual([
|
||||
'text',
|
||||
'embedding',
|
||||
'classification',
|
||||
'moderation',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProviderTypeMap', () => {
|
||||
it.each([
|
||||
['null', null, false],
|
||||
['undefined', undefined, false],
|
||||
['string', 'litellm:judge', false],
|
||||
['array', ['litellm:judge'], false],
|
||||
['empty object', {}, false],
|
||||
['ProviderOptions (has id)', { id: 'litellm:judge', config: {} }, false],
|
||||
[
|
||||
'ApiProvider instance',
|
||||
{ id: () => 'x', callApi: () => Promise.resolve({ output: '' }) },
|
||||
false,
|
||||
],
|
||||
['ProviderOptionsMap (unknown key)', { 'litellm:judge': { config: {} } }, false],
|
||||
['ProviderOptionsMap (type-named key)', { text: { config: {} } }, false],
|
||||
['type key with empty string value', { text: '' }, false],
|
||||
['type key with undefined value', { text: undefined }, false],
|
||||
])('returns false for %s', (_label, value, expected) => {
|
||||
expect(isProviderTypeMap(value)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['text', { text: 'litellm:judge' }],
|
||||
['text ProviderOptions', { text: { id: 'litellm:judge', config: {} } }],
|
||||
['embedding', { embedding: 'litellm:embed' }],
|
||||
['classification', { classification: 'classifier' }],
|
||||
['moderation', { moderation: 'moderator' }],
|
||||
['multiple', { text: 'litellm:judge', embedding: 'litellm:embed' }],
|
||||
])('returns true for typed map (%s)', (_label, value) => {
|
||||
expect(isProviderTypeMap(value)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildConfiguredProviderMap', () => {
|
||||
it('returns a null-prototype map', () => {
|
||||
const map = buildConfiguredProviderMap([makeProvider('a')]);
|
||||
expect(Object.getPrototypeOf(map)).toBeNull();
|
||||
});
|
||||
|
||||
it('indexes providers by id and label', () => {
|
||||
const provider = makeProvider('openai:gpt-4', 'gpt');
|
||||
const map = buildConfiguredProviderMap([provider]);
|
||||
expect(map['openai:gpt-4']).toBe(provider);
|
||||
expect(map.gpt).toBe(provider);
|
||||
});
|
||||
|
||||
it('does not let a later provider label shadow an earlier provider id', () => {
|
||||
const byId = makeProvider('judge');
|
||||
const byLabel = makeProvider('litellm:judge', 'judge');
|
||||
const map = buildConfiguredProviderMap([byId, byLabel]);
|
||||
expect(map.judge).toBe(byId);
|
||||
expect(map['litellm:judge']).toBe(byLabel);
|
||||
});
|
||||
|
||||
it('is independent of provider iteration order for id/label collisions', () => {
|
||||
const byId = makeProvider('judge');
|
||||
const byLabel = makeProvider('litellm:judge', 'judge');
|
||||
const reverse = buildConfiguredProviderMap([byLabel, byId]);
|
||||
expect(reverse.judge).toBe(byId);
|
||||
expect(reverse['litellm:judge']).toBe(byLabel);
|
||||
});
|
||||
|
||||
it('still applies a label alias when no id collides', () => {
|
||||
const provider = makeProvider('litellm:judge', 'judge');
|
||||
const map = buildConfiguredProviderMap([provider]);
|
||||
expect(map.judge).toBe(provider);
|
||||
expect(map['litellm:judge']).toBe(provider);
|
||||
});
|
||||
|
||||
it('does not surface prototype properties through hasOwn lookups', () => {
|
||||
const map = buildConfiguredProviderMap([makeProvider('a')]);
|
||||
expect(Object.hasOwn(map, '__proto__')).toBe(false);
|
||||
expect(Object.hasOwn(map, 'constructor')).toBe(false);
|
||||
expect(Object.hasOwn(map, 'toString')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns an empty null-prototype map for an empty input', () => {
|
||||
const map = buildConfiguredProviderMap([]);
|
||||
expect(Object.getPrototypeOf(map)).toBeNull();
|
||||
expect(Object.keys(map)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveConfiguredProviderReference', () => {
|
||||
it('preserves a fully resolved provider map when no entry needs env injection', () => {
|
||||
const provider = makeProvider('litellm:judge');
|
||||
const typeMap = { text: provider };
|
||||
|
||||
expect(
|
||||
resolveConfiguredProviderReference(typeMap, buildConfiguredProviderMap([]), {
|
||||
API_KEY: 'suite-key',
|
||||
}),
|
||||
).toBe(typeMap);
|
||||
});
|
||||
|
||||
it('adds env only when a deferred typed provider must still be loaded', () => {
|
||||
const embeddingProvider = makeProvider('litellm:embedding:judge');
|
||||
const typeMap = { text: 'litellm:judge', embedding: embeddingProvider };
|
||||
|
||||
expect(
|
||||
resolveConfiguredProviderReference(typeMap, buildConfiguredProviderMap([]), {
|
||||
API_KEY: 'suite-key',
|
||||
}),
|
||||
).toEqual({
|
||||
text: { id: 'litellm:judge', env: { API_KEY: 'suite-key' } },
|
||||
embedding: embeddingProvider,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves configured typed entries while leaving unconfigured alternatives lazy', () => {
|
||||
const textProvider = makeProvider('litellm:judge');
|
||||
const providerMap = buildConfiguredProviderMap([textProvider]);
|
||||
|
||||
expect(
|
||||
resolveConfiguredProviderReference(
|
||||
{ text: 'litellm:judge', embedding: 'unsupported-provider:unused-embedding' },
|
||||
providerMap,
|
||||
),
|
||||
).toEqual({
|
||||
text: textProvider,
|
||||
embedding: 'unsupported-provider:unused-embedding',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves an id-only typed provider option to a configured provider instance', () => {
|
||||
const textProvider = makeProvider('litellm:judge');
|
||||
const providerMap = buildConfiguredProviderMap([textProvider]);
|
||||
|
||||
expect(
|
||||
resolveConfiguredProviderReference({ text: { id: 'litellm:judge' } }, providerMap),
|
||||
).toEqual({ text: textProvider });
|
||||
});
|
||||
|
||||
it('keeps a typed provider option with overrides inline', () => {
|
||||
const textProvider = makeProvider('litellm:judge');
|
||||
const providerMap = buildConfiguredProviderMap([textProvider]);
|
||||
const inlineProvider = { text: { id: 'litellm:judge', config: { temperature: 0 } } };
|
||||
|
||||
expect(resolveConfiguredProviderReference(inlineProvider, providerMap)).toBe(inlineProvider);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getShareAuthorizedBlob } from '../../src/blobs';
|
||||
import { createBlobInlineCache, inlineBlobRefsForShare } from '../../src/util/inlineBlobsForShare';
|
||||
|
||||
import type { StoredBlob } from '../../src/blobs';
|
||||
|
||||
vi.mock('../../src/blobs', () => ({
|
||||
getShareAuthorizedBlob: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('inlineBlobRefsForShare', () => {
|
||||
const hash = 'a'.repeat(64);
|
||||
const uri = `promptfoo://blob/${hash}`;
|
||||
const bytes = Buffer.from('image-bytes');
|
||||
const storedBlob: StoredBlob = {
|
||||
data: bytes,
|
||||
metadata: {
|
||||
createdAt: '2026-06-08T00:00:00.000Z',
|
||||
key: 'blob-key',
|
||||
mimeType: 'image/png',
|
||||
provider: 'filesystem',
|
||||
sizeBytes: bytes.length,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getShareAuthorizedBlob).mockResolvedValue(storedBlob);
|
||||
});
|
||||
|
||||
it('inlines authorized blob URIs as data URLs', async () => {
|
||||
const result = await inlineBlobRefsForShare(
|
||||
{ output: uri },
|
||||
createBlobInlineCache(),
|
||||
'local-eval-1',
|
||||
);
|
||||
|
||||
expect(result.output).toBe(`data:image/png;base64,${bytes.toString('base64')}`);
|
||||
expect(getShareAuthorizedBlob).toHaveBeenCalledWith(hash, 'local-eval-1');
|
||||
});
|
||||
|
||||
it('does not inline a copied blob URI that is not share-authorized for the eval', async () => {
|
||||
vi.mocked(getShareAuthorizedBlob).mockResolvedValue(null);
|
||||
|
||||
const result = await inlineBlobRefsForShare(
|
||||
{ output: uri },
|
||||
createBlobInlineCache(),
|
||||
'local-eval-1',
|
||||
);
|
||||
|
||||
expect(result.output).toBe(uri);
|
||||
expect(getShareAuthorizedBlob).toHaveBeenCalledWith(hash, 'local-eval-1');
|
||||
});
|
||||
|
||||
it('caches the authorization decision across calls sharing a cache', async () => {
|
||||
vi.mocked(getShareAuthorizedBlob).mockResolvedValue(null);
|
||||
const cache = createBlobInlineCache();
|
||||
|
||||
await inlineBlobRefsForShare({ output: uri }, cache, 'local-eval-1');
|
||||
await inlineBlobRefsForShare([uri], cache, 'local-eval-1');
|
||||
|
||||
expect(getShareAuthorizedBlob).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import invariant from '../../src/util/invariant';
|
||||
|
||||
describe('invariant', () => {
|
||||
it('should not throw when condition is true', () => {
|
||||
expect(() => invariant(true)).not.toThrow();
|
||||
expect(() => invariant(1)).not.toThrow();
|
||||
expect(() => invariant({})).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when condition is false', () => {
|
||||
expect(() => invariant(false)).toThrow('Invariant failed');
|
||||
expect(() => invariant(0)).toThrow('Invariant failed');
|
||||
expect(() => invariant(null)).toThrow('Invariant failed');
|
||||
expect(() => invariant(undefined)).toThrow('Invariant failed');
|
||||
});
|
||||
|
||||
it('should include the provided message in the error', () => {
|
||||
const message = 'Custom error message';
|
||||
expect(() => invariant(false, message)).toThrow('Invariant failed: Custom error message');
|
||||
});
|
||||
|
||||
it('should support message callback functions', () => {
|
||||
const getMessage = () => 'Dynamic message';
|
||||
expect(() => invariant(false, getMessage)).toThrow('Invariant failed: Dynamic message');
|
||||
});
|
||||
|
||||
it('should work for type narrowing', () => {
|
||||
const getValue = (): string | null => 'test';
|
||||
const value = getValue();
|
||||
|
||||
// This should compile without type errors
|
||||
invariant(value !== null, 'Value should not be null');
|
||||
|
||||
// After the invariant, TypeScript should know that value is a string
|
||||
const length: number = value.length;
|
||||
expect(length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { isPathWithinDir } from '../../src/util/isPathWithinDir';
|
||||
|
||||
describe('isPathWithinDir', () => {
|
||||
let testRoot: string;
|
||||
let workspace: string;
|
||||
let outsideDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'permissions-test-'));
|
||||
workspace = path.join(testRoot, 'workspace');
|
||||
outsideDir = path.join(testRoot, 'outside');
|
||||
|
||||
await fs.mkdir(workspace);
|
||||
await fs.mkdir(outsideDir);
|
||||
await fs.mkdir(path.join(workspace, 'subdir'));
|
||||
|
||||
await fs.writeFile(path.join(workspace, 'file.txt'), 'test');
|
||||
await fs.writeFile(path.join(workspace, 'subdir', 'nested.txt'), 'test');
|
||||
await fs.writeFile(path.join(outsideDir, 'external.txt'), 'test');
|
||||
|
||||
// Create a symlink that points outside the workspace (for symlink attack testing)
|
||||
await fs.symlink(path.join(outsideDir, 'external.txt'), path.join(workspace, 'evil-symlink'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should allow files within directory', async () => {
|
||||
await expect(isPathWithinDir('file.txt', workspace)).resolves.toBe(true);
|
||||
await expect(isPathWithinDir('./file.txt', workspace)).resolves.toBe(true);
|
||||
await expect(isPathWithinDir('subdir/nested.txt', workspace)).resolves.toBe(true);
|
||||
await expect(isPathWithinDir('./subdir/nested.txt', workspace)).resolves.toBe(true);
|
||||
await expect(isPathWithinDir(path.join(workspace, 'file.txt'), workspace)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('should block files outside directory using relative paths', async () => {
|
||||
await expect(isPathWithinDir('../outside/external.txt', workspace)).resolves.toBe(false);
|
||||
await expect(isPathWithinDir('../../external.txt', workspace)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should block files outside directory using absolute paths', async () => {
|
||||
await expect(isPathWithinDir('/etc/hosts', workspace)).resolves.toBe(false);
|
||||
await expect(isPathWithinDir(path.join(outsideDir, 'external.txt'), workspace)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should block access to parent directory', async () => {
|
||||
await expect(isPathWithinDir('..', workspace)).resolves.toBe(false);
|
||||
await expect(isPathWithinDir('../', workspace)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should allow directory itself', async () => {
|
||||
await expect(isPathWithinDir('.', workspace)).resolves.toBe(true);
|
||||
await expect(isPathWithinDir(workspace, workspace)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('should handle path traversal attempts', async () => {
|
||||
await expect(isPathWithinDir('subdir/../../outside/external.txt', workspace)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should block symlinks that point outside directory (symlink attack)', async () => {
|
||||
await expect(isPathWithinDir('evil-symlink', workspace)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should allow non-existent paths within directory (for Write tool)', async () => {
|
||||
await expect(isPathWithinDir('nonexistent.txt', workspace)).resolves.toBe(true);
|
||||
await expect(isPathWithinDir('subdir/new-file.txt', workspace)).resolves.toBe(true);
|
||||
|
||||
await expect(isPathWithinDir('../nonexistent.txt', workspace)).resolves.toBe(false);
|
||||
await expect(isPathWithinDir('../../nonexistent.txt', workspace)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error immediately if directory itself does not exist', async () => {
|
||||
const nonExistentDir = path.join(testRoot, 'does-not-exist');
|
||||
|
||||
await expect(isPathWithinDir('file.txt', nonExistentDir)).rejects.toThrow(
|
||||
'Directory does not exist or is inaccessible',
|
||||
);
|
||||
await expect(isPathWithinDir('subdir/file.txt', nonExistentDir)).rejects.toThrow(
|
||||
'Directory does not exist or is inaccessible',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,828 @@
|
||||
import dedent from 'dedent';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ResultFailureReason } from '../../src/types/index';
|
||||
import {
|
||||
convertSlashCommentsToHash,
|
||||
extractFirstJsonObject,
|
||||
extractJsonObjects,
|
||||
getAjv,
|
||||
isValidJson,
|
||||
orderKeys,
|
||||
resetAjv,
|
||||
safeJsonStringify,
|
||||
summarizeEvaluateResultForLogging,
|
||||
} from '../../src/util/json';
|
||||
import { createEvaluateResult } from '../factories/eval';
|
||||
import { createAtomicTestCase, createPrompt } from '../factories/testSuite';
|
||||
import { mockProcessEnv } from './utils';
|
||||
|
||||
import type { EvaluateResult } from '../../src/types/index';
|
||||
|
||||
describe('json utilities', () => {
|
||||
describe('getAjv', () => {
|
||||
beforeAll(() => {
|
||||
mockProcessEnv({ NODE_ENV: 'test' });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_AJV_STRICT_MODE: undefined });
|
||||
resetAjv();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_AJV_STRICT_MODE: undefined });
|
||||
});
|
||||
|
||||
it('should create an Ajv instance with default options', () => {
|
||||
const ajv = getAjv();
|
||||
expect(ajv).toBeDefined();
|
||||
expect(ajv.opts.strictSchema).toBe(true);
|
||||
});
|
||||
|
||||
it('should disable strict mode when PROMPTFOO_DISABLE_AJV_STRICT_MODE is set', () => {
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_AJV_STRICT_MODE: 'true' });
|
||||
const ajv = getAjv();
|
||||
expect(ajv.opts.strictSchema).toBe(false);
|
||||
});
|
||||
|
||||
it('should add formats to the Ajv instance', () => {
|
||||
const ajv = getAjv();
|
||||
expect(ajv.formats).toBeDefined();
|
||||
expect(Object.keys(ajv.formats)).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reuse the same instance on subsequent calls', () => {
|
||||
const firstInstance = getAjv();
|
||||
const secondInstance = getAjv();
|
||||
expect(firstInstance).toBe(secondInstance);
|
||||
});
|
||||
|
||||
it('should reset the instance when resetAjv is called', () => {
|
||||
const firstInstance = getAjv();
|
||||
resetAjv();
|
||||
const secondInstance = getAjv();
|
||||
expect(firstInstance).not.toBe(secondInstance);
|
||||
});
|
||||
|
||||
it('should only allow resetAjv to be called in test environment', () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
try {
|
||||
mockProcessEnv({ NODE_ENV: 'production' });
|
||||
expect(() => resetAjv()).toThrow('resetAjv can only be called in test environment');
|
||||
} finally {
|
||||
mockProcessEnv({ NODE_ENV: originalNodeEnv });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidJson', () => {
|
||||
it('returns true for valid JSON', () => {
|
||||
expect(isValidJson('{"key": "value"}')).toBe(true);
|
||||
expect(isValidJson('[1, 2, 3]')).toBe(true);
|
||||
expect(isValidJson('"string"')).toBe(true);
|
||||
expect(isValidJson('123')).toBe(true);
|
||||
expect(isValidJson('true')).toBe(true);
|
||||
expect(isValidJson('null')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for invalid JSON', () => {
|
||||
expect(isValidJson('{')).toBe(false);
|
||||
expect(isValidJson('["unclosed array"')).toBe(false);
|
||||
expect(isValidJson('{"key": value}')).toBe(false);
|
||||
expect(isValidJson('undefined')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeJsonStringify', () => {
|
||||
it('stringifies simple objects', () => {
|
||||
const obj: { key: string; number: number } = { key: 'value', number: 123 };
|
||||
expect(safeJsonStringify(obj)).toBe('{"key":"value","number":123}');
|
||||
});
|
||||
|
||||
it('handles circular references', () => {
|
||||
const obj: { key: string; circular?: any } = { key: 'value' };
|
||||
obj.circular = obj;
|
||||
expect(safeJsonStringify(obj)).toBe('{"key":"value"}');
|
||||
});
|
||||
|
||||
it('pretty prints when specified', () => {
|
||||
const obj: { key: string; nested: { inner: string } } = {
|
||||
key: 'value',
|
||||
nested: { inner: 'content' },
|
||||
};
|
||||
const expected = dedent`
|
||||
{
|
||||
"key": "value",
|
||||
"nested": {
|
||||
"inner": "content"
|
||||
}
|
||||
}`;
|
||||
expect(safeJsonStringify(obj, true)).toBe(expected);
|
||||
});
|
||||
|
||||
it('handles null values', () => {
|
||||
expect(safeJsonStringify(null)).toBe('null');
|
||||
});
|
||||
|
||||
it('handles undefined values', () => {
|
||||
expect(safeJsonStringify(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined or strips non-serializable values', () => {
|
||||
expect(safeJsonStringify(() => {})).toBeUndefined(); // Function
|
||||
expect(safeJsonStringify(Symbol('sym'))).toBeUndefined(); // Symbol
|
||||
expect(safeJsonStringify({ key: 'value' })).toBe('{"key":"value"}');
|
||||
});
|
||||
|
||||
it('returns undefined for circular references in arrays', () => {
|
||||
const arr: (number | any[])[] = [1, 2, 3];
|
||||
arr.push(arr);
|
||||
expect(safeJsonStringify(arr)).toBe('[1,2,3,null]');
|
||||
});
|
||||
|
||||
it('handles complex nested structures', () => {
|
||||
const complex: {
|
||||
string: string;
|
||||
number: number;
|
||||
boolean: boolean;
|
||||
null: null;
|
||||
array: (number | string | { three: number })[];
|
||||
nested: { a: number; b: number[] };
|
||||
} = {
|
||||
string: 'value',
|
||||
number: 123,
|
||||
boolean: true,
|
||||
null: null,
|
||||
array: [1, 'two', { three: 3 }],
|
||||
nested: {
|
||||
a: 1,
|
||||
b: [2, 3],
|
||||
},
|
||||
};
|
||||
const result = safeJsonStringify(complex);
|
||||
expect(result).toBeDefined(); // Ensure it returns a string
|
||||
expect(JSON.parse(result as string)).toEqual(complex); // Ensure it matches the original structure
|
||||
});
|
||||
|
||||
it('handles arrays with circular references', () => {
|
||||
const arr: any[] = [1, 2, 3];
|
||||
arr.push(arr);
|
||||
expect(safeJsonStringify(arr)).toBe('[1,2,3,null]');
|
||||
});
|
||||
|
||||
it('handles nested circular references', () => {
|
||||
const obj: any = { a: { b: {} } };
|
||||
obj.a.b.c = obj.a;
|
||||
expect(safeJsonStringify(obj)).toBe('{"a":{"b":{}}}');
|
||||
});
|
||||
|
||||
it('preserves shared non-circular references', () => {
|
||||
const shared = { value: 'shared' };
|
||||
expect(safeJsonStringify({ a: shared, b: shared, c: [shared] })).toBe(
|
||||
'{"a":{"value":"shared"},"b":{"value":"shared"},"c":[{"value":"shared"}]}',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves non-circular nested structures', () => {
|
||||
const nested = { a: { b: { c: 1 } }, d: [1, 2, { e: 3 }] };
|
||||
expect(JSON.parse(safeJsonStringify(nested) as string)).toEqual(nested);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJsonObjects', () => {
|
||||
it('should extract a single JSON object from a string', () => {
|
||||
const input = '{"key": "value"}';
|
||||
const expectedOutput = [{ key: 'value' }];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should extract multiple JSON objects from a string', () => {
|
||||
const input = 'yolo {"key1": "value1"} some text {"key2": "value2"} fomo';
|
||||
const expectedOutput = [{ key1: 'value1' }, { key2: 'value2' }];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should return an empty array if no JSON objects are found', () => {
|
||||
const input = 'no json here';
|
||||
const expectedOutput: any[] = [];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle nested JSON objects', () => {
|
||||
const input = 'wassup {"outer": {"inner": "value"}, "foo": [1,2,3,4]}';
|
||||
const expectedOutput = [{ outer: { inner: 'value' }, foo: [1, 2, 3, 4] }];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle comments', () => {
|
||||
const input = `{
|
||||
"reason": "all good",
|
||||
"score": 1.0 // perfect score
|
||||
}`;
|
||||
const expectedOutput = [{ reason: 'all good', score: 1.0 }];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle jsonl', () => {
|
||||
const input = `{"reason": "hello", "score": 1.0}
|
||||
{"reason": "world", "score": 0.0}`;
|
||||
const expectedOutput = [
|
||||
{ reason: 'hello', score: 1.0 },
|
||||
{ reason: 'world', score: 0.0 },
|
||||
];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle invalid JSON gracefully', () => {
|
||||
const input = '{"key": "value" some text {"key2": "value2"}';
|
||||
const expectedOutput = [{ key2: 'value2' }];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle incomplete JSON', () => {
|
||||
const input = `{
|
||||
"incomplete": "object"`;
|
||||
expect(extractJsonObjects(input)).toEqual([
|
||||
{
|
||||
incomplete: 'object',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle string containing incomplete JSON', () => {
|
||||
const input = dedent`{
|
||||
"key1": "value1",
|
||||
"key2": {
|
||||
"nested": "value2"
|
||||
},
|
||||
"key3": "value3"
|
||||
}
|
||||
{
|
||||
"incomplete": "object"`;
|
||||
expect(extractJsonObjects(input)).toEqual([
|
||||
{
|
||||
key1: 'value1',
|
||||
key2: {
|
||||
nested: 'value2',
|
||||
},
|
||||
key3: 'value3',
|
||||
},
|
||||
{
|
||||
incomplete: 'object',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle this case', () => {
|
||||
const obj = {
|
||||
vars: [
|
||||
{
|
||||
language: 'Klingon',
|
||||
body: 'Live long and prosper',
|
||||
},
|
||||
{
|
||||
language: 'Elvish',
|
||||
body: 'Good morning',
|
||||
},
|
||||
{
|
||||
language: 'Esperanto',
|
||||
body: 'I love learning languages',
|
||||
},
|
||||
{
|
||||
language: 'Morse Code',
|
||||
body: 'Help',
|
||||
},
|
||||
{
|
||||
language: 'Emoji',
|
||||
body: 'I am feeling happy 😊',
|
||||
},
|
||||
{
|
||||
language: 'Binary',
|
||||
body: 'Yes',
|
||||
},
|
||||
{
|
||||
language: 'Javascript',
|
||||
body: 'Hello, World!',
|
||||
},
|
||||
{
|
||||
language: 'Shakespearean',
|
||||
body: 'To be or not to be',
|
||||
},
|
||||
{
|
||||
language: 'Leet Speak',
|
||||
body: 'You are amazing',
|
||||
},
|
||||
{
|
||||
language: 'Old English',
|
||||
body: 'What is thy name?',
|
||||
},
|
||||
{
|
||||
language: 'Yoda Speak',
|
||||
body: 'Strong with the force, you are',
|
||||
},
|
||||
],
|
||||
};
|
||||
const input = JSON.stringify(obj);
|
||||
expect(extractJsonObjects(input)).toEqual([obj]);
|
||||
});
|
||||
|
||||
it('should handle YAML-style unquoted strings', () => {
|
||||
const input = '{key: value, another_key: another value}';
|
||||
const expectedOutput = [{ key: 'value', another_key: 'another value' }];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
describe('convertSlashCommentsToHash', () => {
|
||||
it('should convert basic // comments to # comments', () => {
|
||||
const input = 'some text // this is a comment';
|
||||
const expected = 'some text # this is a comment';
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should not convert // inside double quoted strings', () => {
|
||||
const input = '"this // is not a comment" // but this is';
|
||||
const expected = '"this // is not a comment" # but this is';
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should not convert // inside single quoted strings', () => {
|
||||
const input = "'don't convert // here' // convert here";
|
||||
const expected = "'don't convert // here' # convert here";
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle escaped quotes in strings', () => {
|
||||
const input = '"escaped \\" quote // not a comment" // real comment';
|
||||
const expected = '"escaped \\" quote // not a comment" # real comment';
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle multiple comments in one line', () => {
|
||||
const input = 'text // first comment // not a second comment';
|
||||
const expected = 'text # first comment // not a second comment';
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle comments at the start of the line', () => {
|
||||
const input = '// comment at start\ntext // comment at end';
|
||||
const expected = '# comment at start\ntext # comment at end';
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle strings with multiple lines', () => {
|
||||
const input = dedent`
|
||||
line1 // comment 1
|
||||
"string with // not a comment"
|
||||
line3 // comment 2`;
|
||||
const expected = dedent`
|
||||
line1 # comment 1
|
||||
"string with // not a comment"
|
||||
line3 # comment 2`;
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle nested quotes', () => {
|
||||
const input = '"outer \\"inner // not comment\\" still outer" // real comment';
|
||||
const expected = '"outer \\"inner // not comment\\" still outer" # real comment';
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle mixed single and double quotes', () => {
|
||||
const input = '"contains \'nested\' // not comment" // real comment';
|
||||
const expected = '"contains \'nested\' // not comment" # real comment';
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
expect(convertSlashCommentsToHash('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle strings without comments', () => {
|
||||
const input = 'no comments here';
|
||||
expect(convertSlashCommentsToHash(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('should handle strings with only a comment', () => {
|
||||
const input = '// just a comment';
|
||||
const expected = '# just a comment';
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle multiple sequential comment markers', () => {
|
||||
const input = 'text //// double comment';
|
||||
const expected = 'text ## double comment';
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle JSON-like structures', () => {
|
||||
const input =
|
||||
'{\n "key": "value", // comment here\n "key2": "value2" // another comment\n}';
|
||||
const expected =
|
||||
'{\n "key": "value", # comment here\n "key2": "value2" # another comment\n}';
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle comments after different types of values', () => {
|
||||
const inputs = [
|
||||
'null // after null',
|
||||
'true // after boolean',
|
||||
'42 // after number',
|
||||
'"string" // after string',
|
||||
'[] // after array',
|
||||
'{} // after object',
|
||||
];
|
||||
const expected = inputs.map((input) => input.replace('//', '#'));
|
||||
inputs.forEach((input, i) => {
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected[i]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not treat URL schemes as comments', () => {
|
||||
const inputs = [
|
||||
'url: http://example.com/path',
|
||||
'url: https://example.com/path',
|
||||
'url: http://example.com//double-slash',
|
||||
];
|
||||
inputs.forEach((input) => {
|
||||
expect(convertSlashCommentsToHash(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
it('should still convert comments after URLs', () => {
|
||||
const input = 'url: http://example.com/path // trailing comment';
|
||||
const expected = 'url: http://example.com/path # trailing comment';
|
||||
expect(convertSlashCommentsToHash(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip non-object YAML values', () => {
|
||||
const input = 'plain string {key: value} 42 {another: obj} true';
|
||||
const expectedOutput = [{ key: 'value' }, { another: 'obj' }];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle YAML with mixed quoted and unquoted strings', () => {
|
||||
const input = '{unquoted: value, "quoted": "value", another: "mixed"}';
|
||||
const expectedOutput = [{ unquoted: 'value', quoted: 'value', another: 'mixed' }];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should ignore YAML arrays and scalars', () => {
|
||||
const input = '[1, 2, 3] {obj: value} plain: string {another: obj}';
|
||||
const expectedOutput = [{ obj: 'value' }, { another: 'obj' }];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle YAML with special characters', () => {
|
||||
const input = '{key: value with spaces, special: value!@#$%, empty:}';
|
||||
const expectedOutput = [{ key: 'value with spaces', special: 'value!@#$%', empty: null }];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
describe('LLM output scenarios', () => {
|
||||
it('should handle JSON with inline comments', () => {
|
||||
const input = dedent`
|
||||
{
|
||||
"reason": "The test passed",
|
||||
"score": 1.0 # The score is perfect because all criteria were met
|
||||
}`;
|
||||
const expectedOutput = [
|
||||
{
|
||||
reason: 'The test passed',
|
||||
score: 1.0,
|
||||
},
|
||||
];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle JSON with multiline string containing markdown', () => {
|
||||
const input = dedent`
|
||||
{
|
||||
"reason": "The summary provided is mostly accurate but contains some omissions:
|
||||
|
||||
- Point 1: Details here
|
||||
- Point 2: More details
|
||||
|
||||
Some additional context...",
|
||||
"score": 0.75
|
||||
}`;
|
||||
const expectedOutput = [
|
||||
{
|
||||
reason:
|
||||
'The summary provided is mostly accurate but contains some omissions:\n- Point 1: Details here - Point 2: More details\nSome additional context...',
|
||||
score: 0.75,
|
||||
},
|
||||
];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle JSON with unescaped backticks', () => {
|
||||
const input = dedent`
|
||||
{
|
||||
"reason": "\`\`The summary accurately reflects...",
|
||||
"score": 1.0
|
||||
}`;
|
||||
const expectedOutput = [
|
||||
{
|
||||
reason: '``The summary accurately reflects...',
|
||||
score: 1.0,
|
||||
},
|
||||
];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle JSON with trailing commas', () => {
|
||||
const input = dedent`
|
||||
{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
}`;
|
||||
const expectedOutput = [
|
||||
{
|
||||
key1: 'value1',
|
||||
key2: 'value2',
|
||||
},
|
||||
];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should handle multiple JSON objects in LLM output with text in between', () => {
|
||||
const input = dedent`
|
||||
Here's my analysis:
|
||||
{
|
||||
"first_point": "valid observation",
|
||||
"score": 0.8
|
||||
}
|
||||
|
||||
And another point:
|
||||
{
|
||||
"second_point": "another observation",
|
||||
"score": 0.9
|
||||
}`;
|
||||
const expectedOutput = [
|
||||
{
|
||||
first_point: 'valid observation',
|
||||
score: 0.8,
|
||||
},
|
||||
{
|
||||
second_point: 'another observation',
|
||||
score: 0.9,
|
||||
},
|
||||
];
|
||||
expect(extractJsonObjects(input)).toEqual(expectedOutput);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('orderKeys', () => {
|
||||
it('orders keys according to specified order', () => {
|
||||
const obj = { c: 3, a: 1, b: 2 };
|
||||
const order: (keyof typeof obj)[] = ['a', 'b', 'c'];
|
||||
const result = orderKeys(obj, order);
|
||||
expect(Object.keys(result)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('places unspecified keys at the end', () => {
|
||||
const obj = { d: 4, b: 2, a: 1, c: 3 };
|
||||
const order: (keyof typeof obj)[] = ['a', 'b'];
|
||||
const result = orderKeys(obj, order);
|
||||
expect(Object.keys(result)).toEqual(['a', 'b', 'd', 'c']);
|
||||
});
|
||||
|
||||
it('ignores specified keys that do not exist in the object', () => {
|
||||
const obj = { a: 1, c: 3 };
|
||||
const order = ['a', 'b', 'c', 'd'] as (keyof typeof obj)[];
|
||||
const result = orderKeys(obj, order);
|
||||
expect(Object.keys(result)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('returns an empty object when input is empty', () => {
|
||||
const obj = {};
|
||||
const order = ['a', 'b', 'c'] as (keyof typeof obj)[];
|
||||
const result = orderKeys(obj, order);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns the original object when order is empty', () => {
|
||||
const obj = { c: 3, a: 1, b: 2 };
|
||||
const order: (keyof typeof obj)[] = [];
|
||||
const result = orderKeys(obj, order);
|
||||
expect(result).toEqual(obj);
|
||||
});
|
||||
|
||||
it('preserves nested object structures', () => {
|
||||
const obj = { c: { x: 1 }, a: [1, 2], b: 2 };
|
||||
const order: (keyof typeof obj)[] = ['a', 'b', 'c'];
|
||||
const result = orderKeys(obj, order);
|
||||
expect(result).toEqual({ a: [1, 2], b: 2, c: { x: 1 } });
|
||||
});
|
||||
|
||||
it('handles objects with symbol keys', () => {
|
||||
const sym1 = Symbol('sym1');
|
||||
const sym2 = Symbol('sym2');
|
||||
const obj = { [sym1]: 1, b: 2, [sym2]: 3, a: 4 };
|
||||
const order: (keyof typeof obj)[] = ['a', 'b'];
|
||||
const result = orderKeys(obj, order);
|
||||
expect(Object.getOwnPropertySymbols(result)).toEqual([sym1, sym2]);
|
||||
expect(Object.keys(result)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('maintains the correct types for keys and values', () => {
|
||||
const obj = { a: 'string', b: 42, c: true, d: null, e: undefined };
|
||||
const order: (keyof typeof obj)[] = ['b', 'a', 'c', 'd', 'e'];
|
||||
const result = orderKeys(obj, order);
|
||||
expect(result).toEqual({ b: 42, a: 'string', c: true, d: null, e: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFirstJsonObject', () => {
|
||||
it('should extract the first JSON object from a string', () => {
|
||||
const input = '{"key1": "value1"} {"key2": "value2"}';
|
||||
const expected = { key1: 'value1' };
|
||||
expect(extractFirstJsonObject(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should throw an error when no JSON objects are found', () => {
|
||||
const input = 'no json here';
|
||||
expect(() => extractFirstJsonObject(input)).toThrow(
|
||||
'Expected a JSON object, but got "no json here"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeEvaluateResultForLogging', () => {
|
||||
// Create test EvaluateResult with reasonable data size
|
||||
const createTestEvaluateResult = (): EvaluateResult =>
|
||||
createEvaluateResult({
|
||||
id: 'test-eval-result',
|
||||
success: false,
|
||||
score: 0.5,
|
||||
error: 'Test error',
|
||||
failureReason: ResultFailureReason.ERROR,
|
||||
latencyMs: 100,
|
||||
promptId: 'test-prompt-id',
|
||||
provider: { id: 'test-provider', label: 'Test Provider' },
|
||||
response: {
|
||||
output: 'This is a test output that is reasonably sized for testing purposes.',
|
||||
error: undefined,
|
||||
cached: false,
|
||||
cost: 0.01,
|
||||
tokenUsage: { total: 1000, prompt: 500, completion: 500 },
|
||||
metadata: {
|
||||
model: 'gpt-4',
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
additionalData: 'Some additional metadata for testing',
|
||||
},
|
||||
},
|
||||
testCase: createAtomicTestCase({
|
||||
description: 'Test case with regular data',
|
||||
vars: { input: 'test input', context: 'test context' },
|
||||
}),
|
||||
prompt: createPrompt('Test prompt', {
|
||||
display: 'Test Prompt Display',
|
||||
label: 'Test Prompt Label',
|
||||
}),
|
||||
vars: {
|
||||
userInput: 'test user input',
|
||||
systemPrompt: 'test system prompt',
|
||||
},
|
||||
});
|
||||
|
||||
it('should throw TypeError for null or undefined input', () => {
|
||||
expect(() => summarizeEvaluateResultForLogging(null as any)).toThrow(TypeError);
|
||||
expect(() => summarizeEvaluateResultForLogging(undefined as any)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should create a safe summary of evaluation results', () => {
|
||||
const testResult = createTestEvaluateResult();
|
||||
const summary = summarizeEvaluateResultForLogging(testResult);
|
||||
|
||||
expect(summary.id).toBe('test-eval-result');
|
||||
expect(summary.testIdx).toBe(0);
|
||||
expect(summary.promptIdx).toBe(0);
|
||||
expect(summary.success).toBe(false);
|
||||
expect(summary.score).toBe(0.5);
|
||||
expect(summary.error).toBe('Test error');
|
||||
expect(summary.failureReason).toBe(ResultFailureReason.ERROR);
|
||||
|
||||
expect(summary.provider?.id).toBe('test-provider');
|
||||
expect(summary.provider?.label).toBe('Test Provider');
|
||||
|
||||
expect(summary.response?.output).toBeDefined();
|
||||
expect(summary.response?.cached).toBe(false);
|
||||
expect(summary.response?.cost).toBe(0.01);
|
||||
expect(summary.response?.tokenUsage).toEqual({
|
||||
total: 1000,
|
||||
prompt: 500,
|
||||
completion: 500,
|
||||
});
|
||||
|
||||
expect(summary.response?.metadata?.keys).toContain('model');
|
||||
expect(summary.response?.metadata?.keys).toContain('timestamp');
|
||||
expect(summary.response?.metadata?.keys).toContain('additionalData');
|
||||
expect(summary.response?.metadata?.keyCount).toBe(3);
|
||||
|
||||
expect(summary.testCase?.description).toBe('Test case with regular data');
|
||||
expect(summary.testCase?.vars).toEqual(['input', 'context']);
|
||||
});
|
||||
|
||||
it('should handle custom options', () => {
|
||||
const testResult = createTestEvaluateResult();
|
||||
const summary = summarizeEvaluateResultForLogging(testResult, 20, false);
|
||||
|
||||
expect(summary.response?.output?.length).toBeLessThanOrEqual(35); // 20 + '...[truncated]'
|
||||
expect(summary.response?.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle EvaluateResult with minimal data', () => {
|
||||
const minimalResult: EvaluateResult = createEvaluateResult({
|
||||
id: 'minimal-result',
|
||||
testIdx: 1,
|
||||
promptIdx: 2,
|
||||
score: 1.0,
|
||||
latencyMs: 50,
|
||||
promptId: 'minimal-prompt-id',
|
||||
provider: { id: 'minimal-provider' },
|
||||
prompt: createPrompt('test prompt', { label: 'Test Prompt' }),
|
||||
});
|
||||
|
||||
const summary = summarizeEvaluateResultForLogging(minimalResult);
|
||||
|
||||
expect(summary.id).toBe('minimal-result');
|
||||
expect(summary.success).toBe(true);
|
||||
expect(summary.score).toBe(1.0);
|
||||
expect(summary.provider?.id).toBe('minimal-provider');
|
||||
});
|
||||
|
||||
it('should handle long output strings by truncating them', () => {
|
||||
const longOutput = 'A'.repeat(1000);
|
||||
const resultWithLongOutput: EvaluateResult = {
|
||||
...createTestEvaluateResult(),
|
||||
response: {
|
||||
output: longOutput,
|
||||
cached: false,
|
||||
},
|
||||
};
|
||||
|
||||
const summary = summarizeEvaluateResultForLogging(resultWithLongOutput, 100);
|
||||
|
||||
expect(summary.response?.output?.length).toBeLessThanOrEqual(115); // 100 + '...[truncated]'
|
||||
expect(summary.response?.output).toContain('...[truncated]');
|
||||
});
|
||||
|
||||
it('should be safely stringifiable', () => {
|
||||
const testResult = createTestEvaluateResult();
|
||||
const summary = summarizeEvaluateResultForLogging(testResult);
|
||||
|
||||
expect(() => {
|
||||
const result = safeJsonStringify(summary);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe('string');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle non-string output values', () => {
|
||||
const resultWithObjectOutput: EvaluateResult = {
|
||||
...createTestEvaluateResult(),
|
||||
response: {
|
||||
output: { complexObject: 'value', nested: { data: 'test' } },
|
||||
cached: false,
|
||||
},
|
||||
};
|
||||
|
||||
const summary = summarizeEvaluateResultForLogging(resultWithObjectOutput);
|
||||
|
||||
expect(summary.response?.output).toBe('[object Object]');
|
||||
});
|
||||
|
||||
describe('Integration test - evaluator error logging scenario', () => {
|
||||
it('should handle the exact scenario from evaluator.ts without throwing', () => {
|
||||
const testEvalResult = createTestEvaluateResult();
|
||||
const mockError = new Error('Database connection failed');
|
||||
|
||||
expect(() => {
|
||||
const resultSummary = summarizeEvaluateResultForLogging(testEvalResult);
|
||||
const logMessage = `Error saving result: ${mockError} ${safeJsonStringify(resultSummary)}`;
|
||||
|
||||
expect(logMessage).toBeDefined();
|
||||
expect(logMessage).toContain('Error saving result: Error: Database connection failed');
|
||||
expect(logMessage).toContain('"id":"test-eval-result"');
|
||||
expect(logMessage).toContain('"success":false');
|
||||
expect(logMessage.length).toBeLessThan(5000);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should perform efficiently', () => {
|
||||
const testResult = createTestEvaluateResult();
|
||||
|
||||
const start = Date.now();
|
||||
const summary = summarizeEvaluateResultForLogging(testResult);
|
||||
const stringified = safeJsonStringify(summary);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(100);
|
||||
expect(stringified).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as blobs from '../../src/blobs';
|
||||
import { getTraceStore } from '../../src/tracing/store';
|
||||
import { writeOutput } from '../../src/util/index';
|
||||
import { createTempDir, mockProcessEnv, removeTempDir } from './utils';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../src/database', () => ({
|
||||
getDb: vi.fn().mockReturnValue({
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('JSON export with improved error handling', () => {
|
||||
let tempDir: string;
|
||||
let tempFilePath: string;
|
||||
let mockEval: any;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = createTempDir('promptfoo-json-test-');
|
||||
tempFilePath = path.join(tempDir, 'test-export.json');
|
||||
|
||||
mockEval = {
|
||||
id: 'test-eval-id',
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
author: 'test-author',
|
||||
config: { testConfig: true },
|
||||
prompts: [
|
||||
{ raw: 'Test prompt 1', label: 'prompt1' },
|
||||
{ raw: 'Test prompt 2', label: 'prompt2' },
|
||||
],
|
||||
toEvaluateSummary: vi.fn(),
|
||||
getResultsCount: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
removeTempDir(tempDir);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('normal JSON export', () => {
|
||||
beforeEach(() => {
|
||||
mockEval.toEvaluateSummary.mockResolvedValue({
|
||||
version: 3,
|
||||
timestamp: '2025-01-01T00:00:00.000Z',
|
||||
prompts: mockEval.prompts,
|
||||
results: [
|
||||
{ testIdx: 0, promptIdx: 0, success: true, score: 1.0 },
|
||||
{ testIdx: 1, promptIdx: 0, success: true, score: 0.9 },
|
||||
],
|
||||
stats: {
|
||||
successes: 2,
|
||||
failures: 0,
|
||||
tokenUsage: { total: 100, prompt: 50, completion: 50 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should export JSON successfully', async () => {
|
||||
await writeOutput(tempFilePath, mockEval, 'https://share.url');
|
||||
|
||||
expect(fs.existsSync(tempFilePath)).toBe(true);
|
||||
const content = fs.readFileSync(tempFilePath, 'utf8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
// Verify structure matches expected OutputFile format
|
||||
expect(parsed).toHaveProperty('evalId', 'test-eval-id');
|
||||
expect(parsed).toHaveProperty('results');
|
||||
expect(parsed.results).toHaveProperty('version', 3);
|
||||
expect(parsed.results.results).toHaveLength(2);
|
||||
expect(parsed).toHaveProperty('config', { testConfig: true });
|
||||
expect(parsed).toHaveProperty('shareableUrl', 'https://share.url');
|
||||
expect(parsed).toHaveProperty('metadata');
|
||||
});
|
||||
|
||||
it('should export persisted vars and runtime options for round-trip imports', async () => {
|
||||
mockEval.vars = ['topic', 'tone'];
|
||||
mockEval.runtimeOptions = { cache: false, maxConcurrency: 2 };
|
||||
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
|
||||
const content = fs.readFileSync(tempFilePath, 'utf8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
expect(parsed).toHaveProperty('vars', ['topic', 'tone']);
|
||||
expect(parsed).toHaveProperty('runtimeOptions', { cache: false, maxConcurrency: 2 });
|
||||
});
|
||||
|
||||
it('should embed referenced blob bytes only when media export is enabled', async () => {
|
||||
const hash = 'a'.repeat(64);
|
||||
const data = Buffer.from('portable image');
|
||||
mockEval.toEvaluateSummary.mockResolvedValue({
|
||||
version: 3,
|
||||
timestamp: '2025-01-01T00:00:00.000Z',
|
||||
prompts: mockEval.prompts,
|
||||
results: [{ response: { output: `promptfoo://blob/${hash}` } }],
|
||||
stats: { successes: 1, failures: 0 },
|
||||
});
|
||||
vi.spyOn(blobs, 'getBlobByHash').mockResolvedValue({
|
||||
data,
|
||||
metadata: {
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: data.length,
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
provider: 'filesystem',
|
||||
key: hash,
|
||||
},
|
||||
});
|
||||
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
expect(JSON.parse(fs.readFileSync(tempFilePath, 'utf8'))).not.toHaveProperty('blobAssets');
|
||||
|
||||
await writeOutput(tempFilePath, mockEval, null, { includeMedia: true });
|
||||
const parsed = JSON.parse(fs.readFileSync(tempFilePath, 'utf8'));
|
||||
expect(parsed.blobAssets).toEqual([
|
||||
{
|
||||
hash,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: data.length,
|
||||
data: data.toString('base64'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should embed blob bytes referenced only by exported traces', async () => {
|
||||
const hash = 'b'.repeat(64);
|
||||
const data = Buffer.from('portable trace image');
|
||||
const traceSpy = vi.spyOn(getTraceStore(), 'getTracesByEvaluation').mockResolvedValue([
|
||||
{
|
||||
traceId: 'trace-media-export',
|
||||
evaluationId: mockEval.id,
|
||||
testCaseId: 'trace-media-case',
|
||||
metadata: { attachment: `promptfoo://blob/${hash}` },
|
||||
spans: [],
|
||||
},
|
||||
]);
|
||||
vi.spyOn(blobs, 'getBlobByHash').mockResolvedValue({
|
||||
data,
|
||||
metadata: {
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: data.length,
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
provider: 'filesystem',
|
||||
key: hash,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await writeOutput(tempFilePath, mockEval, null, { includeMedia: true });
|
||||
|
||||
const parsed = JSON.parse(fs.readFileSync(tempFilePath, 'utf8'));
|
||||
expect(parsed.traces[0].metadata.attachment).toBe(`promptfoo://blob/${hash}`);
|
||||
expect(parsed.blobAssets).toEqual([
|
||||
{
|
||||
hash,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: data.length,
|
||||
data: data.toString('base64'),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
traceSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not embed response blob bytes when response output stripping is enabled', async () => {
|
||||
const restoreEnv = mockProcessEnv({ PROMPTFOO_STRIP_RESPONSE_OUTPUT: 'true' });
|
||||
const hash = 'c'.repeat(64);
|
||||
const getBlobSpy = vi.spyOn(blobs, 'getBlobByHash');
|
||||
mockEval.toEvaluateSummary.mockResolvedValue({
|
||||
version: 3,
|
||||
timestamp: '2025-01-01T00:00:00.000Z',
|
||||
prompts: mockEval.prompts,
|
||||
results: [
|
||||
{
|
||||
response: {
|
||||
output: '[output stripped]',
|
||||
metadata: { blobUris: [`promptfoo://blob/${hash}`] },
|
||||
},
|
||||
},
|
||||
],
|
||||
stats: { successes: 1, failures: 0 },
|
||||
});
|
||||
|
||||
try {
|
||||
await writeOutput(tempFilePath, mockEval, null, { includeMedia: true });
|
||||
|
||||
const parsed = JSON.parse(fs.readFileSync(tempFilePath, 'utf8'));
|
||||
expect(parsed.results.results[0].response.metadata.blobUris).toEqual([
|
||||
`promptfoo://blob/${hash}`,
|
||||
]);
|
||||
expect(parsed).not.toHaveProperty('blobAssets');
|
||||
expect(getBlobSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
getBlobSpy.mockRestore();
|
||||
restoreEnv();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle null shareableUrl', async () => {
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
|
||||
const content = fs.readFileSync(tempFilePath, 'utf8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
expect(parsed.shareableUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('should maintain proper JSON formatting', async () => {
|
||||
await writeOutput(tempFilePath, mockEval, 'https://test.url');
|
||||
|
||||
const content = fs.readFileSync(tempFilePath, 'utf8');
|
||||
|
||||
// Verify proper 2-space indentation
|
||||
expect(content).toContain('{\n "evalId":');
|
||||
expect(content).toContain(' "results": {');
|
||||
expect(content).toContain(' "version":');
|
||||
|
||||
// Should be valid JSON
|
||||
expect(() => JSON.parse(content)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should include all required metadata fields', async () => {
|
||||
await writeOutput(tempFilePath, mockEval, 'https://test.url');
|
||||
|
||||
const content = fs.readFileSync(tempFilePath, 'utf8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
expect(parsed.metadata).toHaveProperty('promptfooVersion');
|
||||
expect(parsed.metadata).toHaveProperty('nodeVersion');
|
||||
expect(parsed.metadata).toHaveProperty('platform');
|
||||
expect(parsed.metadata).toHaveProperty('exportedAt');
|
||||
expect(parsed.metadata).toHaveProperty('author', 'test-author');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory limit error handling', () => {
|
||||
beforeEach(() => {
|
||||
mockEval.getResultsCount.mockResolvedValue(50000);
|
||||
mockEval.toEvaluateSummary.mockImplementation(() => {
|
||||
throw new RangeError('Invalid string length');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle RangeError gracefully with helpful message', async () => {
|
||||
await expect(writeOutput(tempFilePath, mockEval, null)).rejects.toThrow(
|
||||
'Dataset too large for JSON export',
|
||||
);
|
||||
|
||||
await expect(writeOutput(tempFilePath, mockEval, null)).rejects.toThrow(
|
||||
'Consider using JSONL format instead',
|
||||
);
|
||||
});
|
||||
|
||||
it('should include result count in error message', async () => {
|
||||
await expect(writeOutput(tempFilePath, mockEval, null)).rejects.toThrow('50000 results');
|
||||
});
|
||||
|
||||
it('should not create output file when memory error occurs', async () => {
|
||||
try {
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
} catch (_error) {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
expect(fs.existsSync(tempFilePath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('other error handling', () => {
|
||||
it('should propagate non-RangeError exceptions', async () => {
|
||||
const testError = new Error('Database connection failed');
|
||||
mockEval.toEvaluateSummary.mockRejectedValue(testError);
|
||||
|
||||
await expect(writeOutput(tempFilePath, mockEval, null)).rejects.toThrow(
|
||||
'Database connection failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle file system errors', async () => {
|
||||
// Use a path that will definitely fail - writing to a file that already exists as a directory
|
||||
const dirAsFile = path.join(tempDir, 'directory-as-file');
|
||||
fs.mkdirSync(dirAsFile);
|
||||
const invalidPath = dirAsFile; // Try to write to a directory path as if it were a file
|
||||
|
||||
mockEval.toEvaluateSummary.mockResolvedValue({
|
||||
version: 3,
|
||||
results: [],
|
||||
stats: { successes: 0, failures: 0 },
|
||||
});
|
||||
|
||||
await expect(writeOutput(invalidPath, mockEval, null)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('should maintain exact same JSON structure as original implementation', async () => {
|
||||
const expectedSummary = {
|
||||
version: 3,
|
||||
timestamp: '2025-01-01T00:00:00.000Z',
|
||||
prompts: [
|
||||
{ raw: 'Test prompt 1', label: 'prompt1' },
|
||||
{ raw: 'Test prompt 2', label: 'prompt2' },
|
||||
],
|
||||
results: [
|
||||
{
|
||||
testIdx: 0,
|
||||
promptIdx: 0,
|
||||
success: true,
|
||||
score: 1.0,
|
||||
vars: { input: 'test' },
|
||||
output: 'response',
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
successes: 1,
|
||||
failures: 0,
|
||||
errors: 0,
|
||||
tokenUsage: { total: 100, prompt: 50, completion: 50 },
|
||||
},
|
||||
};
|
||||
|
||||
mockEval.toEvaluateSummary.mockResolvedValue(expectedSummary);
|
||||
|
||||
await writeOutput(tempFilePath, mockEval, 'https://share.url');
|
||||
|
||||
const content = fs.readFileSync(tempFilePath, 'utf8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
// Verify exact structure
|
||||
expect(Object.keys(parsed).sort()).toEqual(
|
||||
['evalId', 'results', 'config', 'shareableUrl', 'metadata'].sort(),
|
||||
);
|
||||
|
||||
expect(parsed.results).toEqual(expectedSummary);
|
||||
});
|
||||
|
||||
it('should handle both EvaluateSummaryV2 and V3 formats', async () => {
|
||||
const v2Summary = {
|
||||
version: 2,
|
||||
timestamp: '2025-01-01T00:00:00.000Z',
|
||||
results: [{ testIdx: 0, success: true }],
|
||||
table: { head: { prompts: [], vars: [] }, body: [] },
|
||||
stats: { successes: 1, failures: 0 },
|
||||
};
|
||||
|
||||
mockEval.toEvaluateSummary.mockResolvedValue(v2Summary);
|
||||
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
|
||||
const content = fs.readFileSync(tempFilePath, 'utf8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
expect(parsed.results.version).toBe(2);
|
||||
expect(parsed.results).toHaveProperty('table');
|
||||
expect(parsed.results.results).toEqual([{ testIdx: 0, success: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance considerations', () => {
|
||||
it('should complete export in reasonable time for moderate datasets', async () => {
|
||||
// Create a moderate-sized dataset
|
||||
const moderateResults = Array.from({ length: 1000 }, (_, i) => ({
|
||||
testIdx: i,
|
||||
promptIdx: 0,
|
||||
success: true,
|
||||
score: Math.random(),
|
||||
vars: { input: `test input ${i}` },
|
||||
output: `test output ${i}`,
|
||||
}));
|
||||
|
||||
mockEval.toEvaluateSummary.mockResolvedValue({
|
||||
version: 3,
|
||||
timestamp: '2025-01-01T00:00:00.000Z',
|
||||
prompts: mockEval.prompts,
|
||||
results: moderateResults,
|
||||
stats: { successes: 1000, failures: 0 },
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Should complete within reasonable time (5 seconds for 1000 results)
|
||||
expect(duration).toBeLessThan(5000);
|
||||
|
||||
// Verify file was created and has content
|
||||
expect(fs.existsSync(tempFilePath)).toBe(true);
|
||||
const stats = fs.statSync(tempFilePath);
|
||||
expect(stats.size).toBeGreaterThan(1000); // Should have substantial content
|
||||
}, 10000); // 10 second timeout
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { writeOutput } from '../../src/util/index';
|
||||
import { createTempDir, removeTempDir } from './utils';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../src/database', () => ({
|
||||
getDb: vi.fn().mockReturnValue({
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('JSONL output with proper line endings', () => {
|
||||
let tempDir: string;
|
||||
let tempFilePath: string;
|
||||
let mockEval: any;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = createTempDir('promptfoo-jsonl-test-');
|
||||
tempFilePath = path.join(tempDir, 'test-export.jsonl');
|
||||
|
||||
mockEval = {
|
||||
id: 'test-eval-id',
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
author: 'test-author',
|
||||
config: { testConfig: true },
|
||||
prompts: [
|
||||
{ raw: 'Test prompt 1', label: 'prompt1' },
|
||||
{ raw: 'Test prompt 2', label: 'prompt2' },
|
||||
],
|
||||
fetchResultsBatched: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
removeTempDir(tempDir);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should produce valid JSONL with proper line endings between batches', async () => {
|
||||
// Mock fetchResultsBatched to return multiple batches
|
||||
const batch1 = [
|
||||
{ testIdx: 0, success: true, score: 1.0, output: 'result 1' },
|
||||
{ testIdx: 1, success: true, score: 0.9, output: 'result 2' },
|
||||
];
|
||||
const batch2 = [
|
||||
{ testIdx: 2, success: true, score: 0.8, output: 'result 3' },
|
||||
{ testIdx: 3, success: false, score: 0.0, output: 'result 4' },
|
||||
];
|
||||
|
||||
// Create async iterator for batches
|
||||
const batchIterator = [batch1, batch2];
|
||||
|
||||
mockEval.fetchResultsBatched = vi.fn().mockImplementation(async function* () {
|
||||
for (const batch of batchIterator) {
|
||||
yield batch;
|
||||
}
|
||||
});
|
||||
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
|
||||
expect(fs.existsSync(tempFilePath)).toBe(true);
|
||||
const content = fs.readFileSync(tempFilePath, 'utf8');
|
||||
|
||||
// Split by lines and filter out empty lines
|
||||
const lines = content.split(/\r?\n/).filter((line) => line.trim() !== '');
|
||||
|
||||
// Should have exactly 4 JSON objects (2 per batch)
|
||||
expect(lines).toHaveLength(4);
|
||||
|
||||
// Each line should be valid JSON
|
||||
lines.forEach((line, index) => {
|
||||
expect(() => JSON.parse(line)).not.toThrow();
|
||||
const parsed = JSON.parse(line);
|
||||
expect(parsed).toHaveProperty('testIdx', index);
|
||||
expect(parsed).toHaveProperty('output', `result ${index + 1}`);
|
||||
});
|
||||
|
||||
// File should end with a newline
|
||||
expect(content.endsWith('\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle single batch correctly', async () => {
|
||||
const singleBatch = [{ testIdx: 0, success: true, score: 1.0, output: 'single result' }];
|
||||
|
||||
mockEval.fetchResultsBatched = vi.fn().mockImplementation(async function* () {
|
||||
yield singleBatch;
|
||||
});
|
||||
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
|
||||
const content = fs.readFileSync(tempFilePath, 'utf8');
|
||||
const lines = content.split(/\r?\n/).filter((line) => line.trim() !== '');
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(() => JSON.parse(lines[0])).not.toThrow();
|
||||
expect(content.endsWith('\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty batches gracefully', async () => {
|
||||
mockEval.fetchResultsBatched = vi.fn().mockImplementation(async function* () {
|
||||
yield [];
|
||||
yield [{ testIdx: 0, success: true, score: 1.0, output: 'after empty' }];
|
||||
yield [];
|
||||
});
|
||||
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
|
||||
const content = fs.readFileSync(tempFilePath, 'utf8');
|
||||
const lines = content.split(/\r?\n/).filter((line) => line.trim() !== '');
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(() => JSON.parse(lines[0])).not.toThrow();
|
||||
const parsed = JSON.parse(lines[0]);
|
||||
expect(parsed.output).toBe('after empty');
|
||||
});
|
||||
|
||||
it('should use OS-specific line endings', async () => {
|
||||
const batch = [{ testIdx: 0, success: true, score: 1.0, output: 'test result' }];
|
||||
|
||||
mockEval.fetchResultsBatched = vi.fn().mockImplementation(async function* () {
|
||||
yield batch;
|
||||
});
|
||||
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
|
||||
const content = fs.readFileSync(tempFilePath, 'utf8');
|
||||
|
||||
// Content should end with the OS-specific line ending
|
||||
expect(content.endsWith(os.EOL)).toBe(true);
|
||||
|
||||
// For multiple results, should use OS line endings between them
|
||||
const multiResultBatch = [
|
||||
{ testIdx: 0, success: true, score: 1.0, output: 'result 1' },
|
||||
{ testIdx: 1, success: true, score: 0.9, output: 'result 2' },
|
||||
];
|
||||
|
||||
mockEval.fetchResultsBatched = vi.fn().mockImplementation(async function* () {
|
||||
yield multiResultBatch;
|
||||
});
|
||||
|
||||
// Clear the file
|
||||
fs.unlinkSync(tempFilePath);
|
||||
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
|
||||
const multiContent = fs.readFileSync(tempFilePath, 'utf8');
|
||||
const expectedContent =
|
||||
multiResultBatch.map((result) => JSON.stringify(result)).join(os.EOL) + os.EOL;
|
||||
|
||||
expect(multiContent).toBe(expectedContent);
|
||||
});
|
||||
|
||||
it('should preserve the existing artifact when a rewrite fails', async () => {
|
||||
fs.writeFileSync(tempFilePath, '{"status":"existing"}\n');
|
||||
mockEval.fetchResultsBatched = vi.fn().mockImplementation(async function* () {
|
||||
yield [{ testIdx: 0, success: true, score: 1.0, output: 'replacement' }];
|
||||
throw new Error('simulated rewrite failure');
|
||||
});
|
||||
|
||||
await expect(writeOutput(tempFilePath, mockEval, null)).rejects.toThrow(
|
||||
'simulated rewrite failure',
|
||||
);
|
||||
|
||||
expect(fs.readFileSync(tempFilePath, 'utf8')).toBe('{"status":"existing"}\n');
|
||||
expect(fs.readdirSync(tempDir)).toEqual(['test-export.jsonl']);
|
||||
});
|
||||
|
||||
it('should preserve existing artifact permissions during a rewrite', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(tempFilePath, '{"status":"existing"}\n', { mode: 0o644 });
|
||||
fs.chmodSync(tempFilePath, 0o644);
|
||||
mockEval.fetchResultsBatched = vi.fn().mockImplementation(async function* () {
|
||||
yield [{ testIdx: 0, success: true, score: 1.0, output: 'replacement' }];
|
||||
});
|
||||
|
||||
const originalUmask = process.umask(0o077);
|
||||
try {
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
} finally {
|
||||
process.umask(originalUmask);
|
||||
}
|
||||
|
||||
expect(fs.statSync(tempFilePath).mode & 0o777).toBe(0o644);
|
||||
});
|
||||
|
||||
it('should preserve an existing artifact symlink during a rewrite', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = path.join(tempDir, 'target.jsonl');
|
||||
fs.writeFileSync(targetPath, '{"status":"existing"}\n');
|
||||
fs.symlinkSync(targetPath, tempFilePath);
|
||||
mockEval.fetchResultsBatched = vi.fn().mockImplementation(async function* () {
|
||||
yield [{ testIdx: 0, success: true, score: 1.0, output: 'replacement' }];
|
||||
});
|
||||
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
|
||||
expect(fs.lstatSync(tempFilePath).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readFileSync(targetPath, 'utf8')).toContain('"output":"replacement"');
|
||||
});
|
||||
|
||||
it('should preserve a dangling artifact symlink and create its target during a rewrite', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = path.join(tempDir, 'missing-target.jsonl');
|
||||
fs.symlinkSync(path.basename(targetPath), tempFilePath);
|
||||
mockEval.fetchResultsBatched = vi.fn().mockImplementation(async function* () {
|
||||
yield [{ testIdx: 0, success: true, score: 1.0, output: 'replacement' }];
|
||||
});
|
||||
|
||||
await writeOutput(tempFilePath, mockEval, null);
|
||||
|
||||
expect(fs.lstatSync(tempFilePath).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readFileSync(targetPath, 'utf8')).toContain('"output":"replacement"');
|
||||
});
|
||||
|
||||
it('should rewrite an artifact with a long valid basename', async () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const longFilePath = path.join(tempDir, `${'a'.repeat(240)}.jsonl`);
|
||||
mockEval.fetchResultsBatched = vi.fn().mockImplementation(async function* () {
|
||||
yield [{ testIdx: 0, success: true, score: 1.0, output: 'replacement' }];
|
||||
});
|
||||
|
||||
await writeOutput(longFilePath, mockEval, null);
|
||||
|
||||
expect(fs.readFileSync(longFilePath, 'utf8')).toContain('"output":"replacement"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
formatLibsqlBindingErrorMessage,
|
||||
getLibsqlBindingTarget,
|
||||
} from '../../src/util/libsqlBindingErrors';
|
||||
|
||||
function makeBindingError(target: string): NodeJS.ErrnoException {
|
||||
const error: NodeJS.ErrnoException = new Error(
|
||||
`Cannot find module '@libsql/${target}'\nRequire stack:\n- /app/node_modules/libsql/index.js`,
|
||||
);
|
||||
error.code = 'MODULE_NOT_FOUND';
|
||||
return error;
|
||||
}
|
||||
|
||||
describe('libsql binding errors', () => {
|
||||
it('extracts the missing libsql target from ESM import failures', () => {
|
||||
const error: NodeJS.ErrnoException = new Error(
|
||||
"Cannot find package '@libsql/linux-x64-gnu' imported from /app/node_modules/@libsql/client/lib-esm/node.js",
|
||||
);
|
||||
error.code = 'ERR_MODULE_NOT_FOUND';
|
||||
|
||||
expect(getLibsqlBindingTarget(error)).toBe('linux-x64-gnu');
|
||||
});
|
||||
|
||||
it('extracts the missing libsql target from the require stack', () => {
|
||||
expect(getLibsqlBindingTarget(makeBindingError('darwin-arm64'))).toBe('darwin-arm64');
|
||||
expect(getLibsqlBindingTarget(makeBindingError('linux-x64-musl'))).toBe('linux-x64-musl');
|
||||
});
|
||||
|
||||
it('formats actionable repair instructions with the detected target and platform', () => {
|
||||
const message = formatLibsqlBindingErrorMessage(makeBindingError('linux-x64-gnu'));
|
||||
|
||||
expect(message).toContain('Required package: @libsql/linux-x64-gnu');
|
||||
expect(message).toContain(`${process.platform}-${process.arch}`);
|
||||
expect(message).toContain('libsql-binding-not-found');
|
||||
});
|
||||
|
||||
it('ignores MODULE_NOT_FOUND errors that are not from libsql', () => {
|
||||
const error: NodeJS.ErrnoException = new Error("Cannot find module 'some-other-pkg'");
|
||||
error.code = 'MODULE_NOT_FOUND';
|
||||
|
||||
expect(getLibsqlBindingTarget(error)).toBeUndefined();
|
||||
expect(formatLibsqlBindingErrorMessage(error)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores missing libsql wrapper packages that are not platform bindings', () => {
|
||||
for (const packageName of ['client', 'client-wasm', 'core', 'hrana-client']) {
|
||||
const error: NodeJS.ErrnoException = new Error(
|
||||
`Cannot find module '@libsql/${packageName}'\nRequire stack:\n- /app/index.js`,
|
||||
);
|
||||
error.code = 'MODULE_NOT_FOUND';
|
||||
|
||||
expect(getLibsqlBindingTarget(error)).toBeUndefined();
|
||||
expect(formatLibsqlBindingErrorMessage(error)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores libsql-mentioning errors that are not MODULE_NOT_FOUND', () => {
|
||||
const error: NodeJS.ErrnoException = new Error("Cannot find module '@libsql/darwin-arm64'");
|
||||
// No `code` set — looks like the right message but wrong shape.
|
||||
|
||||
expect(getLibsqlBindingTarget(error)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores non-Error values', () => {
|
||||
expect(getLibsqlBindingTarget('boom')).toBeUndefined();
|
||||
expect(getLibsqlBindingTarget(undefined)).toBeUndefined();
|
||||
expect(formatLibsqlBindingErrorMessage(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getLogFiles } from '../../src/util/logFiles';
|
||||
|
||||
vi.mock('fs');
|
||||
|
||||
// Helper to create mock Dirent objects
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function createDirent(name: string, isFile: boolean = true): any {
|
||||
return {
|
||||
name,
|
||||
isFile: () => isFile,
|
||||
isDirectory: () => !isFile,
|
||||
isBlockDevice: () => false,
|
||||
isCharacterDevice: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
isFIFO: () => false,
|
||||
isSocket: () => false,
|
||||
parentPath: '/logs',
|
||||
path: '/logs',
|
||||
};
|
||||
}
|
||||
|
||||
describe('logFiles utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getLogFiles', () => {
|
||||
it('should return empty array if directory does not exist', () => {
|
||||
const error = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readdirSync).mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = getLogFiles('/nonexistent/path');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array if directory has no log files', () => {
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||
createDirent('readme.txt'),
|
||||
createDirent('config.json'),
|
||||
]);
|
||||
|
||||
const result = getLogFiles('/logs');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return log files sorted by modification time (newest first)', () => {
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||
createDirent('promptfoo-debug-2024-01-01.log'),
|
||||
createDirent('promptfoo-error-2024-01-02.log'),
|
||||
createDirent('promptfoo-debug-2024-01-03.log'),
|
||||
]);
|
||||
|
||||
const dates = {
|
||||
'promptfoo-debug-2024-01-01.log': new Date('2024-01-01'),
|
||||
'promptfoo-error-2024-01-02.log': new Date('2024-01-02'),
|
||||
'promptfoo-debug-2024-01-03.log': new Date('2024-01-03'),
|
||||
};
|
||||
|
||||
vi.mocked(fs.statSync).mockImplementation((filePath: unknown) => {
|
||||
const fileName = path.basename(filePath as string);
|
||||
return { mtime: dates[fileName as keyof typeof dates] } as fs.Stats;
|
||||
});
|
||||
|
||||
const result = getLogFiles('/logs');
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].name).toBe('promptfoo-debug-2024-01-03.log');
|
||||
expect(result[1].name).toBe('promptfoo-error-2024-01-02.log');
|
||||
expect(result[2].name).toBe('promptfoo-debug-2024-01-01.log');
|
||||
});
|
||||
|
||||
it('should filter files by promptfoo prefix and .log suffix', () => {
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||
createDirent('promptfoo-debug.log'),
|
||||
createDirent('other-debug.log'),
|
||||
createDirent('promptfoo-error.txt'),
|
||||
createDirent('promptfoo.log'),
|
||||
createDirent('readme.md'),
|
||||
]);
|
||||
|
||||
vi.mocked(fs.statSync).mockReturnValue({ mtime: new Date() } as fs.Stats);
|
||||
|
||||
const result = getLogFiles('/logs');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('promptfoo-debug.log');
|
||||
});
|
||||
|
||||
it('should filter out directories', () => {
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||
createDirent('promptfoo-debug.log', true),
|
||||
createDirent('promptfoo-subdir.log', false), // This is a directory
|
||||
]);
|
||||
|
||||
vi.mocked(fs.statSync).mockReturnValue({ mtime: new Date() } as fs.Stats);
|
||||
|
||||
const result = getLogFiles('/logs');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('promptfoo-debug.log');
|
||||
});
|
||||
|
||||
it('should include correct path in returned objects', () => {
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([createDirent('promptfoo-test.log')]);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ mtime: new Date() } as fs.Stats);
|
||||
|
||||
const result = getLogFiles('/my/log/dir');
|
||||
|
||||
expect(result[0].path).toBe(path.join('/my/log/dir', 'promptfoo-test.log'));
|
||||
});
|
||||
|
||||
it('should throw error if directory cannot be read (non-ENOENT)', () => {
|
||||
const error = new Error('Permission denied') as NodeJS.ErrnoException;
|
||||
error.code = 'EACCES';
|
||||
vi.mocked(fs.readdirSync).mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
expect(() => getLogFiles('/protected/logs')).toThrow('Permission denied');
|
||||
});
|
||||
|
||||
it('should skip files that are deleted between readdir and stat (race condition)', () => {
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([
|
||||
createDirent('promptfoo-exists.log'),
|
||||
createDirent('promptfoo-deleted.log'),
|
||||
createDirent('promptfoo-also-exists.log'),
|
||||
]);
|
||||
|
||||
vi.mocked(fs.statSync).mockImplementation((filePath: unknown) => {
|
||||
if ((filePath as string).includes('deleted')) {
|
||||
const error = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return { mtime: new Date() } as fs.Stats;
|
||||
});
|
||||
|
||||
const result = getLogFiles('/logs');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((f) => f.name)).toContain('promptfoo-exists.log');
|
||||
expect(result.map((f) => f.name)).toContain('promptfoo-also-exists.log');
|
||||
expect(result.map((f) => f.name)).not.toContain('promptfoo-deleted.log');
|
||||
});
|
||||
|
||||
it('should rethrow non-ENOENT errors from statSync', () => {
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([createDirent('promptfoo-test.log')]);
|
||||
|
||||
vi.mocked(fs.statSync).mockImplementation(() => {
|
||||
const error = new Error('Permission denied') as NodeJS.ErrnoException;
|
||||
error.code = 'EACCES';
|
||||
throw error;
|
||||
});
|
||||
|
||||
expect(() => getLogFiles('/logs')).toThrow('Permission denied');
|
||||
});
|
||||
|
||||
it('should return files with correct mtime', () => {
|
||||
const testDate = new Date('2024-06-15T10:30:00Z');
|
||||
vi.mocked(fs.readdirSync).mockReturnValue([createDirent('promptfoo-test.log')]);
|
||||
vi.mocked(fs.statSync).mockReturnValue({ mtime: testDate } as fs.Stats);
|
||||
|
||||
const result = getLogFiles('/logs');
|
||||
|
||||
expect(result[0].mtime).toEqual(testDate);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import fsSync from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies before importing the module under test
|
||||
vi.mock('fs/promises');
|
||||
vi.mock('fs');
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockGetConfigDirectoryPath = vi.fn().mockReturnValue('/home/user/.promptfoo');
|
||||
vi.mock('../../src/util/config/manage', () => ({
|
||||
getConfigDirectoryPath: () => mockGetConfigDirectoryPath(),
|
||||
}));
|
||||
|
||||
const mockGetEnvString = vi.fn().mockReturnValue('');
|
||||
vi.mock('../../src/envars', () => ({
|
||||
getEnvString: (key: string) => mockGetEnvString(key),
|
||||
getEnvBool: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import {
|
||||
findLogFile,
|
||||
formatFileSize,
|
||||
getLogDirectory,
|
||||
getLogFiles,
|
||||
getLogFilesSync,
|
||||
} from '../../src/util/logs';
|
||||
|
||||
describe('util/logs', () => {
|
||||
const mockFs = vi.mocked(fs);
|
||||
const mockFsSync = vi.mocked(fsSync);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetConfigDirectoryPath.mockReturnValue('/home/user/.promptfoo');
|
||||
mockGetEnvString.mockReturnValue('');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getLogDirectory', () => {
|
||||
it('returns default directory when PROMPTFOO_LOG_DIR not set', () => {
|
||||
mockGetEnvString.mockReturnValue('');
|
||||
|
||||
const result = getLogDirectory();
|
||||
expect(result).toBe(path.join('/home/user/.promptfoo', 'logs'));
|
||||
});
|
||||
|
||||
it('respects PROMPTFOO_LOG_DIR environment variable', () => {
|
||||
mockGetEnvString.mockReturnValue('/custom/log/dir');
|
||||
|
||||
const result = getLogDirectory();
|
||||
expect(result).toBe(path.resolve('/custom/log/dir'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogFiles (async)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetEnvString.mockReturnValue('');
|
||||
});
|
||||
|
||||
it('returns empty array when directory does not exist', async () => {
|
||||
mockFs.access.mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await getLogFiles();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when no log files found', async () => {
|
||||
mockFs.access.mockResolvedValue(undefined);
|
||||
mockFs.readdir.mockResolvedValue([]);
|
||||
|
||||
const result = await getLogFiles();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters non-promptfoo files', async () => {
|
||||
mockFs.access.mockResolvedValue(undefined);
|
||||
mockFs.readdir.mockResolvedValue([
|
||||
'other-file.log',
|
||||
'promptfoo-debug-2024-01-01_10-00-00.log',
|
||||
'readme.txt',
|
||||
] as any);
|
||||
mockFs.stat.mockResolvedValue({
|
||||
mtime: new Date('2024-01-01T10:00:00Z'),
|
||||
size: 1024,
|
||||
} as any);
|
||||
|
||||
const result = await getLogFiles();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('promptfoo-debug-2024-01-01_10-00-00.log');
|
||||
});
|
||||
|
||||
it('filters by type when specified', async () => {
|
||||
mockFs.access.mockResolvedValue(undefined);
|
||||
mockFs.readdir.mockResolvedValue([
|
||||
'promptfoo-debug-2024-01-01_10-00-00.log',
|
||||
'promptfoo-error-2024-01-01_10-00-00.log',
|
||||
] as any);
|
||||
mockFs.stat.mockResolvedValue({
|
||||
mtime: new Date('2024-01-01T10:00:00Z'),
|
||||
size: 1024,
|
||||
} as any);
|
||||
|
||||
const debugFiles = await getLogFiles('debug');
|
||||
expect(debugFiles).toHaveLength(1);
|
||||
expect(debugFiles[0].type).toBe('debug');
|
||||
|
||||
const errorFiles = await getLogFiles('error');
|
||||
expect(errorFiles).toHaveLength(1);
|
||||
expect(errorFiles[0].type).toBe('error');
|
||||
|
||||
const allFiles = await getLogFiles('all');
|
||||
expect(allFiles).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('sorts by modification time (newest first)', async () => {
|
||||
mockFs.access.mockResolvedValue(undefined);
|
||||
mockFs.readdir.mockResolvedValue([
|
||||
'promptfoo-debug-2024-01-01_10-00-00.log',
|
||||
'promptfoo-debug-2024-01-02_10-00-00.log',
|
||||
] as any);
|
||||
|
||||
const dates = [new Date('2024-01-01T10:00:00Z'), new Date('2024-01-02T10:00:00Z')];
|
||||
let callIndex = 0;
|
||||
mockFs.stat.mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
mtime: dates[callIndex++ % 2],
|
||||
size: 1024,
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const result = await getLogFiles();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Newest should be first
|
||||
expect(result[0].mtime.getTime()).toBeGreaterThanOrEqual(result[1].mtime.getTime());
|
||||
});
|
||||
|
||||
it('correctly identifies log types', async () => {
|
||||
mockFs.access.mockResolvedValue(undefined);
|
||||
mockFs.readdir.mockResolvedValue([
|
||||
'promptfoo-debug-2024-01-01_10-00-00.log',
|
||||
'promptfoo-error-2024-01-01_10-00-00.log',
|
||||
] as any);
|
||||
mockFs.stat.mockResolvedValue({
|
||||
mtime: new Date('2024-01-01T10:00:00Z'),
|
||||
size: 1024,
|
||||
} as any);
|
||||
|
||||
const result = await getLogFiles('all');
|
||||
|
||||
const debugFile = result.find((f) => f.name.includes('-debug-'));
|
||||
const errorFile = result.find((f) => f.name.includes('-error-'));
|
||||
|
||||
expect(debugFile?.type).toBe('debug');
|
||||
expect(errorFile?.type).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogFilesSync', () => {
|
||||
beforeEach(() => {
|
||||
mockGetEnvString.mockReturnValue('');
|
||||
});
|
||||
|
||||
it('returns empty array when directory does not exist', () => {
|
||||
mockFsSync.existsSync.mockReturnValue(false);
|
||||
|
||||
const result = getLogFilesSync();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when no log files found', () => {
|
||||
mockFsSync.existsSync.mockReturnValue(true);
|
||||
mockFsSync.readdirSync.mockReturnValue([]);
|
||||
|
||||
const result = getLogFilesSync();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters non-promptfoo files', () => {
|
||||
mockFsSync.existsSync.mockReturnValue(true);
|
||||
mockFsSync.readdirSync.mockReturnValue([
|
||||
'other-file.log',
|
||||
'promptfoo-debug-2024-01-01_10-00-00.log',
|
||||
'readme.txt',
|
||||
] as any);
|
||||
mockFsSync.statSync.mockReturnValue({
|
||||
mtime: new Date('2024-01-01T10:00:00Z'),
|
||||
size: 1024,
|
||||
} as any);
|
||||
|
||||
const result = getLogFilesSync();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('promptfoo-debug-2024-01-01_10-00-00.log');
|
||||
});
|
||||
|
||||
it('filters by type when specified', () => {
|
||||
mockFsSync.existsSync.mockReturnValue(true);
|
||||
mockFsSync.readdirSync.mockReturnValue([
|
||||
'promptfoo-debug-2024-01-01_10-00-00.log',
|
||||
'promptfoo-error-2024-01-01_10-00-00.log',
|
||||
] as any);
|
||||
mockFsSync.statSync.mockReturnValue({
|
||||
mtime: new Date('2024-01-01T10:00:00Z'),
|
||||
size: 1024,
|
||||
} as any);
|
||||
|
||||
const debugFiles = getLogFilesSync('debug');
|
||||
expect(debugFiles).toHaveLength(1);
|
||||
expect(debugFiles[0].type).toBe('debug');
|
||||
|
||||
const errorFiles = getLogFilesSync('error');
|
||||
expect(errorFiles).toHaveLength(1);
|
||||
expect(errorFiles[0].type).toBe('error');
|
||||
|
||||
const allFiles = getLogFilesSync('all');
|
||||
expect(allFiles).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findLogFile', () => {
|
||||
beforeEach(() => {
|
||||
mockFsSync.existsSync.mockReturnValue(true);
|
||||
mockGetEnvString.mockReturnValue('');
|
||||
});
|
||||
|
||||
it('resolves absolute paths directly', () => {
|
||||
const absolutePath = '/absolute/path/to/logfile.log';
|
||||
mockFsSync.existsSync.mockImplementation((p) => p === absolutePath);
|
||||
|
||||
const result = findLogFile(absolutePath);
|
||||
expect(result).toBe(absolutePath);
|
||||
});
|
||||
|
||||
it('resolves filenames in log directory', () => {
|
||||
const filename = 'promptfoo-debug-2024-01-01_10-00-00.log';
|
||||
const expectedPath = path.join('/home/user/.promptfoo/logs', filename);
|
||||
|
||||
mockFsSync.existsSync.mockImplementation((p) => {
|
||||
// Return false for absolute path check, true for log directory path
|
||||
if (p === filename) {
|
||||
return false;
|
||||
}
|
||||
return p === expectedPath;
|
||||
});
|
||||
|
||||
const result = findLogFile(filename);
|
||||
expect(result).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it('performs fuzzy matching on partial names', () => {
|
||||
const logDir = path.join('/home/user/.promptfoo', 'logs');
|
||||
mockFsSync.readdirSync.mockReturnValue([
|
||||
'promptfoo-debug-2024-01-01_10-00-00.log',
|
||||
'promptfoo-debug-2024-01-02_10-00-00.log',
|
||||
] as any);
|
||||
mockFsSync.statSync.mockReturnValue({
|
||||
mtime: new Date('2024-01-01T10:00:00Z'),
|
||||
size: 1024,
|
||||
} as any);
|
||||
|
||||
// Make existsSync return false for direct paths to trigger fuzzy matching
|
||||
mockFsSync.existsSync.mockImplementation((p) => {
|
||||
const pStr = String(p);
|
||||
return pStr === logDir;
|
||||
});
|
||||
|
||||
const result = findLogFile('2024-01-01');
|
||||
expect(result).toContain('2024-01-01');
|
||||
});
|
||||
|
||||
it('returns null when file not found', () => {
|
||||
mockFsSync.existsSync.mockReturnValue(false);
|
||||
mockFsSync.readdirSync.mockReturnValue([]);
|
||||
|
||||
const result = findLogFile('nonexistent.log');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it('formats zero bytes', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B');
|
||||
});
|
||||
|
||||
it('formats bytes', () => {
|
||||
expect(formatFileSize(512)).toBe('512 B');
|
||||
});
|
||||
|
||||
it('formats kilobytes', () => {
|
||||
expect(formatFileSize(1024)).toBe('1.0 KB');
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB');
|
||||
});
|
||||
|
||||
it('formats megabytes', () => {
|
||||
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB');
|
||||
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
|
||||
});
|
||||
|
||||
it('formats gigabytes', () => {
|
||||
expect(formatFileSize(1024 * 1024 * 1024)).toBe('1.0 GB');
|
||||
});
|
||||
|
||||
it('formats terabytes', () => {
|
||||
expect(formatFileSize(1024 * 1024 * 1024 * 1024)).toBe('1.0 TB');
|
||||
});
|
||||
|
||||
it('handles very large files without overflow', () => {
|
||||
// 10 PB - should cap at TB
|
||||
expect(formatFileSize(10 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe('10240.0 TB');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Test utility for creating mock ChildProcess objects
|
||||
*
|
||||
* This utility standardizes mock ChildProcess creation across tests,
|
||||
* ensuring all required properties (killed, kill, etc.) are included
|
||||
* and event handlers are properly set up.
|
||||
*/
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export interface MockChildProcessOptions {
|
||||
/** Exit code to return on 'close' event (default: 0) */
|
||||
exitCode?: number | null;
|
||||
/** Error to emit on 'error' event (if set, close won't fire) */
|
||||
error?: Error;
|
||||
/** Data to emit on stdout 'data' event */
|
||||
stdoutData?: string | Buffer;
|
||||
/** Data to emit on stderr 'data' event */
|
||||
stderrData?: string | Buffer;
|
||||
/** Whether the process is killed (default: false) */
|
||||
killed?: boolean;
|
||||
/** Custom event handlers for advanced scenarios */
|
||||
customEventHandlers?: Record<string, (callback: (...args: any[]) => void) => void>;
|
||||
}
|
||||
|
||||
export interface MockChildProcess {
|
||||
stdout: { on: ReturnType<typeof vi.fn> };
|
||||
stderr: { on: ReturnType<typeof vi.fn> };
|
||||
killed: boolean;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock ChildProcess object with proper event simulation.
|
||||
*
|
||||
* By default, the mock:
|
||||
* - Has `killed: false` and `kill: vi.fn()` for signal handling
|
||||
* - Emits 'close' event with exitCode 0
|
||||
* - Has empty stdout/stderr handlers
|
||||
*
|
||||
* @example Basic usage - successful process
|
||||
* ```typescript
|
||||
* const mock = createMockChildProcess({ exitCode: 0 });
|
||||
* (spawn as Mock).mockReturnValue(mock);
|
||||
* ```
|
||||
*
|
||||
* @example Process with output
|
||||
* ```typescript
|
||||
* const mock = createMockChildProcess({
|
||||
* exitCode: 0,
|
||||
* stdoutData: JSON.stringify({ result: 'success' }),
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example Process that errors
|
||||
* ```typescript
|
||||
* const mock = createMockChildProcess({
|
||||
* error: new Error('spawn failed'),
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example Process with non-zero exit
|
||||
* ```typescript
|
||||
* const mock = createMockChildProcess({
|
||||
* exitCode: 1,
|
||||
* stderrData: 'Error: something went wrong',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createMockChildProcess(options: MockChildProcessOptions = {}): MockChildProcess {
|
||||
const {
|
||||
exitCode = 0,
|
||||
error,
|
||||
stdoutData,
|
||||
stderrData,
|
||||
killed = false,
|
||||
customEventHandlers,
|
||||
} = options;
|
||||
|
||||
const mockProcess: MockChildProcess = {
|
||||
stdout: {
|
||||
on: vi.fn().mockImplementation((event: string, callback: (data: Buffer) => void) => {
|
||||
if (event === 'data' && stdoutData) {
|
||||
const data = typeof stdoutData === 'string' ? Buffer.from(stdoutData) : stdoutData;
|
||||
// Use setImmediate to simulate async event emission
|
||||
setImmediate(() => callback(data));
|
||||
}
|
||||
}),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn().mockImplementation((event: string, callback: (data: Buffer) => void) => {
|
||||
if (event === 'data' && stderrData) {
|
||||
const data = typeof stderrData === 'string' ? Buffer.from(stderrData) : stderrData;
|
||||
setImmediate(() => callback(data));
|
||||
}
|
||||
}),
|
||||
},
|
||||
killed,
|
||||
kill: vi.fn(),
|
||||
on: vi.fn().mockImplementation(function (
|
||||
this: MockChildProcess,
|
||||
event: string,
|
||||
callback: (...args: any[]) => void,
|
||||
) {
|
||||
// Check for custom event handlers first
|
||||
if (customEventHandlers?.[event]) {
|
||||
customEventHandlers[event](callback);
|
||||
return mockProcess;
|
||||
}
|
||||
|
||||
// Default event handling
|
||||
if (event === 'error' && error) {
|
||||
// Error event fires immediately
|
||||
setImmediate(() => callback(error));
|
||||
} else if (event === 'close' && !error) {
|
||||
// Close event fires after data events
|
||||
setImmediate(() => callback(exitCode));
|
||||
}
|
||||
return mockProcess;
|
||||
}),
|
||||
};
|
||||
|
||||
return mockProcess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type assertion helper to cast mock to ChildProcess
|
||||
*/
|
||||
export function asMockChildProcess(mock: MockChildProcess): ChildProcess {
|
||||
return mock as unknown as ChildProcess;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getModelAuditCurrentVersion } from '../../src/updates';
|
||||
import { checkModelAuditInstalled } from '../../src/util/modelAuditInstall';
|
||||
|
||||
vi.mock('../../src/updates', () => ({
|
||||
getModelAuditCurrentVersion: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedGetModelAuditCurrentVersion = vi.mocked(getModelAuditCurrentVersion);
|
||||
|
||||
describe('checkModelAuditInstalled', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetModelAuditCurrentVersion.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetModelAuditCurrentVersion.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'0.2.16',
|
||||
'1.0.0',
|
||||
'0.2.19',
|
||||
])('returns the installed version when modelaudit reports %s', async (version) => {
|
||||
mockedGetModelAuditCurrentVersion.mockResolvedValue(version);
|
||||
|
||||
await expect(checkModelAuditInstalled()).resolves.toEqual({ installed: true, version });
|
||||
});
|
||||
|
||||
it('returns installed false when modelaudit is not installed', async () => {
|
||||
mockedGetModelAuditCurrentVersion.mockResolvedValue(null);
|
||||
|
||||
await expect(checkModelAuditInstalled()).resolves.toEqual({ installed: false, version: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getModelAuditVerdict,
|
||||
parseCompleteModelAuditResults,
|
||||
} from '../../src/util/modelAuditResults';
|
||||
|
||||
describe('modelAuditResults', () => {
|
||||
it('parses complete scan results', () => {
|
||||
const results = parseCompleteModelAuditResults({
|
||||
total_checks: 2,
|
||||
passed_checks: 1,
|
||||
failed_checks: 1,
|
||||
has_errors: false,
|
||||
issues: [],
|
||||
checks: [
|
||||
{ name: 'safe', status: 'passed', message: 'ok' },
|
||||
{ name: 'pickle', status: 'failed', message: 'failed' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(results.failed_checks).toBe(1);
|
||||
expect(getModelAuditVerdict(results)).toEqual({
|
||||
hasFindings: true,
|
||||
hasErrors: true,
|
||||
hasFailedChecks: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps informational-only results clean', () => {
|
||||
expect(
|
||||
getModelAuditVerdict({
|
||||
has_errors: false,
|
||||
issues: [{ severity: 'info', message: 'metadata only' }],
|
||||
checks: [],
|
||||
}),
|
||||
).toEqual({
|
||||
hasFindings: false,
|
||||
hasErrors: false,
|
||||
hasFailedChecks: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { accumulateNamedMetric, backfillNamedScoreWeights } from '../../src/util/namedMetrics';
|
||||
|
||||
describe('accumulateNamedMetric', () => {
|
||||
it('preserves weighted totals from grading results while keeping assertion counts', () => {
|
||||
const metrics = {
|
||||
namedScores: {},
|
||||
namedScoresCount: {},
|
||||
namedScoreWeights: {},
|
||||
};
|
||||
|
||||
accumulateNamedMetric(metrics, {
|
||||
metricName: 'accuracy',
|
||||
metricValue: 0.75,
|
||||
gradingResult: {
|
||||
pass: false,
|
||||
score: 0.75,
|
||||
reason: 'weighted metric',
|
||||
namedScoreWeights: { accuracy: 4 },
|
||||
componentResults: [
|
||||
{
|
||||
pass: true,
|
||||
score: 1,
|
||||
reason: 'critical passed',
|
||||
assertion: { type: 'contains', value: 'critical', metric: 'accuracy' },
|
||||
},
|
||||
{
|
||||
pass: false,
|
||||
score: 0,
|
||||
reason: 'optional failed',
|
||||
assertion: { type: 'contains', value: 'missing', metric: 'accuracy' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(metrics).toEqual({
|
||||
namedScores: { accuracy: 3 },
|
||||
namedScoresCount: { accuracy: 2 },
|
||||
namedScoreWeights: { accuracy: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to rendered assertion counts when stored weights are absent', () => {
|
||||
const metrics = {
|
||||
namedScores: {},
|
||||
namedScoresCount: {},
|
||||
namedScoreWeights: {},
|
||||
};
|
||||
|
||||
accumulateNamedMetric(metrics, {
|
||||
metricName: 'accuracy:alpha',
|
||||
metricValue: 0.8,
|
||||
testVars: { suffix: 'alpha' },
|
||||
gradingResult: {
|
||||
pass: true,
|
||||
score: 0.8,
|
||||
reason: 'templated metric',
|
||||
componentResults: [
|
||||
{
|
||||
pass: true,
|
||||
score: 0.8,
|
||||
reason: 'templated metric',
|
||||
assertion: { type: 'contains', value: 'alpha', metric: 'accuracy:{{ suffix }}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(metrics).toEqual({
|
||||
namedScores: { 'accuracy:alpha': 0.8 },
|
||||
namedScoresCount: { 'accuracy:alpha': 1 },
|
||||
namedScoreWeights: { 'accuracy:alpha': 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('backfillNamedScoreWeights', () => {
|
||||
it('fills missing weights from assertion counts without overwriting existing weights', () => {
|
||||
const metrics = {
|
||||
namedScores: { accuracy: 1.6, safety: 0.8 },
|
||||
namedScoresCount: { accuracy: 2, safety: 1 },
|
||||
namedScoreWeights: { accuracy: 4 },
|
||||
};
|
||||
|
||||
backfillNamedScoreWeights(metrics);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
namedScores: { accuracy: 1.6, safety: 0.8 },
|
||||
namedScoresCount: { accuracy: 2, safety: 1 },
|
||||
namedScoreWeights: { accuracy: 4, safety: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes missing weights from assertion counts for legacy metrics', () => {
|
||||
const metrics = {
|
||||
namedScores: { accuracy: 1.6 },
|
||||
namedScoresCount: { accuracy: 2 },
|
||||
};
|
||||
|
||||
backfillNamedScoreWeights(metrics);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
namedScores: { accuracy: 1.6 },
|
||||
namedScoresCount: { accuracy: 2 },
|
||||
namedScoreWeights: { accuracy: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockFetchWithProxy = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../../src/util/fetch/index', () => ({
|
||||
fetchWithProxy: mockFetchWithProxy,
|
||||
}));
|
||||
|
||||
import { fetchOAuthToken, TOKEN_REFRESH_BUFFER_MS } from '../../src/util/oauth';
|
||||
|
||||
describe('oauth utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchWithProxy.mockReset();
|
||||
});
|
||||
|
||||
describe('TOKEN_REFRESH_BUFFER_MS', () => {
|
||||
it('should be 60 seconds', () => {
|
||||
expect(TOKEN_REFRESH_BUFFER_MS).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchOAuthToken', () => {
|
||||
it('should fetch token with client_credentials grant', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
access_token: 'test-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
};
|
||||
mockFetchWithProxy.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await fetchOAuthToken({
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
grantType: 'client_credentials',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
});
|
||||
|
||||
expect(result.accessToken).toBe('test-token');
|
||||
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||
|
||||
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://auth.example.com/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: expect.stringContaining('grant_type=client_credentials'),
|
||||
});
|
||||
|
||||
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
|
||||
expect(callBody).toContain('client_id=test-client');
|
||||
expect(callBody).toContain('client_secret=test-secret');
|
||||
});
|
||||
|
||||
it('should fetch token with password grant', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
access_token: 'password-token',
|
||||
expires_in: 7200,
|
||||
}),
|
||||
};
|
||||
mockFetchWithProxy.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await fetchOAuthToken({
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
grantType: 'password',
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
});
|
||||
|
||||
expect(result.accessToken).toBe('password-token');
|
||||
|
||||
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
|
||||
expect(callBody).toContain('grant_type=password');
|
||||
expect(callBody).toContain('username=testuser');
|
||||
expect(callBody).toContain('password=testpass');
|
||||
});
|
||||
|
||||
it('should include scopes when provided', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
access_token: 'scoped-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
};
|
||||
mockFetchWithProxy.mockResolvedValue(mockResponse);
|
||||
|
||||
await fetchOAuthToken({
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
grantType: 'client_credentials',
|
||||
clientId: 'test-client',
|
||||
scopes: ['read', 'write', 'admin'],
|
||||
});
|
||||
|
||||
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
|
||||
expect(callBody).toContain('scope=read+write+admin');
|
||||
});
|
||||
|
||||
it('should work without optional clientId and clientSecret', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
access_token: 'minimal-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
};
|
||||
mockFetchWithProxy.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await fetchOAuthToken({
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
grantType: 'client_credentials',
|
||||
});
|
||||
|
||||
expect(result.accessToken).toBe('minimal-token');
|
||||
|
||||
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
|
||||
expect(callBody).not.toContain('client_id');
|
||||
expect(callBody).not.toContain('client_secret');
|
||||
});
|
||||
|
||||
it('should default expires_in to 3600 seconds when not provided', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
access_token: 'no-expiry-token',
|
||||
// No expires_in field
|
||||
}),
|
||||
};
|
||||
mockFetchWithProxy.mockResolvedValue(mockResponse);
|
||||
|
||||
const beforeFetch = Date.now();
|
||||
const result = await fetchOAuthToken({
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
grantType: 'client_credentials',
|
||||
});
|
||||
|
||||
// Should default to 3600 seconds (1 hour)
|
||||
const expectedMinExpiry = beforeFetch + 3600 * 1000;
|
||||
const expectedMaxExpiry = Date.now() + 3600 * 1000;
|
||||
expect(result.expiresAt).toBeGreaterThanOrEqual(expectedMinExpiry);
|
||||
expect(result.expiresAt).toBeLessThanOrEqual(expectedMaxExpiry);
|
||||
});
|
||||
|
||||
it('should use provided expires_in value', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
access_token: 'custom-expiry-token',
|
||||
expires_in: 1800, // 30 minutes
|
||||
}),
|
||||
};
|
||||
mockFetchWithProxy.mockResolvedValue(mockResponse);
|
||||
|
||||
const beforeFetch = Date.now();
|
||||
const result = await fetchOAuthToken({
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
grantType: 'client_credentials',
|
||||
});
|
||||
|
||||
// Should use 1800 seconds (30 minutes)
|
||||
const expectedMinExpiry = beforeFetch + 1800 * 1000;
|
||||
const expectedMaxExpiry = Date.now() + 1800 * 1000;
|
||||
expect(result.expiresAt).toBeGreaterThanOrEqual(expectedMinExpiry);
|
||||
expect(result.expiresAt).toBeLessThanOrEqual(expectedMaxExpiry);
|
||||
});
|
||||
|
||||
it('should throw error when response is not ok', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: vi.fn().mockResolvedValue('{"error":"invalid_client"}'),
|
||||
};
|
||||
mockFetchWithProxy.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(
|
||||
fetchOAuthToken({
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
grantType: 'client_credentials',
|
||||
clientId: 'bad-client',
|
||||
}),
|
||||
).rejects.toThrow('OAuth token request failed with status 401 Unauthorized');
|
||||
});
|
||||
|
||||
it('should throw error when access_token is missing in response', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
// No access_token field
|
||||
token_type: 'Bearer',
|
||||
}),
|
||||
};
|
||||
mockFetchWithProxy.mockResolvedValue(mockResponse);
|
||||
|
||||
await expect(
|
||||
fetchOAuthToken({
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
grantType: 'client_credentials',
|
||||
}),
|
||||
).rejects.toThrow('OAuth token response missing access_token');
|
||||
});
|
||||
|
||||
it('should handle password grant without username/password gracefully', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
access_token: 'token-without-creds',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
};
|
||||
mockFetchWithProxy.mockResolvedValue(mockResponse);
|
||||
|
||||
// Password grant without username/password - the function should still make the request
|
||||
// (server will likely reject it, but that's not our concern here)
|
||||
const result = await fetchOAuthToken({
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
grantType: 'password',
|
||||
});
|
||||
|
||||
expect(result.accessToken).toBe('token-without-creds');
|
||||
|
||||
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
|
||||
expect(callBody).toContain('grant_type=password');
|
||||
// Should not have username= or password= fields (distinct from grant_type=password)
|
||||
expect(callBody).not.toContain('username=');
|
||||
expect(callBody).not.toContain('&password=');
|
||||
});
|
||||
|
||||
it('should not include empty scopes array', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
access_token: 'no-scope-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
};
|
||||
mockFetchWithProxy.mockResolvedValue(mockResponse);
|
||||
|
||||
await fetchOAuthToken({
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
grantType: 'client_credentials',
|
||||
scopes: [],
|
||||
});
|
||||
|
||||
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
|
||||
expect(callBody).not.toContain('scope');
|
||||
});
|
||||
|
||||
it('should URL-encode special characters in credentials', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue({
|
||||
access_token: 'encoded-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
};
|
||||
mockFetchWithProxy.mockResolvedValue(mockResponse);
|
||||
|
||||
await fetchOAuthToken({
|
||||
tokenUrl: 'https://auth.example.com/token',
|
||||
grantType: 'password',
|
||||
clientId: 'client+id',
|
||||
clientSecret: 'secret&value',
|
||||
username: 'user@example.com',
|
||||
password: 'pass=word!',
|
||||
});
|
||||
|
||||
const callBody = mockFetchWithProxy.mock.calls[0][1].body;
|
||||
// URLSearchParams should encode special characters
|
||||
expect(callBody).toContain('client_id=client%2Bid');
|
||||
expect(callBody).toContain('client_secret=secret%26value');
|
||||
expect(callBody).toContain('username=user%40example.com');
|
||||
expect(callBody).toContain('password=pass%3Dword%21');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { removeEmpty } from '../../src/util/objectUtils';
|
||||
|
||||
describe('objectUtils', () => {
|
||||
describe('removeEmpty', () => {
|
||||
it('removes empty arrays from the top level', () => {
|
||||
const input = {
|
||||
name: 'test',
|
||||
emptyArray: [],
|
||||
nonEmptyArray: [1, 2, 3],
|
||||
};
|
||||
|
||||
const result = removeEmpty(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'test',
|
||||
nonEmptyArray: [1, 2, 3],
|
||||
});
|
||||
expect(result.emptyArray).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes empty objects from the top level', () => {
|
||||
const input = {
|
||||
name: 'test',
|
||||
emptyObj: {},
|
||||
nonEmptyObj: { key: 'value' },
|
||||
};
|
||||
|
||||
const result = removeEmpty(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'test',
|
||||
nonEmptyObj: { key: 'value' },
|
||||
});
|
||||
expect(result.emptyObj).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not remove empty arrays or objects in nested objects', () => {
|
||||
const input = {
|
||||
level1: {
|
||||
emptyArr: [],
|
||||
emptyObj: {},
|
||||
validKey: 'value',
|
||||
},
|
||||
};
|
||||
|
||||
const result = removeEmpty(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
emptyArr: [],
|
||||
emptyObj: {},
|
||||
validKey: 'value',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not modify the original object', () => {
|
||||
const original = {
|
||||
name: 'test',
|
||||
emptyArray: [],
|
||||
nested: { empty: {} },
|
||||
};
|
||||
|
||||
const originalCopy = JSON.parse(JSON.stringify(original));
|
||||
removeEmpty(original);
|
||||
|
||||
expect(original).toEqual(originalCopy);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getOutputFileFormat, SUPPORTED_OUTPUT_FILE_FORMATS } from '../../src/util/outputFormats';
|
||||
|
||||
describe('outputFormats', () => {
|
||||
it('detects JUnit XML outputs by the full suffix', () => {
|
||||
expect(getOutputFileFormat('results.junit.xml')).toBe('junit.xml');
|
||||
expect(getOutputFileFormat('reports/ci.RESULTS.JUNIT.XML')).toBe('junit.xml');
|
||||
});
|
||||
|
||||
it('keeps Promptfoo XML separate from JUnit XML routing', () => {
|
||||
expect(getOutputFileFormat('results.xml')).toBe('xml');
|
||||
expect(getOutputFileFormat('results.junit')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('advertises junit.xml as a supported output format', () => {
|
||||
expect(SUPPORTED_OUTPUT_FILE_FORMATS).toContain('junit.xml');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isMissingPackageImportError } from '../../src/util/packageImportErrors';
|
||||
|
||||
describe('isMissingPackageImportError', () => {
|
||||
it('recognizes missing optional packages', () => {
|
||||
const error = Object.assign(new Error('Cannot find package @googleapis/sheets'), {
|
||||
code: 'ERR_MODULE_NOT_FOUND',
|
||||
});
|
||||
|
||||
expect(isMissingPackageImportError(error, '@googleapis/sheets')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes CommonJS-style missing optional package errors', () => {
|
||||
const error = Object.assign(new Error('Missing optional dependency @googleapis/sheets'), {
|
||||
code: 'MODULE_NOT_FOUND',
|
||||
});
|
||||
|
||||
expect(isMissingPackageImportError(error, '@googleapis/sheets')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores import failures for unrelated packages', () => {
|
||||
const error = new Error('Cannot find package @promptfoo/unrelated');
|
||||
|
||||
expect(isMissingPackageImportError(error, '@googleapis/sheets')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves import-time runtime errors from present packages', () => {
|
||||
const error = new Error('@googleapis/sheets failed during initialization');
|
||||
|
||||
expect(isMissingPackageImportError(error, '@googleapis/sheets')).toBe(false);
|
||||
});
|
||||
|
||||
it('recognizes the optional package itself from a realistic ESM message', () => {
|
||||
const error = Object.assign(
|
||||
new Error(
|
||||
"Cannot find package '@googleapis/sheets' imported from /app/dist/src/googleSheets.js",
|
||||
),
|
||||
{ code: 'ERR_MODULE_NOT_FOUND' },
|
||||
);
|
||||
|
||||
expect(isMissingPackageImportError(error, '@googleapis/sheets')).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes a subpath import of the optional package', () => {
|
||||
const error = Object.assign(
|
||||
new Error(
|
||||
"Cannot find package '@googleapis/sheets/build/index' imported from /app/dist/src/googleSheets.js",
|
||||
),
|
||||
{ code: 'ERR_MODULE_NOT_FOUND' },
|
||||
);
|
||||
|
||||
expect(isMissingPackageImportError(error, '@googleapis/sheets')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not misreport a missing transitive dependency as the optional package (ESM)', () => {
|
||||
// The optional package is installed, but one of ITS dependencies is not.
|
||||
// The package name appears only in the importer path, not as the failed
|
||||
// specifier, so it must not be reported as the package itself being absent.
|
||||
const error = Object.assign(
|
||||
new Error(
|
||||
"Cannot find package 'gaxios' imported from /app/node_modules/@googleapis/sheets/build/index.js",
|
||||
),
|
||||
{ code: 'ERR_MODULE_NOT_FOUND' },
|
||||
);
|
||||
|
||||
expect(isMissingPackageImportError(error, '@googleapis/sheets')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not misreport a missing transitive dependency as the optional package (CommonJS)', () => {
|
||||
const error = Object.assign(
|
||||
new Error(
|
||||
"Cannot find module 'gaxios'\nRequire stack:\n- /app/node_modules/@googleapis/sheets/build/index.js",
|
||||
),
|
||||
{ code: 'MODULE_NOT_FOUND' },
|
||||
);
|
||||
|
||||
expect(isMissingPackageImportError(error, '@googleapis/sheets')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { safeJoin, safeResolve } from '../../src/util/pathUtils';
|
||||
|
||||
/**
|
||||
* Helper to create file:// URLs in a cross-platform way
|
||||
*/
|
||||
function getFileUrl(path: string): string {
|
||||
return process.platform === 'win32'
|
||||
? `file:///C:/${path.replace(/\\/g, '/')}`
|
||||
: `file:///${path}`;
|
||||
}
|
||||
|
||||
describe('pathUtils', () => {
|
||||
describe('safeResolve', () => {
|
||||
it('returns absolute path unchanged', () => {
|
||||
const absolutePath = path.resolve('/absolute/path/file.txt');
|
||||
expect(safeResolve('some/base/path', absolutePath)).toBe(absolutePath);
|
||||
});
|
||||
|
||||
it('returns file URL unchanged', () => {
|
||||
const fileUrl = getFileUrl('absolute/path/file.txt');
|
||||
expect(safeResolve('some/base/path', fileUrl)).toBe(fileUrl);
|
||||
});
|
||||
|
||||
it('returns Windows file URL unchanged', () => {
|
||||
const windowsFileUrl = 'file://C:/path/file.txt';
|
||||
expect(safeResolve('some/base/path', windowsFileUrl)).toBe(windowsFileUrl);
|
||||
});
|
||||
|
||||
it('returns HTTP URLs unchanged', () => {
|
||||
const httpUrl = 'https://example.com/file.txt';
|
||||
expect(safeResolve('some/base/path', httpUrl)).toBe(httpUrl);
|
||||
});
|
||||
|
||||
it('resolves relative paths', () => {
|
||||
const expected = path.resolve('base/path', 'relative/file.txt');
|
||||
expect(safeResolve('base/path', 'relative/file.txt')).toBe(expected);
|
||||
});
|
||||
|
||||
it('handles multiple path segments', () => {
|
||||
const absolutePath = path.resolve('/absolute/path/file.txt');
|
||||
expect(safeResolve('base', 'path', absolutePath)).toBe(absolutePath);
|
||||
|
||||
const expected = path.resolve('base', 'path', 'relative/file.txt');
|
||||
expect(safeResolve('base', 'path', 'relative/file.txt')).toBe(expected);
|
||||
});
|
||||
|
||||
it('handles empty input', () => {
|
||||
expect(safeResolve()).toBe(path.resolve());
|
||||
expect(safeResolve('')).toBe(path.resolve(''));
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeJoin', () => {
|
||||
it('returns absolute path unchanged', () => {
|
||||
const absolutePath = path.resolve('/absolute/path/file.txt');
|
||||
expect(safeJoin('some/base/path', absolutePath)).toBe(absolutePath);
|
||||
});
|
||||
|
||||
it('returns file URL unchanged', () => {
|
||||
const fileUrl = getFileUrl('absolute/path/file.txt');
|
||||
expect(safeJoin('some/base/path', fileUrl)).toBe(fileUrl);
|
||||
});
|
||||
|
||||
it('returns Windows file URL unchanged', () => {
|
||||
const windowsFileUrl = 'file://C:/path/file.txt';
|
||||
expect(safeJoin('some/base/path', windowsFileUrl)).toBe(windowsFileUrl);
|
||||
});
|
||||
|
||||
it('returns HTTP URLs unchanged', () => {
|
||||
const httpUrl = 'https://example.com/file.txt';
|
||||
expect(safeJoin('some/base/path', httpUrl)).toBe(httpUrl);
|
||||
});
|
||||
|
||||
it('joins relative paths', () => {
|
||||
const expected = path.join('base/path', 'relative/file.txt');
|
||||
expect(safeJoin('base/path', 'relative/file.txt')).toBe(expected);
|
||||
});
|
||||
|
||||
it('handles multiple path segments', () => {
|
||||
const absolutePath = path.resolve('/absolute/path/file.txt');
|
||||
expect(safeJoin('base', 'path', absolutePath)).toBe(absolutePath);
|
||||
|
||||
const expected = path.join('base', 'path', 'relative/file.txt');
|
||||
expect(safeJoin('base', 'path', 'relative/file.txt')).toBe(expected);
|
||||
});
|
||||
|
||||
it('handles empty input', () => {
|
||||
expect(safeJoin()).toBe(path.join());
|
||||
expect(safeJoin('')).toBe(path.join(''));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
_createBrowserProcessShim,
|
||||
_isBrowserEnvironment,
|
||||
getProcessShim,
|
||||
} from '../../src/util/processShim';
|
||||
|
||||
describe('processShim', () => {
|
||||
describe('_isBrowserEnvironment', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalSelf = globalThis.self;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Restore original globals
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
} else {
|
||||
(globalThis as Record<string, unknown>).window = originalWindow;
|
||||
}
|
||||
if (originalSelf === undefined) {
|
||||
delete (globalThis as Record<string, unknown>).self;
|
||||
} else {
|
||||
(globalThis as Record<string, unknown>).self = originalSelf;
|
||||
}
|
||||
});
|
||||
|
||||
it('should return false in Node.js environment', () => {
|
||||
// In Node.js, process.versions.node exists
|
||||
expect(_isBrowserEnvironment()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false even when window is defined in Node.js (jsdom/happy-dom)', () => {
|
||||
// This tests the fix for test environments that define window
|
||||
(globalThis as Record<string, unknown>).window = {};
|
||||
|
||||
// Should still return false because process.versions.node exists
|
||||
expect(_isBrowserEnvironment()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false even when self.importScripts is defined in Node.js', () => {
|
||||
// Test environments might also mock web worker globals
|
||||
(globalThis as Record<string, unknown>).self = {
|
||||
importScripts: () => {},
|
||||
};
|
||||
|
||||
// Should still return false because process.versions.node exists
|
||||
expect(_isBrowserEnvironment()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_createBrowserProcessShim', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return an object with env property', () => {
|
||||
const shim = _createBrowserProcessShim();
|
||||
|
||||
expect(shim.env).toBeDefined();
|
||||
expect(typeof shim.env).toBe('object');
|
||||
});
|
||||
|
||||
it('should return an object with mainModule property', () => {
|
||||
const shim = _createBrowserProcessShim();
|
||||
|
||||
expect(shim.mainModule).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw an error when mainModule.require is called', () => {
|
||||
const shim = _createBrowserProcessShim();
|
||||
|
||||
expect(() => {
|
||||
shim.mainModule!.require('fs');
|
||||
}).toThrow('require() is not available in browser transforms');
|
||||
});
|
||||
|
||||
it('should have mainModule with expected structure', () => {
|
||||
const shim = _createBrowserProcessShim();
|
||||
const mainModule = shim.mainModule!;
|
||||
|
||||
expect(mainModule.exports).toEqual({});
|
||||
expect(mainModule.id).toBe('.');
|
||||
expect(mainModule.filename).toBe('');
|
||||
expect(mainModule.loaded).toBe(true);
|
||||
expect(mainModule.children).toEqual([]);
|
||||
expect(mainModule.paths).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProcessShim', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Restore original globals
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as Record<string, unknown>).window;
|
||||
} else {
|
||||
(globalThis as Record<string, unknown>).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it('should return a process shim in Node.js environment', () => {
|
||||
const shim = getProcessShim();
|
||||
|
||||
expect(shim).toBeDefined();
|
||||
expect(shim.mainModule).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return a working require function in Node.js environment', () => {
|
||||
const shim = getProcessShim();
|
||||
|
||||
// In Node.js, mainModule.require should work
|
||||
const path = shim.mainModule!.require('path');
|
||||
expect(path.join).toBeDefined();
|
||||
expect(typeof path.join).toBe('function');
|
||||
});
|
||||
|
||||
it('should return Node.js shim even when window is defined (jsdom/happy-dom)', () => {
|
||||
// This tests that we correctly detect Node.js in test environments
|
||||
(globalThis as Record<string, unknown>).window = {};
|
||||
|
||||
const shim = getProcessShim();
|
||||
|
||||
// Should still work because process.versions.node exists
|
||||
expect(shim).toBeDefined();
|
||||
const path = shim.mainModule!.require('path');
|
||||
expect(path.join).toBeDefined();
|
||||
});
|
||||
|
||||
it('should cache the Node.js shim for subsequent calls', () => {
|
||||
const shim1 = getProcessShim();
|
||||
const shim2 = getProcessShim();
|
||||
|
||||
// Should return the same cached instance
|
||||
expect(shim1).toBe(shim2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProcessShim integration with transforms', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should allow inline transforms to access process shim', () => {
|
||||
const shim = getProcessShim();
|
||||
|
||||
// Create an inline transform function like the actual code does
|
||||
const transformFn = new Function(
|
||||
'data',
|
||||
'context',
|
||||
'process',
|
||||
'return data.toUpperCase()',
|
||||
) as (data: string, context: object, process: typeof global.process) => string;
|
||||
|
||||
const result = transformFn('hello', {}, shim);
|
||||
|
||||
expect(result).toBe('HELLO');
|
||||
});
|
||||
|
||||
it('should allow inline transforms to use process.mainModule.require in Node.js', () => {
|
||||
const shim = getProcessShim();
|
||||
|
||||
// Create a transform that uses require
|
||||
const transformFn = new Function(
|
||||
'data',
|
||||
'context',
|
||||
'process',
|
||||
`
|
||||
const path = process.mainModule.require('path');
|
||||
return path.basename(data);
|
||||
`,
|
||||
) as (data: string, context: object, process: typeof global.process) => string;
|
||||
|
||||
const result = transformFn('/foo/bar/test.txt', {}, shim);
|
||||
|
||||
expect(result).toBe('test.txt');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
detectInstaller,
|
||||
isRunningUnderNpx,
|
||||
promptfooCommand,
|
||||
} from '../../src/util/promptfooCommand';
|
||||
import { mockProcessEnv } from './utils';
|
||||
|
||||
describe('nextCommand', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
mockProcessEnv({ ...originalEnv }, { clear: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockProcessEnv(originalEnv, { clear: true });
|
||||
});
|
||||
|
||||
describe('detectInstaller', () => {
|
||||
it('should detect npx from npm_execpath', () => {
|
||||
mockProcessEnv({ npm_execpath: '/path/to/npx' });
|
||||
expect(detectInstaller()).toBe('npx');
|
||||
});
|
||||
|
||||
it('should detect npx from npm_lifecycle_script', () => {
|
||||
mockProcessEnv({ npm_lifecycle_script: 'npx some-command' });
|
||||
expect(detectInstaller()).toBe('npx');
|
||||
});
|
||||
|
||||
it('should detect npx from process.execPath', () => {
|
||||
const originalExecPath = process.execPath;
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: '/path/to/npx/node',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
expect(detectInstaller()).toBe('npx');
|
||||
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: originalExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect npx from npm_config_user_agent', () => {
|
||||
mockProcessEnv({ npm_config_user_agent: 'npx/10.5.0 npm/10.8.2 node/v18.20.4' });
|
||||
expect(detectInstaller()).toBe('npx');
|
||||
});
|
||||
|
||||
it('should detect brew from npm_config_prefix', () => {
|
||||
mockProcessEnv({ npm_config_prefix: '/usr/local/Homebrew/Cellar/node/20.0.0/bin' });
|
||||
expect(detectInstaller()).toBe('brew');
|
||||
});
|
||||
|
||||
it('should detect brew from process.execPath', () => {
|
||||
const originalExecPath = process.execPath;
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: '/usr/local/Homebrew/bin/node',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
expect(detectInstaller()).toBe('brew');
|
||||
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: originalExecPath,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect npm-global from npm_config_user_agent', () => {
|
||||
mockProcessEnv({ npm_config_user_agent: 'npm/10.8.2 node/v18.20.4' });
|
||||
expect(detectInstaller()).toBe('npm-global');
|
||||
});
|
||||
|
||||
it('should return unknown for unrecognized patterns', () => {
|
||||
mockProcessEnv({ npm_config_user_agent: 'yarn/1.22.0' });
|
||||
expect(detectInstaller()).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should prioritize npm_execpath over user agent', () => {
|
||||
mockProcessEnv({ npm_execpath: '/path/to/npx' });
|
||||
mockProcessEnv({ npm_config_user_agent: 'npm/10.8.2 node/v18.20.4' });
|
||||
expect(detectInstaller()).toBe('npx');
|
||||
});
|
||||
|
||||
it('should prioritize npm_lifecycle_script over user agent', () => {
|
||||
mockProcessEnv({ npm_lifecycle_script: 'npx some-command' });
|
||||
mockProcessEnv({ npm_config_user_agent: 'npm/10.8.2 node/v18.20.4' });
|
||||
expect(detectInstaller()).toBe('npx');
|
||||
});
|
||||
|
||||
it('should handle empty environment variables', () => {
|
||||
mockProcessEnv({ npm_execpath: undefined });
|
||||
mockProcessEnv({ npm_lifecycle_script: undefined });
|
||||
mockProcessEnv({ npm_config_prefix: undefined });
|
||||
mockProcessEnv({ npm_config_user_agent: undefined });
|
||||
expect(detectInstaller()).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should handle case insensitive homebrew detection', () => {
|
||||
mockProcessEnv({ npm_config_prefix: '/usr/local/homebrew/cellar/node/20.0.0/bin' });
|
||||
expect(detectInstaller()).toBe('brew');
|
||||
});
|
||||
|
||||
it('should detect brew with Windows-style paths', () => {
|
||||
mockProcessEnv({ npm_config_prefix: 'C:\\Users\\user\\homebrew\\Cellar\\node\\20.0.0\\bin' });
|
||||
expect(detectInstaller()).toBe('brew');
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptfooCommand', () => {
|
||||
it('should return npx command for npx installer', () => {
|
||||
mockProcessEnv({ npm_execpath: '/path/to/npx' });
|
||||
expect(promptfooCommand('eval')).toBe('npx promptfoo@latest eval');
|
||||
});
|
||||
|
||||
it('should return regular command for brew installer', () => {
|
||||
mockProcessEnv({ npm_config_prefix: '/usr/local/Homebrew/Cellar/node/20.0.0/bin' });
|
||||
expect(promptfooCommand('eval')).toBe('promptfoo eval');
|
||||
});
|
||||
|
||||
it('should return regular command for npm-global installer', () => {
|
||||
mockProcessEnv({ npm_config_user_agent: 'npm/10.8.2 node/v18.20.4' });
|
||||
expect(promptfooCommand('eval')).toBe('promptfoo eval');
|
||||
});
|
||||
|
||||
it('should return regular command for unknown installer', () => {
|
||||
mockProcessEnv({ npm_config_user_agent: 'yarn/1.22.0' });
|
||||
expect(promptfooCommand('eval')).toBe('promptfoo eval');
|
||||
});
|
||||
|
||||
it('should handle complex subcommands', () => {
|
||||
mockProcessEnv({ npm_execpath: '/path/to/npx' });
|
||||
expect(promptfooCommand('redteam init')).toBe('npx promptfoo@latest redteam init');
|
||||
});
|
||||
|
||||
it('should handle subcommands with flags', () => {
|
||||
mockProcessEnv({ npm_execpath: '/path/to/npx' });
|
||||
expect(promptfooCommand('eval -c config.yaml')).toBe(
|
||||
'npx promptfoo@latest eval -c config.yaml',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty subcommand for npx', () => {
|
||||
mockProcessEnv({ npm_execpath: '/path/to/npx' });
|
||||
expect(promptfooCommand('')).toBe('npx promptfoo@latest');
|
||||
});
|
||||
|
||||
it('should handle empty subcommand for non-npx', () => {
|
||||
mockProcessEnv({ npm_config_prefix: '/usr/local/Homebrew/Cellar/node/20.0.0/bin' });
|
||||
expect(promptfooCommand('')).toBe('promptfoo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRunningUnderNpx (legacy compatibility)', () => {
|
||||
it('should return true for npx installer', () => {
|
||||
mockProcessEnv({ npm_execpath: '/path/to/npx' });
|
||||
expect(isRunningUnderNpx()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-npx installers', () => {
|
||||
mockProcessEnv({ npm_config_prefix: '/usr/local/Homebrew/Cellar/node/20.0.0/bin' });
|
||||
expect(isRunningUnderNpx()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle partial matches in strings', () => {
|
||||
mockProcessEnv({ npm_execpath: '/some/path/with-npx-in-middle/but-not-npx' });
|
||||
expect(detectInstaller()).toBe('npx'); // Should still match because contains 'npx'
|
||||
});
|
||||
|
||||
it('should handle npm_config_user_agent with multiple patterns', () => {
|
||||
mockProcessEnv({ npm_config_user_agent: 'npx/10.5.0 npm/10.8.2 node/v18.20.4' });
|
||||
expect(detectInstaller()).toBe('npx'); // Should detect npx, not npm
|
||||
});
|
||||
|
||||
it('should handle undefined environment variables gracefully', () => {
|
||||
mockProcessEnv({ npm_execpath: undefined });
|
||||
mockProcessEnv({ npm_lifecycle_script: undefined });
|
||||
mockProcessEnv({ npm_config_prefix: undefined });
|
||||
mockProcessEnv({ npm_config_user_agent: undefined });
|
||||
|
||||
expect(() => detectInstaller()).not.toThrow();
|
||||
expect(detectInstaller()).toBe('unknown');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,620 @@
|
||||
import * as path from 'path';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
checkProviderApiKeys,
|
||||
doesProviderRefMatch,
|
||||
getProviderDescription,
|
||||
getProviderIdentifier,
|
||||
isAnthropicProvider,
|
||||
isGoogleProvider,
|
||||
isOpenAiProvider,
|
||||
isProviderAllowed,
|
||||
providerToIdentifier,
|
||||
sanitizeProviderIdForLog,
|
||||
} from '../../src/util/provider';
|
||||
import {
|
||||
canonicalizeProviderId,
|
||||
isProviderConfigFileReference,
|
||||
normalizeProviderRef,
|
||||
} from '../../src/util/providerRef';
|
||||
import { createMockProvider } from '../factories/provider';
|
||||
|
||||
import type { ApiProvider } from '../../src/types/index';
|
||||
|
||||
describe('normalizeProviderRef', () => {
|
||||
it('identifies provider config file references without treating script providers as config', () => {
|
||||
expect(isProviderConfigFileReference('file://providers.yaml')).toBe(true);
|
||||
expect(isProviderConfigFileReference('file://providers.yml')).toBe(true);
|
||||
expect(isProviderConfigFileReference('file://providers.json')).toBe(true);
|
||||
expect(isProviderConfigFileReference('file://provider.js')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes ProviderOptionsMap refs with explicit id overrides', () => {
|
||||
const descriptor = normalizeProviderRef({
|
||||
'openai:responses:gpt-5.4': {
|
||||
id: 'custom-openai',
|
||||
label: 'Fast OpenAI',
|
||||
config: { temperature: 0.2 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(descriptor).toMatchObject({
|
||||
kind: 'map',
|
||||
id: 'custom-openai',
|
||||
label: 'Fast OpenAI',
|
||||
loadProviderPath: 'openai:responses:gpt-5.4',
|
||||
loadOptions: {
|
||||
id: 'custom-openai',
|
||||
label: 'Fast OpenAI',
|
||||
config: { temperature: 0.2 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat ProviderOptions-shaped objects as ProviderOptionsMap refs', () => {
|
||||
expect(normalizeProviderRef({ config: { temperature: 0.2 } })).toMatchObject({
|
||||
kind: 'unknown',
|
||||
id: 'unknown',
|
||||
});
|
||||
expect(normalizeProviderRef({ prompts: ['prompt1'] })).toMatchObject({
|
||||
kind: 'unknown',
|
||||
id: 'unknown',
|
||||
});
|
||||
expect(normalizeProviderRef({ config: { id: 'openai:gpt-4' } })).toMatchObject({
|
||||
kind: 'unknown',
|
||||
id: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires ProviderOptionsMap refs to use a single provider id key', () => {
|
||||
expect(
|
||||
normalizeProviderRef({
|
||||
'openai:gpt-4': { config: { temperature: 0.2 } },
|
||||
'anthropic:messages:claude-sonnet-4-5': { config: { temperature: 0.1 } },
|
||||
}),
|
||||
).toMatchObject({
|
||||
kind: 'unknown',
|
||||
id: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('guards every ProviderOptions key against ProviderOptionsMap misclassification', () => {
|
||||
// Each key from the ProviderOptions interface must be guarded so that
|
||||
// objects like { transform: "..." } are not mistaken for { "transform": { ...providerOpts } }.
|
||||
// If you add a key to ProviderOptions, add it here too.
|
||||
const providerOptionKeys = [
|
||||
'id',
|
||||
'label',
|
||||
'config',
|
||||
'prompts',
|
||||
'transform',
|
||||
'delay',
|
||||
'env',
|
||||
'inputs',
|
||||
];
|
||||
for (const key of providerOptionKeys) {
|
||||
const obj = { [key]: { nested: true } };
|
||||
const descriptor = normalizeProviderRef(obj);
|
||||
expect(descriptor.kind, `key "${key}" should not produce kind 'map'`).not.toBe('map');
|
||||
}
|
||||
});
|
||||
|
||||
it('uses function labels before positional custom-function fallbacks', () => {
|
||||
const labeled = Object.assign(async () => ({ output: 'ok' }), { label: 'custom-label' });
|
||||
const unlabeled = async () => ({ output: 'ok' });
|
||||
|
||||
expect(normalizeProviderRef(labeled, { index: 3 })).toMatchObject({
|
||||
kind: 'function',
|
||||
id: 'custom-label',
|
||||
label: 'custom-label',
|
||||
});
|
||||
expect(normalizeProviderRef(unlabeled, { index: 3 })).toMatchObject({
|
||||
kind: 'function',
|
||||
id: 'custom-function-3',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to labels for malformed provider configs used by filtering', () => {
|
||||
expect(normalizeProviderRef({ id: '', label: 'Provider2' }, { index: 1 })).toMatchObject({
|
||||
kind: 'unknown',
|
||||
id: 'Provider2',
|
||||
label: 'Provider2',
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies string providers as named vs file based on extension', () => {
|
||||
expect(normalizeProviderRef('openai:responses:gpt-5.4')).toMatchObject({
|
||||
kind: 'named',
|
||||
id: 'openai:responses:gpt-5.4',
|
||||
loadProviderPath: 'openai:responses:gpt-5.4',
|
||||
});
|
||||
expect(normalizeProviderRef('file://providers.yaml')).toMatchObject({
|
||||
kind: 'file',
|
||||
id: 'file://providers.yaml',
|
||||
loadProviderPath: 'file://providers.yaml',
|
||||
});
|
||||
expect(normalizeProviderRef('file://provider.js')).toMatchObject({
|
||||
kind: 'named',
|
||||
id: 'file://provider.js',
|
||||
loadProviderPath: 'file://provider.js',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes ProviderOptionsMap without nested id override (key becomes id)', () => {
|
||||
const descriptor = normalizeProviderRef({
|
||||
'openai:responses:gpt-5.4': {
|
||||
config: { temperature: 0.2 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(descriptor).toMatchObject({
|
||||
kind: 'map',
|
||||
id: 'openai:responses:gpt-5.4',
|
||||
loadProviderPath: 'openai:responses:gpt-5.4',
|
||||
loadOptions: {
|
||||
id: 'openai:responses:gpt-5.4',
|
||||
config: { temperature: 0.2 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns kind unknown for empty string provider IDs', () => {
|
||||
expect(normalizeProviderRef('')).toMatchObject({ kind: 'unknown', id: 'unknown' });
|
||||
expect(normalizeProviderRef('', { index: 2 })).toMatchObject({
|
||||
kind: 'unknown',
|
||||
id: 'unknown-2',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns kind unknown for null, undefined, and non-provider types', () => {
|
||||
expect(normalizeProviderRef(null)).toMatchObject({ kind: 'unknown', id: 'unknown' });
|
||||
expect(normalizeProviderRef(undefined)).toMatchObject({ kind: 'unknown', id: 'unknown' });
|
||||
expect(normalizeProviderRef(42)).toMatchObject({ kind: 'unknown', id: 'unknown' });
|
||||
expect(normalizeProviderRef([])).toMatchObject({ kind: 'unknown', id: 'unknown' });
|
||||
expect(normalizeProviderRef([{ id: 'openai:gpt-4' }])).toMatchObject({
|
||||
kind: 'unknown',
|
||||
id: 'unknown',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonicalizeProviderId', () => {
|
||||
it('resolves relative file:// paths to absolute', () => {
|
||||
const cwd = process.cwd();
|
||||
expect(canonicalizeProviderId('file://./provider.js')).toBe(
|
||||
`file://${path.join(cwd, 'provider.js')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves absolute file:// paths', () => {
|
||||
expect(canonicalizeProviderId('file:///absolute/path.js')).toBe('file:///absolute/path.js');
|
||||
});
|
||||
|
||||
it('resolves exec: paths with slashes', () => {
|
||||
const cwd = process.cwd();
|
||||
expect(canonicalizeProviderId('exec:./script.py')).toBe(`exec:${path.join(cwd, 'script.py')}`);
|
||||
});
|
||||
|
||||
it('preserves exec: paths without slashes', () => {
|
||||
expect(canonicalizeProviderId('exec:my-script')).toBe('exec:my-script');
|
||||
});
|
||||
|
||||
it('resolves python: paths with slashes', () => {
|
||||
const cwd = process.cwd();
|
||||
expect(canonicalizeProviderId('python:./provider.py')).toBe(
|
||||
`python:${path.join(cwd, 'provider.py')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves golang: paths with slashes', () => {
|
||||
const cwd = process.cwd();
|
||||
expect(canonicalizeProviderId('golang:./main.go')).toBe(`golang:${path.join(cwd, 'main.go')}`);
|
||||
});
|
||||
|
||||
it('preserves golang: paths without slashes', () => {
|
||||
expect(canonicalizeProviderId('golang:my-binary')).toBe('golang:my-binary');
|
||||
});
|
||||
|
||||
it('wraps bare .js/.ts/.mjs paths with file://', () => {
|
||||
const cwd = process.cwd();
|
||||
expect(canonicalizeProviderId('./provider.js')).toBe(`file://${path.join(cwd, 'provider.js')}`);
|
||||
expect(canonicalizeProviderId('./provider.ts')).toBe(`file://${path.join(cwd, 'provider.ts')}`);
|
||||
expect(canonicalizeProviderId('./provider.mjs')).toBe(
|
||||
`file://${path.join(cwd, 'provider.mjs')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not wrap bare .js/.ts/.mjs without path separators', () => {
|
||||
expect(canonicalizeProviderId('provider.js')).toBe('provider.js');
|
||||
});
|
||||
|
||||
it('passes through plain provider IDs unchanged', () => {
|
||||
expect(canonicalizeProviderId('openai:responses:gpt-5.4')).toBe('openai:responses:gpt-5.4');
|
||||
expect(canonicalizeProviderId('echo')).toBe('echo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('providerToIdentifier', () => {
|
||||
it('works with provider string', () => {
|
||||
expect(providerToIdentifier('gpt-3.5-turbo')).toStrictEqual('gpt-3.5-turbo');
|
||||
});
|
||||
|
||||
it('works with provider id undefined', () => {
|
||||
expect(providerToIdentifier(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('works with ApiProvider', () => {
|
||||
const providerId = 'custom';
|
||||
const apiProvider = {
|
||||
id() {
|
||||
return providerId;
|
||||
},
|
||||
callApi: vi.fn(),
|
||||
} as ApiProvider;
|
||||
|
||||
expect(providerToIdentifier(apiProvider)).toStrictEqual(providerId);
|
||||
});
|
||||
|
||||
it('works with ProviderOptions', () => {
|
||||
const providerId = 'custom';
|
||||
const providerOptions = {
|
||||
id: providerId,
|
||||
};
|
||||
|
||||
expect(providerToIdentifier(providerOptions)).toStrictEqual(providerId);
|
||||
});
|
||||
|
||||
it('uses label when present on ProviderOptions', () => {
|
||||
const providerOptions = {
|
||||
id: 'file://provider.js',
|
||||
label: 'my-provider',
|
||||
};
|
||||
|
||||
expect(providerToIdentifier(providerOptions)).toStrictEqual('my-provider');
|
||||
});
|
||||
|
||||
it('canonicalizes relative file paths to absolute', () => {
|
||||
const originalCwd = process.cwd();
|
||||
expect(providerToIdentifier('file://./provider.js')).toStrictEqual(
|
||||
`file://${path.join(originalCwd, 'provider.js')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('canonicalizes JavaScript files without file:// prefix', () => {
|
||||
const originalCwd = process.cwd();
|
||||
expect(providerToIdentifier('./provider.js')).toStrictEqual(
|
||||
`file://${path.join(originalCwd, 'provider.js')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves absolute file paths', () => {
|
||||
expect(providerToIdentifier('file:///absolute/path/provider.js')).toStrictEqual(
|
||||
'file:///absolute/path/provider.js',
|
||||
);
|
||||
});
|
||||
|
||||
it('canonicalizes exec: paths', () => {
|
||||
const originalCwd = process.cwd();
|
||||
expect(providerToIdentifier('exec:./script.py')).toStrictEqual(
|
||||
`exec:${path.join(originalCwd, 'script.py')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('canonicalizes python: paths', () => {
|
||||
const originalCwd = process.cwd();
|
||||
expect(providerToIdentifier('python:./provider.py')).toStrictEqual(
|
||||
`python:${path.join(originalCwd, 'provider.py')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderIdentifier', () => {
|
||||
it('returns label when present', () => {
|
||||
const provider = createMockProvider({ id: 'openai:gpt-4', label: 'my-custom-label' });
|
||||
expect(getProviderIdentifier(provider)).toBe('my-custom-label');
|
||||
});
|
||||
|
||||
it('returns id when no label', () => {
|
||||
const provider = createMockProvider({ id: 'openai:gpt-4' });
|
||||
expect(getProviderIdentifier(provider)).toBe('openai:gpt-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderDescription', () => {
|
||||
it('returns both label and id when both present', () => {
|
||||
const provider = createMockProvider({ id: 'openai:gpt-4', label: 'my-custom-label' });
|
||||
expect(getProviderDescription(provider)).toBe('my-custom-label (openai:gpt-4)');
|
||||
});
|
||||
|
||||
it('returns only id when no label', () => {
|
||||
const provider = createMockProvider({ id: 'openai:gpt-4' });
|
||||
expect(getProviderDescription(provider)).toBe('openai:gpt-4');
|
||||
});
|
||||
|
||||
it('returns only id when label equals id', () => {
|
||||
const provider = createMockProvider({ id: 'openai:gpt-4', label: 'openai:gpt-4' });
|
||||
expect(getProviderDescription(provider)).toBe('openai:gpt-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeProviderIdForLog', () => {
|
||||
it('redacts credentials in URL provider IDs', () => {
|
||||
expect(
|
||||
sanitizeProviderIdForLog(
|
||||
'http://example.com/api?cursor=sk-123456789012345678901234567890&safe=ok',
|
||||
),
|
||||
).toBe('http://example.com/api?cursor=%5BREDACTED%5D&safe=ok');
|
||||
});
|
||||
|
||||
it('leaves non-URL provider IDs unchanged', () => {
|
||||
expect(sanitizeProviderIdForLog('openai:gpt-4.1')).toBe('openai:gpt-4.1');
|
||||
});
|
||||
|
||||
it('preserves provider descriptions while sanitizing URL IDs', () => {
|
||||
expect(
|
||||
sanitizeProviderIdForLog(
|
||||
'http://example.com/api?cursor=sk-123456789012345678901234567890 (HttpProvider)',
|
||||
),
|
||||
).toBe('http://example.com/api?cursor=%5BREDACTED%5D (HttpProvider)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('doesProviderRefMatch', () => {
|
||||
const createProvider = (id: string, label?: string): ApiProvider =>
|
||||
createMockProvider({ id, label });
|
||||
|
||||
it('matches exact label', () => {
|
||||
const provider = createProvider('openai:gpt-4', 'fast-model');
|
||||
expect(doesProviderRefMatch('fast-model', provider)).toBe(true);
|
||||
expect(doesProviderRefMatch('slow-model', provider)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches exact id', () => {
|
||||
const provider = createProvider('openai:gpt-4');
|
||||
expect(doesProviderRefMatch('openai:gpt-4', provider)).toBe(true);
|
||||
expect(doesProviderRefMatch('openai:gpt-3.5', provider)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches wildcard on id', () => {
|
||||
const provider = createProvider('openai:gpt-4');
|
||||
expect(doesProviderRefMatch('openai:*', provider)).toBe(true);
|
||||
expect(doesProviderRefMatch('anthropic:*', provider)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches wildcard on label', () => {
|
||||
const provider = createProvider('openai:gpt-4', 'openai-fast');
|
||||
expect(doesProviderRefMatch('openai-*', provider)).toBe(true);
|
||||
expect(doesProviderRefMatch('anthropic-*', provider)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches legacy prefix on id', () => {
|
||||
const provider = createProvider('openai:gpt-4');
|
||||
expect(doesProviderRefMatch('openai', provider)).toBe(true);
|
||||
expect(doesProviderRefMatch('anthropic', provider)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches legacy prefix on label', () => {
|
||||
const provider = createProvider('custom-provider', 'openai:custom');
|
||||
expect(doesProviderRefMatch('openai', provider)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match partial strings', () => {
|
||||
const provider = createProvider('openai:gpt-4');
|
||||
expect(doesProviderRefMatch('openai:gpt', provider)).toBe(false);
|
||||
expect(doesProviderRefMatch('gpt-4', provider)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches canonicalized file paths', () => {
|
||||
// Provider has relative path, ref uses absolute - should still match after canonicalization
|
||||
const absolutePath = path.resolve('./my-provider.js');
|
||||
const provider = createProvider(`file://${absolutePath}`);
|
||||
expect(doesProviderRefMatch('./my-provider.js', provider)).toBe(true);
|
||||
expect(doesProviderRefMatch(`file://${absolutePath}`, provider)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProviderAllowed', () => {
|
||||
const createProvider = (id: string, label?: string): ApiProvider =>
|
||||
createMockProvider({ id, label });
|
||||
|
||||
it('allows all providers when no filter', () => {
|
||||
const provider = createProvider('openai:gpt-4');
|
||||
expect(isProviderAllowed(provider, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks all providers with empty array', () => {
|
||||
const provider = createProvider('openai:gpt-4');
|
||||
expect(isProviderAllowed(provider, [])).toBe(false);
|
||||
});
|
||||
|
||||
it('allows matching providers', () => {
|
||||
const provider = createProvider('openai:gpt-4', 'fast-model');
|
||||
expect(isProviderAllowed(provider, ['fast-model'])).toBe(true);
|
||||
expect(isProviderAllowed(provider, ['openai:gpt-4'])).toBe(true);
|
||||
expect(isProviderAllowed(provider, ['openai:*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks non-matching providers', () => {
|
||||
const provider = createProvider('openai:gpt-4', 'fast-model');
|
||||
expect(isProviderAllowed(provider, ['slow-model'])).toBe(false);
|
||||
expect(isProviderAllowed(provider, ['anthropic:*'])).toBe(false);
|
||||
});
|
||||
|
||||
it('allows if any filter matches', () => {
|
||||
const provider = createProvider('openai:gpt-4');
|
||||
expect(isProviderAllowed(provider, ['anthropic:*', 'openai:*'])).toBe(true);
|
||||
expect(isProviderAllowed(provider, ['anthropic:*', 'google:*'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOpenAiProvider', () => {
|
||||
it('detects direct OpenAI providers', () => {
|
||||
expect(isOpenAiProvider('openai:chat:gpt-4o')).toBe(true);
|
||||
expect(isOpenAiProvider('openai:gpt-4')).toBe(true);
|
||||
expect(isOpenAiProvider('openai:completion:gpt-3.5-turbo')).toBe(true);
|
||||
expect(isOpenAiProvider('openai:embedding:text-embedding-ada-002')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects Azure OpenAI providers', () => {
|
||||
// azureopenai: is always OpenAI
|
||||
expect(isOpenAiProvider('azureopenai:chat:my-deployment')).toBe(true);
|
||||
// azure: with OpenAI model indicators
|
||||
expect(isOpenAiProvider('azure:chat:gpt-4-deployment')).toBe(true);
|
||||
expect(isOpenAiProvider('azure:chat:my-gpt-35-turbo')).toBe(true);
|
||||
expect(isOpenAiProvider('azure:completion:davinci-002')).toBe(true);
|
||||
expect(isOpenAiProvider('azure:embedding:text-embedding-ada-002')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match Azure without OpenAI model indicators', () => {
|
||||
expect(isOpenAiProvider('azure:chat:my-custom-deployment')).toBe(false);
|
||||
expect(isOpenAiProvider('azure:foundry-agent:my-agent')).toBe(false);
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
expect(isOpenAiProvider('OpenAI:chat:gpt-4')).toBe(true);
|
||||
expect(isOpenAiProvider('AZUREOPENAI:chat:my-deployment')).toBe(true);
|
||||
expect(isOpenAiProvider('AZURE:chat:GPT-4-deployment')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match non-OpenAI providers', () => {
|
||||
expect(isOpenAiProvider('anthropic:messages:claude-3-5-sonnet')).toBe(false);
|
||||
expect(isOpenAiProvider('google:gemini-pro')).toBe(false);
|
||||
expect(isOpenAiProvider('bedrock:anthropic.claude-3-5-sonnet')).toBe(false);
|
||||
expect(isOpenAiProvider('vertex:gemini-2.0-flash')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAnthropicProvider', () => {
|
||||
it('detects direct Anthropic providers', () => {
|
||||
expect(isAnthropicProvider('anthropic:messages:claude-3-5-sonnet')).toBe(true);
|
||||
expect(isAnthropicProvider('anthropic:completion:claude-2.1')).toBe(true);
|
||||
expect(isAnthropicProvider('anthropic:claude-opus-4-6')).toBe(true);
|
||||
expect(isAnthropicProvider('anthropic:claude-opus-4-5-20251101')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects Bedrock with Claude models', () => {
|
||||
expect(isAnthropicProvider('bedrock:anthropic.claude-3-5-sonnet-20240620-v1:0')).toBe(true);
|
||||
expect(isAnthropicProvider('bedrock:anthropic.claude-opus-4-6-v1')).toBe(true);
|
||||
expect(isAnthropicProvider('bedrock:anthropic.claude-opus-4-5-20251101-v1:0')).toBe(true);
|
||||
expect(isAnthropicProvider('bedrock:converse:anthropic.claude-3-opus-20240229-v1:0')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isAnthropicProvider('bedrock:us.anthropic.claude-3-5-sonnet-20240620-v1:0')).toBe(true);
|
||||
expect(isAnthropicProvider('bedrock:eu.anthropic.claude-3-haiku-20240307-v1:0')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects Vertex with Claude models', () => {
|
||||
expect(isAnthropicProvider('vertex:claude-3-5-sonnet@20240620')).toBe(true);
|
||||
expect(isAnthropicProvider('vertex:claude-3-opus@20240229')).toBe(true);
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
expect(isAnthropicProvider('ANTHROPIC:messages:claude-3-5-sonnet')).toBe(true);
|
||||
expect(isAnthropicProvider('BEDROCK:anthropic.CLAUDE-3-5-sonnet')).toBe(true);
|
||||
expect(isAnthropicProvider('VERTEX:CLAUDE-3-opus')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match non-Anthropic providers', () => {
|
||||
expect(isAnthropicProvider('openai:chat:gpt-4o')).toBe(false);
|
||||
expect(isAnthropicProvider('google:gemini-pro')).toBe(false);
|
||||
expect(isAnthropicProvider('bedrock:amazon.titan-text-express-v1')).toBe(false);
|
||||
expect(isAnthropicProvider('vertex:gemini-2.0-flash')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGoogleProvider', () => {
|
||||
it('detects direct Google AI Studio providers', () => {
|
||||
expect(isGoogleProvider('google:gemini-pro')).toBe(true);
|
||||
expect(isGoogleProvider('google:gemini-2.0-flash')).toBe(true);
|
||||
expect(isGoogleProvider('google:live:gemini-2.0-flash')).toBe(true);
|
||||
expect(isGoogleProvider('google:image:gemini-pro-vision')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects Vertex with Google models', () => {
|
||||
expect(isGoogleProvider('vertex:gemini-2.0-flash')).toBe(true);
|
||||
expect(isGoogleProvider('vertex:gemini-pro')).toBe(true);
|
||||
expect(isGoogleProvider('vertex:text-bison')).toBe(true);
|
||||
expect(isGoogleProvider('vertex:llama-3.3-70b')).toBe(true);
|
||||
});
|
||||
|
||||
it('is case insensitive', () => {
|
||||
expect(isGoogleProvider('GOOGLE:gemini-pro')).toBe(true);
|
||||
expect(isGoogleProvider('VERTEX:GEMINI-2.0-flash')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match Vertex with Claude models (they are Anthropic)', () => {
|
||||
expect(isGoogleProvider('vertex:claude-3-5-sonnet@20240620')).toBe(false);
|
||||
expect(isGoogleProvider('vertex:claude-3-opus@20240229')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match non-Google providers', () => {
|
||||
expect(isGoogleProvider('openai:chat:gpt-4o')).toBe(false);
|
||||
expect(isGoogleProvider('anthropic:messages:claude-3-5-sonnet')).toBe(false);
|
||||
expect(isGoogleProvider('bedrock:anthropic.claude-3-5-sonnet')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkProviderApiKeys', () => {
|
||||
const providerWithKey = (id: string, extras: Record<string, unknown> = {}): ApiProvider =>
|
||||
Object.assign(createMockProvider({ id, config: {} }), {
|
||||
getApiKey: () => undefined,
|
||||
requiresApiKey: () => true,
|
||||
...extras,
|
||||
}) as unknown as ApiProvider;
|
||||
|
||||
it('detects missing API key and maps to correct env var', () => {
|
||||
const provider = providerWithKey('openai:gpt-4');
|
||||
|
||||
const result = checkProviderApiKeys([provider]);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('OPENAI_API_KEY')).toEqual(['openai:gpt-4']);
|
||||
});
|
||||
|
||||
it('uses descriptions only when requested for user-facing error output', () => {
|
||||
const provider = providerWithKey('openai:gpt-4', { label: 'primary' });
|
||||
|
||||
expect(checkProviderApiKeys([provider]).get('OPENAI_API_KEY')).toEqual(['openai:gpt-4']);
|
||||
expect(
|
||||
checkProviderApiKeys([provider], { useDescriptions: true }).get('OPENAI_API_KEY'),
|
||||
).toEqual(['primary (openai:gpt-4)']);
|
||||
});
|
||||
|
||||
it('skips providers with valid key, no getApiKey method, or requiresApiKey false', () => {
|
||||
const providers: ApiProvider[] = [
|
||||
providerWithKey('openai:gpt-4', { getApiKey: () => 'sk-1234' }),
|
||||
createMockProvider({ id: 'http:custom' }),
|
||||
providerWithKey('openai:gpt-4', { requiresApiKey: () => false }),
|
||||
Object.assign(
|
||||
createMockProvider({ id: 'litellm:gpt-4', config: { apiKeyRequired: false } }),
|
||||
{ getApiKey: () => undefined },
|
||||
) as unknown as ApiProvider,
|
||||
];
|
||||
|
||||
const result = checkProviderApiKeys(providers);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('skips azure providers (Azure AD token auth)', () => {
|
||||
const provider = providerWithKey('azure:gpt-4');
|
||||
|
||||
const result = checkProviderApiKeys([provider]);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates multiple providers sharing the same env var', () => {
|
||||
const providers: ApiProvider[] = [
|
||||
providerWithKey('openai:gpt-4'),
|
||||
providerWithKey('openai:gpt-5-mini'),
|
||||
Object.assign(
|
||||
createMockProvider({ id: 'anthropic:claude-sonnet-4-5-20250514', config: {} }),
|
||||
{ getApiKey: () => undefined },
|
||||
) as unknown as ApiProvider,
|
||||
];
|
||||
|
||||
const result = checkProviderApiKeys(providers);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get('OPENAI_API_KEY')).toEqual(['openai:gpt-4', 'openai:gpt-5-mini']);
|
||||
expect(result.get('ANTHROPIC_API_KEY')).toEqual(['anthropic:claude-sonnet-4-5-20250514']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getActualPrompt, getActualPromptWithFallback } from '../../src/util/providerResponse';
|
||||
|
||||
import type { ProviderResponse } from '../../src/types/providers';
|
||||
|
||||
describe('getActualPrompt', () => {
|
||||
it('should return undefined for undefined response', () => {
|
||||
expect(getActualPrompt(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for response without prompt', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
};
|
||||
expect(getActualPrompt(response)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return string prompt directly', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
prompt: 'Hello, world!',
|
||||
};
|
||||
expect(getActualPrompt(response)).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it('should return undefined for empty string prompt', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
prompt: '',
|
||||
};
|
||||
// Empty string is truthy check but empty, so we return undefined
|
||||
expect(getActualPrompt(response)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should stringify chat message array', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
prompt: [
|
||||
{ role: 'system', content: 'You are helpful' },
|
||||
{ role: 'user', content: 'Hello' },
|
||||
],
|
||||
};
|
||||
expect(getActualPrompt(response)).toBe(
|
||||
'[{"role":"system","content":"You are helpful"},{"role":"user","content":"Hello"}]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should format chat message array when formatted option is true', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
prompt: [
|
||||
{ role: 'system', content: 'You are helpful' },
|
||||
{ role: 'user', content: 'Hello' },
|
||||
],
|
||||
};
|
||||
const result = getActualPrompt(response, { formatted: true });
|
||||
expect(result).toContain('\n');
|
||||
expect(result).toContain(' ');
|
||||
});
|
||||
|
||||
it('should return undefined for empty array prompt', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
prompt: [],
|
||||
};
|
||||
// Empty array is not useful
|
||||
expect(getActualPrompt(response)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fall back to redteamFinalPrompt when prompt is not set', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
metadata: {
|
||||
redteamFinalPrompt: 'fallback prompt',
|
||||
},
|
||||
};
|
||||
expect(getActualPrompt(response)).toBe('fallback prompt');
|
||||
});
|
||||
|
||||
it('should prioritize prompt over redteamFinalPrompt', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
prompt: 'provider prompt',
|
||||
metadata: {
|
||||
redteamFinalPrompt: 'fallback prompt',
|
||||
},
|
||||
};
|
||||
expect(getActualPrompt(response)).toBe('provider prompt');
|
||||
});
|
||||
|
||||
it('should not fall back to redteamFinalPrompt when prompt is empty string', () => {
|
||||
// Empty string means provider explicitly set no prompt, so we don't fall back
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
prompt: '',
|
||||
metadata: {
|
||||
redteamFinalPrompt: 'fallback prompt',
|
||||
},
|
||||
};
|
||||
// With empty string prompt explicitly set, we return undefined (not the fallback)
|
||||
expect(getActualPrompt(response)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActualPromptWithFallback', () => {
|
||||
it('should return provider prompt when available', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
prompt: 'provider prompt',
|
||||
};
|
||||
expect(getActualPromptWithFallback(response, 'original prompt')).toBe('provider prompt');
|
||||
});
|
||||
|
||||
it('should return redteamFinalPrompt when prompt not available', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
metadata: {
|
||||
redteamFinalPrompt: 'redteam prompt',
|
||||
},
|
||||
};
|
||||
expect(getActualPromptWithFallback(response, 'original prompt')).toBe('redteam prompt');
|
||||
});
|
||||
|
||||
it('should return original prompt when neither prompt nor redteamFinalPrompt available', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
};
|
||||
expect(getActualPromptWithFallback(response, 'original prompt')).toBe('original prompt');
|
||||
});
|
||||
|
||||
it('should return original prompt for undefined response', () => {
|
||||
expect(getActualPromptWithFallback(undefined, 'original prompt')).toBe('original prompt');
|
||||
});
|
||||
|
||||
it('should return original prompt when prompt is empty string', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
prompt: '',
|
||||
};
|
||||
expect(getActualPromptWithFallback(response, 'original prompt')).toBe('original prompt');
|
||||
});
|
||||
|
||||
it('should return original prompt when prompt is empty array', () => {
|
||||
const response: ProviderResponse = {
|
||||
output: 'test output',
|
||||
prompt: [],
|
||||
};
|
||||
expect(getActualPromptWithFallback(response, 'original prompt')).toBe('original prompt');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import readline from 'readline';
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createReadlineInterface, promptUser, promptYesNo } from '../../src/util/readline';
|
||||
|
||||
vi.mock('readline');
|
||||
|
||||
vi.mock('../../src/util/readline', () => ({
|
||||
createReadlineInterface: vi.fn(),
|
||||
promptUser: vi.fn(),
|
||||
promptYesNo: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('readline utils', () => {
|
||||
let mockInterface: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockInterface = {
|
||||
question: vi.fn(),
|
||||
close: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(readline.createInterface).mockReturnValue(mockInterface);
|
||||
vi.mocked(createReadlineInterface).mockReturnValue(mockInterface);
|
||||
vi.mocked(promptUser).mockReset();
|
||||
vi.mocked(promptYesNo).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('createReadlineInterface', () => {
|
||||
it('should create readline interface with stdin/stdout', () => {
|
||||
const result = createReadlineInterface();
|
||||
|
||||
expect(createReadlineInterface).toHaveBeenCalledWith();
|
||||
expect(result).toBe(mockInterface);
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptUser', () => {
|
||||
it('should resolve with user answer', async () => {
|
||||
const question = 'Test question?';
|
||||
const answer = 'Test answer';
|
||||
|
||||
vi.mocked(promptUser).mockResolvedValue(answer);
|
||||
|
||||
const result = await promptUser(question);
|
||||
expect(result).toBe(answer);
|
||||
expect(promptUser).toHaveBeenCalledWith(question);
|
||||
});
|
||||
|
||||
it('should reject on error', async () => {
|
||||
const error = new Error('Test error');
|
||||
|
||||
vi.mocked(promptUser).mockRejectedValue(error);
|
||||
|
||||
await expect(promptUser('Test question?')).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
it('should reject if readline creation fails', async () => {
|
||||
const error = new Error('Creation failed');
|
||||
|
||||
vi.mocked(promptUser).mockRejectedValue(error);
|
||||
|
||||
await expect(promptUser('Test question?')).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptYesNo', () => {
|
||||
it('should return true for "y" with default no', async () => {
|
||||
vi.mocked(promptYesNo).mockResolvedValue(true);
|
||||
|
||||
const result = await promptYesNo('Test question?', false);
|
||||
expect(result).toBe(true);
|
||||
expect(promptYesNo).toHaveBeenCalledWith('Test question?', false);
|
||||
});
|
||||
|
||||
it('should return false for "n" with default yes', async () => {
|
||||
vi.mocked(promptYesNo).mockResolvedValue(false);
|
||||
|
||||
const result = await promptYesNo('Test question?', true);
|
||||
expect(result).toBe(false);
|
||||
expect(promptYesNo).toHaveBeenCalledWith('Test question?', true);
|
||||
});
|
||||
|
||||
it('should return default value for empty response', async () => {
|
||||
vi.mocked(promptYesNo).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(promptYesNo('Test question?', true)).resolves.toBe(true);
|
||||
await expect(promptYesNo('Test question?', false)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should handle different case inputs', async () => {
|
||||
vi.mocked(promptYesNo).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(promptYesNo('Test question?')).resolves.toBe(true);
|
||||
await expect(promptYesNo('Test question?', true)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should append correct suffix based on default value', async () => {
|
||||
vi.mocked(promptYesNo).mockResolvedValue(true);
|
||||
|
||||
await promptYesNo('Test question?', true);
|
||||
expect(promptYesNo).toHaveBeenCalledWith('Test question?', true);
|
||||
|
||||
await promptYesNo('Test question?', false);
|
||||
expect(promptYesNo).toHaveBeenCalledWith('Test question?', false);
|
||||
});
|
||||
|
||||
it('should return true for non-n input with defaultYes true', async () => {
|
||||
vi.mocked(promptYesNo).mockResolvedValue(true);
|
||||
|
||||
const result = await promptYesNo('Test question?', true);
|
||||
expect(result).toBe(true);
|
||||
expect(promptYesNo).toHaveBeenCalledWith('Test question?', true);
|
||||
});
|
||||
|
||||
it('should return false for input not starting with y with defaultYes false', async () => {
|
||||
vi.mocked(promptYesNo).mockResolvedValue(false);
|
||||
|
||||
const result = await promptYesNo('Test question?', false);
|
||||
expect(result).toBe(false);
|
||||
expect(promptYesNo).toHaveBeenCalledWith('Test question?', false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getDb } from '../../src/database/index';
|
||||
import { evalResultsTable, evalsTable } from '../../src/database/tables';
|
||||
import { getUserEmail, isLoggedIntoCloud } from '../../src/globalConfig/accounts';
|
||||
import { runDbMigrations } from '../../src/migrate';
|
||||
import {
|
||||
checkRedteamProbeLimit,
|
||||
getMonthlyRedteamProbeUsage,
|
||||
getMonthStartTimestamp,
|
||||
MONTHLY_PROBE_LIMIT,
|
||||
} from '../../src/util/redteamProbeLimit';
|
||||
|
||||
vi.mock('../../src/globalConfig/accounts', async () => {
|
||||
const actual = await vi.importActual('../../src/globalConfig/accounts');
|
||||
return {
|
||||
...actual,
|
||||
isLoggedIntoCloud: vi.fn().mockReturnValue(false),
|
||||
getUserEmail: vi.fn().mockReturnValue('test@example.com'),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to insert a redteam eval with result rows directly into the database.
|
||||
*/
|
||||
async function insertRedteamEval(opts: {
|
||||
createdAt?: number;
|
||||
numResults?: number;
|
||||
numRequestsPerResult?: number;
|
||||
}): Promise<string> {
|
||||
const db = await getDb();
|
||||
const evalId = randomUUID();
|
||||
const createdAt = opts.createdAt ?? Date.now();
|
||||
const numResults = opts.numResults ?? 1;
|
||||
const numRequestsPerResult = opts.numRequestsPerResult ?? 1;
|
||||
|
||||
await db
|
||||
.insert(evalsTable)
|
||||
.values({
|
||||
id: evalId,
|
||||
createdAt,
|
||||
config: { redteam: { plugins: [], strategies: [] } } as any,
|
||||
results: {},
|
||||
isRedteam: true,
|
||||
})
|
||||
.run();
|
||||
|
||||
for (let i = 0; i < numResults; i++) {
|
||||
await db
|
||||
.insert(evalResultsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
evalId,
|
||||
promptIdx: 0,
|
||||
testIdx: i,
|
||||
testCase: { vars: {} },
|
||||
prompt: { raw: 'test', label: 'test' },
|
||||
provider: { id: 'test-provider' },
|
||||
response: {
|
||||
output: 'test output',
|
||||
tokenUsage: {
|
||||
total: 10,
|
||||
prompt: 5,
|
||||
completion: 5,
|
||||
numRequests: numRequestsPerResult,
|
||||
},
|
||||
},
|
||||
success: true,
|
||||
score: 1,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
return evalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to insert a non-redteam eval.
|
||||
*/
|
||||
async function insertNonRedteamEval(opts?: {
|
||||
createdAt?: number;
|
||||
numResults?: number;
|
||||
}): Promise<string> {
|
||||
const db = await getDb();
|
||||
const evalId = randomUUID();
|
||||
const createdAt = opts?.createdAt ?? Date.now();
|
||||
const numResults = opts?.numResults ?? 1;
|
||||
|
||||
await db
|
||||
.insert(evalsTable)
|
||||
.values({
|
||||
id: evalId,
|
||||
createdAt,
|
||||
config: { providers: ['test'] } as any,
|
||||
results: {},
|
||||
isRedteam: false,
|
||||
})
|
||||
.run();
|
||||
|
||||
for (let i = 0; i < numResults; i++) {
|
||||
await db
|
||||
.insert(evalResultsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
evalId,
|
||||
promptIdx: 0,
|
||||
testIdx: i,
|
||||
testCase: { vars: {} },
|
||||
prompt: { raw: 'test', label: 'test' },
|
||||
provider: { id: 'test-provider' },
|
||||
response: {
|
||||
output: 'test output',
|
||||
tokenUsage: { total: 10, prompt: 5, completion: 5, numRequests: 1 },
|
||||
},
|
||||
success: true,
|
||||
score: 1,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
return evalId;
|
||||
}
|
||||
|
||||
describe('redteamProbeLimit', () => {
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const db = await getDb();
|
||||
await db.run('DELETE FROM eval_results');
|
||||
await db.run('DELETE FROM evals_to_datasets');
|
||||
await db.run('DELETE FROM evals_to_prompts');
|
||||
await db.run('DELETE FROM evals');
|
||||
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-15T12:00:00.000Z'));
|
||||
vi.mocked(isLoggedIntoCloud).mockReturnValue(false);
|
||||
vi.mocked(getUserEmail).mockReturnValue('test@example.com');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getMonthStartTimestamp', () => {
|
||||
it('should return the first day of the current month at midnight', () => {
|
||||
const result = getMonthStartTimestamp();
|
||||
const date = new Date(result);
|
||||
expect(date.getDate()).toBe(1);
|
||||
expect(date.getHours()).toBe(0);
|
||||
expect(date.getMinutes()).toBe(0);
|
||||
expect(date.getSeconds()).toBe(0);
|
||||
expect(date.getMilliseconds()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMonthlyRedteamProbeUsage', () => {
|
||||
it('should return 0 when there are no evals', async () => {
|
||||
await expect(getMonthlyRedteamProbeUsage()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('should count probes from redteam evals in the current month', async () => {
|
||||
await insertRedteamEval({ numResults: 3, numRequestsPerResult: 1 });
|
||||
await expect(getMonthlyRedteamProbeUsage()).resolves.toBe(3);
|
||||
});
|
||||
|
||||
it('should count multi-turn probes correctly using numRequests', async () => {
|
||||
// Simulates a multi-turn strategy where each result has multiple target calls
|
||||
await insertRedteamEval({ numResults: 2, numRequestsPerResult: 10 });
|
||||
await expect(getMonthlyRedteamProbeUsage()).resolves.toBe(20);
|
||||
});
|
||||
|
||||
it('should not count non-redteam evals', async () => {
|
||||
await insertNonRedteamEval({ numResults: 5 });
|
||||
await expect(getMonthlyRedteamProbeUsage()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('should not count evals from previous months', async () => {
|
||||
await insertRedteamEval({
|
||||
createdAt: getMonthStartTimestamp() - 1,
|
||||
numResults: 5,
|
||||
numRequestsPerResult: 1,
|
||||
});
|
||||
await expect(getMonthlyRedteamProbeUsage()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('should aggregate probes across multiple redteam evals', async () => {
|
||||
await insertRedteamEval({ numResults: 3, numRequestsPerResult: 1 });
|
||||
await insertRedteamEval({ numResults: 2, numRequestsPerResult: 5 });
|
||||
// 3*1 + 2*5 = 13
|
||||
await expect(getMonthlyRedteamProbeUsage()).resolves.toBe(13);
|
||||
});
|
||||
|
||||
it('should fall back to 1 per result when numRequests is not present', async () => {
|
||||
const db = await getDb();
|
||||
const evalId = randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
await db
|
||||
.insert(evalsTable)
|
||||
.values({
|
||||
id: evalId,
|
||||
createdAt: now,
|
||||
config: { redteam: { plugins: [] } },
|
||||
results: {},
|
||||
isRedteam: true,
|
||||
})
|
||||
.run();
|
||||
|
||||
// Insert result without numRequests in tokenUsage
|
||||
await db
|
||||
.insert(evalResultsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
evalId,
|
||||
promptIdx: 0,
|
||||
testIdx: 0,
|
||||
testCase: { vars: {} },
|
||||
prompt: { raw: 'test', label: 'test' },
|
||||
provider: { id: 'test-provider' },
|
||||
response: {
|
||||
output: 'test output',
|
||||
tokenUsage: { total: 10, prompt: 5, completion: 5 },
|
||||
},
|
||||
success: true,
|
||||
score: 1,
|
||||
})
|
||||
.run();
|
||||
|
||||
await expect(getMonthlyRedteamProbeUsage()).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('should detect redteam evals via config JSON even if isRedteam column is false', async () => {
|
||||
const db = await getDb();
|
||||
const evalId = randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
// Insert with isRedteam=false but config has redteam key
|
||||
await db
|
||||
.insert(evalsTable)
|
||||
.values({
|
||||
id: evalId,
|
||||
createdAt: now,
|
||||
config: { redteam: { plugins: ['harmful'] } } as any,
|
||||
results: {},
|
||||
isRedteam: false,
|
||||
})
|
||||
.run();
|
||||
|
||||
await db
|
||||
.insert(evalResultsTable)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
evalId,
|
||||
promptIdx: 0,
|
||||
testIdx: 0,
|
||||
testCase: { vars: {} },
|
||||
prompt: { raw: 'test', label: 'test' },
|
||||
provider: { id: 'test-provider' },
|
||||
response: {
|
||||
output: 'test',
|
||||
tokenUsage: { total: 10, prompt: 5, completion: 5, numRequests: 3 },
|
||||
},
|
||||
success: true,
|
||||
score: 1,
|
||||
})
|
||||
.run();
|
||||
|
||||
await expect(getMonthlyRedteamProbeUsage()).resolves.toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRedteamProbeLimit', () => {
|
||||
it('should return withinLimit=true when no usage', async () => {
|
||||
const result = await checkRedteamProbeLimit();
|
||||
expect(result.withinLimit).toBe(true);
|
||||
expect(result.used).toBe(0);
|
||||
expect(result.remaining).toBe(MONTHLY_PROBE_LIMIT);
|
||||
expect(result.limit).toBe(MONTHLY_PROBE_LIMIT);
|
||||
});
|
||||
|
||||
it('should return withinLimit=true when under limit', async () => {
|
||||
await insertRedteamEval({ numResults: 10, numRequestsPerResult: 1 });
|
||||
const result = await checkRedteamProbeLimit();
|
||||
expect(result.withinLimit).toBe(true);
|
||||
expect(result.used).toBe(10);
|
||||
expect(result.remaining).toBe(MONTHLY_PROBE_LIMIT - 10);
|
||||
});
|
||||
|
||||
it('should return withinLimit=false when at or over limit', async () => {
|
||||
// Insert enough probes to exceed the limit
|
||||
// We'll use a large numRequests to simulate hitting the limit
|
||||
const numResultsNeeded = 1000;
|
||||
const numRequestsPerResult = Math.ceil(MONTHLY_PROBE_LIMIT / numResultsNeeded) + 1;
|
||||
await insertRedteamEval({ numResults: numResultsNeeded, numRequestsPerResult });
|
||||
|
||||
const result = await checkRedteamProbeLimit();
|
||||
expect(result.withinLimit).toBe(false);
|
||||
expect(result.used).toBeGreaterThanOrEqual(MONTHLY_PROBE_LIMIT);
|
||||
expect(result.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it('should exempt cloud-authenticated users', async () => {
|
||||
vi.mocked(isLoggedIntoCloud).mockReturnValue(true);
|
||||
|
||||
// Even with probes, cloud users should be exempt
|
||||
await insertRedteamEval({ numResults: 10, numRequestsPerResult: 1 });
|
||||
|
||||
const result = await checkRedteamProbeLimit();
|
||||
expect(result.withinLimit).toBe(true);
|
||||
expect(result.remaining).toBe(Number.POSITIVE_INFINITY);
|
||||
expect(result.limit).toBe(Number.POSITIVE_INFINITY);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,588 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { renderEnvOnlyInObject, renderVarsInObject } from '../../src/util/render';
|
||||
import { mockProcessEnv } from './utils';
|
||||
|
||||
describe('renderVarsInObject', () => {
|
||||
beforeEach(() => {
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: undefined });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockProcessEnv({ TEST_ENV_VAR: undefined });
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: undefined });
|
||||
});
|
||||
|
||||
it('should render environment variables in objects', async () => {
|
||||
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
|
||||
const obj = { text: '{{ env.TEST_ENV_VAR }}' };
|
||||
const rendered = renderVarsInObject(obj, {});
|
||||
expect(rendered).toEqual({ text: 'env_value' });
|
||||
});
|
||||
|
||||
it('should return object unchanged when no vars provided', async () => {
|
||||
const obj = { text: '{{ variable }}', number: 42 };
|
||||
const rendered = renderVarsInObject(obj);
|
||||
expect(rendered).toEqual(obj);
|
||||
});
|
||||
|
||||
it('should return object unchanged when vars is empty object', async () => {
|
||||
const obj = { text: '{{ variable }}', number: 42 };
|
||||
const rendered = renderVarsInObject(obj, {});
|
||||
// Empty object {} is truthy, so templating still runs but with no variables
|
||||
expect(rendered).toEqual({ text: '', number: 42 });
|
||||
});
|
||||
|
||||
it('should return object unchanged when PROMPTFOO_DISABLE_TEMPLATING is true', async () => {
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
|
||||
const obj = { text: '{{ variable }}' };
|
||||
const vars = { variable: 'test_value' };
|
||||
const rendered = renderVarsInObject(obj, vars);
|
||||
expect(rendered).toEqual(obj);
|
||||
});
|
||||
|
||||
it('should render variables in string objects', async () => {
|
||||
const obj = 'Hello {{ name }}!';
|
||||
const vars = { name: 'World' };
|
||||
const rendered = renderVarsInObject(obj, vars);
|
||||
expect(rendered).toBe('Hello World!');
|
||||
});
|
||||
|
||||
it('should render variables in array objects', async () => {
|
||||
const obj = ['{{ greeting }}', '{{ name }}', 42];
|
||||
const vars = { greeting: 'Hello', name: 'World' };
|
||||
const rendered = renderVarsInObject(obj, vars);
|
||||
expect(rendered).toEqual(['Hello', 'World', 42]);
|
||||
});
|
||||
|
||||
it('should render variables in nested arrays', async () => {
|
||||
const obj = [
|
||||
['{{ item1 }}', '{{ item2 }}'],
|
||||
['static', '{{ item3 }}'],
|
||||
];
|
||||
const vars = { item1: 'first', item2: 'second', item3: 'third' };
|
||||
const rendered = renderVarsInObject(obj, vars);
|
||||
expect(rendered).toEqual([
|
||||
['first', 'second'],
|
||||
['static', 'third'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render variables in nested objects', async () => {
|
||||
const obj = {
|
||||
level1: {
|
||||
level2: {
|
||||
text: '{{ variable }}',
|
||||
number: 42,
|
||||
},
|
||||
array: ['{{ item }}'],
|
||||
},
|
||||
};
|
||||
const vars = { variable: 'nested_value', item: 'array_item' };
|
||||
const rendered = renderVarsInObject(obj, vars);
|
||||
expect(rendered).toEqual({
|
||||
level1: {
|
||||
level2: {
|
||||
text: 'nested_value',
|
||||
number: 42,
|
||||
},
|
||||
array: ['array_item'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle function objects by calling them with vars', async () => {
|
||||
const mockFunction = vi.fn().mockReturnValue({ result: '{{ value }}' });
|
||||
const vars = { value: 'function_result' };
|
||||
const rendered = renderVarsInObject(mockFunction, vars);
|
||||
|
||||
expect(mockFunction).toHaveBeenCalledWith({ vars });
|
||||
// Function result is NOT recursively templated because vars is not passed in recursive call
|
||||
expect(rendered).toEqual({ result: '{{ value }}' });
|
||||
});
|
||||
|
||||
it('should handle null values', async () => {
|
||||
const obj = null;
|
||||
const vars = { variable: 'test' };
|
||||
const rendered = renderVarsInObject(obj, vars);
|
||||
expect(rendered).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined values', async () => {
|
||||
const obj = undefined;
|
||||
const vars = { variable: 'test' };
|
||||
const rendered = renderVarsInObject(obj, vars);
|
||||
expect(rendered).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle primitive number values', async () => {
|
||||
const obj = 42;
|
||||
const vars = { variable: 'test' };
|
||||
const rendered = renderVarsInObject(obj, vars);
|
||||
expect(rendered).toBe(42);
|
||||
});
|
||||
|
||||
it('should handle primitive boolean values', async () => {
|
||||
const obj = true;
|
||||
const vars = { variable: 'test' };
|
||||
const rendered = renderVarsInObject(obj, vars);
|
||||
expect(rendered).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle objects with null properties', async () => {
|
||||
const obj = { nullProp: null, text: '{{ variable }}' };
|
||||
const vars = { variable: 'test_value' };
|
||||
const rendered = renderVarsInObject(obj, vars);
|
||||
expect(rendered).toEqual({ nullProp: null, text: 'test_value' });
|
||||
});
|
||||
|
||||
it('should handle mixed type objects', async () => {
|
||||
const obj = {
|
||||
string: '{{ text }}',
|
||||
number: 42,
|
||||
boolean: true,
|
||||
nullValue: null,
|
||||
array: ['{{ item }}', 123],
|
||||
nested: {
|
||||
deep: '{{ deep_value }}',
|
||||
},
|
||||
};
|
||||
const vars = { text: 'rendered', item: 'array_item', deep_value: 'deep_rendered' };
|
||||
const rendered = renderVarsInObject(obj, vars);
|
||||
expect(rendered).toEqual({
|
||||
string: 'rendered',
|
||||
number: 42,
|
||||
boolean: true,
|
||||
nullValue: null,
|
||||
array: ['array_item', 123],
|
||||
nested: {
|
||||
deep: 'deep_rendered',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle function that returns complex object structure', async () => {
|
||||
const complexFunction = vi.fn().mockReturnValue({
|
||||
data: {
|
||||
items: ['{{ item1 }}', '{{ item2 }}'],
|
||||
metadata: { value: '{{ meta }}' },
|
||||
},
|
||||
});
|
||||
const vars = { item1: 'first', item2: 'second', meta: 'metadata_value' };
|
||||
const rendered = renderVarsInObject(complexFunction, vars);
|
||||
|
||||
expect(complexFunction).toHaveBeenCalledWith({ vars });
|
||||
// Function result is NOT recursively templated because vars is not passed in recursive call
|
||||
expect(rendered).toEqual({
|
||||
data: {
|
||||
items: ['{{ item1 }}', '{{ item2 }}'],
|
||||
metadata: { value: '{{ meta }}' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderEnvOnlyInObject', () => {
|
||||
beforeEach(() => {
|
||||
mockProcessEnv({ TEST_ENV_VAR: undefined });
|
||||
mockProcessEnv({ AZURE_ENDPOINT: undefined });
|
||||
mockProcessEnv({ API_VERSION: undefined });
|
||||
mockProcessEnv({ PORT: undefined });
|
||||
mockProcessEnv({ API_HOST: undefined });
|
||||
mockProcessEnv({ BASE_URL: undefined });
|
||||
mockProcessEnv({ EMPTY_VAR: undefined });
|
||||
mockProcessEnv({ SPECIAL_CHARS: undefined });
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: undefined });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockProcessEnv({ TEST_ENV_VAR: undefined });
|
||||
mockProcessEnv({ AZURE_ENDPOINT: undefined });
|
||||
mockProcessEnv({ API_VERSION: undefined });
|
||||
mockProcessEnv({ PORT: undefined });
|
||||
mockProcessEnv({ API_HOST: undefined });
|
||||
mockProcessEnv({ BASE_URL: undefined });
|
||||
mockProcessEnv({ EMPTY_VAR: undefined });
|
||||
mockProcessEnv({ SPECIAL_CHARS: undefined });
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: undefined });
|
||||
});
|
||||
|
||||
describe('Basic rendering', () => {
|
||||
it('should render simple dot notation env vars', async () => {
|
||||
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
|
||||
expect(renderEnvOnlyInObject('{{ env.TEST_ENV_VAR }}')).toBe('env_value');
|
||||
});
|
||||
|
||||
it('should render bracket notation with single quotes', async () => {
|
||||
mockProcessEnv({ ['VAR-WITH-DASH']: 'dash_value' });
|
||||
expect(renderEnvOnlyInObject("{{ env['VAR-WITH-DASH'] }}")).toBe('dash_value');
|
||||
});
|
||||
|
||||
it('should render bracket notation with double quotes', async () => {
|
||||
mockProcessEnv({ ['VAR_NAME']: 'value' });
|
||||
expect(renderEnvOnlyInObject('{{ env["VAR_NAME"] }}')).toBe('value');
|
||||
});
|
||||
|
||||
it('should handle whitespace variations', async () => {
|
||||
mockProcessEnv({ TEST: 'value' });
|
||||
expect(renderEnvOnlyInObject('{{env.TEST}}')).toBe('value');
|
||||
expect(renderEnvOnlyInObject('{{ env.TEST }}')).toBe('value');
|
||||
expect(renderEnvOnlyInObject('{{ env.TEST}}')).toBe('value');
|
||||
expect(renderEnvOnlyInObject('{{env.TEST }}')).toBe('value');
|
||||
});
|
||||
|
||||
it('should render empty string env vars', async () => {
|
||||
mockProcessEnv({ EMPTY_VAR: '' });
|
||||
expect(renderEnvOnlyInObject('{{ env.EMPTY_VAR }}')).toBe('');
|
||||
});
|
||||
|
||||
it('should render env vars with special characters in value', async () => {
|
||||
mockProcessEnv({ SPECIAL_CHARS: 'value with spaces & $pecial chars!' });
|
||||
expect(renderEnvOnlyInObject('{{ env.SPECIAL_CHARS }}')).toBe(
|
||||
'value with spaces & $pecial chars!',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filters and expressions (NEW functionality)', () => {
|
||||
it('should support default filter with fallback', async () => {
|
||||
mockProcessEnv({ EXISTING: 'exists' });
|
||||
expect(renderEnvOnlyInObject("{{ env.EXISTING | default('fallback') }}")).toBe('exists');
|
||||
// NEW: When env var doesn't exist but has default filter, Nunjucks renders it
|
||||
expect(renderEnvOnlyInObject("{{ env.NONEXISTENT | default('fallback') }}")).toBe('fallback');
|
||||
});
|
||||
|
||||
it('should support upper filter', async () => {
|
||||
mockProcessEnv({ LOWERCASE: 'lowercase' });
|
||||
expect(renderEnvOnlyInObject('{{ env.LOWERCASE | upper }}')).toBe('LOWERCASE');
|
||||
});
|
||||
|
||||
it('should support lower filter', async () => {
|
||||
mockProcessEnv({ UPPERCASE: 'UPPERCASE' });
|
||||
expect(renderEnvOnlyInObject('{{ env.UPPERCASE | lower }}')).toBe('uppercase');
|
||||
});
|
||||
|
||||
it('should support chained filters', async () => {
|
||||
mockProcessEnv({ TEST: 'test' });
|
||||
expect(renderEnvOnlyInObject("{{ env.TEST | default('x') | upper }}")).toBe('TEST');
|
||||
});
|
||||
|
||||
it('should support complex filter expressions', async () => {
|
||||
mockProcessEnv({ PORT: '8080' });
|
||||
expect(renderEnvOnlyInObject('{{ env.PORT | int }}')).toBe('8080');
|
||||
});
|
||||
|
||||
it('should handle filter with closing brace in argument', async () => {
|
||||
mockProcessEnv({ VAR: 'value' });
|
||||
// This is a tricky case: the default value contains }
|
||||
expect(renderEnvOnlyInObject("{{ env.VAR | default('}') }}")).toBe('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Preservation of non-env templates', () => {
|
||||
it('should preserve vars templates', async () => {
|
||||
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
|
||||
expect(renderEnvOnlyInObject('{{ env.TEST_ENV_VAR }}, {{ vars.myVar }}')).toBe(
|
||||
'env_value, {{ vars.myVar }}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve prompt templates', async () => {
|
||||
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
|
||||
expect(renderEnvOnlyInObject('{{ env.TEST_ENV_VAR }}, {{ prompt }}')).toBe(
|
||||
'env_value, {{ prompt }}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve multiple non-env templates', async () => {
|
||||
mockProcessEnv({ API_HOST: 'api.example.com' });
|
||||
const template =
|
||||
'Host: {{ env.API_HOST }}, Message: {{ vars.msg }}, Context: {{ context }}, Prompt: {{ prompt }}';
|
||||
const expected =
|
||||
'Host: api.example.com, Message: {{ vars.msg }}, Context: {{ context }}, Prompt: {{ prompt }}';
|
||||
expect(renderEnvOnlyInObject(template)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should preserve templates with filters on non-env vars', async () => {
|
||||
expect(renderEnvOnlyInObject("{{ vars.name | default('Guest') }}")).toBe(
|
||||
"{{ vars.name | default('Guest') }}",
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve env templates in _conversation runtime vars', async () => {
|
||||
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
|
||||
const config = {
|
||||
tests: [
|
||||
{
|
||||
vars: {
|
||||
_conversation: [
|
||||
{
|
||||
input: 'Tell me a secret',
|
||||
output: 'The answer is {{ env.TEST_ENV_VAR }}',
|
||||
},
|
||||
],
|
||||
regularVar: '{{ env.TEST_ENV_VAR }}',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(renderEnvOnlyInObject(config)).toEqual({
|
||||
tests: [
|
||||
{
|
||||
vars: {
|
||||
_conversation: [
|
||||
{
|
||||
input: 'Tell me a secret',
|
||||
output: 'The answer is {{ env.TEST_ENV_VAR }}',
|
||||
},
|
||||
],
|
||||
regularVar: 'env_value',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Undefined env vars', () => {
|
||||
it('should preserve template if env var does not exist', async () => {
|
||||
expect(renderEnvOnlyInObject('{{ env.NONEXISTENT }}')).toBe('{{ env.NONEXISTENT }}');
|
||||
});
|
||||
|
||||
it('should preserve bracket notation if env var does not exist', async () => {
|
||||
expect(renderEnvOnlyInObject("{{ env['MISSING'] }}")).toBe("{{ env['MISSING'] }}");
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex data structures', () => {
|
||||
it('should work with nested objects', async () => {
|
||||
mockProcessEnv({ LEVEL1: 'value1' });
|
||||
mockProcessEnv({ LEVEL2: 'value2' });
|
||||
const obj = {
|
||||
level1: {
|
||||
level2: {
|
||||
env1: '{{ env.LEVEL1 }}',
|
||||
env2: '{{ env.LEVEL2 }}',
|
||||
vars: '{{ vars.test }}',
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(renderEnvOnlyInObject(obj)).toEqual({
|
||||
level1: {
|
||||
level2: {
|
||||
env1: 'value1',
|
||||
env2: 'value2',
|
||||
vars: '{{ vars.test }}',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should work with arrays', async () => {
|
||||
mockProcessEnv({ ENV1: 'value1' });
|
||||
mockProcessEnv({ ENV2: 'value2' });
|
||||
const arr = ['{{ env.ENV1 }}', '{{ vars.test }}', '{{ env.ENV2 }}', 42];
|
||||
expect(renderEnvOnlyInObject(arr)).toEqual(['value1', '{{ vars.test }}', 'value2', 42]);
|
||||
});
|
||||
|
||||
it('should work with mixed nested structures', async () => {
|
||||
mockProcessEnv({ API_KEY: 'secret123' });
|
||||
const config = {
|
||||
api: {
|
||||
key: '{{ env.API_KEY }}',
|
||||
endpoints: ['{{ env.BASE_URL }}/users', '{{ env.BASE_URL }}/posts'],
|
||||
},
|
||||
request: {
|
||||
body: { message: '{{ vars.message }}' },
|
||||
headers: { authorization: 'Bearer {{ env.API_KEY }}' },
|
||||
},
|
||||
};
|
||||
const rendered = renderEnvOnlyInObject(config);
|
||||
expect(rendered).toEqual({
|
||||
api: {
|
||||
key: 'secret123',
|
||||
endpoints: ['{{ env.BASE_URL }}/users', '{{ env.BASE_URL }}/posts'],
|
||||
},
|
||||
request: {
|
||||
body: { message: '{{ vars.message }}' },
|
||||
headers: { authorization: 'Bearer secret123' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Primitive types', () => {
|
||||
it('should handle null', async () => {
|
||||
expect(renderEnvOnlyInObject(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined', async () => {
|
||||
expect(renderEnvOnlyInObject(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle numbers', async () => {
|
||||
expect(renderEnvOnlyInObject(42)).toBe(42);
|
||||
});
|
||||
|
||||
it('should handle booleans', async () => {
|
||||
expect(renderEnvOnlyInObject(true)).toBe(true);
|
||||
expect(renderEnvOnlyInObject(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should preserve template on Nunjucks render error', async () => {
|
||||
mockProcessEnv({ TEST: 'value' });
|
||||
// Malformed filter that would cause Nunjucks error
|
||||
const template = '{{ env.TEST | nonexistent_filter }}';
|
||||
const rendered = renderEnvOnlyInObject(template);
|
||||
// Should preserve the template if rendering fails
|
||||
expect(rendered).toBe(template);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PROMPTFOO_DISABLE_TEMPLATING flag', () => {
|
||||
it('should return unchanged when flag is set', async () => {
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
|
||||
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
|
||||
expect(renderEnvOnlyInObject('{{ env.TEST_ENV_VAR }}')).toBe('{{ env.TEST_ENV_VAR }}');
|
||||
});
|
||||
|
||||
it('should return unchanged objects when flag is set', async () => {
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
|
||||
mockProcessEnv({ TEST: 'value' });
|
||||
const obj = { key: '{{ env.TEST }}' };
|
||||
expect(renderEnvOnlyInObject(obj)).toEqual({ key: '{{ env.TEST }}' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world scenarios', () => {
|
||||
it('should handle Azure provider config with mixed templates', async () => {
|
||||
mockProcessEnv({ AZURE_ENDPOINT: 'test.openai.azure.com' });
|
||||
mockProcessEnv({ API_VERSION: '2024-02-15' });
|
||||
const config = {
|
||||
apiHost: '{{ env.AZURE_ENDPOINT }}',
|
||||
apiVersion: '{{ env.API_VERSION }}',
|
||||
body: {
|
||||
message: '{{ vars.userMessage }}',
|
||||
user: '{{ vars.userId }}',
|
||||
},
|
||||
};
|
||||
expect(renderEnvOnlyInObject(config)).toEqual({
|
||||
apiHost: 'test.openai.azure.com',
|
||||
apiVersion: '2024-02-15',
|
||||
body: {
|
||||
message: '{{ vars.userMessage }}',
|
||||
user: '{{ vars.userId }}',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle HTTP provider with runtime vars', async () => {
|
||||
mockProcessEnv({ BASE_URL: 'https://api.example.com' });
|
||||
const config = {
|
||||
url: '{{ env.BASE_URL }}/query',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer {{ env.API_TOKEN }}',
|
||||
},
|
||||
body: {
|
||||
query: '{{ vars.userQuery }}',
|
||||
context: '{{ vars.context }}',
|
||||
},
|
||||
};
|
||||
const rendered = renderEnvOnlyInObject(config);
|
||||
expect(rendered).toEqual({
|
||||
url: 'https://api.example.com/query',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer {{ env.API_TOKEN }}', // Undefined, preserved
|
||||
},
|
||||
body: {
|
||||
query: '{{ vars.userQuery }}', // Runtime vars preserved
|
||||
context: '{{ vars.context }}', // Runtime vars preserved
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex provider config with filters', async () => {
|
||||
mockProcessEnv({ API_HOST: 'api.example.com' });
|
||||
mockProcessEnv({ PORT: '8080' });
|
||||
const config = {
|
||||
baseUrl: "{{ env.API_HOST | default('localhost') }}:{{ env.PORT }}",
|
||||
timeout: '{{ env.TIMEOUT | default(30000) }}',
|
||||
request: {
|
||||
body: '{{ vars.payload }}',
|
||||
},
|
||||
};
|
||||
const rendered = renderEnvOnlyInObject(config);
|
||||
expect(rendered).toEqual({
|
||||
baseUrl: 'api.example.com:8080',
|
||||
timeout: '30000', // TIMEOUT undefined, but default filter renders it
|
||||
request: {
|
||||
body: '{{ vars.payload }}',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle multiple env vars in same string', async () => {
|
||||
mockProcessEnv({ HOST: 'example.com' });
|
||||
mockProcessEnv({ PORT: '8080' });
|
||||
expect(renderEnvOnlyInObject('{{ env.HOST }}:{{ env.PORT }}')).toBe('example.com:8080');
|
||||
});
|
||||
|
||||
it('should handle env vars in middle of text', async () => {
|
||||
mockProcessEnv({ NAME: 'World' });
|
||||
expect(renderEnvOnlyInObject('Hello {{ env.NAME }}!')).toBe('Hello World!');
|
||||
});
|
||||
|
||||
it('should handle templates with newlines', async () => {
|
||||
mockProcessEnv({ VAR: 'value' });
|
||||
expect(
|
||||
renderEnvOnlyInObject(`Line 1: {{ env.VAR }}
|
||||
Line 2: {{ vars.test }}`),
|
||||
).toBe(`Line 1: value
|
||||
Line 2: {{ vars.test }}`);
|
||||
});
|
||||
|
||||
it('should render env vars in strings longer than 50000 chars', async () => {
|
||||
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
|
||||
const padding = 'x'.repeat(60000);
|
||||
const template = `${padding} {{ env.TEST_ENV_VAR }} ${padding}`;
|
||||
expect(template.length).toBeGreaterThan(50000);
|
||||
expect(renderEnvOnlyInObject(template)).toBe(`${padding} env_value ${padding}`);
|
||||
});
|
||||
|
||||
it('should preserve non-env templates in long strings', async () => {
|
||||
const padding = 'x'.repeat(60000);
|
||||
const template = `${padding} {{ vars.myVar }} ${padding}`;
|
||||
expect(renderEnvOnlyInObject(template)).toBe(template);
|
||||
});
|
||||
|
||||
it('should handle long strings with mixed env and non-env templates', async () => {
|
||||
mockProcessEnv({ HOST: 'example.com' });
|
||||
const padding = 'x'.repeat(60000);
|
||||
const template = `${padding} {{ env.HOST }} {{ vars.test }} ${padding}`;
|
||||
expect(renderEnvOnlyInObject(template)).toBe(
|
||||
`${padding} example.com {{ vars.test }} ${padding}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle long strings with no templates at all', async () => {
|
||||
const longString = 'a'.repeat(100000);
|
||||
expect(renderEnvOnlyInObject(longString)).toBe(longString);
|
||||
});
|
||||
|
||||
it('should not confuse env in other contexts', async () => {
|
||||
mockProcessEnv({ TEST: 'value' });
|
||||
// Should not match "environment" or other words containing "env"
|
||||
expect(renderEnvOnlyInObject('environment {{ vars.test }}')).toBe(
|
||||
'environment {{ vars.test }}',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../src/envars', () => ({
|
||||
getEnvString: vi.fn().mockReturnValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { getEnvString } from '../../src/envars';
|
||||
import logger from '../../src/logger';
|
||||
import { isRunningUnderNpx, printBorder } from '../../src/util/runtime';
|
||||
|
||||
describe('runtime utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('printBorder', () => {
|
||||
it('should call logger.info with a border string', () => {
|
||||
printBorder();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(logger.info).mock.calls[0][0];
|
||||
expect(typeof call).toBe('string');
|
||||
expect(call).toMatch(/^=+$/);
|
||||
});
|
||||
|
||||
it('should print a border of repeated equals signs', () => {
|
||||
printBorder();
|
||||
|
||||
const border = vi.mocked(logger.info).mock.calls[0][0] as string;
|
||||
expect(border.length).toBeGreaterThan(0);
|
||||
expect(border.split('').every((char) => char === '=')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRunningUnderNpx', () => {
|
||||
const originalExecPath = process.execPath;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: originalExecPath,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return false when not running under npx', () => {
|
||||
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: '/usr/local/bin/node',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(isRunningUnderNpx()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when npm_execpath contains npx', () => {
|
||||
(vi.mocked(getEnvString) as any).mockImplementation((key: string) => {
|
||||
if (key === 'npm_execpath') {
|
||||
return '/usr/local/lib/node_modules/npm/bin/npx-cli.js';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: '/usr/local/bin/node',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(isRunningUnderNpx()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when process.execPath contains npx', () => {
|
||||
vi.mocked(getEnvString).mockReturnValue(undefined as unknown as string);
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: '/home/user/.npm/_npx/123/node_modules/.bin/node',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(isRunningUnderNpx()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when npm_lifecycle_script contains npx', () => {
|
||||
(vi.mocked(getEnvString) as any).mockImplementation((key: string) => {
|
||||
if (key === 'npm_lifecycle_script') {
|
||||
return 'npx promptfoo eval';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: '/usr/local/bin/node',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(isRunningUnderNpx()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when env vars are empty strings', () => {
|
||||
vi.mocked(getEnvString).mockReturnValue('');
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: '/usr/local/bin/node',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(isRunningUnderNpx()).toBe(false);
|
||||
});
|
||||
|
||||
it('should check npm_execpath first', () => {
|
||||
(vi.mocked(getEnvString) as any).mockImplementation((key: string) => {
|
||||
if (key === 'npm_execpath') {
|
||||
return '/path/to/npx';
|
||||
}
|
||||
if (key === 'npm_lifecycle_script') {
|
||||
return 'some other script';
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: '/usr/local/bin/node',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(isRunningUnderNpx()).toBe(true);
|
||||
expect(getEnvString).toHaveBeenCalledWith('npm_execpath');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createSecureTempDirectory,
|
||||
removeSecureTempDirectory,
|
||||
writeSecureTempFile,
|
||||
} from '../../src/util/secureTempFiles';
|
||||
|
||||
describe('secure temporary files', () => {
|
||||
let tempDirectory: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDirectory) {
|
||||
await removeSecureTempDirectory(tempDirectory);
|
||||
tempDirectory = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('creates private directories and exclusively writes restricted files', async () => {
|
||||
tempDirectory = await createSecureTempDirectory('promptfoo-secure-temp-test-');
|
||||
const filePath = await writeSecureTempFile(tempDirectory, 'payload.json', '{"ok":true}');
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
expect((await fs.stat(tempDirectory)).mode & 0o777).toBe(0o700);
|
||||
expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
|
||||
await expect(
|
||||
writeSecureTempFile(tempDirectory, 'payload.json', 'replacement'),
|
||||
).rejects.toMatchObject({ code: 'EEXIST' });
|
||||
});
|
||||
|
||||
it('rejects filenames that are not simple leaf names', async () => {
|
||||
tempDirectory = await createSecureTempDirectory('promptfoo-secure-temp-test-');
|
||||
|
||||
for (const filename of ['../payload.json', path.resolve('payload.json'), '.', '..', '']) {
|
||||
await expect(writeSecureTempFile(tempDirectory, filename, 'data')).rejects.toThrow(
|
||||
'Secure temporary file names must be simple leaf names',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects directory prefixes that are not simple leaf names', async () => {
|
||||
for (const prefix of ['../promptfoo-', path.resolve('promptfoo-'), '.', '..', '']) {
|
||||
await expect(createSecureTempDirectory(prefix)).rejects.toThrow(
|
||||
'Secure temporary directory prefixes must be simple leaf names',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('removes the temporary directory recursively', async () => {
|
||||
tempDirectory = await createSecureTempDirectory('promptfoo-secure-temp-test-');
|
||||
await writeSecureTempFile(tempDirectory, 'payload.json', 'data');
|
||||
|
||||
await removeSecureTempDirectory(tempDirectory);
|
||||
await expect(fs.access(tempDirectory)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
tempDirectory = undefined;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import opener from 'opener';
|
||||
import { beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest';
|
||||
import { getDefaultPort, VERSION } from '../../src/constants';
|
||||
import logger from '../../src/logger';
|
||||
import * as remoteGeneration from '../../src/redteam/remoteGeneration';
|
||||
import * as readlineUtils from '../../src/util/readline';
|
||||
// Import the module under test after mocks are set up
|
||||
import {
|
||||
__clearFeatureCache,
|
||||
BrowserBehavior,
|
||||
checkServerFeatureSupport,
|
||||
checkServerRunning,
|
||||
openBrowser,
|
||||
} from '../../src/util/server';
|
||||
|
||||
// Mock opener
|
||||
vi.mock('opener', () => ({ default: vi.fn() }));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the readline utilities
|
||||
vi.mock('../../src/util/readline', () => ({
|
||||
promptYesNo: vi.fn(),
|
||||
promptUser: vi.fn(),
|
||||
createReadlineInterface: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fetchWithProxy
|
||||
vi.mock('../../src/util/fetch/index', () => ({
|
||||
fetchWithProxy: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock remoteGeneration
|
||||
vi.mock('../../src/redteam/remoteGeneration', () => ({
|
||||
getRemoteVersionUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the mocked fetchWithProxy for use in tests
|
||||
import * as fetchModule from '../../src/util/fetch/index';
|
||||
|
||||
const mockFetchWithProxy = fetchModule.fetchWithProxy as MockedFunction<
|
||||
typeof fetchModule.fetchWithProxy
|
||||
>;
|
||||
|
||||
describe('Server Utilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('checkServerRunning', () => {
|
||||
it('should return true when server is running with matching version', async () => {
|
||||
mockFetchWithProxy.mockResolvedValueOnce({
|
||||
json: async () => ({ status: 'OK', version: VERSION }),
|
||||
} as Response);
|
||||
|
||||
const result = await checkServerRunning();
|
||||
|
||||
expect(mockFetchWithProxy).toHaveBeenCalledWith(
|
||||
`http://localhost:${getDefaultPort()}/health`,
|
||||
{
|
||||
headers: {
|
||||
'x-promptfoo-silent': 'true',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when server status is not OK', async () => {
|
||||
mockFetchWithProxy.mockResolvedValueOnce({
|
||||
json: async () => ({ status: 'ERROR', version: VERSION }),
|
||||
} as Response);
|
||||
|
||||
const result = await checkServerRunning();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when server version does not match', async () => {
|
||||
mockFetchWithProxy.mockResolvedValueOnce({
|
||||
json: async () => ({ status: 'OK', version: 'wrong-version' }),
|
||||
} as Response);
|
||||
|
||||
const result = await checkServerRunning();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when fetch throws an error', async () => {
|
||||
mockFetchWithProxy.mockRejectedValueOnce(new Error('Connection refused'));
|
||||
|
||||
const result = await checkServerRunning();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No existing server found'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom port when provided', async () => {
|
||||
const customPort = 4000;
|
||||
mockFetchWithProxy.mockResolvedValueOnce({
|
||||
json: async () => ({ status: 'OK', version: VERSION }),
|
||||
} as Response);
|
||||
|
||||
await checkServerRunning(customPort);
|
||||
|
||||
expect(mockFetchWithProxy).toHaveBeenCalledWith(`http://localhost:${customPort}/health`, {
|
||||
headers: {
|
||||
'x-promptfoo-silent': 'true',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openBrowser', () => {
|
||||
it('should open browser with default URL when BrowserBehavior.OPEN', async () => {
|
||||
await openBrowser(BrowserBehavior.OPEN);
|
||||
|
||||
expect(opener).toHaveBeenCalledWith(`http://localhost:${getDefaultPort()}`);
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Press Ctrl+C'));
|
||||
});
|
||||
|
||||
it('should open browser with report URL when BrowserBehavior.OPEN_TO_REPORT', async () => {
|
||||
await openBrowser(BrowserBehavior.OPEN_TO_REPORT);
|
||||
|
||||
expect(opener).toHaveBeenCalledWith(`http://localhost:${getDefaultPort()}/report`);
|
||||
});
|
||||
|
||||
it('should open browser with redteam setup URL when BrowserBehavior.OPEN_TO_REDTEAM_CREATE', async () => {
|
||||
await openBrowser(BrowserBehavior.OPEN_TO_REDTEAM_CREATE);
|
||||
|
||||
expect(opener).toHaveBeenCalledWith(`http://localhost:${getDefaultPort()}/redteam/setup`);
|
||||
});
|
||||
|
||||
it('should open browser with eval setup URL when BrowserBehavior.OPEN_TO_EVAL_SETUP', async () => {
|
||||
await openBrowser(BrowserBehavior.OPEN_TO_EVAL_SETUP);
|
||||
|
||||
expect(opener).toHaveBeenCalledWith(`http://localhost:${getDefaultPort()}/setup`);
|
||||
});
|
||||
|
||||
it('should not open browser when BrowserBehavior.SKIP', async () => {
|
||||
await openBrowser(BrowserBehavior.SKIP);
|
||||
|
||||
expect(opener).not.toHaveBeenCalled();
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle opener errors gracefully', async () => {
|
||||
vi.mocked(opener).mockImplementationOnce(() => {
|
||||
throw new Error('Failed to open browser');
|
||||
});
|
||||
|
||||
await openBrowser(BrowserBehavior.OPEN);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to open browser'));
|
||||
});
|
||||
|
||||
it('should ask user before opening browser when BrowserBehavior.ASK', async () => {
|
||||
// Mock promptYesNo to return true
|
||||
vi.mocked(readlineUtils.promptYesNo).mockResolvedValueOnce(true);
|
||||
|
||||
await openBrowser(BrowserBehavior.ASK);
|
||||
|
||||
expect(readlineUtils.promptYesNo).toHaveBeenCalledWith('Open URL in browser?', false);
|
||||
expect(opener).toHaveBeenCalledWith(`http://localhost:${getDefaultPort()}`);
|
||||
});
|
||||
|
||||
it('should not open browser when user answers no to ASK prompt', async () => {
|
||||
// Mock promptYesNo to return false
|
||||
vi.mocked(readlineUtils.promptYesNo).mockResolvedValueOnce(false);
|
||||
|
||||
await openBrowser(BrowserBehavior.ASK);
|
||||
|
||||
expect(readlineUtils.promptYesNo).toHaveBeenCalledWith('Open URL in browser?', false);
|
||||
expect(opener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use custom port when provided', async () => {
|
||||
const customPort = 5000;
|
||||
await openBrowser(BrowserBehavior.OPEN, customPort);
|
||||
|
||||
expect(opener).toHaveBeenCalledWith(`http://localhost:${customPort}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkServerFeatureSupport', () => {
|
||||
const featureName = 'test-feature';
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear the feature cache before each test to ensure isolation
|
||||
__clearFeatureCache();
|
||||
vi.clearAllMocks();
|
||||
// Setup default mock for getRemoteVersionUrl to return a valid URL
|
||||
vi.mocked(remoteGeneration.getRemoteVersionUrl).mockReturnValue(
|
||||
'https://api.promptfoo.app/version',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true when server buildDate is after required date', async () => {
|
||||
const requiredDate = '2024-01-01T00:00:00Z';
|
||||
const serverBuildDate = '2024-06-15T10:30:00Z';
|
||||
|
||||
mockFetchWithProxy.mockResolvedValueOnce({
|
||||
json: async () => ({ buildDate: serverBuildDate, version: '1.0.0' }),
|
||||
} as Response);
|
||||
|
||||
const result = await checkServerFeatureSupport(featureName, requiredDate);
|
||||
|
||||
expect(mockFetchWithProxy).toHaveBeenCalledWith('https://api.promptfoo.app/version', {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`${featureName}: buildDate=${serverBuildDate}, required=${requiredDate}, supported=true`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when server buildDate is before required date', async () => {
|
||||
const requiredDate = '2024-06-01T00:00:00Z';
|
||||
const serverBuildDate = '2024-01-15T10:30:00Z';
|
||||
|
||||
mockFetchWithProxy.mockResolvedValueOnce({
|
||||
json: async () => ({ buildDate: serverBuildDate, version: '1.0.0' }),
|
||||
} as Response);
|
||||
|
||||
const result = await checkServerFeatureSupport(featureName, requiredDate);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`${featureName}: buildDate=${serverBuildDate}, required=${requiredDate}, supported=false`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when no version info available', async () => {
|
||||
mockFetchWithProxy.mockResolvedValueOnce({
|
||||
json: async () => ({}),
|
||||
} as Response);
|
||||
|
||||
const result = await checkServerFeatureSupport(featureName, '2024-01-01T00:00:00Z');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`${featureName}: no version info, assuming not supported`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true when no remote URL is available (local server assumption)', async () => {
|
||||
// Mock getRemoteVersionUrl to return null for this specific test
|
||||
vi.mocked(remoteGeneration.getRemoteVersionUrl).mockReturnValueOnce(null);
|
||||
|
||||
const result = await checkServerFeatureSupport(featureName, '2024-01-01T00:00:00Z');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockFetchWithProxy).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`No remote URL available for ${featureName}, assuming local server supports it`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when fetchWithProxy throws an error', async () => {
|
||||
mockFetchWithProxy.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const result = await checkServerFeatureSupport(featureName, '2024-01-01T00:00:00Z');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Version check failed for ${featureName}, assuming not supported: Error: Network error`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should cache results to avoid repeated API calls', async () => {
|
||||
const requiredDate = '2024-01-01T00:00:00Z';
|
||||
const serverBuildDate = '2024-06-15T10:30:00Z';
|
||||
|
||||
mockFetchWithProxy.mockResolvedValue({
|
||||
json: async () => ({ buildDate: serverBuildDate, version: '1.0.0' }),
|
||||
} as Response);
|
||||
|
||||
// First call
|
||||
const result1 = await checkServerFeatureSupport(featureName, requiredDate);
|
||||
// Second call with same parameters
|
||||
const result2 = await checkServerFeatureSupport(featureName, requiredDate);
|
||||
|
||||
expect(result1).toBe(true);
|
||||
expect(result2).toBe(true);
|
||||
// fetchWithProxy should only be called once due to caching
|
||||
expect(mockFetchWithProxy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle different feature names with separate cache entries', async () => {
|
||||
const feature1 = 'feature-one';
|
||||
const feature2 = 'feature-two';
|
||||
const requiredDate = '2024-01-01T00:00:00Z';
|
||||
|
||||
mockFetchWithProxy
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({ buildDate: '2024-06-15T10:30:00Z', version: '1.0.0' }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({ buildDate: '2023-12-15T10:30:00Z', version: '1.0.0' }),
|
||||
} as Response);
|
||||
|
||||
const result1 = await checkServerFeatureSupport(feature1, requiredDate);
|
||||
const result2 = await checkServerFeatureSupport(feature2, requiredDate);
|
||||
|
||||
expect(result1).toBe(true);
|
||||
expect(result2).toBe(false);
|
||||
expect(mockFetchWithProxy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle timezone differences correctly', async () => {
|
||||
const requiredDate = '2024-06-01T00:00:00Z'; // UTC
|
||||
const serverBuildDate = '2024-06-01T08:00:00+08:00'; // Same moment in different timezone
|
||||
|
||||
mockFetchWithProxy.mockResolvedValueOnce({
|
||||
json: async () => ({ buildDate: serverBuildDate, version: '1.0.0' }),
|
||||
} as Response);
|
||||
|
||||
const result = await checkServerFeatureSupport(featureName, requiredDate);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { shouldShareResults } from '../../src/util/sharing';
|
||||
|
||||
vi.mock('../../src/envars', () => ({
|
||||
getEnvBool: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/globalConfig/cloud', () => ({
|
||||
cloudConfig: {
|
||||
isEnabled: vi.fn().mockReturnValue(false),
|
||||
getSharing: vi.fn().mockReturnValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
import { getEnvBool } from '../../src/envars';
|
||||
import { cloudConfig } from '../../src/globalConfig/cloud';
|
||||
|
||||
describe('shouldShareResults', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvBool).mockReturnValue(false);
|
||||
vi.mocked(cloudConfig.isEnabled).mockReturnValue(false);
|
||||
vi.mocked(cloudConfig.getSharing).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('explicit disable', () => {
|
||||
it('should return false when cliNoShare is true', () => {
|
||||
expect(shouldShareResults({ cliNoShare: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when cliShare is false', () => {
|
||||
expect(shouldShareResults({ cliShare: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when PROMPTFOO_DISABLE_SHARING env var is set', () => {
|
||||
vi.mocked(getEnvBool).mockReturnValue(true);
|
||||
expect(shouldShareResults({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit enable', () => {
|
||||
it('should return true when cliShare is true', () => {
|
||||
expect(shouldShareResults({ cliShare: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config file settings', () => {
|
||||
it('should return true when configShare is truthy', () => {
|
||||
expect(shouldShareResults({ configShare: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when configShare is false', () => {
|
||||
expect(shouldShareResults({ configShare: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when configSharing is truthy', () => {
|
||||
expect(shouldShareResults({ configSharing: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when configSharing is false', () => {
|
||||
expect(shouldShareResults({ configSharing: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default behavior', () => {
|
||||
it('should return false when cloud is not enabled', () => {
|
||||
vi.mocked(cloudConfig.isEnabled).mockReturnValue(false);
|
||||
expect(shouldShareResults({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when cloud is enabled but sharing is undefined (pre-migration backward compat)', () => {
|
||||
vi.mocked(cloudConfig.isEnabled).mockReturnValue(true);
|
||||
vi.mocked(cloudConfig.getSharing).mockReturnValue(undefined);
|
||||
expect(shouldShareResults({})).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when cloud is enabled but sharing is false', () => {
|
||||
vi.mocked(cloudConfig.isEnabled).mockReturnValue(true);
|
||||
vi.mocked(cloudConfig.getSharing).mockReturnValue(false);
|
||||
expect(shouldShareResults({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when cloud is enabled and sharing is true', () => {
|
||||
vi.mocked(cloudConfig.isEnabled).mockReturnValue(true);
|
||||
vi.mocked(cloudConfig.getSharing).mockReturnValue(true);
|
||||
expect(shouldShareResults({})).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
import nunjucks from 'nunjucks';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import cliState from '../../src/cliState';
|
||||
import {
|
||||
analyzeTemplateReference,
|
||||
extractVariablesFromTemplate,
|
||||
extractVariablesFromTemplates,
|
||||
getNunjucksEngine,
|
||||
templateReferencesVariable,
|
||||
} from '../../src/util/templates';
|
||||
import { mockProcessEnv } from './utils';
|
||||
|
||||
describe('extractVariablesFromTemplate', () => {
|
||||
it('should extract simple variables', () => {
|
||||
const template = 'Hello {{ name }}, welcome to {{ place }}!';
|
||||
expect(extractVariablesFromTemplate(template)).toEqual(['name', 'place']);
|
||||
});
|
||||
|
||||
it('should extract variables without spaces', () => {
|
||||
const template = 'Hello {{name}}, welcome to {{place}}!';
|
||||
expect(extractVariablesFromTemplate(template)).toEqual(['name', 'place']);
|
||||
});
|
||||
|
||||
it('should extract variables with dot notation', () => {
|
||||
const template = 'Hello {{ user.name }}, your score is {{ game.score }}!';
|
||||
expect(extractVariablesFromTemplate(template)).toEqual(['user.name', 'game.score']);
|
||||
});
|
||||
|
||||
it('should extract variables with underscores', () => {
|
||||
const template = 'Your order {{ order_id }} will arrive on {{ delivery_date }}.';
|
||||
expect(extractVariablesFromTemplate(template)).toEqual(['order_id', 'delivery_date']);
|
||||
});
|
||||
|
||||
it('should extract variables with numbers', () => {
|
||||
const template = 'Player1: {{ player1 }}, Player2: {{ player2 }}';
|
||||
expect(extractVariablesFromTemplate(template)).toEqual(['player1', 'player2']);
|
||||
});
|
||||
|
||||
it('should extract variables used in filters', () => {
|
||||
const template = '{{ name | capitalize }} - {{ date | date("yyyy-MM-dd") }}';
|
||||
expect(extractVariablesFromTemplate(template)).toEqual(['name', 'date']);
|
||||
});
|
||||
|
||||
it('should extract variables used in complex expressions', () => {
|
||||
const template = '{% if user.age > 18 %}Welcome, {{ user.name }}!{% endif %}';
|
||||
expect(extractVariablesFromTemplate(template)).toEqual(['user.age', 'user.name']);
|
||||
});
|
||||
|
||||
it('should extract variables from for loops', () => {
|
||||
const template = '{% for item in items %}{{ item.name }}{% endfor %}';
|
||||
expect(extractVariablesFromTemplate(template)).toEqual(['item.name', 'items']);
|
||||
});
|
||||
|
||||
it('should extract variables with multiple occurrences', () => {
|
||||
const template = '{{ name }} {{ age }} {{ name }}';
|
||||
expect(extractVariablesFromTemplate(template)).toEqual(['name', 'age']);
|
||||
});
|
||||
|
||||
it('should not extract variables from comments', () => {
|
||||
const template = '{# This is a comment with {{ variable }} #}{{ actual_variable }}';
|
||||
expect(extractVariablesFromTemplate(template)).toEqual(['actual_variable']);
|
||||
});
|
||||
|
||||
it('should handle empty templates', () => {
|
||||
const template = '';
|
||||
expect(extractVariablesFromTemplate(template)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle templates without variables', () => {
|
||||
const template = 'This is a static template without variables.';
|
||||
expect(extractVariablesFromTemplate(template)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractVariablesFromTemplates', () => {
|
||||
it('should extract variables from multiple templates', () => {
|
||||
const templates = [
|
||||
'Hello {{ name }}, welcome to {{ place }}!',
|
||||
'{{ user.age }} years old',
|
||||
'{% for item in items %}{{ item.name }}{% endfor %}',
|
||||
];
|
||||
|
||||
const result = extractVariablesFromTemplates(templates);
|
||||
|
||||
expect(result).toEqual(['name', 'place', 'user.age', 'item.name', 'items']);
|
||||
});
|
||||
|
||||
it('should handle empty array of templates', () => {
|
||||
const templates: string[] = [];
|
||||
|
||||
const result = extractVariablesFromTemplates(templates);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should deduplicate variables across templates', () => {
|
||||
const templates = ['Hello {{ name }}!', 'Welcome, {{ name }}!', '{{ age }} years old'];
|
||||
|
||||
const result = extractVariablesFromTemplates(templates);
|
||||
|
||||
expect(result).toEqual(['name', 'age']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('templateReferencesVariable', () => {
|
||||
it.each<[string, string]>([
|
||||
['basic symbol read', '{{ _conversation[0].output }}'],
|
||||
['filter input', '{{ _conversation | length }}'],
|
||||
['chained filters', '{{ _conversation | first | length }}'],
|
||||
['if condition', '{% if _conversation %}yes{% endif %}'],
|
||||
['elif condition', '{% if false %}no{% elif _conversation %}yes{% endif %}'],
|
||||
['ternary true branch', '{{ "yes" if _conversation else "no" }}'],
|
||||
['ternary condition', '{{ first if _conversation else second }}'],
|
||||
['ternary else branch', '{{ a if x else _conversation }}'],
|
||||
['logical expression', '{{ _conversation and foo }}'],
|
||||
['not expression', '{{ not _conversation }}'],
|
||||
['comparison', '{{ _conversation == [] }}'],
|
||||
['less-than', '{{ x < _conversation }}'],
|
||||
['for loop iterable', '{% for turn in _conversation %}{{ turn.output }}{% endfor %}'],
|
||||
['function argument', '{{ summarize(_conversation) }}'],
|
||||
['keyword argument value', '{{ fn(arg=_conversation) }}'],
|
||||
['filter argument', '{{ input | default(_conversation) }}'],
|
||||
['is test with call arg', '{{ foo is divisibleby(_conversation) }}'],
|
||||
['is test function argument value', '{{ foo is _conversation(_conversation) }}'],
|
||||
['bracket index lookup', '{{ obj[_conversation] }}'],
|
||||
['array literal member', '{{ [_conversation, other] }}'],
|
||||
['group expression', '{{ (_conversation) }}'],
|
||||
['negated expression', '{{ -_conversation }}'],
|
||||
['concat', '{{ "a" ~ _conversation ~ "b" }}'],
|
||||
['dict literal value', '{{ {"history": _conversation}["history"][0].output }}'],
|
||||
['set value binding', '{% set history = _conversation %}{{ history }}'],
|
||||
['set block binding', '{% set history %}{{ _conversation }}{% endset %}{{ history }}'],
|
||||
['macro body free variable', '{% macro render() %}{{ _conversation }}{% endmacro %}'],
|
||||
['macro default value', '{% macro render(x=_conversation) %}{{ x }}{% endmacro %}'],
|
||||
[
|
||||
'macro default value with body ref',
|
||||
'{% macro render(x=_conversation) %}{{ _conversation }}{% endmacro %}',
|
||||
],
|
||||
[
|
||||
'macro self-referential default value',
|
||||
'{% macro render(_conversation=_conversation) %}{{ _conversation[0].output }}{% endmacro %}{{ render() }}',
|
||||
],
|
||||
['include expression', '{% include _conversation %}'],
|
||||
['extends expression', '{% extends _conversation %}{% block b %}{% endblock %}'],
|
||||
['from-import template expression', '{% from template_name import foo %}{{ _conversation }}'],
|
||||
['block body reference', '{% block body %}{{ _conversation }}{% endblock %}'],
|
||||
[
|
||||
'call block body reference',
|
||||
'{% macro m() %}{{ caller() }}{% endmacro %}{% call m() %}{{ _conversation }}{% endcall %}',
|
||||
],
|
||||
[
|
||||
'call block body reference with unrelated caller arg',
|
||||
'{% macro m() %}{{ caller("local") }}{% endmacro %}{% call(x) m() %}{{ _conversation }}{% endcall %}',
|
||||
],
|
||||
[
|
||||
'call block caller default value',
|
||||
'{% macro m() %}{{ caller() }}{% endmacro %}{% call(x=_conversation) m() %}{{ x[0].output }}{% endcall %}',
|
||||
],
|
||||
[
|
||||
'call block caller self-referential default value',
|
||||
'{% macro m() %}{{ caller() }}{% endmacro %}{% call(_conversation=_conversation) m() %}{{ _conversation[0].output }}{% endcall %}',
|
||||
],
|
||||
['asyncEach iterable', '{% asyncEach turn in _conversation %}{{ turn.output }}{% endeach %}'],
|
||||
['asyncAll iterable', '{% asyncAll turn in _conversation %}{{ turn.output }}{% endall %}'],
|
||||
])('detects a real reference in %s', (_label, template) => {
|
||||
expect(templateReferencesVariable(template, '_conversation')).toBe(true);
|
||||
});
|
||||
|
||||
it.each<[string, string]>([
|
||||
['substring in plain text', 'Summarize the pre_conversation_context for {{ question }}'],
|
||||
['substring in symbol name', '{{ pre_conversation_context }}'],
|
||||
['string literal', '{{ "_conversation" }}'],
|
||||
['comment mention', '{# _conversation #}{{ question }}'],
|
||||
['property of another object', '{{ foo._conversation }}'],
|
||||
['bracket lookup on another object', '{{ foo["_conversation"] }}'],
|
||||
['dict literal key', '{{ {_conversation: input} }}'],
|
||||
['filter name', '{{ input | _conversation }}'],
|
||||
['is test name', '{{ input is _conversation }}'],
|
||||
['is test function name', '{{ input is _conversation(foo) }}'],
|
||||
['raw block', '{% raw %}{{ _conversation }}{% endraw %}'],
|
||||
['for-loop target shadow', '{% for _conversation in items %}{{ _conversation }}{% endfor %}'],
|
||||
['macro argument shadow', '{% macro render(_conversation) %}{{ _conversation }}{% endmacro %}'],
|
||||
['set value shadow', '{% set _conversation = [] %}{{ _conversation }}'],
|
||||
['set block shadow', '{% set _conversation %}local{% endset %}{{ _conversation }}'],
|
||||
[
|
||||
'set inside loop body',
|
||||
'{% for i in range(3) %}{% set _conversation = [] %}{{ _conversation }}{% endfor %}',
|
||||
],
|
||||
['import target shadow', '{% import "x.njk" as _conversation %}{{ _conversation }}'],
|
||||
['from-import alias binding', '{% from "x.njk" import foo as _conversation %}{{ question }}'],
|
||||
['block name label', '{% block _conversation %}hi{% endblock %}'],
|
||||
[
|
||||
'for-loop tuple target shadow',
|
||||
'{% for k, _conversation in items %}{{ _conversation }}{% endfor %}',
|
||||
],
|
||||
[
|
||||
'nested for target shadow',
|
||||
'{% for _conversation in outer %}{% for x in _conversation %}{{ _conversation }}{% endfor %}{% endfor %}',
|
||||
],
|
||||
['set tuple target shadow', '{% set a, _conversation = pair %}{{ _conversation }}'],
|
||||
[
|
||||
'macro keyword-default param name shadow',
|
||||
'{% macro render(_conversation=something) %}{{ _conversation }}{% endmacro %}',
|
||||
],
|
||||
[
|
||||
'macro keyword default left-to-right shadow',
|
||||
'{% macro render(_conversation=[], history=_conversation) %}{{ history | length }}{% endmacro %}{{ render() }}',
|
||||
],
|
||||
[
|
||||
'macro positional param used by keyword default',
|
||||
'{% macro render(_conversation, history=_conversation) %}{{ history }}{% endmacro %}',
|
||||
],
|
||||
[
|
||||
'call block caller argument shadow',
|
||||
'{% macro m() %}{{ caller("local") }}{% endmacro %}{% call(_conversation) m() %}{{ _conversation }}{% endcall %}',
|
||||
],
|
||||
[
|
||||
'call block caller keyword default left-to-right shadow',
|
||||
'{% macro m() %}{{ caller() }}{% endmacro %}{% call(_conversation=[], history=_conversation) m() %}{{ history | length }}{% endcall %}',
|
||||
],
|
||||
[
|
||||
'asyncEach target shadow',
|
||||
'{% asyncEach _conversation in items %}{{ _conversation }}{% endeach %}',
|
||||
],
|
||||
[
|
||||
'asyncAll target shadow',
|
||||
'{% asyncAll _conversation in items %}{{ _conversation }}{% endall %}',
|
||||
],
|
||||
])('ignores %s', (_label, template) => {
|
||||
expect(templateReferencesVariable(template, '_conversation')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles from-import aliases without shadowing source names', () => {
|
||||
expect(templateReferencesVariable('{% from "x.njk" import foo %}{{ foo }}', 'foo')).toBe(false);
|
||||
expect(
|
||||
templateReferencesVariable('{% from "x.njk" import foo as helper %}{{ helper }}', 'helper'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
templateReferencesVariable('{% from "x.njk" import foo as helper %}{{ foo }}', 'foo'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
templateReferencesVariable(
|
||||
'{% from templateName import foo as helper %}{{ helper }}',
|
||||
'templateName',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty variable names', () => {
|
||||
expect(templateReferencesVariable('{{ _conversation }}', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for templates that do not mention the variable at all', () => {
|
||||
expect(templateReferencesVariable('{{ question }}', '_conversation')).toBe(false);
|
||||
expect(templateReferencesVariable('', '_conversation')).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to a conservative match when the template fails to parse', () => {
|
||||
// Old substring-based code would force serial execution here; the new
|
||||
// AST-based code must preserve that safety envelope when parsing fails
|
||||
// rather than silently returning false.
|
||||
expect(templateReferencesVariable('{{ _conversation', '_conversation')).toBe(true);
|
||||
expect(templateReferencesVariable('{{ _conversation[0].output {% if %}', '_conversation')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(templateReferencesVariable('{{ _conversation }}{% endif %}', '_conversation')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw on malformed templates', () => {
|
||||
expect(() => templateReferencesVariable('{{ _conversation', '_conversation')).not.toThrow();
|
||||
expect(() => templateReferencesVariable('{% for %}', '_conversation')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeTemplateReference', () => {
|
||||
it('reports parsed=true when the template parses', () => {
|
||||
expect(analyzeTemplateReference('{{ _conversation }}', '_conversation')).toEqual({
|
||||
referenced: true,
|
||||
parsed: true,
|
||||
});
|
||||
expect(analyzeTemplateReference('{{ question }}', '_conversation')).toEqual({
|
||||
referenced: false,
|
||||
parsed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports parsed=false and conservative referenced=true on parse failure', () => {
|
||||
expect(analyzeTemplateReference('{{ _conversation', '_conversation')).toEqual({
|
||||
referenced: true,
|
||||
parsed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports parsed=true with referenced=false when the variable is not mentioned', () => {
|
||||
// The textual fast path returns parsed=true because nothing was actually parsed
|
||||
// — no parse failure to surface.
|
||||
expect(analyzeTemplateReference('{{ question }}', '_conversation')).toEqual({
|
||||
referenced: false,
|
||||
parsed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNunjucksEngine', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockProcessEnv({ ...originalEnv }, { clear: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockProcessEnv(originalEnv, { clear: true });
|
||||
});
|
||||
|
||||
it('should return a nunjucks environment by default', () => {
|
||||
const engine = getNunjucksEngine();
|
||||
expect(engine).toBeInstanceOf(nunjucks.Environment);
|
||||
expect(engine.renderString('Hello {{ name }}', { name: 'World' })).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('should return a simple render function when PROMPTFOO_DISABLE_TEMPLATING is set', () => {
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
|
||||
const engine = getNunjucksEngine();
|
||||
expect(engine.renderString('Hello {{ name }}', { name: 'World' })).toBe('Hello {{ name }}');
|
||||
});
|
||||
|
||||
it('should return a nunjucks environment when isGrader is true, regardless of PROMPTFOO_DISABLE_TEMPLATING', () => {
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
|
||||
const engine = getNunjucksEngine({}, false, true);
|
||||
expect(engine).toBeInstanceOf(nunjucks.Environment);
|
||||
expect(engine.renderString('Hello {{ name }}', { name: 'Grader' })).toBe('Hello Grader');
|
||||
});
|
||||
|
||||
it('should use nunjucks when isGrader is true, even if PROMPTFOO_DISABLE_TEMPLATING is set', () => {
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
|
||||
const engine = getNunjucksEngine({}, false, true);
|
||||
expect(engine).toBeInstanceOf(nunjucks.Environment);
|
||||
expect(engine.renderString('Hello {{ name }}', { name: 'Grader' })).toBe('Hello Grader');
|
||||
});
|
||||
|
||||
it('should add custom filters when provided', () => {
|
||||
const customFilters = {
|
||||
uppercase: (str: string) => str.toUpperCase(),
|
||||
add: (a: number, b: number) => (a + b).toString(),
|
||||
};
|
||||
const engine = getNunjucksEngine(customFilters);
|
||||
expect(engine.renderString('{{ "hello" | uppercase }}', {})).toBe('HELLO');
|
||||
expect(engine.renderString('{{ 5 | add(3) }}', {})).toBe('8');
|
||||
});
|
||||
|
||||
it('should add built-in load filter for JSON parsing', () => {
|
||||
const engine = getNunjucksEngine();
|
||||
const jsonString = '{"name": "test", "value": 42}';
|
||||
const template = `{{ '${jsonString}' | load }}`;
|
||||
const result = engine.renderString(template, {});
|
||||
expect(result).toBe('[object Object]');
|
||||
|
||||
// Test that the filter actually parses JSON correctly
|
||||
const jsonData = '{"key": "value"}';
|
||||
const loadFilter = engine.getFilter('load');
|
||||
expect(loadFilter).toBeDefined();
|
||||
expect(loadFilter(jsonData)).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('should handle load filter with invalid JSON', () => {
|
||||
const engine = getNunjucksEngine();
|
||||
const loadFilter = engine.getFilter('load');
|
||||
expect(loadFilter).toBeDefined();
|
||||
expect(() => loadFilter('invalid json')).toThrow('Unexpected token');
|
||||
});
|
||||
|
||||
it('should add environment variables as globals under "env"', () => {
|
||||
mockProcessEnv({ TEST_VAR: 'test_value' });
|
||||
const engine = getNunjucksEngine();
|
||||
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('test_value');
|
||||
});
|
||||
|
||||
it('should throw an error when throwOnUndefined is true and a variable is undefined', () => {
|
||||
const engine = getNunjucksEngine({}, true);
|
||||
expect(() => {
|
||||
engine.renderString('{{ undefined_var }}', {});
|
||||
}).toThrow(/attempted to output null or undefined value/);
|
||||
});
|
||||
|
||||
it('should not throw an error when throwOnUndefined is false and a variable is undefined', () => {
|
||||
const engine = getNunjucksEngine({}, false);
|
||||
expect(() => {
|
||||
engine.renderString('{{ undefined_var }}', {});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should respect all parameters when provided', () => {
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
|
||||
const customFilters = {
|
||||
double: (n: number) => (n * 2).toString(),
|
||||
};
|
||||
const engine = getNunjucksEngine(customFilters, true, true);
|
||||
expect(engine).toBeInstanceOf(nunjucks.Environment);
|
||||
expect(engine.renderString('{{ 5 | double }}', {})).toBe('10');
|
||||
expect(() => {
|
||||
engine.renderString('{{ undefined_var }}', {});
|
||||
}).toThrow(/attempted to output null or undefined value/);
|
||||
});
|
||||
|
||||
describe('environment variables as globals', () => {
|
||||
it('should add environment variables as globals by default', () => {
|
||||
mockProcessEnv({ TEST_VAR: 'test_value' });
|
||||
const engine = getNunjucksEngine();
|
||||
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('test_value');
|
||||
});
|
||||
|
||||
it('should merge cliState.config.env with process.env', () => {
|
||||
const initialConfig = { ...cliState.config };
|
||||
|
||||
cliState.config = {
|
||||
env: {
|
||||
PROCESS_VAR: 'overridden_value',
|
||||
CONFIG_VAR: 'config_value',
|
||||
},
|
||||
};
|
||||
const engine = getNunjucksEngine();
|
||||
const rendered = engine.renderString('{{ env.PROCESS_VAR }}', {});
|
||||
expect(rendered).toBe('overridden_value');
|
||||
expect(engine.renderString('{{ env.CONFIG_VAR }}', {})).toBe('config_value');
|
||||
cliState.config = initialConfig;
|
||||
});
|
||||
|
||||
it('should handle undefined cliState.config', () => {
|
||||
mockProcessEnv({ TEST_VAR: 'test_value' });
|
||||
const engine = getNunjucksEngine();
|
||||
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('test_value');
|
||||
});
|
||||
|
||||
it('should handle undefined cliState.config.env', () => {
|
||||
mockProcessEnv({ TEST_VAR: 'test_value' });
|
||||
const engine = getNunjucksEngine();
|
||||
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('test_value');
|
||||
});
|
||||
|
||||
it('should disable process.env but allow config env variables when PROMPTFOO_DISABLE_TEMPLATE_ENV_VARS is true', () => {
|
||||
mockProcessEnv({ TEST_VAR: 'test_value' });
|
||||
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATE_ENV_VARS: 'true' });
|
||||
const initialConfig = { ...cliState.config };
|
||||
cliState.config = {
|
||||
env: {
|
||||
CONFIG_VAR: 'config_value',
|
||||
},
|
||||
};
|
||||
const engine = getNunjucksEngine();
|
||||
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('');
|
||||
expect(engine.renderString('{{ env.CONFIG_VAR }}', {})).toBe('config_value');
|
||||
cliState.config = initialConfig;
|
||||
});
|
||||
|
||||
it('should disable process.env but allow config env variables when PROMPTFOO_SELF_HOSTED is true', () => {
|
||||
mockProcessEnv({ TEST_VAR: 'test_value' });
|
||||
mockProcessEnv({ PROMPTFOO_SELF_HOSTED: 'true' });
|
||||
const initialConfig = { ...cliState.config };
|
||||
cliState.config = {
|
||||
env: {
|
||||
CONFIG_VAR: 'config_value',
|
||||
},
|
||||
};
|
||||
const engine = getNunjucksEngine();
|
||||
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('');
|
||||
expect(engine.renderString('{{ env.CONFIG_VAR }}', {})).toBe('config_value');
|
||||
cliState.config = initialConfig;
|
||||
});
|
||||
|
||||
it('should handle empty config env object', () => {
|
||||
mockProcessEnv({ TEST_VAR: 'test_value' });
|
||||
const initialConfig = { ...cliState.config };
|
||||
cliState.config = {
|
||||
env: {},
|
||||
};
|
||||
const engine = getNunjucksEngine();
|
||||
expect(engine.renderString('{{ env.TEST_VAR }}', {})).toBe('test_value');
|
||||
cliState.config = initialConfig;
|
||||
});
|
||||
|
||||
it('should prioritize config env variables over process.env when both exist', () => {
|
||||
mockProcessEnv({ SHARED_VAR: 'process_value' });
|
||||
const initialConfig = { ...cliState.config };
|
||||
cliState.config = {
|
||||
env: {
|
||||
SHARED_VAR: 'config_value',
|
||||
},
|
||||
};
|
||||
const engine = getNunjucksEngine();
|
||||
expect(engine.renderString('{{ env.SHARED_VAR }}', {})).toBe('config_value');
|
||||
cliState.config = initialConfig;
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ellipsize } from '../../src/util/text';
|
||||
|
||||
describe('ellipsize', () => {
|
||||
it('should not modify string shorter than maxLen', () => {
|
||||
const str = 'hello';
|
||||
expect(ellipsize(str, 10)).toBe('hello');
|
||||
});
|
||||
|
||||
it('should truncate string and add ellipsis when longer than maxLen', () => {
|
||||
const str = 'hello world';
|
||||
expect(ellipsize(str, 8)).toBe('hello...');
|
||||
});
|
||||
|
||||
it('should handle string equal to maxLen', () => {
|
||||
const str = 'hello';
|
||||
expect(ellipsize(str, 5)).toBe('hello');
|
||||
});
|
||||
|
||||
it('should handle very short maxLen', () => {
|
||||
const str = 'hello';
|
||||
expect(ellipsize(str, 4)).toBe('h...');
|
||||
});
|
||||
|
||||
it('should return an empty string for negative maxLen', () => {
|
||||
expect(ellipsize('hello', -1)).toBe('');
|
||||
expect(ellipsize('hello', -5)).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(ellipsize('', 5)).toBe('');
|
||||
});
|
||||
|
||||
it('should hard-truncate when maxLen is less than 3', () => {
|
||||
// maxLen < 3 has no room for "..."; the result must still respect maxLen
|
||||
// (previously slice(0, maxLen - 3) used a negative index and overflowed).
|
||||
expect(ellipsize('hello', 2)).toBe('he');
|
||||
expect(ellipsize('hello', 1)).toBe('h');
|
||||
expect(ellipsize('hello', 0)).toBe('');
|
||||
expect(ellipsize('hello', 3)).toBe('...');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import logger from '../../src/logger';
|
||||
import { TokenUsageTracker } from '../../src/util/tokenUsage';
|
||||
|
||||
import type { TokenUsage } from '../../src/types/shared';
|
||||
|
||||
describe('TokenUsageTracker', () => {
|
||||
it('redacts URL credentials in provider ids when logging tracked usage', () => {
|
||||
// The debug message string is the only redaction layer for provider ids —
|
||||
// logger sanitizes context objects, never message strings.
|
||||
const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => logger);
|
||||
try {
|
||||
TokenUsageTracker.getInstance().trackUsage(
|
||||
'https://api.example.com/v1?api_key=sk-12345678901234567890 (HttpProvider)',
|
||||
{ total: 1 },
|
||||
);
|
||||
const messages = debugSpy.mock.calls.map((call) => String(call[0])).join('\n');
|
||||
expect(messages).toContain('api_key=%5BREDACTED%5D');
|
||||
expect(messages).toContain('(HttpProvider)');
|
||||
expect(messages).not.toContain('sk-12345678901234567890');
|
||||
} finally {
|
||||
debugSpy.mockRestore();
|
||||
TokenUsageTracker.getInstance().resetAllUsage();
|
||||
}
|
||||
});
|
||||
|
||||
let tracker: TokenUsageTracker;
|
||||
|
||||
beforeEach(() => {
|
||||
tracker = TokenUsageTracker.getInstance();
|
||||
tracker.resetAllUsage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
tracker.cleanup();
|
||||
});
|
||||
|
||||
it('should track token usage for a provider', () => {
|
||||
const usage: TokenUsage = {
|
||||
total: 100,
|
||||
prompt: 50,
|
||||
completion: 50,
|
||||
cached: 10,
|
||||
numRequests: 1,
|
||||
completionDetails: {
|
||||
reasoning: 20,
|
||||
acceptedPrediction: 15,
|
||||
rejectedPrediction: 5,
|
||||
},
|
||||
assertions: {
|
||||
total: 30,
|
||||
prompt: 10,
|
||||
completion: 15,
|
||||
cached: 5,
|
||||
},
|
||||
};
|
||||
|
||||
tracker.trackUsage('test-provider', usage);
|
||||
const tracked = tracker.getProviderUsage('test-provider');
|
||||
|
||||
expect(tracked).toEqual({
|
||||
...usage,
|
||||
completionDetails: {
|
||||
reasoning: 20,
|
||||
acceptedPrediction: 15,
|
||||
rejectedPrediction: 5,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
assertions: {
|
||||
...usage.assertions,
|
||||
numRequests: 0,
|
||||
completionDetails: {
|
||||
reasoning: 0,
|
||||
acceptedPrediction: 0,
|
||||
rejectedPrediction: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined token usage', () => {
|
||||
tracker.trackUsage('test-provider', undefined);
|
||||
expect(tracker.getProviderUsage('test-provider')).toEqual(
|
||||
expect.objectContaining({ numRequests: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge token usage for the same provider', () => {
|
||||
const usage1: TokenUsage = {
|
||||
total: 100,
|
||||
prompt: 50,
|
||||
completion: 50,
|
||||
cached: 10,
|
||||
numRequests: 1,
|
||||
completionDetails: {
|
||||
reasoning: 20,
|
||||
acceptedPrediction: 15,
|
||||
rejectedPrediction: 5,
|
||||
},
|
||||
assertions: {
|
||||
total: 30,
|
||||
prompt: 10,
|
||||
completion: 15,
|
||||
cached: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const usage2: TokenUsage = {
|
||||
total: 200,
|
||||
prompt: 100,
|
||||
completion: 100,
|
||||
cached: 20,
|
||||
numRequests: 2,
|
||||
completionDetails: {
|
||||
reasoning: 40,
|
||||
acceptedPrediction: 30,
|
||||
rejectedPrediction: 10,
|
||||
},
|
||||
assertions: {
|
||||
total: 60,
|
||||
prompt: 20,
|
||||
completion: 30,
|
||||
cached: 10,
|
||||
},
|
||||
};
|
||||
|
||||
tracker.trackUsage('test-provider', usage1);
|
||||
tracker.trackUsage('test-provider', usage2);
|
||||
|
||||
const merged = tracker.getProviderUsage('test-provider');
|
||||
expect(merged).toEqual({
|
||||
total: 300,
|
||||
prompt: 150,
|
||||
completion: 150,
|
||||
cached: 30,
|
||||
numRequests: 3,
|
||||
completionDetails: {
|
||||
reasoning: 60,
|
||||
acceptedPrediction: 45,
|
||||
rejectedPrediction: 15,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
assertions: {
|
||||
total: 90,
|
||||
prompt: 30,
|
||||
completion: 45,
|
||||
cached: 15,
|
||||
numRequests: 0,
|
||||
completionDetails: {
|
||||
reasoning: 0,
|
||||
acceptedPrediction: 0,
|
||||
rejectedPrediction: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should get provider IDs', () => {
|
||||
tracker.trackUsage('provider1', { total: 100 });
|
||||
tracker.trackUsage('provider2', { total: 200 });
|
||||
|
||||
expect(tracker.getProviderIds()).toEqual(['provider1', 'provider2']);
|
||||
});
|
||||
|
||||
it('should get total usage across all providers', () => {
|
||||
tracker.trackUsage('provider1', {
|
||||
total: 100,
|
||||
prompt: 50,
|
||||
completion: 50,
|
||||
cached: 10,
|
||||
numRequests: 1,
|
||||
completionDetails: {
|
||||
reasoning: 20,
|
||||
acceptedPrediction: 15,
|
||||
rejectedPrediction: 5,
|
||||
},
|
||||
assertions: {
|
||||
total: 30,
|
||||
prompt: 10,
|
||||
completion: 15,
|
||||
cached: 5,
|
||||
},
|
||||
});
|
||||
|
||||
tracker.trackUsage('provider2', {
|
||||
total: 200,
|
||||
prompt: 100,
|
||||
completion: 100,
|
||||
cached: 20,
|
||||
numRequests: 2,
|
||||
completionDetails: {
|
||||
reasoning: 40,
|
||||
acceptedPrediction: 30,
|
||||
rejectedPrediction: 10,
|
||||
},
|
||||
assertions: {
|
||||
total: 60,
|
||||
prompt: 20,
|
||||
completion: 30,
|
||||
cached: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(tracker.getTotalUsage()).toEqual({
|
||||
total: 300,
|
||||
prompt: 150,
|
||||
completion: 150,
|
||||
cached: 30,
|
||||
numRequests: 3,
|
||||
completionDetails: {
|
||||
reasoning: 60,
|
||||
acceptedPrediction: 45,
|
||||
rejectedPrediction: 15,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
assertions: {
|
||||
total: 90,
|
||||
prompt: 30,
|
||||
completion: 45,
|
||||
cached: 15,
|
||||
numRequests: 0,
|
||||
completionDetails: {
|
||||
reasoning: 0,
|
||||
acceptedPrediction: 0,
|
||||
rejectedPrediction: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset provider usage', () => {
|
||||
tracker.trackUsage('provider1', { total: 100 });
|
||||
tracker.resetProviderUsage('provider1');
|
||||
expect(tracker.getProviderUsage('provider1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reset all usage', () => {
|
||||
tracker.trackUsage('provider1', { total: 100 });
|
||||
tracker.trackUsage('provider2', { total: 200 });
|
||||
tracker.resetAllUsage();
|
||||
expect(tracker.getProviderIds()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,673 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { TokenUsageTracker } from '../../src/util/tokenUsage';
|
||||
import {
|
||||
aggregateUsageFromSpans,
|
||||
extractUsageFromSpan,
|
||||
getTokenUsage,
|
||||
getTokenUsageByProvider,
|
||||
getTokenUsageByTestIndex,
|
||||
getTokenUsageFromEvaluation,
|
||||
getTokenUsageFromTrace,
|
||||
} from '../../src/util/tokenUsageCompat';
|
||||
|
||||
import type { SpanData } from '../../src/tracing/store';
|
||||
import type { TraceData } from '../../src/types/tracing';
|
||||
|
||||
// Mock the TraceStore
|
||||
vi.mock('../../src/tracing/store', () => ({
|
||||
getTraceStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the TokenUsageTracker
|
||||
vi.mock('../../src/util/tokenUsage', () => ({
|
||||
TokenUsageTracker: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { getTraceStore } from '../../src/tracing/store';
|
||||
|
||||
describe('tokenUsageCompat', () => {
|
||||
type TraceStoreType = ReturnType<typeof getTraceStore>;
|
||||
type TokenUsageTrackerInstance = ReturnType<typeof TokenUsageTracker.getInstance>;
|
||||
|
||||
const mockTraceStore = {
|
||||
getSpans: vi.fn<TraceStoreType['getSpans']>(),
|
||||
getTracesByEvaluation: vi.fn<TraceStoreType['getTracesByEvaluation']>(),
|
||||
};
|
||||
|
||||
const mockTracker = {
|
||||
getProviderUsage: vi.fn<TokenUsageTrackerInstance['getProviderUsage']>(),
|
||||
getTotalUsage: vi.fn<TokenUsageTrackerInstance['getTotalUsage']>(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getTraceStore).mockReturnValue(mockTraceStore as unknown as TraceStoreType);
|
||||
vi.mocked(TokenUsageTracker.getInstance).mockReturnValue(
|
||||
mockTracker as unknown as TokenUsageTrackerInstance,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('extractUsageFromSpan', () => {
|
||||
it('should return undefined for spans without attributes', () => {
|
||||
const span: SpanData = {
|
||||
spanId: 'span-1',
|
||||
name: 'test',
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
expect(extractUsageFromSpan(span)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for spans without GenAI usage attributes', () => {
|
||||
const span: SpanData = {
|
||||
spanId: 'span-1',
|
||||
name: 'test',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.system': 'openai',
|
||||
'gen_ai.request.model': 'gpt-4',
|
||||
},
|
||||
};
|
||||
|
||||
expect(extractUsageFromSpan(span)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should extract input tokens', () => {
|
||||
const span: SpanData = {
|
||||
spanId: 'span-1',
|
||||
name: 'test',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
},
|
||||
};
|
||||
|
||||
const usage = extractUsageFromSpan(span);
|
||||
expect(usage).toEqual({
|
||||
numRequests: 1,
|
||||
prompt: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract output tokens', () => {
|
||||
const span: SpanData = {
|
||||
spanId: 'span-1',
|
||||
name: 'test',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
},
|
||||
};
|
||||
|
||||
const usage = extractUsageFromSpan(span);
|
||||
expect(usage).toEqual({
|
||||
numRequests: 1,
|
||||
completion: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract all standard usage attributes', () => {
|
||||
const span: SpanData = {
|
||||
spanId: 'span-1',
|
||||
name: 'test',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
'gen_ai.usage.cached_tokens': 25,
|
||||
},
|
||||
};
|
||||
|
||||
const usage = extractUsageFromSpan(span);
|
||||
expect(usage).toEqual({
|
||||
numRequests: 1,
|
||||
prompt: 100,
|
||||
completion: 50,
|
||||
total: 150,
|
||||
cached: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract completion details attributes', () => {
|
||||
const span: SpanData = {
|
||||
spanId: 'span-1',
|
||||
name: 'test',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
'gen_ai.usage.reasoning_tokens': 20,
|
||||
'gen_ai.usage.accepted_prediction_tokens': 10,
|
||||
'gen_ai.usage.rejected_prediction_tokens': 5,
|
||||
},
|
||||
};
|
||||
|
||||
const usage = extractUsageFromSpan(span);
|
||||
expect(usage).toEqual({
|
||||
numRequests: 1,
|
||||
prompt: 100,
|
||||
completion: 50,
|
||||
total: 150,
|
||||
completionDetails: {
|
||||
reasoning: 20,
|
||||
acceptedPrediction: 10,
|
||||
rejectedPrediction: 5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract cache token completion details attributes', () => {
|
||||
const span: SpanData = {
|
||||
spanId: 'span-1',
|
||||
name: 'test',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
'gen_ai.usage.cache_read_input_tokens': 200,
|
||||
'gen_ai.usage.cache_creation_input_tokens': 30,
|
||||
},
|
||||
};
|
||||
|
||||
const usage = extractUsageFromSpan(span);
|
||||
expect(usage).toEqual({
|
||||
numRequests: 1,
|
||||
prompt: 100,
|
||||
completion: 50,
|
||||
total: 150,
|
||||
completionDetails: {
|
||||
cacheReadInputTokens: 200,
|
||||
cacheCreationInputTokens: 30,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore non-numeric attribute values', () => {
|
||||
const span: SpanData = {
|
||||
spanId: 'span-1',
|
||||
name: 'test',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 'not-a-number',
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
},
|
||||
};
|
||||
|
||||
const usage = extractUsageFromSpan(span);
|
||||
expect(usage).toEqual({
|
||||
numRequests: 1,
|
||||
completion: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateUsageFromSpans', () => {
|
||||
it('should return empty usage for empty spans array', () => {
|
||||
const result = aggregateUsageFromSpans([]);
|
||||
expect(result).toEqual({
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
total: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: {
|
||||
reasoning: 0,
|
||||
acceptedPrediction: 0,
|
||||
rejectedPrediction: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
assertions: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: {
|
||||
reasoning: 0,
|
||||
acceptedPrediction: 0,
|
||||
rejectedPrediction: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should aggregate usage from multiple spans', () => {
|
||||
const spans: SpanData[] = [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
spanId: 'span-2',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 1000,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 200,
|
||||
'gen_ai.usage.output_tokens': 75,
|
||||
'gen_ai.usage.total_tokens': 275,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateUsageFromSpans(spans);
|
||||
expect(result.prompt).toBe(300);
|
||||
expect(result.completion).toBe(125);
|
||||
expect(result.total).toBe(425);
|
||||
expect(result.numRequests).toBe(2);
|
||||
});
|
||||
|
||||
it('should skip spans without usage attributes', () => {
|
||||
const spans: SpanData[] = [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'internal-span',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'some.other.attribute': 'value',
|
||||
},
|
||||
},
|
||||
{
|
||||
spanId: 'span-2',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 1000,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateUsageFromSpans(spans);
|
||||
expect(result.prompt).toBe(100);
|
||||
expect(result.completion).toBe(50);
|
||||
expect(result.total).toBe(150);
|
||||
expect(result.numRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('should aggregate completion details from multiple spans', () => {
|
||||
const spans: SpanData[] = [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'chat o1',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
'gen_ai.usage.reasoning_tokens': 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
spanId: 'span-2',
|
||||
name: 'chat o1',
|
||||
startTime: 1000,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
'gen_ai.usage.reasoning_tokens': 30,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregateUsageFromSpans(spans);
|
||||
expect(result.completionDetails?.reasoning).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenUsageFromTrace', () => {
|
||||
it('should fetch spans and aggregate usage', async () => {
|
||||
const spans: SpanData[] = [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockTraceStore.getSpans.mockResolvedValue(spans);
|
||||
|
||||
const result = await getTokenUsageFromTrace('trace-123');
|
||||
|
||||
expect(mockTraceStore.getSpans).toHaveBeenCalledWith('trace-123', {
|
||||
sanitizeAttributes: false,
|
||||
includeInternalSpans: true,
|
||||
});
|
||||
expect(result.prompt).toBe(100);
|
||||
expect(result.completion).toBe(50);
|
||||
expect(result.total).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenUsageFromEvaluation', () => {
|
||||
it('should aggregate usage from all traces in evaluation', async () => {
|
||||
const traces: TraceData[] = [
|
||||
{
|
||||
traceId: 'trace-1',
|
||||
evaluationId: 'eval-456',
|
||||
testCaseId: 'test-1',
|
||||
spans: [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
traceId: 'trace-2',
|
||||
evaluationId: 'eval-456',
|
||||
testCaseId: 'test-2',
|
||||
spans: [
|
||||
{
|
||||
spanId: 'span-2',
|
||||
name: 'chat claude-3',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 200,
|
||||
'gen_ai.usage.output_tokens': 100,
|
||||
'gen_ai.usage.total_tokens': 300,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTraceStore.getTracesByEvaluation.mockResolvedValue(traces);
|
||||
|
||||
const result = await getTokenUsageFromEvaluation('eval-456');
|
||||
|
||||
expect(mockTraceStore.getTracesByEvaluation).toHaveBeenCalledWith('eval-456', {
|
||||
sanitizeAttributes: false,
|
||||
});
|
||||
expect(result.prompt).toBe(300);
|
||||
expect(result.completion).toBe(150);
|
||||
expect(result.total).toBe(450);
|
||||
expect(result.numRequests).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenUsage', () => {
|
||||
it('should use OTEL data when traceId is provided', async () => {
|
||||
const spans: SpanData[] = [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockTraceStore.getSpans.mockResolvedValue(spans);
|
||||
|
||||
const result = await getTokenUsage({ traceId: 'trace-123' });
|
||||
|
||||
expect(mockTraceStore.getSpans).toHaveBeenCalled();
|
||||
expect(result.prompt).toBe(100);
|
||||
});
|
||||
|
||||
it('should use OTEL data when evalId is provided', async () => {
|
||||
const traces: TraceData[] = [
|
||||
{
|
||||
traceId: 'trace-1',
|
||||
evaluationId: 'eval-456',
|
||||
testCaseId: 'test-1',
|
||||
spans: [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTraceStore.getTracesByEvaluation.mockResolvedValue(traces);
|
||||
|
||||
const result = await getTokenUsage({ evalId: 'eval-456' });
|
||||
|
||||
expect(mockTraceStore.getTracesByEvaluation).toHaveBeenCalled();
|
||||
expect(result.prompt).toBe(100);
|
||||
});
|
||||
|
||||
it('should fall back to legacy tracker for providerId query', async () => {
|
||||
mockTracker.getProviderUsage.mockReturnValue({
|
||||
prompt: 500,
|
||||
completion: 200,
|
||||
total: 700,
|
||||
numRequests: 5,
|
||||
});
|
||||
|
||||
const result = await getTokenUsage({ providerId: 'openai:gpt-4' });
|
||||
|
||||
expect(mockTracker.getProviderUsage).toHaveBeenCalledWith('openai:gpt-4');
|
||||
expect(result.prompt).toBe(500);
|
||||
});
|
||||
|
||||
it('should fall back to legacy tracker getTotalUsage for empty query', async () => {
|
||||
mockTracker.getTotalUsage.mockReturnValue({
|
||||
prompt: 1000,
|
||||
completion: 500,
|
||||
total: 1500,
|
||||
numRequests: 10,
|
||||
});
|
||||
|
||||
const result = await getTokenUsage({});
|
||||
|
||||
expect(mockTracker.getTotalUsage).toHaveBeenCalled();
|
||||
expect(result.total).toBe(1500);
|
||||
});
|
||||
|
||||
it('should return empty usage when provider not found', async () => {
|
||||
mockTracker.getProviderUsage.mockReturnValue(undefined);
|
||||
|
||||
const result = await getTokenUsage({ providerId: 'unknown-provider' });
|
||||
|
||||
expect(result.numRequests).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenUsageByProvider', () => {
|
||||
it('should group usage by provider ID', async () => {
|
||||
const spans: SpanData[] = [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'promptfoo.provider.id': 'openai:gpt-4',
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
spanId: 'span-2',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 1000,
|
||||
attributes: {
|
||||
'promptfoo.provider.id': 'openai:gpt-4',
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
spanId: 'span-3',
|
||||
name: 'chat claude-3',
|
||||
startTime: 2000,
|
||||
attributes: {
|
||||
'promptfoo.provider.id': 'anthropic:claude-3-opus',
|
||||
'gen_ai.usage.input_tokens': 200,
|
||||
'gen_ai.usage.output_tokens': 100,
|
||||
'gen_ai.usage.total_tokens': 300,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockTraceStore.getSpans.mockResolvedValue(spans);
|
||||
|
||||
const result = await getTokenUsageByProvider('trace-123');
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get('openai:gpt-4')?.total).toBe(300);
|
||||
expect(result.get('openai:gpt-4')?.numRequests).toBe(2);
|
||||
expect(result.get('anthropic:claude-3-opus')?.total).toBe(300);
|
||||
expect(result.get('anthropic:claude-3-opus')?.numRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('should skip spans without provider ID', async () => {
|
||||
const spans: SpanData[] = [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'internal-span',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
spanId: 'span-2',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 1000,
|
||||
attributes: {
|
||||
'promptfoo.provider.id': 'openai:gpt-4',
|
||||
'gen_ai.usage.input_tokens': 200,
|
||||
'gen_ai.usage.output_tokens': 100,
|
||||
'gen_ai.usage.total_tokens': 300,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockTraceStore.getSpans.mockResolvedValue(spans);
|
||||
|
||||
const result = await getTokenUsageByProvider('trace-123');
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('openai:gpt-4')?.total).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenUsageByTestIndex', () => {
|
||||
it('should group usage by test index', async () => {
|
||||
const spans: SpanData[] = [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'promptfoo.test.index': 0,
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
spanId: 'span-2',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 1000,
|
||||
attributes: {
|
||||
'promptfoo.test.index': 0,
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
spanId: 'span-3',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 2000,
|
||||
attributes: {
|
||||
'promptfoo.test.index': 1,
|
||||
'gen_ai.usage.input_tokens': 200,
|
||||
'gen_ai.usage.output_tokens': 100,
|
||||
'gen_ai.usage.total_tokens': 300,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockTraceStore.getSpans.mockResolvedValue(spans);
|
||||
|
||||
const result = await getTokenUsageByTestIndex('trace-123');
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(0)?.total).toBe(300);
|
||||
expect(result.get(0)?.numRequests).toBe(2);
|
||||
expect(result.get(1)?.total).toBe(300);
|
||||
expect(result.get(1)?.numRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('should skip spans without test index', async () => {
|
||||
const spans: SpanData[] = [
|
||||
{
|
||||
spanId: 'span-1',
|
||||
name: 'synthesis-span',
|
||||
startTime: 0,
|
||||
attributes: {
|
||||
'gen_ai.usage.input_tokens': 100,
|
||||
'gen_ai.usage.output_tokens': 50,
|
||||
'gen_ai.usage.total_tokens': 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
spanId: 'span-2',
|
||||
name: 'chat gpt-4',
|
||||
startTime: 1000,
|
||||
attributes: {
|
||||
'promptfoo.test.index': 0,
|
||||
'gen_ai.usage.input_tokens': 200,
|
||||
'gen_ai.usage.output_tokens': 100,
|
||||
'gen_ai.usage.total_tokens': 300,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockTraceStore.getSpans.mockResolvedValue(spans);
|
||||
|
||||
const result = await getTokenUsageByTestIndex('trace-123');
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(0)?.total).toBe(300);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,425 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
accumulateGradingRequest,
|
||||
accumulateResponseTokenUsage,
|
||||
accumulateTokenUsage,
|
||||
createEmptyAssertions,
|
||||
createEmptyTokenUsage,
|
||||
normalizeTokenUsage,
|
||||
} from '../../src/util/tokenUsageUtils';
|
||||
|
||||
import type { TokenUsage } from '../../src/types/shared';
|
||||
|
||||
describe('tokenUsageUtils', () => {
|
||||
describe('createEmptyTokenUsage', () => {
|
||||
it('should create an empty token usage object with all fields initialized to zero', () => {
|
||||
const result = createEmptyTokenUsage();
|
||||
|
||||
expect(result).toEqual({
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
total: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: {
|
||||
reasoning: 0,
|
||||
acceptedPrediction: 0,
|
||||
rejectedPrediction: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
assertions: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: {
|
||||
reasoning: 0,
|
||||
acceptedPrediction: 0,
|
||||
rejectedPrediction: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return Required<TokenUsage> type', () => {
|
||||
const result = createEmptyTokenUsage();
|
||||
|
||||
// This test checks that all optional fields are actually present
|
||||
expect(result.prompt).toBeDefined();
|
||||
expect(result.completion).toBeDefined();
|
||||
expect(result.cached).toBeDefined();
|
||||
expect(result.total).toBeDefined();
|
||||
expect(result.numRequests).toBeDefined();
|
||||
expect(result.completionDetails).toBeDefined();
|
||||
expect(result.assertions).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accumulateTokenUsage', () => {
|
||||
it('should accumulate basic token fields', () => {
|
||||
const target: TokenUsage = createEmptyTokenUsage();
|
||||
const update = {
|
||||
prompt: 10,
|
||||
completion: 20,
|
||||
cached: 5,
|
||||
total: 30,
|
||||
};
|
||||
|
||||
accumulateTokenUsage(target, update);
|
||||
|
||||
expect(target.prompt).toBe(10);
|
||||
expect(target.completion).toBe(20);
|
||||
expect(target.cached).toBe(5);
|
||||
expect(target.total).toBe(30);
|
||||
});
|
||||
|
||||
it('should handle undefined update gracefully', () => {
|
||||
const target: TokenUsage = createEmptyTokenUsage();
|
||||
const originalTarget = { ...target };
|
||||
|
||||
accumulateTokenUsage(target, undefined);
|
||||
|
||||
expect(target).toEqual(originalTarget);
|
||||
});
|
||||
|
||||
it('should accumulate numRequests when provided', () => {
|
||||
const target: TokenUsage = createEmptyTokenUsage();
|
||||
|
||||
accumulateTokenUsage(target, { numRequests: 3 });
|
||||
expect(target.numRequests).toBe(3);
|
||||
|
||||
accumulateTokenUsage(target, { numRequests: 2 });
|
||||
expect(target.numRequests).toBe(5);
|
||||
});
|
||||
|
||||
it('should increment numRequests by 1 when incrementRequests is true and numRequests not provided', () => {
|
||||
const target: TokenUsage = createEmptyTokenUsage();
|
||||
|
||||
accumulateTokenUsage(target, { total: 10 }, true);
|
||||
expect(target.numRequests).toBe(1);
|
||||
|
||||
accumulateTokenUsage(target, { total: 5 }, true);
|
||||
expect(target.numRequests).toBe(2);
|
||||
});
|
||||
|
||||
it('should not increment numRequests when incrementRequests is false and numRequests not provided', () => {
|
||||
const target: TokenUsage = createEmptyTokenUsage();
|
||||
|
||||
accumulateTokenUsage(target, { total: 10 }, false);
|
||||
expect(target.numRequests).toBe(0);
|
||||
|
||||
accumulateTokenUsage(target, { total: 5 });
|
||||
expect(target.numRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('should accumulate completion details', () => {
|
||||
const target: TokenUsage = createEmptyTokenUsage();
|
||||
|
||||
accumulateTokenUsage(target, {
|
||||
completionDetails: {
|
||||
reasoning: 5,
|
||||
acceptedPrediction: 3,
|
||||
rejectedPrediction: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(target.completionDetails).toMatchObject({
|
||||
reasoning: 5,
|
||||
acceptedPrediction: 3,
|
||||
rejectedPrediction: 2,
|
||||
});
|
||||
|
||||
accumulateTokenUsage(target, {
|
||||
completionDetails: {
|
||||
reasoning: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(target.completionDetails).toMatchObject({
|
||||
reasoning: 15,
|
||||
acceptedPrediction: 3,
|
||||
rejectedPrediction: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should accumulate assertion tokens', () => {
|
||||
const target: TokenUsage = createEmptyTokenUsage();
|
||||
|
||||
accumulateTokenUsage(target, {
|
||||
assertions: {
|
||||
total: 10,
|
||||
prompt: 5,
|
||||
completion: 5,
|
||||
cached: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(target.assertions?.total).toBe(10);
|
||||
expect(target.assertions?.prompt).toBe(5);
|
||||
expect(target.assertions?.completion).toBe(5);
|
||||
expect(target.assertions?.cached).toBe(2);
|
||||
});
|
||||
|
||||
it('should accumulate assertion completion details', () => {
|
||||
const target: TokenUsage = createEmptyTokenUsage();
|
||||
|
||||
accumulateTokenUsage(target, {
|
||||
assertions: {
|
||||
completionDetails: {
|
||||
reasoning: 5,
|
||||
acceptedPrediction: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(target.assertions?.completionDetails).toMatchObject({
|
||||
reasoning: 5,
|
||||
acceptedPrediction: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing fields with undefined or 0', () => {
|
||||
const target: TokenUsage = {
|
||||
total: 10,
|
||||
// Other fields undefined
|
||||
};
|
||||
|
||||
accumulateTokenUsage(target, {
|
||||
prompt: 5,
|
||||
completion: 7,
|
||||
});
|
||||
|
||||
expect(target.total).toBe(10);
|
||||
expect(target.prompt).toBe(5);
|
||||
expect(target.completion).toBe(7);
|
||||
expect(target.cached).toBe(0); // addNumbers converts undefined to 0
|
||||
});
|
||||
});
|
||||
|
||||
describe('accumulateResponseTokenUsage', () => {
|
||||
it('should accumulate token usage from response with tokenUsage', () => {
|
||||
const target = createEmptyTokenUsage();
|
||||
const response = {
|
||||
tokenUsage: {
|
||||
total: 100,
|
||||
prompt: 60,
|
||||
completion: 40,
|
||||
numRequests: 1,
|
||||
},
|
||||
};
|
||||
|
||||
accumulateResponseTokenUsage(target, response);
|
||||
|
||||
expect(target.total).toBe(100);
|
||||
expect(target.prompt).toBe(60);
|
||||
expect(target.completion).toBe(40);
|
||||
expect(target.numRequests).toBe(1);
|
||||
});
|
||||
|
||||
it('should increment numRequests when response exists but has no tokenUsage', () => {
|
||||
const target = createEmptyTokenUsage();
|
||||
const response = {};
|
||||
|
||||
accumulateResponseTokenUsage(target, response);
|
||||
|
||||
expect(target.numRequests).toBe(1);
|
||||
expect(target.total).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle undefined response', () => {
|
||||
const target = createEmptyTokenUsage();
|
||||
|
||||
accumulateResponseTokenUsage(target, undefined);
|
||||
|
||||
expect(target.numRequests).toBe(0);
|
||||
expect(target.total).toBe(0);
|
||||
});
|
||||
|
||||
it('should accumulate multiple responses', () => {
|
||||
const target = createEmptyTokenUsage();
|
||||
|
||||
accumulateResponseTokenUsage(target, {
|
||||
tokenUsage: { total: 50, prompt: 30, completion: 20, numRequests: 1 },
|
||||
});
|
||||
accumulateResponseTokenUsage(target, {
|
||||
tokenUsage: { total: 30, prompt: 20, completion: 10, numRequests: 1 },
|
||||
});
|
||||
|
||||
expect(target.total).toBe(80);
|
||||
expect(target.prompt).toBe(50);
|
||||
expect(target.completion).toBe(30);
|
||||
expect(target.numRequests).toBe(2);
|
||||
});
|
||||
|
||||
it('should not increment numRequests when countAsRequest is false', () => {
|
||||
const target = createEmptyTokenUsage();
|
||||
|
||||
accumulateResponseTokenUsage(
|
||||
target,
|
||||
{
|
||||
tokenUsage: { total: 50, prompt: 30, completion: 20, numRequests: 1 },
|
||||
},
|
||||
{ countAsRequest: false },
|
||||
);
|
||||
|
||||
expect(target.total).toBe(50);
|
||||
expect(target.prompt).toBe(30);
|
||||
expect(target.completion).toBe(20);
|
||||
expect(target.numRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('should not increment numRequests from response-only entries when countAsRequest is false', () => {
|
||||
const target = createEmptyTokenUsage();
|
||||
|
||||
accumulateResponseTokenUsage(target, {}, { countAsRequest: false });
|
||||
|
||||
expect(target.total).toBe(0);
|
||||
expect(target.numRequests).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accumulateGradingRequest', () => {
|
||||
it('counts the request without token usage when the grader reports none', () => {
|
||||
const assertions = createEmptyAssertions();
|
||||
accumulateGradingRequest(assertions, undefined);
|
||||
|
||||
expect(assertions.numRequests).toBe(1);
|
||||
expect(assertions.total).toBe(0);
|
||||
});
|
||||
|
||||
it('counts the request and folds in reported assertion token usage', () => {
|
||||
const assertions = createEmptyAssertions();
|
||||
accumulateGradingRequest(assertions, { total: 9, prompt: 5, completion: 4, numRequests: 3 });
|
||||
|
||||
expect(assertions.numRequests).toBe(1);
|
||||
expect(assertions.total).toBe(9);
|
||||
expect(assertions.prompt).toBe(5);
|
||||
expect(assertions.completion).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTokenUsage', () => {
|
||||
it('should return fully populated TokenUsage with defaults for undefined input', () => {
|
||||
const result = normalizeTokenUsage(undefined);
|
||||
|
||||
expect(result).toEqual({
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: {
|
||||
reasoning: 0,
|
||||
acceptedPrediction: 0,
|
||||
rejectedPrediction: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
assertions: {
|
||||
total: 0,
|
||||
prompt: 0,
|
||||
completion: 0,
|
||||
cached: 0,
|
||||
numRequests: 0,
|
||||
completionDetails: {
|
||||
reasoning: 0,
|
||||
acceptedPrediction: 0,
|
||||
rejectedPrediction: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve provided values', () => {
|
||||
const result = normalizeTokenUsage({
|
||||
total: 100,
|
||||
prompt: 60,
|
||||
completion: 40,
|
||||
});
|
||||
|
||||
expect(result.total).toBe(100);
|
||||
expect(result.prompt).toBe(60);
|
||||
expect(result.completion).toBe(40);
|
||||
});
|
||||
|
||||
it('should fill in missing fields with defaults', () => {
|
||||
const result = normalizeTokenUsage({
|
||||
total: 50,
|
||||
});
|
||||
|
||||
expect(result.total).toBe(50);
|
||||
expect(result.prompt).toBe(0);
|
||||
expect(result.completion).toBe(0);
|
||||
expect(result.cached).toBe(0);
|
||||
expect(result.numRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve completionDetails if provided', () => {
|
||||
const result = normalizeTokenUsage({
|
||||
completionDetails: {
|
||||
reasoning: 10,
|
||||
acceptedPrediction: 5,
|
||||
rejectedPrediction: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.completionDetails).toEqual({
|
||||
reasoning: 10,
|
||||
acceptedPrediction: 5,
|
||||
rejectedPrediction: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve assertions if provided', () => {
|
||||
const result = normalizeTokenUsage({
|
||||
assertions: {
|
||||
total: 20,
|
||||
prompt: 10,
|
||||
completion: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.assertions.total).toBe(20);
|
||||
expect(result.assertions.prompt).toBe(10);
|
||||
expect(result.assertions.completion).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const result = normalizeTokenUsage({});
|
||||
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.prompt).toBe(0);
|
||||
expect(result.completion).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle partial completionDetails', () => {
|
||||
const result = normalizeTokenUsage({
|
||||
completionDetails: {
|
||||
reasoning: 5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.completionDetails.reasoning).toBe(5);
|
||||
// Other fields may be undefined or 0 depending on source
|
||||
});
|
||||
|
||||
it('should return Required<TokenUsage> type', () => {
|
||||
const result = normalizeTokenUsage({ total: 10 });
|
||||
|
||||
// TypeScript compile-time check: all fields should be non-optional
|
||||
expect(result.total).toBeDefined();
|
||||
expect(result.prompt).toBeDefined();
|
||||
expect(result.completion).toBeDefined();
|
||||
expect(result.cached).toBeDefined();
|
||||
expect(result.numRequests).toBeDefined();
|
||||
expect(result.completionDetails).toBeDefined();
|
||||
expect(result.assertions).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,608 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { importModule } from '../../src/esm';
|
||||
import logger from '../../src/logger';
|
||||
import { runPython } from '../../src/python/pythonUtils';
|
||||
import {
|
||||
FILE_TRANSFORM_LABEL,
|
||||
getTransformErrorMessage,
|
||||
getTransformLabel,
|
||||
INLINE_FUNCTION_LABEL,
|
||||
INLINE_STRING_LABEL,
|
||||
TransformInputType,
|
||||
transform,
|
||||
} from '../../src/util/transform';
|
||||
|
||||
import type { TransformFunction } from '../../src/types/transform';
|
||||
|
||||
vi.mock('../../src/esm', () => ({
|
||||
importModule: vi.fn(),
|
||||
getDirectory: vi.fn().mockReturnValue('/test/dir'),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/python/pythonUtils', () => ({
|
||||
runPython: vi.fn().mockImplementation(async (_filePath, _functionName, args) => {
|
||||
const [output] = args;
|
||||
return output.toUpperCase() + ' FROM PYTHON';
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
unlink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('glob', () => ({
|
||||
globSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/database', () => ({
|
||||
getDb: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedImportModule = vi.mocked(importModule);
|
||||
|
||||
describe('util', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('transform', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('transforms output using a direct function', async () => {
|
||||
const output = 'original output';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
const transformFunction = 'output.toUpperCase()';
|
||||
const transformedOutput = await transform(transformFunction, output, context);
|
||||
expect(transformedOutput).toBe('ORIGINAL OUTPUT');
|
||||
});
|
||||
|
||||
it('transforms vars using a direct function', async () => {
|
||||
const vars = { key: 'value' };
|
||||
const context = { vars: {}, prompt: { id: '123' } };
|
||||
const transformFunction = 'JSON.stringify(vars)';
|
||||
const transformedOutput = await transform(
|
||||
transformFunction,
|
||||
vars,
|
||||
context,
|
||||
true,
|
||||
TransformInputType.VARS,
|
||||
);
|
||||
expect(transformedOutput).toBe('{"key":"value"}');
|
||||
});
|
||||
|
||||
it('transforms output using an imported function from a file', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
mockedImportModule.mockResolvedValueOnce((output: string) => output.toUpperCase());
|
||||
|
||||
const transformFunctionPath = 'file://transform.js';
|
||||
const transformedOutput = await transform(transformFunctionPath, output, context);
|
||||
expect(transformedOutput).toBe('HELLO');
|
||||
});
|
||||
|
||||
it('transforms vars using a direct function from a file', async () => {
|
||||
const vars = { key: 'value' };
|
||||
const context = { vars: {}, prompt: {} };
|
||||
mockedImportModule.mockResolvedValueOnce((vars: any) => ({
|
||||
...vars,
|
||||
key: 'transformed',
|
||||
}));
|
||||
const transformFunctionPath = 'file://transform.js';
|
||||
const transformedOutput = await transform(
|
||||
transformFunctionPath,
|
||||
vars,
|
||||
context,
|
||||
true,
|
||||
TransformInputType.VARS,
|
||||
);
|
||||
expect(transformedOutput).toEqual({ key: 'transformed' });
|
||||
});
|
||||
|
||||
it('throws error if transform function does not return a value', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const transformFunction = ''; // Empty function, returns undefined
|
||||
await expect(transform(transformFunction, output, context)).rejects.toThrow(
|
||||
'Transform function did not return a value',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error if file does not export a function', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
mockedImportModule.mockResolvedValueOnce('banana');
|
||||
const transformFunctionPath = 'file://transform.js';
|
||||
await expect(transform(transformFunctionPath, output, context)).rejects.toThrow(
|
||||
'Transform transform.js must export a function, have a default export as a function, or export the specified function "undefined"',
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Error loading transform function from file',
|
||||
expect.objectContaining({
|
||||
transform: FILE_TRANSFORM_LABEL,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('transforms output using a Python file', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
const pythonFilePath = 'file://transform.py';
|
||||
|
||||
const transformedOutput = await transform(pythonFilePath, output, context);
|
||||
expect(transformedOutput).toBe('HELLO FROM PYTHON');
|
||||
});
|
||||
|
||||
it('throws error for unsupported file format', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const unsupportedFilePath = 'file://transform.txt';
|
||||
|
||||
await expect(transform(unsupportedFilePath, output, context)).rejects.toThrow(
|
||||
'Unsupported transform file format: file://transform.txt',
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Error loading transform function from file',
|
||||
expect.objectContaining({
|
||||
message: 'Unsupported transform file format: file://transform.txt',
|
||||
transform: FILE_TRANSFORM_LABEL,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('transforms output using a multi-line function', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
const multiLineFunction = `
|
||||
const uppercased = output.toUpperCase();
|
||||
return uppercased + ' WORLD';
|
||||
`;
|
||||
|
||||
const transformedOutput = await transform(multiLineFunction, output, context);
|
||||
expect(transformedOutput).toBe('HELLO WORLD');
|
||||
});
|
||||
|
||||
it('transforms output using a default export function from a file', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
mockedImportModule.mockResolvedValueOnce(
|
||||
(output: string) => output.toUpperCase() + ' DEFAULT',
|
||||
);
|
||||
|
||||
const transformFunctionPath = 'file://transform.js';
|
||||
const transformedOutput = await transform(transformFunctionPath, output, context);
|
||||
expect(transformedOutput).toBe('HELLO DEFAULT');
|
||||
});
|
||||
|
||||
it('transforms output using a named function from a JavaScript file', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
// When a function name is specified, importModule returns that specific function
|
||||
mockedImportModule.mockResolvedValueOnce((output: string) => output.toUpperCase() + ' NAMED');
|
||||
|
||||
const transformFunctionPath = 'file://transform.js:namedFunction';
|
||||
const transformedOutput = await transform(transformFunctionPath, output, context);
|
||||
expect(transformedOutput).toBe('HELLO NAMED');
|
||||
});
|
||||
|
||||
it('transforms output using a named function from a Python file', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
const pythonFilePath = 'file://transform.py:custom_transform';
|
||||
|
||||
const transformedOutput = await transform(pythonFilePath, output, context);
|
||||
expect(transformedOutput).toBe('HELLO FROM PYTHON');
|
||||
expect(runPython).toHaveBeenCalledWith(
|
||||
expect.stringContaining('transform.py'),
|
||||
'custom_transform',
|
||||
[output, expect.any(Object)],
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to get_transform for Python files when no function name is provided', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
const pythonFilePath = 'file://transform.py';
|
||||
|
||||
const transformedOutput = await transform(pythonFilePath, output, context);
|
||||
expect(transformedOutput).toBe('HELLO FROM PYTHON');
|
||||
expect(runPython).toHaveBeenCalledWith(
|
||||
expect.stringContaining('transform.py'),
|
||||
'get_transform',
|
||||
[output, expect.any(Object)],
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw error when validateReturn is false and function returns undefined', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const transformFunction = ''; // Empty function, returns undefined
|
||||
const result = await transform(transformFunction, output, context, false);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws error when validateReturn is true and function returns undefined', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const transformFunction = ''; // Empty function, returns undefined
|
||||
await expect(transform(transformFunction, output, context, true)).rejects.toThrow(
|
||||
'Transform function did not return a value',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw error when validateReturn is false and function returns null', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const transformFunction = 'null'; // Will be wrapped with "return" automatically
|
||||
const result = await transform(transformFunction, output, context, false);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('throws error when validateReturn is true and function returns null', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const transformFunction = 'null'; // Will be wrapped with "return" automatically
|
||||
await expect(transform(transformFunction, output, context, true)).rejects.toThrow(
|
||||
'Transform function did not return a value',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles file transform function errors gracefully', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const errorMessage = 'File not found';
|
||||
|
||||
mockedImportModule.mockRejectedValueOnce(new Error(errorMessage));
|
||||
|
||||
const transformFunctionPath = 'file://transform.js';
|
||||
await expect(transform(transformFunctionPath, output, context)).rejects.toThrow(errorMessage);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Error loading transform function from file',
|
||||
expect.objectContaining({
|
||||
message: errorMessage,
|
||||
transform: FILE_TRANSFORM_LABEL,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles inline transform function errors gracefully', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const invalidFunction = 'invalid javascript code {';
|
||||
|
||||
await expect(transform(invalidFunction, output, context)).rejects.toThrow(
|
||||
'Unexpected identifier',
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Error creating inline transform function',
|
||||
expect.objectContaining({
|
||||
transform: expect.stringContaining(INLINE_STRING_LABEL),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('ESM compatibility', () => {
|
||||
it('provides process.mainModule.require for backwards compatibility with CommonJS patterns', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
// This is the exact pattern users were using before ESM migration
|
||||
// const require = process.mainModule.require;
|
||||
const transformFunction = `
|
||||
const require = process.mainModule.require;
|
||||
const path = require('node:path');
|
||||
return path.basename('/foo/bar/baz.txt');
|
||||
`;
|
||||
const result = await transform(transformFunction, output, context);
|
||||
expect(result).toBe('baz.txt');
|
||||
});
|
||||
|
||||
it('allows direct use of process.mainModule.require without assignment', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const transformFunction = `
|
||||
const fs = process.mainModule.require('node:fs');
|
||||
const os = process.mainModule.require('node:os');
|
||||
return typeof fs.existsSync === 'function' && typeof os.homedir === 'function' ? output.toUpperCase() : output;
|
||||
`;
|
||||
const result = await transform(transformFunction, output, context);
|
||||
expect(result).toBe('HELLO');
|
||||
});
|
||||
|
||||
it('preserves other process properties like process.env', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const transformFunction = `
|
||||
// process.env should still work
|
||||
const env = process.env;
|
||||
return typeof env === 'object' ? 'env-works' : 'env-broken';
|
||||
`;
|
||||
const result = await transform(transformFunction, output, context);
|
||||
expect(result).toBe('env-works');
|
||||
});
|
||||
});
|
||||
|
||||
describe('file path handling', () => {
|
||||
it('handles absolute paths in transform files', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
|
||||
mockedImportModule.mockResolvedValueOnce((output: string) => output.toUpperCase());
|
||||
|
||||
const transformFunctionPath = 'file://transform.js';
|
||||
const transformedOutput = await transform(transformFunctionPath, output, context);
|
||||
expect(transformedOutput).toBe('HELLO');
|
||||
});
|
||||
|
||||
it('handles file URLs in transform files', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
|
||||
mockedImportModule.mockResolvedValueOnce((output: string) => output.toUpperCase());
|
||||
|
||||
const transformFunctionPath = 'file://transform.js';
|
||||
const transformedOutput = await transform(transformFunctionPath, output, context);
|
||||
expect(transformedOutput).toBe('HELLO');
|
||||
});
|
||||
|
||||
it('handles Python files with absolute paths', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
const pythonFilePath = 'file://transform.py';
|
||||
|
||||
const transformedOutput = await transform(pythonFilePath, output, context);
|
||||
expect(transformedOutput).toBe('HELLO FROM PYTHON');
|
||||
expect(runPython).toHaveBeenCalledWith(
|
||||
expect.stringContaining('transform.py'),
|
||||
'get_transform',
|
||||
[output, expect.any(Object)],
|
||||
);
|
||||
});
|
||||
|
||||
it('handles complex nested paths', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
|
||||
mockedImportModule.mockResolvedValueOnce((output: string) => output.toUpperCase());
|
||||
|
||||
const transformFunctionPath = 'file://deeply/nested/path/with spaces/transform.js';
|
||||
const transformedOutput = await transform(transformFunctionPath, output, context);
|
||||
expect(transformedOutput).toBe('HELLO');
|
||||
});
|
||||
|
||||
it('handles paths with special characters', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
|
||||
mockedImportModule.mockResolvedValueOnce((output: string) => output.toUpperCase());
|
||||
|
||||
const transformFunctionPath = 'file://path/with-hyphens/and_underscores/transform.js';
|
||||
const transformedOutput = await transform(transformFunctionPath, output, context);
|
||||
expect(transformedOutput).toBe('HELLO');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline function transforms (Node.js package)', () => {
|
||||
it('transforms output using a directly passed function', async () => {
|
||||
const output = 'hello world';
|
||||
const context = { vars: { key: 'value' }, prompt: { id: '123' } };
|
||||
const fn: TransformFunction = (output) => String(output).toUpperCase();
|
||||
|
||||
const result = await transform(fn, output, context);
|
||||
expect(result).toBe('HELLO WORLD');
|
||||
});
|
||||
|
||||
it('transforms output using an async function', async () => {
|
||||
const output = 'hello';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const fn: TransformFunction = async (output) => {
|
||||
return output + ' async';
|
||||
};
|
||||
|
||||
const result = await transform(fn, output, context);
|
||||
expect(result).toBe('hello async');
|
||||
});
|
||||
|
||||
it('passes context to the function', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: { name: 'Alice' }, prompt: { label: 'greeting' } };
|
||||
const fn: TransformFunction = (output, ctx: any) => `${output} for ${ctx.vars.name}`;
|
||||
|
||||
const result = await transform(fn, output, context);
|
||||
expect(result).toBe('test for Alice');
|
||||
});
|
||||
|
||||
it('transforms vars using a directly passed function', async () => {
|
||||
const vars = { key: 'value', other: 'data' };
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const fn: TransformFunction = (vars: any) => ({ ...vars, key: vars.key.toUpperCase() });
|
||||
|
||||
const result = await transform(fn, vars, context, true, TransformInputType.VARS);
|
||||
expect(result).toEqual({ key: 'VALUE', other: 'data' });
|
||||
});
|
||||
|
||||
it('throws when inline function returns undefined and validateReturn is true', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const fn: TransformFunction = () => undefined as any;
|
||||
|
||||
await expect(transform(fn, output, context, true)).rejects.toThrow(
|
||||
'Transform function did not return a value',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when inline function returns undefined and validateReturn is false', async () => {
|
||||
const output = 'test';
|
||||
const context = { vars: {}, prompt: {} };
|
||||
const fn: TransformFunction = () => undefined as any;
|
||||
|
||||
const result = await transform(fn, output, context, false);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles function that accesses metadata from context', async () => {
|
||||
const output = 'raw output';
|
||||
const context = {
|
||||
vars: {},
|
||||
prompt: {},
|
||||
metadata: { toolCalls: [{ name: 'search' }, { name: 'calculate' }] },
|
||||
};
|
||||
const fn: TransformFunction = (_output, ctx: any) => {
|
||||
const tools = ctx.metadata?.toolCalls ?? [];
|
||||
return tools.map((t: any) => t.name).join(', ');
|
||||
};
|
||||
|
||||
const result = await transform(fn, output, context);
|
||||
expect(result).toBe('search, calculate');
|
||||
});
|
||||
|
||||
it('propagates errors thrown by inline functions', async () => {
|
||||
const fn: TransformFunction = () => {
|
||||
throw new Error('user transform error');
|
||||
};
|
||||
|
||||
await expect(transform(fn, 'test', { vars: {}, prompt: {} })).rejects.toThrow(
|
||||
'user transform error',
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Error in transform function',
|
||||
expect.objectContaining({
|
||||
message: 'user transform error',
|
||||
transform: expect.stringContaining(INLINE_FUNCTION_LABEL),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('wraps thrown errors with the transform label and preserves the original via cause', async () => {
|
||||
const original = new Error('raw user error');
|
||||
const fn: TransformFunction = () => {
|
||||
throw original;
|
||||
};
|
||||
|
||||
try {
|
||||
await transform(fn, 'test', { vars: {}, prompt: {} });
|
||||
throw new Error('expected transform to throw');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
const e = err as Error & { cause?: unknown };
|
||||
// Wrapped message includes the label AND the original error text.
|
||||
expect(e.message).toContain('Transform failed (');
|
||||
expect(e.message).toContain(INLINE_FUNCTION_LABEL);
|
||||
expect(e.message).toContain('raw user error');
|
||||
// The original error is attached via the standard `cause` chain.
|
||||
expect(e.cause).toBe(original);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves thenables returned by inline functions', async () => {
|
||||
// Bare thenable built via Object.defineProperty to dodge the `noThenProperty`
|
||||
// lint rule; verifies `transform()` awaits anything Promise-like.
|
||||
const thenable = Object.defineProperty({}, 'then', {
|
||||
value: (resolve: (value: unknown) => void) => resolve('thenable-resolved'),
|
||||
enumerable: true,
|
||||
}) as unknown as Promise<string>;
|
||||
const fn: TransformFunction = () => thenable;
|
||||
|
||||
const result = await transform(fn, 'ignored', { vars: {}, prompt: {} });
|
||||
expect(result).toBe('thenable-resolved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTransformErrorMessage', () => {
|
||||
it('returns the raw message from a transform-wrapped Error via .cause', () => {
|
||||
const raw = new Error('underlying boom');
|
||||
const wrapped = new Error('Transform failed ([inline function]: fn): underlying boom');
|
||||
(wrapped as Error & { cause?: unknown }).cause = raw;
|
||||
expect(getTransformErrorMessage(wrapped)).toBe('underlying boom');
|
||||
});
|
||||
|
||||
it('falls back to the error message when there is no cause', () => {
|
||||
expect(getTransformErrorMessage(new Error('plain'))).toBe('plain');
|
||||
});
|
||||
|
||||
it('stringifies non-Error throw values', () => {
|
||||
expect(getTransformErrorMessage('just a string')).toBe('just a string');
|
||||
expect(getTransformErrorMessage(42)).toBe('42');
|
||||
});
|
||||
|
||||
it('returns the outer message when the cause is not an Error', () => {
|
||||
const wrapped = new Error('outer');
|
||||
(wrapped as Error & { cause?: unknown }).cause = 'a string cause';
|
||||
expect(getTransformErrorMessage(wrapped)).toBe('outer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTransformLabel', () => {
|
||||
it('shows inline string transforms verbatim', () => {
|
||||
expect(getTransformLabel('output.toUpperCase()')).toBe(
|
||||
`${INLINE_STRING_LABEL}: output.toUpperCase()`,
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses whitespace in multi-line inline strings', () => {
|
||||
expect(getTransformLabel('const x = output;\n return x.trim();')).toBe(
|
||||
`${INLINE_STRING_LABEL}: const x = output; return x.trim();`,
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates long inline string transforms', () => {
|
||||
const longExpr = 'output.' + 'a'.repeat(200);
|
||||
const label = getTransformLabel(longExpr);
|
||||
expect(label.startsWith(`${INLINE_STRING_LABEL}: `)).toBe(true);
|
||||
expect(label.endsWith('…')).toBe(true);
|
||||
expect(label.length).toBeLessThan(longExpr.length);
|
||||
});
|
||||
|
||||
it('does not truncate an inline string exactly at the max length', () => {
|
||||
const exprAtLimit = 'a'.repeat(80);
|
||||
expect(getTransformLabel(exprAtLimit)).toBe(`${INLINE_STRING_LABEL}: ${exprAtLimit}`);
|
||||
});
|
||||
|
||||
it('truncates an inline string one character over the max length', () => {
|
||||
const exprOverLimit = 'a'.repeat(81);
|
||||
const label = getTransformLabel(exprOverLimit);
|
||||
// Label body is 79 chars + ellipsis → total 80 chars after the prefix.
|
||||
expect(label).toBe(`${INLINE_STRING_LABEL}: ${'a'.repeat(79)}…`);
|
||||
});
|
||||
|
||||
it('redacts file transform paths', () => {
|
||||
expect(getTransformLabel('file://transform.js')).toBe(FILE_TRANSFORM_LABEL);
|
||||
});
|
||||
|
||||
it('does not render function source in error labels', async () => {
|
||||
const secretFn = function SECRET_SHOULD_NOT_APPEAR_IN_LABEL() {
|
||||
return undefined;
|
||||
};
|
||||
await expect(transform(secretFn as any, 'test', { vars: {}, prompt: {} })).rejects.toThrow(
|
||||
`Transform function did not return a value\n\n${INLINE_FUNCTION_LABEL}: SECRET_SHOULD_NOT_APPEAR_IN_LABEL`,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns [inline function] for anonymous functions', () => {
|
||||
expect(getTransformLabel(() => 'x')).toBe(INLINE_FUNCTION_LABEL);
|
||||
});
|
||||
|
||||
it('includes function name for named functions', () => {
|
||||
function myTransform() {
|
||||
return 'x';
|
||||
}
|
||||
expect(getTransformLabel(myTransform)).toBe(`${INLINE_FUNCTION_LABEL}: myTransform`);
|
||||
});
|
||||
|
||||
it('falls back to inline label for null or undefined inputs', () => {
|
||||
expect(getTransformLabel(undefined)).toBe(INLINE_STRING_LABEL);
|
||||
expect(getTransformLabel(null)).toBe(INLINE_STRING_LABEL);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mockGlobal, mockProcessEnv } from './utils';
|
||||
|
||||
const envKeys = [
|
||||
'PROMPTFOO_TEST_UTIL_ORIGINAL',
|
||||
'PROMPTFOO_TEST_UTIL_OVERRIDE',
|
||||
'PROMPTFOO_TEST_UTIL_CREATED',
|
||||
];
|
||||
|
||||
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
|
||||
const testGlobalName = '__PROMPTFOO_TEST_UTIL_GLOBAL__';
|
||||
|
||||
function restoreTestEnv(): void {
|
||||
for (const key of envKeys) {
|
||||
const value = originalEnv[key];
|
||||
if (value === undefined) {
|
||||
mockProcessEnv({ [key]: undefined });
|
||||
} else {
|
||||
mockProcessEnv({ [key]: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
restoreTestEnv();
|
||||
Reflect.deleteProperty(globalThis, testGlobalName);
|
||||
vi.doUnmock('node:fs');
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('mockProcessEnv', () => {
|
||||
it('applies overrides and restores the previous environment snapshot', () => {
|
||||
mockProcessEnv({ PROMPTFOO_TEST_UTIL_ORIGINAL: 'original' });
|
||||
|
||||
const restoreEnv = mockProcessEnv({
|
||||
PROMPTFOO_TEST_UTIL_ORIGINAL: 'override',
|
||||
PROMPTFOO_TEST_UTIL_OVERRIDE: 'set',
|
||||
});
|
||||
|
||||
mockProcessEnv({ PROMPTFOO_TEST_UTIL_CREATED: 'created-by-test' });
|
||||
|
||||
expect(process.env.PROMPTFOO_TEST_UTIL_ORIGINAL).toBe('override');
|
||||
expect(process.env.PROMPTFOO_TEST_UTIL_OVERRIDE).toBe('set');
|
||||
|
||||
restoreEnv();
|
||||
|
||||
expect(process.env.PROMPTFOO_TEST_UTIL_ORIGINAL).toBe('original');
|
||||
expect(process.env.PROMPTFOO_TEST_UTIL_OVERRIDE).toBeUndefined();
|
||||
expect(process.env.PROMPTFOO_TEST_UTIL_CREATED).toBeUndefined();
|
||||
});
|
||||
|
||||
it('can start from an empty environment without replacing the process.env object', () => {
|
||||
mockProcessEnv({ PROMPTFOO_TEST_UTIL_ORIGINAL: 'original' });
|
||||
const envReference = process.env;
|
||||
|
||||
const restoreEnv = mockProcessEnv(
|
||||
{
|
||||
PROMPTFOO_TEST_UTIL_OVERRIDE: 'only-value',
|
||||
},
|
||||
{ clear: true },
|
||||
);
|
||||
|
||||
expect(process.env).toBe(envReference);
|
||||
expect(process.env.PROMPTFOO_TEST_UTIL_ORIGINAL).toBeUndefined();
|
||||
expect(process.env.PROMPTFOO_TEST_UTIL_OVERRIDE).toBe('only-value');
|
||||
|
||||
restoreEnv();
|
||||
|
||||
expect(process.env).toBe(envReference);
|
||||
expect(process.env.PROMPTFOO_TEST_UTIL_ORIGINAL).toBe('original');
|
||||
expect(process.env.PROMPTFOO_TEST_UTIL_OVERRIDE).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mockGlobal', () => {
|
||||
it('restores globals that did not previously exist', () => {
|
||||
const restoreGlobal = mockGlobal(testGlobalName, { value: 'mocked' });
|
||||
|
||||
expect((globalThis as Record<string, unknown>)[testGlobalName]).toEqual({ value: 'mocked' });
|
||||
|
||||
restoreGlobal();
|
||||
|
||||
expect(testGlobalName in globalThis).toBe(false);
|
||||
});
|
||||
|
||||
it('restores the previous global descriptor', () => {
|
||||
Object.defineProperty(globalThis, testGlobalName, {
|
||||
configurable: true,
|
||||
get: () => 'original',
|
||||
});
|
||||
|
||||
const restoreGlobal = mockGlobal(testGlobalName, 'mocked');
|
||||
|
||||
expect((globalThis as Record<string, unknown>)[testGlobalName]).toBe('mocked');
|
||||
|
||||
restoreGlobal();
|
||||
|
||||
expect((globalThis as Record<string, unknown>)[testGlobalName]).toBe('original');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeTempDir', () => {
|
||||
it('retries transient recursive cleanup failures', async () => {
|
||||
// Reset the module registry first so the dynamic import below re-evaluates
|
||||
// ./utils against the mocked node:fs, even when this test runs in isolation
|
||||
// (the top-level static import has already cached ./utils with the real fs).
|
||||
vi.resetModules();
|
||||
const rmSync = vi.fn();
|
||||
vi.doMock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, rmSync };
|
||||
});
|
||||
const { removeTempDir } = await import('./utils');
|
||||
|
||||
removeTempDir('/tmp/promptfoo-test-dir');
|
||||
|
||||
expect(rmSync).toHaveBeenCalledWith('/tmp/promptfoo-test-dir', {
|
||||
force: true,
|
||||
maxRetries: 3,
|
||||
recursive: true,
|
||||
retryDelay: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not attempt cleanup without a directory', async () => {
|
||||
vi.resetModules();
|
||||
const rmSync = vi.fn();
|
||||
vi.doMock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return { ...actual, rmSync };
|
||||
});
|
||||
const { removeTempDir } = await import('./utils');
|
||||
|
||||
removeTempDir(undefined);
|
||||
|
||||
expect(rmSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
import type { ApiProvider, ProviderResponse } from '../../src/types/index';
|
||||
|
||||
/**
|
||||
* Creates a deferred promise that can be resolved or rejected externally.
|
||||
* Useful for controlling async flow in tests.
|
||||
*/
|
||||
export function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
export class TestGrader implements ApiProvider {
|
||||
async callApi(): Promise<ProviderResponse> {
|
||||
return {
|
||||
output: JSON.stringify({ pass: true, reason: 'Test grading output' }),
|
||||
tokenUsage: { total: 10, prompt: 5, completion: 5 },
|
||||
};
|
||||
}
|
||||
|
||||
id(): string {
|
||||
return 'TestGradingProvider';
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockResponse(
|
||||
options: {
|
||||
ok?: boolean;
|
||||
body?: any;
|
||||
statusText?: string;
|
||||
status?: number;
|
||||
headers?: Headers;
|
||||
text?: () => Promise<string>;
|
||||
json?: () => Promise<any>;
|
||||
} = { ok: true },
|
||||
): Response {
|
||||
const isOk = options.ok ?? (options.status ? options.status < 400 : true);
|
||||
const mockResponse: Response = {
|
||||
ok: isOk,
|
||||
status: options.status || (isOk ? 200 : 400),
|
||||
statusText: options.statusText || (isOk ? 'OK' : 'Bad Request'),
|
||||
headers: options.headers || new Headers(),
|
||||
redirected: false,
|
||||
type: 'basic',
|
||||
url: 'https://example.com',
|
||||
json: options.json || (() => Promise.resolve(options.body || {})),
|
||||
text: options.text || (() => Promise.resolve('')),
|
||||
blob: () => Promise.resolve(new Blob([])),
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
bodyUsed: false,
|
||||
body: null,
|
||||
clone() {
|
||||
return createMockResponse(options);
|
||||
},
|
||||
} as Response;
|
||||
return mockResponse;
|
||||
}
|
||||
|
||||
export function stripAnsi(value: string): string {
|
||||
return value.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
export type ConsoleMethod = 'debug' | 'error' | 'info' | 'log' | 'warn';
|
||||
|
||||
export function mockConsole(
|
||||
method: ConsoleMethod,
|
||||
implementation: (...args: unknown[]) => void = () => {},
|
||||
): MockInstance {
|
||||
return vi.spyOn(console, method).mockImplementation(implementation);
|
||||
}
|
||||
|
||||
function replaceProcessEnv(nextEnv: Record<string, string | undefined>): void {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
Reflect.deleteProperty(process.env, key);
|
||||
}
|
||||
Object.assign(process.env, nextEnv);
|
||||
}
|
||||
|
||||
export function mockProcessEnv(
|
||||
overrides: Record<string, string | undefined> = {},
|
||||
options: { clear?: boolean } = {},
|
||||
): () => void {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
if (options.clear) {
|
||||
replaceProcessEnv({});
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (value === undefined) {
|
||||
Reflect.deleteProperty(process.env, key);
|
||||
} else {
|
||||
Object.assign(process.env, { [key]: value });
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
replaceProcessEnv(originalEnv);
|
||||
};
|
||||
}
|
||||
|
||||
export const PROXY_ENV_KEYS = [
|
||||
'HTTP_PROXY',
|
||||
'http_proxy',
|
||||
'HTTPS_PROXY',
|
||||
'https_proxy',
|
||||
'ALL_PROXY',
|
||||
'all_proxy',
|
||||
'NO_PROXY',
|
||||
'no_proxy',
|
||||
'NPM_CONFIG_PROXY',
|
||||
'NPM_CONFIG_HTTP_PROXY',
|
||||
'NPM_CONFIG_HTTPS_PROXY',
|
||||
'npm_config_proxy',
|
||||
'npm_config_http_proxy',
|
||||
'npm_config_https_proxy',
|
||||
] as const;
|
||||
|
||||
export function clearProxyEnv(): void {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
Reflect.deleteProperty(process.env, key);
|
||||
}
|
||||
}
|
||||
|
||||
export function mockGlobal<T>(name: string, value: T): () => void {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, name);
|
||||
|
||||
Object.defineProperty(globalThis, name, {
|
||||
configurable: true,
|
||||
value,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, name, descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unique temporary directory in the operating system temp location.
|
||||
*
|
||||
* @param prefix - Optional directory name prefix. Defaults to `promptfoo-test-`.
|
||||
* @returns The absolute path to the newly created temporary directory.
|
||||
*/
|
||||
export function createTempDir(prefix = 'promptfoo-test-'): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a temporary directory created during tests.
|
||||
*
|
||||
* If `tempDir` is undefined, this function is a no-op. Otherwise, it retries recursive removal
|
||||
* briefly to tolerate transient file-handle contention on Windows.
|
||||
*
|
||||
* @param tempDir - Absolute or relative path to the temporary directory to remove.
|
||||
*/
|
||||
export function removeTempDir(tempDir: string | undefined): void {
|
||||
if (!tempDir) {
|
||||
return;
|
||||
}
|
||||
// On Windows, file handles can linger briefly after a stream is closed, so an
|
||||
// immediate recursive delete may throw ENOTEMPTY/EBUSY/EPERM. Retry with a short
|
||||
// backoff (a no-op on POSIX, where these errors don't occur).
|
||||
fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
ProviderReferenceValidationError,
|
||||
validateTestProviderReferences,
|
||||
} from '../../src/util/validateTestProviderReferences';
|
||||
import { createMockProvider } from '../factories/provider';
|
||||
|
||||
import type { Scenario, TestCase } from '../../src/types/index';
|
||||
import type { ApiProvider } from '../../src/types/providers';
|
||||
|
||||
describe('validateTestProviderReferences', () => {
|
||||
const createProvider = (id: string, label?: string): ApiProvider =>
|
||||
createMockProvider({ id, label });
|
||||
|
||||
const providers = [
|
||||
createProvider('openai:gpt-4', 'smart-model'),
|
||||
createProvider('openai:gpt-3.5-turbo', 'fast-model'),
|
||||
];
|
||||
|
||||
it('passes with no providers filter', () => {
|
||||
const tests: TestCase[] = [{ vars: { foo: 'bar' } }];
|
||||
expect(() => validateTestProviderReferences(tests, providers)).not.toThrow();
|
||||
});
|
||||
|
||||
it('passes with valid provider label', () => {
|
||||
const tests: TestCase[] = [{ vars: { foo: 'bar' }, providers: ['smart-model'] }];
|
||||
expect(() => validateTestProviderReferences(tests, providers)).not.toThrow();
|
||||
});
|
||||
|
||||
it('passes with valid provider id', () => {
|
||||
const tests: TestCase[] = [{ vars: { foo: 'bar' }, providers: ['openai:gpt-4'] }];
|
||||
expect(() => validateTestProviderReferences(tests, providers)).not.toThrow();
|
||||
});
|
||||
|
||||
it('passes with wildcard match', () => {
|
||||
const tests: TestCase[] = [{ vars: { foo: 'bar' }, providers: ['openai:*'] }];
|
||||
expect(() => validateTestProviderReferences(tests, providers)).not.toThrow();
|
||||
});
|
||||
|
||||
it('passes with legacy prefix match', () => {
|
||||
const tests: TestCase[] = [{ vars: { foo: 'bar' }, providers: ['openai'] }];
|
||||
expect(() => validateTestProviderReferences(tests, providers)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws for invalid provider reference', () => {
|
||||
const tests: TestCase[] = [{ vars: { foo: 'bar' }, providers: ['nonexistent'] }];
|
||||
expect(() => validateTestProviderReferences(tests, providers)).toThrow(
|
||||
ProviderReferenceValidationError,
|
||||
);
|
||||
expect(() => validateTestProviderReferences(tests, providers)).toThrow(
|
||||
/does not exist.*Available providers/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for invalid provider reference with description', () => {
|
||||
const tests: TestCase[] = [
|
||||
{ description: 'My test case', vars: { foo: 'bar' }, providers: ['nonexistent'] },
|
||||
];
|
||||
expect(() => validateTestProviderReferences(tests, providers)).toThrow(/My test case/);
|
||||
});
|
||||
|
||||
it('validates defaultTest providers', () => {
|
||||
const tests: TestCase[] = [];
|
||||
const defaultTest = { providers: ['smart-model'] };
|
||||
expect(() => validateTestProviderReferences(tests, providers, defaultTest)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws for invalid defaultTest provider reference', () => {
|
||||
const tests: TestCase[] = [];
|
||||
const defaultTest = { providers: ['nonexistent'] };
|
||||
expect(() => validateTestProviderReferences(tests, providers, defaultTest)).toThrow(
|
||||
/defaultTest references provider/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for non-array providers field', () => {
|
||||
const tests: TestCase[] = [{ vars: { foo: 'bar' }, providers: 'not-an-array' as any }];
|
||||
expect(() => validateTestProviderReferences(tests, providers)).toThrow(/must be an array/);
|
||||
});
|
||||
|
||||
it('validates multiple tests', () => {
|
||||
const tests: TestCase[] = [
|
||||
{ vars: { foo: 'bar' }, providers: ['smart-model'] },
|
||||
{ vars: { baz: 'qux' }, providers: ['fast-model'] },
|
||||
];
|
||||
expect(() => validateTestProviderReferences(tests, providers)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws for second invalid test', () => {
|
||||
const tests: TestCase[] = [
|
||||
{ vars: { foo: 'bar' }, providers: ['smart-model'] },
|
||||
{ vars: { baz: 'qux' }, providers: ['nonexistent'] },
|
||||
];
|
||||
expect(() => validateTestProviderReferences(tests, providers)).toThrow(/Test #2/);
|
||||
});
|
||||
|
||||
describe('scenario validation', () => {
|
||||
it('passes with valid scenario tests', () => {
|
||||
const tests: TestCase[] = [];
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
config: [{}],
|
||||
tests: [{ vars: { foo: 'bar' }, providers: ['smart-model'] }],
|
||||
},
|
||||
];
|
||||
expect(() =>
|
||||
validateTestProviderReferences(tests, providers, undefined, scenarios),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws for invalid scenario test provider', () => {
|
||||
const tests: TestCase[] = [];
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
config: [{}],
|
||||
tests: [{ vars: { foo: 'bar' }, providers: ['nonexistent'] }],
|
||||
},
|
||||
];
|
||||
expect(() => validateTestProviderReferences(tests, providers, undefined, scenarios)).toThrow(
|
||||
/Scenario #1 test #1 references provider "nonexistent"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for invalid scenario config provider', () => {
|
||||
const tests: TestCase[] = [];
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
config: [{ providers: ['nonexistent'] }],
|
||||
tests: [],
|
||||
},
|
||||
];
|
||||
expect(() => validateTestProviderReferences(tests, providers, undefined, scenarios)).toThrow(
|
||||
/Scenario #1 config\[0\] references provider "nonexistent"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('includes scenario description in error', () => {
|
||||
const tests: TestCase[] = [];
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
description: 'My scenario',
|
||||
config: [{}],
|
||||
tests: [{ vars: { foo: 'bar' }, providers: ['nonexistent'] }],
|
||||
},
|
||||
];
|
||||
expect(() => validateTestProviderReferences(tests, providers, undefined, scenarios)).toThrow(
|
||||
/Scenario #1 \("My scenario"\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('validates multiple scenarios', () => {
|
||||
const tests: TestCase[] = [];
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
config: [{}],
|
||||
tests: [{ vars: { foo: 'bar' }, providers: ['smart-model'] }],
|
||||
},
|
||||
{
|
||||
config: [{}],
|
||||
tests: [{ vars: { foo: 'bar' }, providers: ['nonexistent'] }],
|
||||
},
|
||||
];
|
||||
expect(() => validateTestProviderReferences(tests, providers, undefined, scenarios)).toThrow(
|
||||
/Scenario #2/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../src/envars', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../src/envars')>()),
|
||||
isCI: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe('verboseToggle', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should export initVerboseToggle function', async () => {
|
||||
const { initVerboseToggle } = await import('../../src/util/verboseToggle');
|
||||
expect(typeof initVerboseToggle).toBe('function');
|
||||
});
|
||||
|
||||
it('should export disableVerboseToggle function', async () => {
|
||||
const { disableVerboseToggle } = await import('../../src/util/verboseToggle');
|
||||
expect(typeof disableVerboseToggle).toBe('function');
|
||||
});
|
||||
|
||||
it('should export isVerboseToggleActive function', async () => {
|
||||
const { isVerboseToggleActive } = await import('../../src/util/verboseToggle');
|
||||
expect(typeof isVerboseToggleActive).toBe('function');
|
||||
});
|
||||
|
||||
it('should return null when not in TTY mode', async () => {
|
||||
// Save original values
|
||||
const originalStdinIsTTY = process.stdin.isTTY;
|
||||
const originalStdoutIsTTY = process.stdout.isTTY;
|
||||
|
||||
// Mock non-TTY mode
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
|
||||
|
||||
const { initVerboseToggle } = await import('../../src/util/verboseToggle');
|
||||
const result = initVerboseToggle({ onInterrupt: vi.fn() });
|
||||
|
||||
expect(result).toBeNull();
|
||||
|
||||
// Restore original values
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalStdinIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: originalStdoutIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initially report toggle as inactive', async () => {
|
||||
const { isVerboseToggleActive } = await import('../../src/util/verboseToggle');
|
||||
// In non-TTY test environment, toggle won't be enabled
|
||||
expect(isVerboseToggleActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('disableVerboseToggle should not throw when not initialized', async () => {
|
||||
const { disableVerboseToggle } = await import('../../src/util/verboseToggle');
|
||||
expect(() => disableVerboseToggle()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should fall back to process.exit(130) when onInterrupt throws so Ctrl+C is never broken', async () => {
|
||||
const originalStdinIsTTY = process.stdin.isTTY;
|
||||
const originalStdoutIsTTY = process.stdout.isTTY;
|
||||
const originalSetRawMode = process.stdin.setRawMode;
|
||||
const onInterrupt = vi.fn(() => {
|
||||
throw new Error('caller-decided shutdown failed');
|
||||
});
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
||||
const stderrWriteSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdin, 'setRawMode', { value: vi.fn(), configurable: true });
|
||||
|
||||
try {
|
||||
const { initVerboseToggle, isVerboseToggleActive } = await import(
|
||||
'../../src/util/verboseToggle'
|
||||
);
|
||||
|
||||
initVerboseToggle({ onInterrupt });
|
||||
expect(() => process.stdin.emit('data', '\u0003')).not.toThrow();
|
||||
|
||||
expect(onInterrupt).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).toHaveBeenCalledWith(130);
|
||||
expect(isVerboseToggleActive()).toBe(false);
|
||||
} finally {
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalStdinIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: originalStdoutIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdin, 'setRawMode', {
|
||||
value: originalSetRawMode,
|
||||
configurable: true,
|
||||
});
|
||||
stderrWriteSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should remove its exit listener during cleanup so init/teardown cycles do not leak', async () => {
|
||||
const originalStdinIsTTY = process.stdin.isTTY;
|
||||
const originalStdoutIsTTY = process.stdout.isTTY;
|
||||
const originalSetRawMode = process.stdin.setRawMode;
|
||||
const stderrWriteSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdin, 'setRawMode', { value: vi.fn(), configurable: true });
|
||||
|
||||
try {
|
||||
const { initVerboseToggle, disableVerboseToggle } = await import(
|
||||
'../../src/util/verboseToggle'
|
||||
);
|
||||
|
||||
const before = process.listenerCount('exit');
|
||||
const cleanup = initVerboseToggle({ onInterrupt: vi.fn() });
|
||||
expect(cleanup).not.toBeNull();
|
||||
expect(process.listenerCount('exit')).toBe(before + 1);
|
||||
|
||||
disableVerboseToggle();
|
||||
expect(process.listenerCount('exit')).toBe(before);
|
||||
} finally {
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalStdinIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: originalStdoutIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdin, 'setRawMode', {
|
||||
value: originalSetRawMode,
|
||||
configurable: true,
|
||||
});
|
||||
stderrWriteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should delegate Ctrl+C to the caller without exiting the process', async () => {
|
||||
const originalStdinIsTTY = process.stdin.isTTY;
|
||||
const originalStdoutIsTTY = process.stdout.isTTY;
|
||||
const originalSetRawMode = process.stdin.setRawMode;
|
||||
const onInterrupt = vi.fn();
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
|
||||
const stderrWriteSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdin, 'setRawMode', {
|
||||
value: vi.fn(),
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const { initVerboseToggle, isVerboseToggleActive } = await import(
|
||||
'../../src/util/verboseToggle'
|
||||
);
|
||||
|
||||
initVerboseToggle({ onInterrupt });
|
||||
process.stdin.emit('data', '\u0003');
|
||||
|
||||
expect(onInterrupt).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
expect(isVerboseToggleActive()).toBe(false);
|
||||
} finally {
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalStdinIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value: originalStdoutIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(process.stdin, 'setRawMode', {
|
||||
value: originalSetRawMode,
|
||||
configurable: true,
|
||||
});
|
||||
stderrWriteSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import type { WriteStream } from 'fs';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { JsonlFileWriter } from '../../src/util/exportToFile/writeToFile';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createWriteStream: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('fs')>()),
|
||||
createWriteStream: mocks.createWriteStream,
|
||||
}));
|
||||
|
||||
function createMockWriteStream({ destroyed = false }: { destroyed?: boolean } = {}) {
|
||||
const stream = Object.assign(new EventEmitter(), {
|
||||
closed: false,
|
||||
destroyed,
|
||||
end: vi.fn(),
|
||||
// Mirror Node semantics: destroy() marks the stream destroyed and emits 'close'.
|
||||
destroy: vi.fn(() => {
|
||||
stream.destroyed = true;
|
||||
stream.emit('close');
|
||||
return stream;
|
||||
}),
|
||||
write: vi.fn((_data: string, callback?: (error?: Error | null) => void) => {
|
||||
callback?.();
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
mocks.createWriteStream.mockReturnValue(stream as unknown as WriteStream);
|
||||
return stream;
|
||||
}
|
||||
|
||||
describe('JsonlFileWriter', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('opens the stream lazily and truncates by default, appending only when requested', async () => {
|
||||
createMockWriteStream();
|
||||
const writer = new JsonlFileWriter('/tmp/results.jsonl');
|
||||
// Constructing the writer must not open (and thus truncate) the destination file.
|
||||
expect(mocks.createWriteStream).not.toHaveBeenCalled();
|
||||
|
||||
await writer.write({ a: 1 });
|
||||
expect(mocks.createWriteStream).toHaveBeenCalledWith('/tmp/results.jsonl', { flags: 'w' });
|
||||
|
||||
mocks.createWriteStream.mockClear();
|
||||
createMockWriteStream();
|
||||
const appender = new JsonlFileWriter('/tmp/results.jsonl', { append: true });
|
||||
await appender.write({ a: 1 });
|
||||
expect(mocks.createWriteStream).toHaveBeenCalledWith('/tmp/results.jsonl', { flags: 'a' });
|
||||
});
|
||||
|
||||
it('leaves the destination untouched when nothing is written', async () => {
|
||||
createMockWriteStream();
|
||||
await expect(new JsonlFileWriter('/tmp/results.jsonl').close()).resolves.toBeUndefined();
|
||||
expect(mocks.createWriteStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the underlying file descriptor to close', async () => {
|
||||
const stream = createMockWriteStream();
|
||||
stream.end.mockImplementation(() => stream);
|
||||
|
||||
const writer = new JsonlFileWriter('/tmp/results.jsonl');
|
||||
await writer.write({ a: 1 });
|
||||
const closePromise = writer.close();
|
||||
let settled = false;
|
||||
closePromise.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
|
||||
stream.emit('close');
|
||||
await expect(closePromise).resolves.toBeUndefined();
|
||||
// Only the persistent recorder remains; close()'s temporary error listener was removed.
|
||||
expect(stream.listenerCount('error')).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects errors emitted while the file descriptor is closing', async () => {
|
||||
const stream = createMockWriteStream();
|
||||
stream.end.mockImplementation(() => stream);
|
||||
|
||||
const writer = new JsonlFileWriter('/tmp/results.jsonl');
|
||||
await writer.write({ a: 1 });
|
||||
const closePromise = writer.close();
|
||||
|
||||
stream.emit('error', new Error('late close error'));
|
||||
stream.emit('close');
|
||||
|
||||
await expect(closePromise).rejects.toThrow(
|
||||
'Failed to close JSONL output /tmp/results.jsonl: late close error',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves immediately when the stream already closed cleanly', async () => {
|
||||
const stream = createMockWriteStream();
|
||||
const writer = new JsonlFileWriter('/tmp/results.jsonl');
|
||||
await writer.write({ a: 1 });
|
||||
|
||||
stream.closed = true;
|
||||
await expect(writer.close()).resolves.toBeUndefined();
|
||||
expect(stream.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects with a previously recorded error when the stream already closed', async () => {
|
||||
const stream = createMockWriteStream();
|
||||
const writer = new JsonlFileWriter('/tmp/results.jsonl');
|
||||
await writer.write({ a: 1 });
|
||||
|
||||
// A real stream that fails mid-run auto-destroys: 'error' and 'close' fire
|
||||
// before close() is ever called. The recorded error must still reject close()
|
||||
// rather than be swallowed (the output file is truncated/incomplete).
|
||||
stream.emit('error', new Error('EBADF: bad file descriptor'));
|
||||
stream.closed = true;
|
||||
|
||||
await expect(writer.close()).rejects.toThrow(
|
||||
'Failed to close JSONL output /tmp/results.jsonl: EBADF: bad file descriptor',
|
||||
);
|
||||
expect(stream.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for a destroyed stream to close without calling end()', async () => {
|
||||
const stream = createMockWriteStream();
|
||||
const writer = new JsonlFileWriter('/tmp/results.jsonl');
|
||||
await writer.write({ a: 1 });
|
||||
|
||||
stream.destroyed = true;
|
||||
const closePromise = writer.close();
|
||||
let settled = false;
|
||||
closePromise.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
expect(stream.end).not.toHaveBeenCalled();
|
||||
|
||||
stream.emit('close');
|
||||
await expect(closePromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('destroys the stream and rejects when end() throws synchronously', async () => {
|
||||
const stream = createMockWriteStream();
|
||||
stream.end.mockImplementation(() => {
|
||||
throw new Error('end failed');
|
||||
});
|
||||
|
||||
const writer = new JsonlFileWriter('/tmp/results.jsonl');
|
||||
await writer.write({ a: 1 });
|
||||
|
||||
await expect(writer.close()).rejects.toThrow(
|
||||
'Failed to close JSONL output /tmp/results.jsonl: end failed',
|
||||
);
|
||||
expect(stream.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects final flush errors with the output path', async () => {
|
||||
const stream = createMockWriteStream();
|
||||
stream.end.mockImplementation(() => {
|
||||
stream.emit('error', new Error('disk full'));
|
||||
stream.emit('close');
|
||||
return stream;
|
||||
});
|
||||
|
||||
const writer = new JsonlFileWriter('/tmp/results.jsonl');
|
||||
await writer.write({ a: 1 });
|
||||
await expect(writer.close()).rejects.toThrow(
|
||||
'Failed to close JSONL output /tmp/results.jsonl: disk full',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { JsonlFileWriter } from '../../src/util/exportToFile/writeToFile';
|
||||
|
||||
// Unmocked counterpart to writeToFile.test.ts: pins the real-stream assumptions the
|
||||
// writer relies on — createWriteStream's default emitClose means 'close' always fires
|
||||
// (close() can never hang), and the file descriptor is released before close() resolves
|
||||
// so the file and its directory can be removed immediately (the Windows ENOTEMPTY race).
|
||||
describe('JsonlFileWriter (real filesystem)', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-jsonl-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('releases the file descriptor before close() resolves', async () => {
|
||||
const file = path.join(dir, 'out.jsonl');
|
||||
const writer = new JsonlFileWriter(file);
|
||||
await writer.write({ a: 1 });
|
||||
await writer.write({ b: 2 });
|
||||
await writer.close();
|
||||
|
||||
const lines = fs.readFileSync(file, 'utf-8').trim().split('\n');
|
||||
expect(lines.map((line) => JSON.parse(line))).toEqual([{ a: 1 }, { b: 2 }]);
|
||||
|
||||
// Non-recursive removal fails (EBUSY/ENOTEMPTY on Windows) if the fd is still held.
|
||||
fs.rmSync(file);
|
||||
fs.rmdirSync(dir);
|
||||
fs.mkdirSync(dir);
|
||||
});
|
||||
|
||||
it('resolves a second close() call on an already-closed stream', async () => {
|
||||
const writer = new JsonlFileWriter(path.join(dir, 'out.jsonl'));
|
||||
await writer.write({ a: 1 });
|
||||
await writer.close();
|
||||
await expect(writer.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the destination untouched when nothing was written', async () => {
|
||||
const file = path.join(dir, 'never.jsonl');
|
||||
await expect(new JsonlFileWriter(file).close()).resolves.toBeUndefined();
|
||||
expect(fs.existsSync(file)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects close() when the stream failed mid-run', async () => {
|
||||
const file = path.join(dir, 'broken.jsonl');
|
||||
const writer = new JsonlFileWriter(file);
|
||||
await writer.write({ a: 1 });
|
||||
// Destroy with an error to simulate an fd failure between writes; the recorded
|
||||
// pre-flush error must reject close() rather than be swallowed.
|
||||
(writer as unknown as { writeStream: { destroy: (error: Error) => void } }).writeStream.destroy(
|
||||
new Error('EBADF: bad file descriptor'),
|
||||
);
|
||||
|
||||
await expect(writer.close()).rejects.toThrow(
|
||||
`Failed to close JSONL output ${file}: EBADF: bad file descriptor`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { parseXlsxFile } from '../../src/util/xlsx';
|
||||
|
||||
// Mock read-excel-file/node module
|
||||
const mockReadXlsxFile = vi.fn();
|
||||
|
||||
vi.mock('read-excel-file/node', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockReadXlsxFile(...args),
|
||||
}));
|
||||
|
||||
type SheetData = unknown[][];
|
||||
|
||||
const createMockSheet = (sheet: string, data: SheetData = []) => ({ sheet, data });
|
||||
|
||||
describe('parseXlsxFile', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should parse xlsx file successfully', async () => {
|
||||
// read-excel-file returns all sheets, each with an array of arrays where first row is headers
|
||||
const mockRows = [
|
||||
['col1', 'col2'], // headers
|
||||
['value1', 'value2'], // data row 1
|
||||
['value3', 'value4'], // data row 2
|
||||
];
|
||||
|
||||
mockReadXlsxFile.mockResolvedValue([createMockSheet('Sheet1', mockRows)]);
|
||||
|
||||
const result = await parseXlsxFile('test.xlsx');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ col1: 'value1', col2: 'value2' },
|
||||
{ col1: 'value3', col2: 'value4' },
|
||||
]);
|
||||
expect(mockReadXlsxFile).toHaveBeenCalledWith('test.xlsx');
|
||||
});
|
||||
|
||||
it('should throw error when read-excel-file module is not installed', async () => {
|
||||
mockReadXlsxFile.mockRejectedValue(new Error("Cannot find module 'read-excel-file/node'"));
|
||||
|
||||
await expect(parseXlsxFile('test.xlsx')).rejects.toThrow(
|
||||
'read-excel-file is not installed. Please install it with: npm install read-excel-file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when file parsing fails', async () => {
|
||||
mockReadXlsxFile.mockRejectedValue(new Error('Failed to read file'));
|
||||
|
||||
await expect(parseXlsxFile('test.xlsx')).rejects.toThrow(
|
||||
'Failed to parse Excel file test.xlsx: Failed to read file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty sheets', async () => {
|
||||
mockReadXlsxFile.mockResolvedValue([createMockSheet('Sheet1', [])]);
|
||||
|
||||
await expect(parseXlsxFile('test.xlsx')).rejects.toThrow(
|
||||
'Sheet "Sheet1" is empty or contains no valid data rows',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when Excel file has no sheets', async () => {
|
||||
mockReadXlsxFile.mockResolvedValue([]);
|
||||
|
||||
await expect(parseXlsxFile('test.xlsx')).rejects.toThrow('Excel file has no sheets');
|
||||
});
|
||||
|
||||
it('should throw error when sheet has no valid column headers', async () => {
|
||||
mockReadXlsxFile.mockResolvedValue([createMockSheet('Sheet1', [['', '', '']])]);
|
||||
|
||||
await expect(parseXlsxFile('test.xlsx')).rejects.toThrow(
|
||||
'Sheet "Sheet1" has no valid column headers',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when sheet has headers but no data rows', async () => {
|
||||
mockReadXlsxFile.mockResolvedValue([createMockSheet('Sheet1', [['col1', 'col2']])]);
|
||||
|
||||
await expect(parseXlsxFile('test.xlsx')).rejects.toThrow(
|
||||
'Sheet "Sheet1" is empty or contains no valid data rows',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when sheet has headers but only empty data rows', async () => {
|
||||
mockReadXlsxFile.mockResolvedValue([
|
||||
createMockSheet('Sheet1', [
|
||||
['col1', 'col2'],
|
||||
['', ''],
|
||||
[null, undefined],
|
||||
]),
|
||||
]);
|
||||
|
||||
await expect(parseXlsxFile('test.xlsx')).rejects.toThrow(
|
||||
'Sheet "Sheet1" contains only empty data. Please ensure the sheet has both headers and data rows.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use first sheet by default', async () => {
|
||||
const mockRows = [
|
||||
['col1', 'col2'],
|
||||
['value1', 'value2'],
|
||||
];
|
||||
|
||||
mockReadXlsxFile.mockResolvedValue([
|
||||
createMockSheet('Sheet1', mockRows),
|
||||
createMockSheet('Sheet2'),
|
||||
]);
|
||||
|
||||
const result = await parseXlsxFile('test.xlsx');
|
||||
|
||||
expect(result).toEqual([{ col1: 'value1', col2: 'value2' }]);
|
||||
expect(mockReadXlsxFile).toHaveBeenCalledWith('test.xlsx');
|
||||
});
|
||||
|
||||
it('should handle malformed Excel files gracefully', async () => {
|
||||
mockReadXlsxFile.mockRejectedValue(new Error('Invalid file format or corrupted file'));
|
||||
|
||||
await expect(parseXlsxFile('corrupted.xlsx')).rejects.toThrow(
|
||||
'Failed to parse Excel file corrupted.xlsx: Invalid file format or corrupted file',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw specific error when file does not exist', async () => {
|
||||
// read-excel-file surfaces ENOENT for missing files; parseXlsxFile rewraps it.
|
||||
mockReadXlsxFile.mockRejectedValue(
|
||||
Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }),
|
||||
);
|
||||
|
||||
await expect(parseXlsxFile('nonexistent.xlsx')).rejects.toThrow(
|
||||
'File not found: nonexistent.xlsx',
|
||||
);
|
||||
});
|
||||
|
||||
describe('sheet selection syntax', () => {
|
||||
it('should select sheet by name using # syntax', async () => {
|
||||
const mockRows = [
|
||||
['col1', 'col2'],
|
||||
['sheet2data', 'value2'],
|
||||
];
|
||||
|
||||
mockReadXlsxFile.mockResolvedValue([
|
||||
createMockSheet('Sheet1'),
|
||||
createMockSheet('DataSheet', mockRows),
|
||||
createMockSheet('Sheet3'),
|
||||
]);
|
||||
|
||||
const result = await parseXlsxFile('test.xlsx#DataSheet');
|
||||
|
||||
expect(result).toEqual([{ col1: 'sheet2data', col2: 'value2' }]);
|
||||
expect(mockReadXlsxFile).toHaveBeenCalledWith('test.xlsx');
|
||||
});
|
||||
|
||||
it('should select sheet by 1-based index using # syntax', async () => {
|
||||
const mockRows = [
|
||||
['col1', 'col2'],
|
||||
['sheet2data', 'value2'],
|
||||
];
|
||||
|
||||
mockReadXlsxFile.mockResolvedValue([
|
||||
createMockSheet('Sheet1'),
|
||||
createMockSheet('Sheet2', mockRows),
|
||||
createMockSheet('Sheet3'),
|
||||
]);
|
||||
|
||||
const result = await parseXlsxFile('test.xlsx#2');
|
||||
|
||||
expect(result).toEqual([{ col1: 'sheet2data', col2: 'value2' }]);
|
||||
expect(mockReadXlsxFile).toHaveBeenCalledWith('test.xlsx');
|
||||
});
|
||||
|
||||
it('should select sheet by index when the index has a leading plus sign', async () => {
|
||||
const mockRows = [
|
||||
['col1', 'col2'],
|
||||
['sheet2data', 'value2'],
|
||||
];
|
||||
|
||||
mockReadXlsxFile.mockResolvedValue([
|
||||
createMockSheet('Sheet1'),
|
||||
createMockSheet('Sheet2', mockRows),
|
||||
]);
|
||||
|
||||
const result = await parseXlsxFile('test.xlsx#+2');
|
||||
|
||||
expect(result).toEqual([{ col1: 'sheet2data', col2: 'value2' }]);
|
||||
expect(mockReadXlsxFile).toHaveBeenCalledWith('test.xlsx');
|
||||
});
|
||||
|
||||
it('should throw error when sheet name does not exist', async () => {
|
||||
mockReadXlsxFile.mockResolvedValue([
|
||||
createMockSheet('Sheet1'),
|
||||
createMockSheet('Sheet2'),
|
||||
createMockSheet('Sheet3'),
|
||||
]);
|
||||
|
||||
await expect(parseXlsxFile('test.xlsx#NonExistent')).rejects.toThrow(
|
||||
'Sheet "NonExistent" not found. Available sheets: Sheet1, Sheet2, Sheet3',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when sheet index is out of bounds', async () => {
|
||||
mockReadXlsxFile.mockResolvedValue([createMockSheet('Sheet1'), createMockSheet('Sheet2')]);
|
||||
|
||||
await expect(parseXlsxFile('test.xlsx#5')).rejects.toThrow(
|
||||
'Sheet index 5 is out of range. Available sheets: 2 (1-2)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when sheet index is 0', async () => {
|
||||
mockReadXlsxFile.mockResolvedValue([createMockSheet('Sheet1'), createMockSheet('Sheet2')]);
|
||||
|
||||
await expect(parseXlsxFile('test.xlsx#0')).rejects.toThrow(
|
||||
'Sheet index 0 is out of range. Available sheets: 2 (1-2)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when sheet index is negative', async () => {
|
||||
mockReadXlsxFile.mockResolvedValue([createMockSheet('Sheet1'), createMockSheet('Sheet2')]);
|
||||
|
||||
await expect(parseXlsxFile('test.xlsx#-1')).rejects.toThrow(
|
||||
'Sheet index -1 is out of range. Available sheets: 2 (1-2)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use first sheet when # is at end with no sheet specifier', async () => {
|
||||
// Empty string after # is falsy, so it defaults to sheet 1
|
||||
const mockRows = [
|
||||
['col1', 'col2'],
|
||||
['value1', 'value2'],
|
||||
];
|
||||
|
||||
mockReadXlsxFile.mockResolvedValue([
|
||||
createMockSheet('Sheet1', mockRows),
|
||||
createMockSheet('Sheet2'),
|
||||
]);
|
||||
|
||||
const result = await parseXlsxFile('test.xlsx#');
|
||||
|
||||
expect(result).toEqual([{ col1: 'value1', col2: 'value2' }]);
|
||||
expect(mockReadXlsxFile).toHaveBeenCalledWith('test.xlsx');
|
||||
});
|
||||
|
||||
it('should handle sheet names with spaces', async () => {
|
||||
const mockRows = [
|
||||
['col1', 'col2'],
|
||||
['data', 'value'],
|
||||
];
|
||||
|
||||
mockReadXlsxFile.mockResolvedValue([
|
||||
createMockSheet('Sheet 1'),
|
||||
createMockSheet('My Data Sheet', mockRows),
|
||||
createMockSheet('Sheet 3'),
|
||||
]);
|
||||
|
||||
const result = await parseXlsxFile('test.xlsx#My Data Sheet');
|
||||
|
||||
expect(result).toEqual([{ col1: 'data', col2: 'value' }]);
|
||||
expect(mockReadXlsxFile).toHaveBeenCalledWith('test.xlsx');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'2024-Q1',
|
||||
'1H-results',
|
||||
'2.9',
|
||||
])('should select sheet by name when the name starts with digits: %s', async (sheetName) => {
|
||||
const mockRows = [
|
||||
['col1', 'col2'],
|
||||
['data', 'value'],
|
||||
];
|
||||
|
||||
mockReadXlsxFile.mockResolvedValue([
|
||||
createMockSheet('Summary'),
|
||||
createMockSheet(sheetName, mockRows),
|
||||
]);
|
||||
|
||||
const result = await parseXlsxFile(`test.xlsx#${sheetName}`);
|
||||
|
||||
expect(result).toEqual([{ col1: 'data', col2: 'value' }]);
|
||||
expect(mockReadXlsxFile).toHaveBeenCalledWith('test.xlsx');
|
||||
});
|
||||
|
||||
it('should throw not-found for a digit-prefixed name instead of out-of-range', async () => {
|
||||
mockReadXlsxFile.mockResolvedValue([createMockSheet('Sheet1'), createMockSheet('Sheet2')]);
|
||||
|
||||
await expect(parseXlsxFile('test.xlsx#1H')).rejects.toThrow(
|
||||
'Sheet "1H" not found. Available sheets: Sheet1, Sheet2',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { loadYaml } from '../../src/util/yamlLoad';
|
||||
|
||||
describe('loadYaml', () => {
|
||||
it('parses plain YAML documents', () => {
|
||||
expect(loadYaml('a: 1\nb: hello')).toEqual({ a: 1, b: 'hello' });
|
||||
});
|
||||
|
||||
it('applies YAML merge keys like js-yaml v4', () => {
|
||||
const content = [
|
||||
'defaults: &defaults',
|
||||
' temperature: 0.5',
|
||||
' max_tokens: 100',
|
||||
'provider:',
|
||||
' <<: *defaults',
|
||||
' max_tokens: 200',
|
||||
].join('\n');
|
||||
|
||||
expect(loadYaml(content)).toEqual({
|
||||
defaults: { temperature: 0.5, max_tokens: 100 },
|
||||
provider: { temperature: 0.5, max_tokens: 200 },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves js-yaml v4 legacy standard tags', () => {
|
||||
expect(loadYaml('!!binary SGVsbG8=')).toEqual(new Uint8Array([72, 101, 108, 108, 111]));
|
||||
expect(loadYaml('2024-01-02')).toEqual(new Date('2024-01-02T00:00:00.000Z'));
|
||||
expect(loadYaml('!!omap [{a: 1}, {b: 2}]')).toEqual([{ a: 1 }, { b: 2 }]);
|
||||
expect(loadYaml('!!pairs [{a: 1}, {b: 2}]')).toEqual([
|
||||
['a', 1],
|
||||
['b', 2],
|
||||
]);
|
||||
expect(loadYaml('!!set {a: null, b: null}')).toEqual({ a: null, b: null });
|
||||
expect(() => loadYaml('!!set {a: not-null}')).toThrow(/cannot resolve a set item/);
|
||||
});
|
||||
|
||||
it('returns undefined for empty input like js-yaml v4', () => {
|
||||
expect(loadYaml('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for whitespace-only input', () => {
|
||||
expect(loadYaml(' \n\t\n')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for comment-only input', () => {
|
||||
expect(loadYaml('# just a comment\n# another comment\n')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still throws on invalid YAML', () => {
|
||||
expect(() => loadYaml('a: [unclosed')).toThrow();
|
||||
});
|
||||
|
||||
it('still throws on multi-document streams like js-yaml v4 load', () => {
|
||||
expect(() => loadYaml('a: 1\n---\nb: 2')).toThrow(/single document/);
|
||||
});
|
||||
|
||||
it('passes through additional load options', () => {
|
||||
expect(() => loadYaml('a: [unclosed', { filename: 'my-config.yaml' })).toThrow(
|
||||
/my-config\.yaml/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user