0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
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
1737 lines
53 KiB
TypeScript
1737 lines
53 KiB
TypeScript
import * as path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { runAssertion } from '../../src/assertions/index';
|
|
import { buildFunctionBody } from '../../src/assertions/javascript';
|
|
import { importModule } from '../../src/esm';
|
|
import { OpenAiChatCompletionProvider } from '../../src/providers/openai/chat';
|
|
import { isPackagePath, loadFromPackage } from '../../src/providers/packageParser';
|
|
|
|
import type { Assertion, AtomicTestCase, GradingResult } from '../../src/types/index';
|
|
|
|
vi.mock('../../src/redteam/remoteGeneration', () => ({
|
|
shouldGenerateRemote: vi.fn().mockReturnValue(false),
|
|
}));
|
|
|
|
vi.mock('proxy-agent', () => ({
|
|
ProxyAgent: vi.fn().mockImplementation(() => ({})),
|
|
}));
|
|
|
|
vi.mock('node:module', () => {
|
|
const mockRequire: NodeJS.Require = {
|
|
resolve: vi.fn() as unknown as NodeJS.RequireResolve,
|
|
} as unknown as NodeJS.Require;
|
|
return {
|
|
createRequire: vi.fn().mockReturnValue(mockRequire),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/util/fetch/index.ts', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/util/fetch/index')>(
|
|
'../../src/util/fetch/index.ts',
|
|
);
|
|
return {
|
|
...actual,
|
|
fetchWithRetries: vi.fn(actual.fetchWithRetries),
|
|
};
|
|
});
|
|
|
|
vi.mock('glob', () => ({
|
|
globSync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('fs', () => ({
|
|
readFileSync: vi.fn(),
|
|
existsSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
mkdirSync: vi.fn(),
|
|
promises: {
|
|
readFile: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/esm', () => ({
|
|
importModule: vi.fn().mockImplementation((_path, _functionName) => {
|
|
// Make sure both parameters are captured in the mock call
|
|
return Promise.resolve();
|
|
}),
|
|
__esModule: true,
|
|
}));
|
|
vi.mock('../../src/database', () => ({
|
|
getDb: vi.fn(),
|
|
}));
|
|
vi.mock('path', async () => {
|
|
const actualPath = await vi.importActual<typeof import('path')>('path');
|
|
const mocked = {
|
|
...actualPath,
|
|
resolve: vi.fn(),
|
|
extname: vi.fn(),
|
|
};
|
|
return {
|
|
...mocked,
|
|
default: mocked,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/cliState', () => ({
|
|
default: {
|
|
basePath: '/base/path',
|
|
},
|
|
basePath: '/base/path',
|
|
}));
|
|
vi.mock('../../src/matchers/rag', async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import('../../src/matchers/rag')>('../../src/matchers/rag');
|
|
return {
|
|
...actual,
|
|
matchesContextRelevance: vi
|
|
.fn()
|
|
.mockResolvedValue({ pass: true, score: 1, reason: 'Mocked reason' }),
|
|
matchesContextFaithfulness: vi
|
|
.fn()
|
|
.mockResolvedValue({ pass: true, score: 1, reason: 'Mocked reason' }),
|
|
};
|
|
});
|
|
|
|
// Add this mock for packageParser
|
|
vi.mock('../../src/providers/packageParser', () => {
|
|
const mockIsPackagePath = vi.fn();
|
|
const mockLoadFromPackage = vi.fn();
|
|
return {
|
|
isPackagePath: mockIsPackagePath,
|
|
loadFromPackage: mockLoadFromPackage,
|
|
__esModule: true, // This is important for proper mocking
|
|
};
|
|
});
|
|
|
|
describe('buildFunctionBody', () => {
|
|
it('should prepend return to simple expressions', () => {
|
|
expect(buildFunctionBody('output === "test"')).toBe('return output === "test"');
|
|
expect(buildFunctionBody('output.length > 5')).toBe('return output.length > 5');
|
|
expect(buildFunctionBody('true')).toBe('return true');
|
|
});
|
|
|
|
it('should inject return before final expression when starting with const', () => {
|
|
expect(buildFunctionBody('const s = output; s === "test"')).toBe(
|
|
'const s = output; return s === "test"',
|
|
);
|
|
expect(buildFunctionBody('const x = 5; const y = 10; x + y')).toBe(
|
|
'const x = 5; const y = 10; return x + y',
|
|
);
|
|
});
|
|
|
|
it('should inject return before final expression when starting with let', () => {
|
|
expect(buildFunctionBody('let x = output.length; x > 5')).toBe(
|
|
'let x = output.length; return x > 5',
|
|
);
|
|
});
|
|
|
|
it('should inject return before final expression when starting with var', () => {
|
|
expect(buildFunctionBody('var x = output.length; x > 5')).toBe(
|
|
'var x = output.length; return x > 5',
|
|
);
|
|
});
|
|
|
|
it('should handle trailing semicolons', () => {
|
|
expect(buildFunctionBody('const x = 5; x > 0;')).toBe('const x = 5; return x > 0');
|
|
expect(buildFunctionBody('const x = 5; x > 0;;')).toBe('const x = 5; return x > 0');
|
|
expect(buildFunctionBody('output === "test";')).toBe('return output === "test"');
|
|
});
|
|
|
|
it('should handle whitespace', () => {
|
|
expect(buildFunctionBody(' const x = 5; x > 0 ')).toBe('const x = 5; return x > 0');
|
|
expect(buildFunctionBody(' output === "test" ')).toBe('return output === "test"');
|
|
});
|
|
|
|
it('should handle declaration without final expression', () => {
|
|
// This is an edge case - user forgot to add the expression
|
|
expect(buildFunctionBody('const x = 5;')).toBe('const x = 5');
|
|
});
|
|
|
|
it('should handle semicolons inside strings in declarations', () => {
|
|
// Semicolon in string within the declaration part (not the final expression)
|
|
expect(buildFunctionBody('const s = "a;b"; s.length')).toBe('const s = "a;b"; return s.length');
|
|
});
|
|
|
|
it('should handle semicolons inside strings in final expression', () => {
|
|
// Critical edge case: semicolon in string is the LAST semicolon in the code
|
|
// This was the bug that caused silent failures
|
|
expect(buildFunctionBody('const s = output; s === "test;value"')).toBe(
|
|
'const s = output; return s === "test;value"',
|
|
);
|
|
expect(buildFunctionBody('const x = output; x.includes(";")')).toBe(
|
|
'const x = output; return x.includes(";")',
|
|
);
|
|
expect(buildFunctionBody('const x = output; x === "a;b;c"')).toBe(
|
|
'const x = output; return x === "a;b;c"',
|
|
);
|
|
});
|
|
|
|
it('should handle single-quoted strings with semicolons', () => {
|
|
expect(buildFunctionBody("const s = output; s === 'test;value'")).toBe(
|
|
"const s = output; return s === 'test;value'",
|
|
);
|
|
expect(buildFunctionBody("const s = 'a;b'; s.length")).toBe("const s = 'a;b'; return s.length");
|
|
});
|
|
|
|
it('should handle template literals with semicolons', () => {
|
|
expect(buildFunctionBody('const s = output; s === `test;value`')).toBe(
|
|
'const s = output; return s === `test;value`',
|
|
);
|
|
expect(buildFunctionBody('const s = `a;b`; s.length')).toBe('const s = `a;b`; return s.length');
|
|
});
|
|
|
|
it('should handle escaped quotes', () => {
|
|
// Escaped quote should not toggle quote state
|
|
expect(buildFunctionBody('const s = output; s === "test\\"with;quotes"')).toBe(
|
|
'const s = output; return s === "test\\"with;quotes"',
|
|
);
|
|
expect(buildFunctionBody("const s = output; s === 'test\\'with;quotes'")).toBe(
|
|
"const s = output; return s === 'test\\'with;quotes'",
|
|
);
|
|
});
|
|
|
|
it('should handle multiple escaped backslashes', () => {
|
|
// \\\\ is two escaped backslashes, so the quote after is NOT escaped
|
|
expect(buildFunctionBody('const s = "a\\\\"; s.length')).toBe(
|
|
'const s = "a\\\\"; return s.length',
|
|
);
|
|
});
|
|
|
|
it('should handle mixed quote types', () => {
|
|
// Single quotes inside double quotes
|
|
expect(buildFunctionBody('const s = output; s === "it\'s;here"')).toBe(
|
|
'const s = output; return s === "it\'s;here"',
|
|
);
|
|
// Double quotes inside single quotes
|
|
expect(buildFunctionBody('const s = output; s === \'say "hi;there"\'')).toBe(
|
|
'const s = output; return s === \'say "hi;there"\'',
|
|
);
|
|
});
|
|
|
|
it('should not modify expressions starting with const-like words', () => {
|
|
// "constant" starts with "const" but isn't a declaration
|
|
expect(buildFunctionBody('constant === true')).toBe('return constant === true');
|
|
});
|
|
});
|
|
|
|
const javascriptStringAssertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'output === "Expected output"',
|
|
};
|
|
|
|
const javascriptMultilineStringAssertion: Assertion = {
|
|
type: 'javascript',
|
|
value: `
|
|
if (output === "Expected output") {
|
|
return {
|
|
pass: true,
|
|
score: 0.5,
|
|
reason: 'Assertion passed',
|
|
};
|
|
}
|
|
return {
|
|
pass: false,
|
|
score: 0,
|
|
reason: 'Assertion failed',
|
|
};`,
|
|
};
|
|
|
|
const javascriptStringAssertionWithNumber: Assertion = {
|
|
type: 'javascript',
|
|
value: 'output.length * 10',
|
|
};
|
|
|
|
const javascriptBooleanAssertionWithConfig: Assertion = {
|
|
type: 'javascript',
|
|
value: 'output.length <= context.config.maximumOutputSize',
|
|
config: {
|
|
maximumOutputSize: 20,
|
|
},
|
|
};
|
|
|
|
const javascriptStringAssertionWithNumberAndThreshold: Assertion = {
|
|
type: 'javascript',
|
|
value: 'output.length * 10',
|
|
threshold: 0.5,
|
|
};
|
|
|
|
const javascriptFunctionAssertion: Assertion = {
|
|
type: 'javascript',
|
|
value: async (_output: string) => ({
|
|
pass: true,
|
|
score: 0.5,
|
|
reason: 'Assertion passed',
|
|
}),
|
|
};
|
|
|
|
const javascriptFunctionFailAssertion: Assertion = {
|
|
type: 'javascript',
|
|
value: async (_output: string) => ({
|
|
pass: false,
|
|
score: 0.5,
|
|
reason: 'Assertion failed',
|
|
}),
|
|
};
|
|
|
|
describe('JavaScript file references', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset all mocks before each test
|
|
vi.mocked(importModule).mockReset();
|
|
vi.mocked(path.resolve).mockReset();
|
|
vi.mocked(isPackagePath).mockReset();
|
|
vi.mocked(loadFromPackage).mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
it('should handle JavaScript file reference with function name', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'file:///path/to/assert.js:customFunction',
|
|
};
|
|
|
|
const mockFn = vi.fn((_output: string) => true);
|
|
vi.mocked(path.resolve).mockReturnValue('/path/to/assert.js');
|
|
vi.mocked(path.extname).mockReturnValue('.js');
|
|
vi.mocked(isPackagePath).mockReturnValue(false);
|
|
|
|
// Mock importModule to return the mock function
|
|
vi.mocked(importModule).mockImplementationOnce((_path, _functionName) => {
|
|
return Promise.resolve({
|
|
customFunction: mockFn,
|
|
});
|
|
});
|
|
|
|
const output = 'Expected output';
|
|
const provider = new OpenAiChatCompletionProvider('gpt-4o-mini');
|
|
const providerResponse = { output };
|
|
|
|
const result = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider,
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse,
|
|
});
|
|
|
|
// Verify the mock was called with both parameters
|
|
expect(importModule).toHaveBeenCalledWith('/path/to/assert.js', 'customFunction');
|
|
expect(mockFn).toHaveBeenCalledWith(output, {
|
|
prompt: 'Some prompt',
|
|
vars: {},
|
|
test: {},
|
|
provider,
|
|
providerResponse,
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should pass assertion config to JavaScript file references', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'file:///path/to/assert.js',
|
|
config: {
|
|
minLength: 5,
|
|
},
|
|
};
|
|
|
|
const mockFn = vi.fn((output: string, context: { config?: { minLength?: number } }) => {
|
|
return output.length >= (context.config?.minLength ?? 0);
|
|
});
|
|
vi.mocked(path.resolve).mockReturnValue('/path/to/assert.js');
|
|
vi.mocked(path.extname).mockReturnValue('.js');
|
|
vi.mocked(isPackagePath).mockReturnValue(false);
|
|
vi.mocked(importModule).mockResolvedValue(mockFn);
|
|
|
|
const output = 'Expected output';
|
|
const provider = new OpenAiChatCompletionProvider('gpt-4o-mini');
|
|
const providerResponse = { output };
|
|
|
|
const result = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider,
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse,
|
|
});
|
|
|
|
expect(mockFn).toHaveBeenCalledWith(output, {
|
|
prompt: 'Some prompt',
|
|
vars: {},
|
|
test: {},
|
|
config: {
|
|
minLength: 5,
|
|
},
|
|
provider,
|
|
providerResponse,
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should handle default export when no function name specified', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'file:///path/to/assert.js',
|
|
};
|
|
|
|
const mockFn = vi.fn((_output: string) => true);
|
|
vi.mocked(path.resolve).mockReturnValue('/path/to/assert.js');
|
|
vi.mocked(path.extname).mockReturnValue('.js');
|
|
vi.mocked(isPackagePath).mockReturnValue(false);
|
|
|
|
// Mock importModule to return the mock function
|
|
vi.mocked(importModule).mockImplementationOnce((_path, _functionName) => {
|
|
return Promise.resolve(mockFn);
|
|
});
|
|
|
|
const output = 'Expected output';
|
|
const provider = new OpenAiChatCompletionProvider('gpt-4o-mini');
|
|
const providerResponse = { output };
|
|
|
|
const result = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider,
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse,
|
|
});
|
|
|
|
expect(importModule).toHaveBeenCalledWith('/path/to/assert.js', undefined);
|
|
expect(mockFn).toHaveBeenCalledWith(output, {
|
|
prompt: 'Some prompt',
|
|
vars: {},
|
|
test: {},
|
|
provider,
|
|
providerResponse,
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should handle default export object with function', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'file:///path/to/assert.js',
|
|
};
|
|
|
|
const mockFn = vi.fn((_output: string) => true);
|
|
vi.mocked(path.resolve).mockReturnValue('/path/to/assert.js');
|
|
vi.mocked(path.extname).mockReturnValue('.js');
|
|
vi.mocked(isPackagePath).mockReturnValue(false);
|
|
|
|
// Mock importModule to handle both parameters
|
|
const mockImportModule = vi.mocked(importModule);
|
|
mockImportModule.mockImplementationOnce((_path, _functionName) => {
|
|
// Return the mock function in a default export object
|
|
return Promise.resolve({ default: mockFn });
|
|
});
|
|
|
|
const output = 'Expected output';
|
|
const provider = new OpenAiChatCompletionProvider('gpt-4o-mini');
|
|
const providerResponse = { output };
|
|
|
|
const result = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider,
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse,
|
|
});
|
|
|
|
expect(importModule).toHaveBeenCalledWith('/path/to/assert.js', undefined);
|
|
expect(mockFn).toHaveBeenCalledWith(output, {
|
|
prompt: 'Some prompt',
|
|
vars: {},
|
|
test: {},
|
|
provider,
|
|
providerResponse,
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should pass when the javascript assertion passes', async () => {
|
|
const output = 'Expected output';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptStringAssertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should pass a score through when the javascript returns a number', async () => {
|
|
const output = 'Expected output';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptStringAssertionWithNumber,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
score: output.length * 10,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should pass when javascript returns an output string that is smaller than the maximum size threshold', async () => {
|
|
const output = 'Expected output';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptBooleanAssertionWithConfig,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
score: 1.0,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should fail when javascript returns an output string that is larger than the maximum size threshold', async () => {
|
|
const output = 'Expected output with some extra characters';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptBooleanAssertionWithConfig,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: false,
|
|
score: 0,
|
|
reason: expect.stringContaining('Custom function returned false'),
|
|
});
|
|
});
|
|
|
|
it('should pass when javascript returns a number above threshold', async () => {
|
|
const output = 'Expected output';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptStringAssertionWithNumberAndThreshold,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
score: output.length * 10,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should fail when javascript returns a number below threshold', async () => {
|
|
const output = '';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptStringAssertionWithNumberAndThreshold,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: false,
|
|
score: output.length * 10,
|
|
reason: expect.stringContaining('Custom function returned false'),
|
|
});
|
|
});
|
|
|
|
it('should set score when javascript returns false', async () => {
|
|
const output = 'Test output';
|
|
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'output.length < 1',
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: false,
|
|
score: 0,
|
|
reason: expect.stringContaining('Custom function returned false'),
|
|
});
|
|
});
|
|
|
|
it('should fail when the javascript assertion fails', async () => {
|
|
const output = 'Different output';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptStringAssertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: false,
|
|
reason: 'Custom function returned false\noutput === "Expected output"',
|
|
});
|
|
});
|
|
|
|
it('should pass when javascript function assertion passes - with vars', async () => {
|
|
const output = 'Expected output';
|
|
|
|
const javascriptStringAssertionWithVars: Assertion = {
|
|
type: 'javascript',
|
|
value: 'output === "Expected output" && context.vars.foo === "bar"',
|
|
};
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptStringAssertionWithVars,
|
|
test: { vars: { foo: 'bar' } } as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should fail when the javascript does not match vars', async () => {
|
|
const output = 'Expected output';
|
|
|
|
const javascriptStringAssertionWithVars: Assertion = {
|
|
type: 'javascript',
|
|
value: 'output === "Expected output" && context.vars.foo === "something else"',
|
|
};
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptStringAssertionWithVars,
|
|
test: { vars: { foo: 'bar' } } as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: false,
|
|
reason:
|
|
'Custom function returned false\noutput === "Expected output" && context.vars.foo === "something else"',
|
|
});
|
|
});
|
|
|
|
// Fix for GitHub issue #7334: Dynamic vars should be resolved when passed to assertions
|
|
it('should use resolved vars parameter over test.vars when provided (issue #7334)', async () => {
|
|
const output = 'Expected output';
|
|
|
|
// Simulates the case where test.vars has an unresolved file:// reference
|
|
// but the vars parameter has the resolved value
|
|
const javascriptStringAssertionWithVars: Assertion = {
|
|
type: 'javascript',
|
|
value: 'context.vars.dynamicVar === "resolved-value"',
|
|
};
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptStringAssertionWithVars,
|
|
test: { vars: { dynamicVar: 'file://some-script.js' } } as AtomicTestCase,
|
|
// Pass resolved vars - this should take precedence over test.vars
|
|
vars: { dynamicVar: 'resolved-value' },
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should fall back to test.vars when vars parameter is not provided', async () => {
|
|
const output = 'Expected output';
|
|
|
|
const javascriptStringAssertionWithVars: Assertion = {
|
|
type: 'javascript',
|
|
value: 'context.vars.foo === "bar"',
|
|
};
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptStringAssertionWithVars,
|
|
test: { vars: { foo: 'bar' } } as AtomicTestCase,
|
|
// No vars parameter - should fall back to test.vars
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should fail when vars parameter has wrong value even if test.vars has correct value (issue #7334 negative)', async () => {
|
|
const output = 'Expected output';
|
|
|
|
// This negative test verifies that vars parameter truly takes precedence over test.vars
|
|
const javascriptStringAssertionWithVars: Assertion = {
|
|
type: 'javascript',
|
|
value: 'context.vars.dynamicVar === "expected-value"',
|
|
};
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptStringAssertionWithVars,
|
|
// test.vars has the "correct" value
|
|
test: { vars: { dynamicVar: 'expected-value' } } as AtomicTestCase,
|
|
// But vars parameter has the wrong value - this should take precedence and fail
|
|
vars: { dynamicVar: 'wrong-value' },
|
|
providerResponse: { output },
|
|
});
|
|
expect(result.pass).toBe(false);
|
|
expect(result.reason).toContain('false');
|
|
});
|
|
|
|
it('should pass when the function returns pass', async () => {
|
|
const output = 'Expected output';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptFunctionAssertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
score: 0.5,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should fail when the function returns fail', async () => {
|
|
const output = 'Expected output';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion: javascriptFunctionFailAssertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: false,
|
|
score: 0.5,
|
|
reason: 'Assertion failed',
|
|
});
|
|
});
|
|
|
|
it('should serialize direct function-valued javascript assertions when they throw', async () => {
|
|
const output = 'Expected output';
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: () => {
|
|
throw new Error('boom');
|
|
},
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: false,
|
|
score: 0,
|
|
reason: expect.stringContaining('Custom function threw error: boom'),
|
|
assertion: {
|
|
type: 'javascript',
|
|
value: expect.any(String),
|
|
},
|
|
});
|
|
expect(typeof result.assertion?.value).toBe('string');
|
|
expect(result.reason).not.toMatch(/\nundefined$/);
|
|
});
|
|
|
|
it('should serialize function-valued assertions returned inside a custom GradingResult', async () => {
|
|
const output = 'Expected output';
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: () => ({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Custom reason',
|
|
assertion: {
|
|
type: 'javascript',
|
|
value: () => false,
|
|
},
|
|
}),
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
score: 1,
|
|
reason: 'Custom reason',
|
|
assertion: {
|
|
type: 'javascript',
|
|
value: expect.any(String),
|
|
},
|
|
});
|
|
expect(typeof result.assertion?.value).toBe('string');
|
|
expect(result.assertion?.value).toContain('() => false');
|
|
});
|
|
|
|
it.each([
|
|
['true', () => true, true, 1],
|
|
['false', () => false, false, 0],
|
|
['a number', () => 0.75, true, 0.75],
|
|
])('should normalize direct function-valued javascript assertions that return %s', async (_type, value, expectedPass, expectedScore) => {
|
|
const output = 'Expected output';
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value,
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: expectedPass,
|
|
score: expectedScore,
|
|
reason: expectedPass ? 'Assertion passed' : 'Custom function returned false',
|
|
assertion: {
|
|
type: 'javascript',
|
|
value: expect.any(String),
|
|
},
|
|
});
|
|
expect(typeof result.assertion?.value).toBe('string');
|
|
});
|
|
|
|
it('should honor threshold when a direct function-valued javascript assertion returns a number', async () => {
|
|
const output = 'Expected output';
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: () => 0.25,
|
|
threshold: 0.5,
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: false,
|
|
score: 0.25,
|
|
reason: 'Custom function returned false',
|
|
assertion: {
|
|
type: 'javascript',
|
|
value: expect.any(String),
|
|
},
|
|
});
|
|
});
|
|
|
|
const inverseFunctionAssertionCases: [string, Assertion, boolean, number, string][] = [
|
|
[
|
|
'boolean results for not-javascript assertions',
|
|
{
|
|
type: 'not-javascript',
|
|
value: () => true,
|
|
},
|
|
false,
|
|
0,
|
|
'Custom function returned true',
|
|
],
|
|
[
|
|
'numeric results for not-javascript assertions',
|
|
{
|
|
type: 'not-javascript',
|
|
value: () => 0.25,
|
|
threshold: 0.5,
|
|
},
|
|
true,
|
|
0.25,
|
|
'Assertion passed',
|
|
],
|
|
[
|
|
'GradingResult results for not-javascript assertions',
|
|
{
|
|
type: 'not-javascript',
|
|
value: () => ({
|
|
pass: true,
|
|
score: 0.75,
|
|
reason: 'Custom reason',
|
|
}),
|
|
},
|
|
false,
|
|
0.75,
|
|
'Custom function returned true',
|
|
],
|
|
];
|
|
|
|
it.each(
|
|
inverseFunctionAssertionCases,
|
|
)('should honor inverse mode for direct function-valued javascript assertions with %s', async (_type, assertion, expectedPass, expectedScore, expectedReason) => {
|
|
const output = 'Expected output';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: expectedPass,
|
|
score: expectedScore,
|
|
reason: expectedReason,
|
|
assertion: {
|
|
type: 'not-javascript',
|
|
value: expect.any(String),
|
|
},
|
|
});
|
|
});
|
|
|
|
const inverseStringAssertionCases: [string, Assertion, boolean, number, string][] = [
|
|
[
|
|
'boolean results for not-javascript assertions',
|
|
{
|
|
type: 'not-javascript',
|
|
value: 'output === "Expected output"',
|
|
},
|
|
false,
|
|
0,
|
|
'Custom function returned true\noutput === "Expected output"',
|
|
],
|
|
[
|
|
'numeric results for not-javascript assertions',
|
|
{
|
|
type: 'not-javascript',
|
|
value: '0.25',
|
|
threshold: 0.5,
|
|
},
|
|
true,
|
|
0.25,
|
|
'Assertion passed',
|
|
],
|
|
[
|
|
'GradingResult results for not-javascript assertions',
|
|
{
|
|
type: 'not-javascript',
|
|
value: `
|
|
return {
|
|
pass: true,
|
|
score: 0.75,
|
|
reason: 'Custom reason',
|
|
};
|
|
`,
|
|
},
|
|
false,
|
|
0.75,
|
|
'Custom function returned true',
|
|
],
|
|
];
|
|
|
|
it.each(
|
|
inverseStringAssertionCases,
|
|
)('should honor inverse mode for inline javascript assertions with %s', async (_type, assertion, expectedPass, expectedScore, expectedReason) => {
|
|
const output = 'Expected output';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: expectedPass,
|
|
score: expectedScore,
|
|
reason: expectedReason,
|
|
assertion: {
|
|
type: 'not-javascript',
|
|
value: expect.any(String),
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should honor inverse mode when a file:// javascript assertion returns a number', async () => {
|
|
const output = 'Expected output';
|
|
|
|
vi.mocked(path.resolve).mockReturnValue('/mocked/path/to/assert.js');
|
|
vi.mocked(path.extname).mockReturnValue('.js');
|
|
vi.mocked(isPackagePath).mockReturnValue(false);
|
|
vi.mocked(importModule).mockResolvedValue(vi.fn(() => 0.25));
|
|
|
|
const assertion: Assertion = {
|
|
type: 'not-javascript',
|
|
value: 'file:///path/to/assert.js',
|
|
threshold: 0.5,
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
score: 0.25,
|
|
reason: 'Assertion passed',
|
|
assertion: {
|
|
type: 'not-javascript',
|
|
value: 'file:///path/to/assert.js',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should pass when the multiline javascript assertion passes', async () => {
|
|
const output = 'Expected output';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
assertion: javascriptMultilineStringAssertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
it('should pass when the multiline javascript assertion fails', async () => {
|
|
const output = 'Not the expected output';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
assertion: javascriptMultilineStringAssertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: false,
|
|
reason: 'Assertion failed',
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['boolean', vi.fn((output: string) => output === 'Expected output'), true, 'Assertion passed'],
|
|
['number', vi.fn((output: string) => output.length), true, 'Assertion passed'],
|
|
[
|
|
'GradingResult',
|
|
vi.fn((_output: string) => ({ pass: true, score: 1, reason: 'Custom reason' })),
|
|
true,
|
|
'Custom reason',
|
|
],
|
|
[
|
|
'boolean',
|
|
vi.fn((output: string) => output !== 'Expected output'),
|
|
false,
|
|
'Custom function returned false',
|
|
],
|
|
['number', vi.fn((_output: string) => 0), false, 'Custom function returned false'],
|
|
[
|
|
'GradingResult',
|
|
vi.fn((_output: string) => ({ pass: false, score: 0.1, reason: 'Custom reason' })),
|
|
false,
|
|
'Custom reason',
|
|
],
|
|
[
|
|
'boolean Promise',
|
|
vi.fn((_output: string) => Promise.resolve(true)),
|
|
true,
|
|
'Assertion passed',
|
|
],
|
|
])('should pass when the file:// assertion with .js file returns a %s', async (_type, mockFn, expectedPass, expectedReason) => {
|
|
const output = 'Expected output';
|
|
|
|
// Mock path.resolve to return a valid path
|
|
vi.mocked(path.resolve).mockReturnValue('/mocked/path/to/assert.js');
|
|
vi.mocked(path.extname).mockReturnValue('.js');
|
|
|
|
// Mock isPackagePath to return false for file:// paths
|
|
vi.mocked(isPackagePath).mockReturnValue(false);
|
|
|
|
// Mock importModule to handle both path and functionName
|
|
const mockImportModule = vi.mocked(importModule);
|
|
mockImportModule.mockImplementation((path, functionName) => {
|
|
// Make sure both parameters are captured in the mock
|
|
mockImportModule.mock.calls.push([path, functionName]);
|
|
return Promise.resolve(mockFn);
|
|
});
|
|
|
|
const fileAssertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'file:///path/to/assert.js',
|
|
};
|
|
|
|
const provider = new OpenAiChatCompletionProvider('gpt-4o-mini');
|
|
const providerResponse = { output };
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider,
|
|
assertion: fileAssertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse,
|
|
});
|
|
|
|
expect(mockFn).toHaveBeenCalledWith(output, {
|
|
prompt: 'Some prompt',
|
|
vars: {},
|
|
test: {},
|
|
provider,
|
|
providerResponse,
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: expectedPass,
|
|
reason: expect.stringContaining(expectedReason),
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['boolean', vi.fn((output: string) => output === 'Expected output'), true, 'Assertion passed'],
|
|
['number', vi.fn((output: string) => output.length), true, 'Assertion passed'],
|
|
[
|
|
'GradingResult',
|
|
vi.fn((_output: string) => ({ pass: true, score: 1, reason: 'Custom reason' })),
|
|
true,
|
|
'Custom reason',
|
|
],
|
|
[
|
|
'boolean',
|
|
vi.fn((output: string) => output !== 'Expected output'),
|
|
false,
|
|
'Custom function returned false',
|
|
],
|
|
['number', vi.fn((_output: string) => 0), false, 'Custom function returned false'],
|
|
[
|
|
'GradingResult',
|
|
vi.fn((_output: string) => ({ pass: false, score: 0.1, reason: 'Custom reason' })),
|
|
false,
|
|
'Custom reason',
|
|
],
|
|
[
|
|
'boolean Promise',
|
|
vi.fn((_output: string) => Promise.resolve(true)),
|
|
true,
|
|
'Assertion passed',
|
|
],
|
|
])('should pass when assertion is a package path', async (_type, mockFn, expectedPass, expectedReason) => {
|
|
const output = 'Expected output';
|
|
|
|
// Mock isPackagePath to return true for package paths
|
|
vi.mocked(isPackagePath).mockReturnValue(true);
|
|
|
|
// Mock loadFromPackage to return the mockFn
|
|
vi.mocked(loadFromPackage).mockResolvedValue(mockFn);
|
|
|
|
const packageAssertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'package:@promptfoo/fake:assertionFunction',
|
|
};
|
|
|
|
const provider = new OpenAiChatCompletionProvider('gpt-4o-mini');
|
|
const providerResponse = { output };
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider,
|
|
assertion: packageAssertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse,
|
|
});
|
|
|
|
expect(mockFn).toHaveBeenCalledWith(output, {
|
|
prompt: 'Some prompt',
|
|
vars: {},
|
|
test: {},
|
|
provider,
|
|
providerResponse,
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: expectedPass,
|
|
reason: expect.stringContaining(expectedReason),
|
|
});
|
|
});
|
|
|
|
it('should resolve js paths relative to the configuration file', async () => {
|
|
const output = 'Expected output';
|
|
const mockFn = vi.fn((output: string) => output === 'Expected output');
|
|
|
|
// Mock path.resolve to return a valid path
|
|
vi.mocked(path.resolve).mockReturnValue('/base/path/path/to/assert.js');
|
|
vi.mocked(path.extname).mockReturnValue('.js');
|
|
|
|
// Mock isPackagePath to return false
|
|
vi.mocked(isPackagePath).mockReturnValue(false);
|
|
|
|
// Mock importModule to return the mockFn
|
|
vi.mocked(importModule).mockResolvedValue(mockFn);
|
|
|
|
const fileAssertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'file://./path/to/assert.js',
|
|
};
|
|
|
|
const provider = new OpenAiChatCompletionProvider('gpt-4o-mini');
|
|
const providerResponse = { output };
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider,
|
|
assertion: fileAssertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse,
|
|
});
|
|
|
|
expect(mockFn).toHaveBeenCalledWith(output, {
|
|
prompt: 'Some prompt',
|
|
vars: {},
|
|
test: {},
|
|
provider,
|
|
providerResponse,
|
|
});
|
|
expect(result).toMatchObject({
|
|
pass: true,
|
|
reason: 'Assertion passed',
|
|
});
|
|
});
|
|
|
|
describe('Single-line assertions with variable declarations', () => {
|
|
it('should handle const declaration in single-line assertion', async () => {
|
|
// The code injects `return` before the final expression, not at the start
|
|
// "const s = ...; s >= 0.5" becomes "const s = ...; return s >= 0.5"
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'const s = JSON.parse(output).score; s >= 0.5 && s <= 0.75',
|
|
};
|
|
|
|
const output = JSON.stringify({ score: 0.67, reason: 'test' });
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.reason).toBe('Assertion passed');
|
|
});
|
|
|
|
it('should handle let declaration in single-line assertion', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'let x = output.length; x > 5',
|
|
};
|
|
|
|
const output = 'Hello World';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.reason).toBe('Assertion passed');
|
|
});
|
|
|
|
it('should handle var declaration in single-line assertion', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'var x = output.length; x > 5',
|
|
};
|
|
|
|
const output = 'Hello World';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.reason).toBe('Assertion passed');
|
|
});
|
|
|
|
it('should handle multiple declarations in single-line assertion', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'const a = 5; const b = 10; a + b === 15',
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output: 'test' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should handle declaration with semicolon in string value', async () => {
|
|
// Semicolons inside strings should not break the parsing
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'const s = "hello; world"; s.length > 5',
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output: 'test' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should handle semicolon in final expression string (critical edge case)', async () => {
|
|
// This is the critical bug fix test - semicolon in final expression's string
|
|
// was causing silent failures before because lastIndexOf(';') found the wrong semicolon
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'const s = output; s === "test;value"',
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output: 'test;value' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.reason).toBe('Assertion passed');
|
|
});
|
|
|
|
it('should correctly evaluate includes() with semicolon argument', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'const x = output; x.includes(";")',
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output: 'hello;world' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should handle single quotes with semicolons in final expression', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: "const s = output; s === 'a;b;c'",
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output: 'a;b;c' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should handle template literals with semicolons in final expression', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'const s = output; s === `test;value`',
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output: 'test;value' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should handle trailing semicolon in assertion', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'const x = 10; x > 5;',
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output: 'test' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should still work with IIFE format', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: '(() => { const s = JSON.parse(output).score; return s >= 0.5 && s <= 0.75; })()',
|
|
};
|
|
|
|
const output = JSON.stringify({ score: 0.67, reason: 'test' });
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should still work with multiline format', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: `const s = JSON.parse(output).score;
|
|
return s >= 0.5 && s <= 0.75;`,
|
|
};
|
|
|
|
const output = JSON.stringify({ score: 0.67, reason: 'test' });
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should fail when assertion evaluates to false', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: 'const x = output.length; x > 100',
|
|
};
|
|
|
|
const output = 'short';
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
assertion,
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output },
|
|
});
|
|
|
|
expect(result.pass).toBe(false);
|
|
expect(result.reason).toContain('Custom function returned false');
|
|
});
|
|
});
|
|
|
|
describe('JavaScript threshold edge cases', () => {
|
|
const baseParams = {
|
|
prompt: 'test',
|
|
provider: new OpenAiChatCompletionProvider('gpt-4o-mini'),
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: { output: '0' },
|
|
};
|
|
|
|
it('should FAIL when score=0 and no threshold (default behavior)', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: '0',
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
...baseParams,
|
|
assertion,
|
|
});
|
|
|
|
expect(result.pass).toBe(false);
|
|
expect(result.score).toBe(0);
|
|
});
|
|
|
|
it('should PASS when score=0 and threshold=0 (explicit zero threshold)', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: '0',
|
|
threshold: 0,
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
...baseParams,
|
|
assertion,
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(0);
|
|
});
|
|
|
|
it('should FAIL when score=0 and threshold=0.1', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: '0',
|
|
threshold: 0.1,
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
...baseParams,
|
|
assertion,
|
|
});
|
|
|
|
expect(result.pass).toBe(false);
|
|
expect(result.score).toBe(0);
|
|
});
|
|
|
|
it('should PASS when score=1 and threshold=0', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: '1',
|
|
threshold: 0,
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
...baseParams,
|
|
assertion,
|
|
providerResponse: { output: '1' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(1);
|
|
});
|
|
|
|
it('should PASS when score=1 and no threshold', async () => {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: '1',
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
...baseParams,
|
|
assertion,
|
|
providerResponse: { output: '1' },
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.score).toBe(1);
|
|
});
|
|
|
|
it('should handle threshold=0 differently from threshold=undefined', async () => {
|
|
const assertionWithoutThreshold: Assertion = {
|
|
type: 'javascript',
|
|
value: '0',
|
|
};
|
|
|
|
const assertionWithZeroThreshold: Assertion = {
|
|
type: 'javascript',
|
|
value: '0',
|
|
threshold: 0,
|
|
};
|
|
|
|
const resultWithoutThreshold: GradingResult = await runAssertion({
|
|
...baseParams,
|
|
assertion: assertionWithoutThreshold,
|
|
});
|
|
|
|
const resultWithZeroThreshold: GradingResult = await runAssertion({
|
|
...baseParams,
|
|
assertion: assertionWithZeroThreshold,
|
|
});
|
|
|
|
// These should have different outcomes
|
|
expect(resultWithoutThreshold.pass).toBe(false); // score > 0 check
|
|
expect(resultWithZeroThreshold.pass).toBe(true); // score >= 0 check
|
|
|
|
// But same score
|
|
expect(resultWithoutThreshold.score).toBe(0);
|
|
expect(resultWithZeroThreshold.score).toBe(0);
|
|
});
|
|
|
|
it('should handle various falsy threshold values correctly', async () => {
|
|
const testCases = [
|
|
{ threshold: 0, expected: true, description: 'threshold=0' },
|
|
{ threshold: undefined, expected: false, description: 'threshold=undefined' },
|
|
// Note: null, empty string, false are treated as valid thresholds and compared numerically
|
|
// null becomes 0 when compared: 0 >= null (which is 0) = true
|
|
{ threshold: null, expected: true, description: 'threshold=null (becomes 0)' },
|
|
// Empty string becomes 0 when compared: 0 >= '' (which is 0) = true
|
|
{ threshold: '', expected: true, description: 'threshold="" (becomes 0)' },
|
|
// false becomes 0 when compared: 0 >= false (which is 0) = true
|
|
{ threshold: false, expected: true, description: 'threshold=false (becomes 0)' },
|
|
];
|
|
|
|
for (const testCase of testCases) {
|
|
const assertion: Assertion = {
|
|
type: 'javascript',
|
|
value: '0',
|
|
threshold: testCase.threshold as any,
|
|
};
|
|
|
|
const result: GradingResult = await runAssertion({
|
|
...baseParams,
|
|
assertion,
|
|
});
|
|
|
|
expect(result.pass).toBe(testCase.expected);
|
|
expect(result.score).toBe(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Metadata access in JavaScript assertions', () => {
|
|
const provider = new OpenAiChatCompletionProvider('gpt-4o-mini');
|
|
|
|
async function runMetadataAssertion(
|
|
value: string,
|
|
metadata?: Record<string, any>,
|
|
): Promise<GradingResult> {
|
|
return runAssertion({
|
|
prompt: 'Some prompt',
|
|
provider,
|
|
assertion: { type: 'javascript', value },
|
|
test: {} as AtomicTestCase,
|
|
providerResponse: {
|
|
output: 'Expected output',
|
|
...(metadata === undefined ? {} : { metadata }),
|
|
},
|
|
});
|
|
}
|
|
|
|
it('should access metadata via context.metadata shortcut', async () => {
|
|
const result = await runMetadataAssertion('context.metadata?.toolCalls <= 10', {
|
|
toolCalls: 5,
|
|
toolNames: ['get_weather', 'search'],
|
|
});
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.reason).toBe('Assertion passed');
|
|
});
|
|
|
|
it('should access metadata via context.providerResponse.metadata (full path)', async () => {
|
|
const result = await runMetadataAssertion(
|
|
'context.providerResponse?.metadata?.toolCalls <= 10',
|
|
{
|
|
toolCalls: 5,
|
|
},
|
|
);
|
|
|
|
expect(result.pass).toBe(true);
|
|
expect(result.reason).toBe('Assertion passed');
|
|
});
|
|
|
|
it('should fail assertion when metadata check fails', async () => {
|
|
const result = await runMetadataAssertion('context.metadata?.toolCalls <= 3', {
|
|
toolCalls: 10,
|
|
});
|
|
|
|
expect(result.pass).toBe(false);
|
|
});
|
|
|
|
it('should handle missing metadata gracefully with nullish coalescing', async () => {
|
|
const result = await runMetadataAssertion('(context.metadata?.toolCalls ?? 0) <= 10');
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('should handle empty metadata object gracefully', async () => {
|
|
const result = await runMetadataAssertion('(context.metadata?.toolCalls ?? 0) <= 10', {});
|
|
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: 'HTTP metadata',
|
|
value: 'context.metadata?.http?.status === 200',
|
|
metadata: {
|
|
http: {
|
|
status: 200,
|
|
statusText: 'OK',
|
|
headers: { 'content-type': 'application/json' },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: 'array metadata',
|
|
value: 'context.metadata?.toolNames?.includes("get_weather")',
|
|
metadata: {
|
|
toolNames: ['get_weather', 'search', 'calculate'],
|
|
},
|
|
},
|
|
{
|
|
name: 'complex metadata assertions with variables',
|
|
value:
|
|
'const meta = context.metadata; meta && meta.toolCalls <= 10 && meta.toolNames?.length <= 5',
|
|
metadata: {
|
|
toolCalls: 5,
|
|
toolNames: ['get_weather', 'search'],
|
|
},
|
|
},
|
|
])('should access $name', async ({ value, metadata }) => {
|
|
const result = await runMetadataAssertion(value, metadata);
|
|
|
|
expect(result).toMatchObject({ pass: true });
|
|
});
|
|
});
|
|
});
|