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
1883 lines
68 KiB
TypeScript
1883 lines
68 KiB
TypeScript
import { createRequire } from 'node:module';
|
|
import * as fs from 'fs';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
type AfterEachExtensionHookContext,
|
|
collectFileMetadata,
|
|
extractTextFromPDF,
|
|
getExtensionHookName,
|
|
renderPrompt,
|
|
resolveVariables,
|
|
runExtensionHook,
|
|
} from '../src/evaluatorHelpers';
|
|
import logger from '../src/logger';
|
|
import { transform } from '../src/util/transform';
|
|
import { createMockProvider } from './factories/provider';
|
|
import { mockProcessEnv } from './util/utils';
|
|
|
|
import type { Prompt, TestCase, TestSuite } from '../src/types/index';
|
|
|
|
// Use vi.hoisted to define mocks and helpers that need to be accessible in vi.mock factories
|
|
const { actualPathResolve, dynamicModuleMocks, fsMocks, mockDynamicModule, mockPathResolve } =
|
|
vi.hoisted(() => {
|
|
const actualPath = require('path');
|
|
const actualPathResolve = actualPath.resolve.bind(actualPath);
|
|
const mockPathResolve = vi.fn((...paths: string[]) => actualPathResolve(...paths));
|
|
const dynamicModuleMocks = new Map<string, any>();
|
|
const mockDynamicModule = (filePath: string, moduleExport: any) => {
|
|
const resolvedPath = actualPathResolve(filePath);
|
|
dynamicModuleMocks.set(resolvedPath, moduleExport);
|
|
};
|
|
const fsMocks = {
|
|
readFileSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
statSync: vi.fn(),
|
|
readdirSync: vi.fn(),
|
|
existsSync: vi.fn(),
|
|
mkdirSync: vi.fn(),
|
|
};
|
|
return { actualPathResolve, dynamicModuleMocks, fsMocks, mockDynamicModule, mockPathResolve };
|
|
});
|
|
|
|
vi.mock('path', async () => {
|
|
const actual = await vi.importActual<typeof import('path')>('path');
|
|
return {
|
|
...actual,
|
|
resolve: (...paths: string[]) => mockPathResolve(...paths),
|
|
};
|
|
});
|
|
|
|
vi.mock('proxy-agent', () => ({
|
|
ProxyAgent: vi.fn().mockImplementation(() => ({})),
|
|
}));
|
|
|
|
vi.mock('glob', () => ({
|
|
globSync: vi.fn(),
|
|
}));
|
|
|
|
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('fs', () => ({
|
|
...fsMocks,
|
|
promises: {
|
|
readFile: fsMocks.readFileSync,
|
|
},
|
|
}));
|
|
|
|
vi.mock('fs/promises', () => ({
|
|
default: {
|
|
readFile: fsMocks.readFileSync,
|
|
},
|
|
readFile: fsMocks.readFileSync,
|
|
}));
|
|
|
|
const mockGetText = vi.fn().mockResolvedValue({ text: 'Extracted PDF text' });
|
|
const mockDestroy = vi.fn().mockResolvedValue(undefined);
|
|
|
|
vi.mock('pdf-parse', () => ({
|
|
__esModule: true,
|
|
PDFParse: vi.fn().mockImplementation(function () {
|
|
return {
|
|
getText: mockGetText,
|
|
destroy: mockDestroy,
|
|
};
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../src/esm', () => ({
|
|
getDirectory: () => '/test/dir',
|
|
importModule: vi.fn(async (filePath: string, functionName?: string) => {
|
|
// Check if we have a dynamic mock for this path
|
|
const resolvedPath = actualPathResolve(filePath);
|
|
if (dynamicModuleMocks.has(resolvedPath)) {
|
|
const mod = dynamicModuleMocks.get(resolvedPath);
|
|
if (functionName) {
|
|
return mod[functionName];
|
|
}
|
|
return mod;
|
|
}
|
|
// For tests that don't set up dynamic mocks, return a simple function
|
|
return (varName: string) => ({ output: `Dynamic value for ${varName}` });
|
|
}),
|
|
resolvePackageEntryPoint: vi.fn((packageName: string, _baseDir: string) => {
|
|
// Return a mock path for the package (matches what dynamic module mocks expect)
|
|
return `/node_modules/${packageName}/index.js`;
|
|
}),
|
|
}));
|
|
vi.mock('../src/database', () => ({
|
|
getDb: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../src/util/transform', () => ({
|
|
transform: vi.fn(),
|
|
}));
|
|
|
|
const mockApiProvider = createMockProvider();
|
|
|
|
function toPrompt(text: string): Prompt {
|
|
return { raw: text, label: text };
|
|
}
|
|
|
|
describe('evaluatorHelpers', () => {
|
|
/**
|
|
* Reset shared mock state before each test to ensure test isolation.
|
|
*
|
|
* - dynamicModuleMocks: Clears the Map used by mockDynamicModule() to prevent
|
|
* module mocks from leaking between tests (e.g., renderPrompt external JS tests)
|
|
* - mockPathResolve: Resets to default implementation since some tests override it
|
|
* with custom behavior (e.g., collectFileMetadata returns only the last path segment)
|
|
* - vi.clearAllMocks(): Clears call history for all mocks to ensure clean assertions
|
|
*/
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
dynamicModuleMocks.clear();
|
|
mockPathResolve.mockReset();
|
|
mockPathResolve.mockImplementation((...paths: string[]) => actualPathResolve(...paths));
|
|
});
|
|
|
|
describe('extractTextFromPDF', () => {
|
|
it('should extract text from PDF successfully', async () => {
|
|
const mockPDFText = 'Extracted PDF text';
|
|
vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(Buffer.from('mock pdf content'));
|
|
|
|
const result = await extractTextFromPDF('test.pdf');
|
|
expect(result).toBe(mockPDFText);
|
|
});
|
|
|
|
it('should throw error when pdf-parse is not installed', async () => {
|
|
vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(Buffer.from('mock pdf content'));
|
|
const pdfParse = await import('pdf-parse');
|
|
vi.mocked(pdfParse.PDFParse).mockImplementationOnce(() => {
|
|
throw new Error("Cannot find module 'pdf-parse'");
|
|
});
|
|
|
|
await expect(extractTextFromPDF('test.pdf')).rejects.toThrow(
|
|
'pdf-parse is not installed. Please install it with: npm install pdf-parse',
|
|
);
|
|
});
|
|
|
|
it('should handle PDF extraction errors', async () => {
|
|
vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(Buffer.from('mock pdf content'));
|
|
mockGetText.mockRejectedValueOnce(new Error('PDF parsing failed'));
|
|
|
|
await expect(extractTextFromPDF('test.pdf')).rejects.toThrow(
|
|
'Failed to extract text from PDF test.pdf: PDF parsing failed',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('renderPrompt', () => {
|
|
beforeEach(() => {
|
|
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: undefined });
|
|
mockProcessEnv({ PROMPTFOO_DISABLE_JSON_AUTOESCAPE: undefined });
|
|
});
|
|
|
|
it('should render a prompt with a single variable', async () => {
|
|
const prompt = toPrompt('Test prompt {{ var1 }}');
|
|
const renderedPrompt = await renderPrompt(prompt, { var1: 'value1' }, {});
|
|
expect(renderedPrompt).toBe('Test prompt value1');
|
|
});
|
|
|
|
it('should render nested variables in non-JSON prompts', async () => {
|
|
const prompt = toPrompt('Test {{ outer[inner] }}');
|
|
const renderedPrompt = await renderPrompt(
|
|
prompt,
|
|
{ outer: { key1: 'value1' }, inner: 'key1' },
|
|
{},
|
|
);
|
|
expect(renderedPrompt).toBe('Test value1');
|
|
});
|
|
|
|
it('should handle complex variable substitutions in non-JSON prompts', async () => {
|
|
const prompt = toPrompt('{{ var1[var2] }}');
|
|
const renderedPrompt = await renderPrompt(
|
|
prompt,
|
|
{
|
|
var1: { hello: 'world' },
|
|
var2: 'hello',
|
|
},
|
|
{},
|
|
);
|
|
expect(renderedPrompt).toBe('world');
|
|
});
|
|
|
|
it('should render a JSON prompt', async () => {
|
|
const prompt = toPrompt('[{"text": "Test prompt "}, {"text": "{{ var1 }}"}]');
|
|
const renderedPrompt = await renderPrompt(prompt, { var1: 'value1' }, {});
|
|
expect(renderedPrompt).toBe(
|
|
JSON.stringify(JSON.parse('[{"text":"Test prompt "},{"text":"value1"}]'), null, 2),
|
|
);
|
|
});
|
|
|
|
it('should render nested variables in JSON prompts', async () => {
|
|
const prompt = toPrompt('{"text": "{{ outer[inner] }}"}');
|
|
const renderedPrompt = await renderPrompt(
|
|
prompt,
|
|
{ outer: { key1: 'value1' }, inner: 'key1' },
|
|
{},
|
|
);
|
|
expect(renderedPrompt).toBe(JSON.stringify({ text: 'value1' }, null, 2));
|
|
});
|
|
|
|
it('should render environment variables in JSON prompts', async () => {
|
|
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
|
|
const prompt = toPrompt('{"text": "{{ env.TEST_ENV_VAR }}"}');
|
|
const renderedPrompt = await renderPrompt(prompt, {}, {});
|
|
expect(renderedPrompt).toBe(JSON.stringify({ text: 'env_value' }, null, 2));
|
|
mockProcessEnv({ TEST_ENV_VAR: undefined });
|
|
});
|
|
|
|
it('should render environment variables in non-JSON prompts', async () => {
|
|
mockProcessEnv({ TEST_ENV_VAR: 'env_value' });
|
|
const prompt = toPrompt('Test prompt {{ env.TEST_ENV_VAR }}');
|
|
const renderedPrompt = await renderPrompt(prompt, {}, {});
|
|
expect(renderedPrompt).toBe('Test prompt env_value');
|
|
mockProcessEnv({ TEST_ENV_VAR: undefined });
|
|
});
|
|
|
|
it('should handle complex variable substitutions in JSON prompts', async () => {
|
|
const prompt = toPrompt('{"message": "{{ var1[var2] }}"}');
|
|
const renderedPrompt = await renderPrompt(
|
|
prompt,
|
|
{
|
|
var1: { hello: 'world' },
|
|
var2: 'hello',
|
|
},
|
|
{},
|
|
);
|
|
expect(renderedPrompt).toBe(JSON.stringify({ message: 'world' }, null, 2));
|
|
});
|
|
|
|
it('should render a JSON prompt and escape the var string', async () => {
|
|
const prompt = toPrompt('[{"text": "Test prompt "}, {"text": "{{ var1 }}"}]');
|
|
const renderedPrompt = await renderPrompt(prompt, { var1: 'He said "hello world!"' }, {});
|
|
expect(renderedPrompt).toBe(
|
|
JSON.stringify(
|
|
JSON.parse('[{"text":"Test prompt "},{"text":"He said \\"hello world!\\""}]'),
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
});
|
|
|
|
it('should render a JSON prompt with nested JSON', async () => {
|
|
const prompt = toPrompt('[{"text": "Test prompt "}, {"text": "{{ var1 }}"}]');
|
|
const renderedPrompt = await renderPrompt(prompt, { var1: '{"nested": "value1"}' }, {});
|
|
expect(renderedPrompt).toBe(
|
|
JSON.stringify(
|
|
JSON.parse('[{"text":"Test prompt "},{"text":"{\\"nested\\": \\"value1\\"}"}]'),
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
});
|
|
|
|
it('should load external yaml files in renderPrompt', async () => {
|
|
const prompt = toPrompt('Test prompt with {{ var1 }}');
|
|
const vars = { var1: 'file://test.txt' };
|
|
const evaluateOptions = {};
|
|
|
|
vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('loaded from file');
|
|
|
|
const renderedPrompt = await renderPrompt(prompt, vars, evaluateOptions);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('test.txt'), 'utf8');
|
|
expect(renderedPrompt).toBe('Test prompt with loaded from file');
|
|
});
|
|
|
|
it('should load external js files in renderPrompt and execute the exported function', async () => {
|
|
const prompt = toPrompt('Test prompt with {{ var1 }} {{ var2 }} {{ var3 }}');
|
|
const vars = {
|
|
var1: 'file:///path/to/testFunction.js',
|
|
var2: 'file:///path/to/testFunction.cjs',
|
|
var3: 'file:///path/to/testFunction.mjs',
|
|
};
|
|
const evaluateOptions = {};
|
|
|
|
// Register dynamic module mocks
|
|
mockDynamicModule('/path/to/testFunction.js', (varName: any, _prompt: any, _vars: any) => ({
|
|
output: `Dynamic value for ${varName}`,
|
|
}));
|
|
mockDynamicModule('/path/to/testFunction.cjs', (varName: any, _prompt: any, _vars: any) => ({
|
|
output: `and ${varName}`,
|
|
}));
|
|
mockDynamicModule('/path/to/testFunction.mjs', (varName: any, _prompt: any, _vars: any) => ({
|
|
output: `and ${varName}`,
|
|
}));
|
|
|
|
const renderedPrompt = await renderPrompt(prompt, vars, evaluateOptions);
|
|
expect(renderedPrompt).toBe('Test prompt with Dynamic value for var1 and var2 and var3');
|
|
});
|
|
|
|
it('should load external js package in renderPrompt and execute the exported function', async () => {
|
|
const prompt = toPrompt('Test prompt with {{ var1 }}');
|
|
const vars = {
|
|
var1: 'package:@promptfoo/fake:testFunction',
|
|
};
|
|
const evaluateOptions = {};
|
|
|
|
const require = createRequire('');
|
|
vi.mocked(require.resolve).mockReturnValueOnce('/node_modules/@promptfoo/fake/index.js');
|
|
|
|
// Register dynamic module mock for the package
|
|
mockDynamicModule('/node_modules/@promptfoo/fake/index.js', {
|
|
testFunction: (varName: any, _prompt: any, _vars: any) => ({
|
|
output: `Dynamic value for ${varName}`,
|
|
}),
|
|
});
|
|
|
|
const renderedPrompt = await renderPrompt(prompt, vars, evaluateOptions);
|
|
expect(renderedPrompt).toBe('Test prompt with Dynamic value for var1');
|
|
});
|
|
|
|
it('should throw a clear error when a package variable does not export a function', async () => {
|
|
const prompt = toPrompt('Test prompt with {{ var1 }}');
|
|
const vars = {
|
|
var1: 'package:@promptfoo/fake:testFunction',
|
|
};
|
|
|
|
mockDynamicModule('/node_modules/@promptfoo/fake/index.js', {
|
|
testFunction: false,
|
|
});
|
|
|
|
await expect(renderPrompt(prompt, vars, {})).rejects.toThrow(
|
|
'Variable source malformed: package:@promptfoo/fake:testFunction must export a function. Received: boolean',
|
|
);
|
|
});
|
|
|
|
it('should load external json files in renderPrompt and parse the JSON content', async () => {
|
|
const prompt = toPrompt('Test prompt with {{ var1 }}');
|
|
const vars = { var1: 'file:///path/to/testData.json' };
|
|
const evaluateOptions = {};
|
|
|
|
vi.spyOn(fs, 'readFileSync').mockReturnValueOnce(JSON.stringify({ key: 'valueFromJson' }));
|
|
|
|
const renderedPrompt = await renderPrompt(prompt, vars, evaluateOptions);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(
|
|
expect.stringContaining('testData.json'),
|
|
'utf8',
|
|
);
|
|
expect(renderedPrompt).toBe('Test prompt with {"key":"valueFromJson"}');
|
|
});
|
|
|
|
it('should load external yaml files in renderPrompt and parse the YAML content', async () => {
|
|
const prompt = toPrompt('Test prompt with {{ var1 }}');
|
|
const vars = { var1: 'file:///path/to/testData.yaml' };
|
|
const evaluateOptions = {};
|
|
|
|
vi.spyOn(fs, 'readFileSync').mockReturnValueOnce('key: valueFromYaml');
|
|
|
|
const renderedPrompt = await renderPrompt(prompt, vars, evaluateOptions);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(
|
|
expect.stringContaining('testData.yaml'),
|
|
'utf8',
|
|
);
|
|
expect(renderedPrompt).toBe('Test prompt with {"key":"valueFromYaml"}');
|
|
});
|
|
|
|
describe('with PROMPTFOO_DISABLE_TEMPLATING', () => {
|
|
beforeEach(() => {
|
|
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'true' });
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: undefined });
|
|
});
|
|
|
|
it('should return raw prompt when templating is disabled', async () => {
|
|
const prompt = toPrompt('Test prompt {{ var1 }}');
|
|
const renderedPrompt = await renderPrompt(prompt, { var1: 'value1' }, {});
|
|
expect(renderedPrompt).toBe('Test prompt {{ var1 }}');
|
|
});
|
|
});
|
|
|
|
it('should render normally when templating is enabled', async () => {
|
|
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: 'false' });
|
|
const prompt = toPrompt('Test prompt {{ var1 }}');
|
|
const renderedPrompt = await renderPrompt(prompt, { var1: 'value1' }, {});
|
|
expect(renderedPrompt).toBe('Test prompt value1');
|
|
mockProcessEnv({ PROMPTFOO_DISABLE_TEMPLATING: undefined });
|
|
});
|
|
|
|
it('should respect Nunjucks raw tags when variable is provided as a string', async () => {
|
|
const prompt = toPrompt('{% raw %}{{ var1 }}{% endraw %}');
|
|
const renderedPrompt = await renderPrompt(prompt, { var1: 'value1' }, {});
|
|
expect(renderedPrompt).toBe('{{ var1 }}');
|
|
});
|
|
|
|
it('should respect Nunjucks raw tags when no variables are provided', async () => {
|
|
const prompt = toPrompt('{% raw %}{{ var1 }}{% endraw %}');
|
|
const renderedPrompt = await renderPrompt(prompt, {}, {});
|
|
expect(renderedPrompt).toBe('{{ var1 }}');
|
|
});
|
|
|
|
it('should respect Nunjucks escaped strings when variable is provided as a string', async () => {
|
|
const prompt = toPrompt(`{{ '{{ var1 }}' }}`);
|
|
const renderedPrompt = await renderPrompt(prompt, { var1: 'value1' }, {});
|
|
expect(renderedPrompt).toBe('{{ var1 }}');
|
|
});
|
|
|
|
it('should respect Nunjucks escaped strings when no variables are provided', async () => {
|
|
const prompt = toPrompt(`{{ '{{ var1 }}' }}`);
|
|
const renderedPrompt = await renderPrompt(prompt, {}, {});
|
|
expect(renderedPrompt).toBe('{{ var1 }}');
|
|
});
|
|
|
|
it('should render variables that are template strings', async () => {
|
|
const prompt = toPrompt('{{ var1 }}');
|
|
const renderedPrompt = await renderPrompt(prompt, { var1: '{{ var2 }}', var2: 'value2' }, {});
|
|
expect(renderedPrompt).toBe('value2');
|
|
});
|
|
|
|
it('should auto-wrap prompts with partial Nunjucks tags in {% raw %}', async () => {
|
|
const prompt = toPrompt('This is a partial tag: {%');
|
|
const renderedPrompt = await renderPrompt(prompt, {}, {});
|
|
expect(renderedPrompt).toBe('This is a partial tag: {%');
|
|
});
|
|
|
|
it('should not double-wrap prompts already wrapped in {% raw %}', async () => {
|
|
const prompt = toPrompt('{% raw %}This is a partial tag: {%{% endraw %}');
|
|
const renderedPrompt = await renderPrompt(prompt, {}, {});
|
|
expect(renderedPrompt).toBe('This is a partial tag: {%');
|
|
});
|
|
|
|
it('should not wrap prompts with valid Nunjucks tags', async () => {
|
|
const prompt = toPrompt('Hello {{ name }}!');
|
|
const renderedPrompt = await renderPrompt(prompt, { name: 'Alice' }, {});
|
|
expect(renderedPrompt).toBe('Hello Alice!');
|
|
expect(renderedPrompt).not.toContain('{% raw %}');
|
|
});
|
|
|
|
it('should auto-wrap prompts with partial variable tags', async () => {
|
|
const prompt = toPrompt('Unfinished variable: {{ name');
|
|
const renderedPrompt = await renderPrompt(prompt, { name: 'Alice' }, {});
|
|
expect(renderedPrompt).toBe('Unfinished variable: {{ name');
|
|
});
|
|
|
|
it('should auto-wrap prompts with partial comment tags', async () => {
|
|
const prompt = toPrompt('Unfinished comment: {# comment');
|
|
const renderedPrompt = await renderPrompt(prompt, {}, {});
|
|
expect(renderedPrompt).toBe('Unfinished comment: {# comment');
|
|
});
|
|
});
|
|
|
|
describe('renderPrompt with prompt functions', () => {
|
|
it('should handle string returns from prompt functions', async () => {
|
|
const promptObj = {
|
|
...toPrompt('test'),
|
|
function: async () => 'Hello, world!',
|
|
};
|
|
const result = await renderPrompt(promptObj, {});
|
|
expect(result).toBe('Hello, world!');
|
|
});
|
|
|
|
it('should handle object/array returns from prompt functions', async () => {
|
|
const messages = [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'Hello' },
|
|
];
|
|
const promptObj = {
|
|
...toPrompt('test'),
|
|
function: async () => messages,
|
|
};
|
|
const result = await renderPrompt(promptObj, {});
|
|
expect(JSON.parse(result)).toEqual(messages);
|
|
});
|
|
|
|
it('should handle PromptFunctionResult returns from prompt functions', async () => {
|
|
const messages = [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'Hello' },
|
|
];
|
|
const promptObj = {
|
|
...toPrompt('test'),
|
|
function: async () => ({
|
|
prompt: messages,
|
|
config: { max_tokens: 10 },
|
|
}),
|
|
config: {},
|
|
};
|
|
const result = await renderPrompt(promptObj, {});
|
|
expect(JSON.parse(result)).toEqual(messages);
|
|
expect(promptObj.config).toEqual({ max_tokens: 10 });
|
|
});
|
|
|
|
it('should set config from prompt function when initial config is undefined', async () => {
|
|
const messages = [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'Hello' },
|
|
];
|
|
|
|
const promptObj = {
|
|
...toPrompt('test'),
|
|
config: undefined,
|
|
function: async () => ({
|
|
prompt: messages,
|
|
config: { max_tokens: 10 },
|
|
}),
|
|
};
|
|
|
|
expect(promptObj.config).toBeUndefined();
|
|
|
|
const result = await renderPrompt(promptObj, {});
|
|
|
|
expect(promptObj.config).toEqual({ max_tokens: 10 });
|
|
expect(JSON.parse(result)).toEqual(messages);
|
|
});
|
|
|
|
it('should replace existing config with function config', async () => {
|
|
const messages = [
|
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
|
{ role: 'user', content: 'Hello' },
|
|
];
|
|
const promptObj = {
|
|
...toPrompt('test'),
|
|
function: async () => ({
|
|
prompt: messages,
|
|
config: {
|
|
temperature: 0.8,
|
|
max_tokens: 20,
|
|
},
|
|
}),
|
|
config: {
|
|
temperature: 0.2,
|
|
top_p: 0.9,
|
|
},
|
|
};
|
|
const result = await renderPrompt(promptObj, {});
|
|
expect(JSON.parse(result)).toEqual(messages);
|
|
expect(promptObj.config).toEqual({
|
|
temperature: 0.8,
|
|
max_tokens: 20,
|
|
top_p: 0.9,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('resolveVariables', () => {
|
|
it('should replace placeholders with corresponding variable values', () => {
|
|
const variables = {
|
|
final: '{{ my_greeting }}, {{name}}!',
|
|
my_greeting: 'Hello',
|
|
name: 'John',
|
|
};
|
|
const expected = { final: 'Hello, John!', my_greeting: 'Hello', name: 'John' };
|
|
expect(resolveVariables(variables)).toEqual(expected);
|
|
});
|
|
|
|
it('should handle nested variable substitutions', () => {
|
|
const variables = { first: '{{second}}', second: '{{third}}', third: 'value' };
|
|
const expected = { first: 'value', second: 'value', third: 'value' };
|
|
expect(resolveVariables(variables)).toEqual(expected);
|
|
});
|
|
|
|
it('should not modify variables without placeholders', () => {
|
|
const variables = { greeting: 'Hello, world!', name: 'John' };
|
|
const expected = { greeting: 'Hello, world!', name: 'John' };
|
|
expect(resolveVariables(variables)).toEqual(expected);
|
|
});
|
|
|
|
it('should not fail if a variable is not found', () => {
|
|
const variables = { greeting: 'Hello, {{name}}!' };
|
|
expect(resolveVariables(variables)).toEqual({ greeting: 'Hello, {{name}}!' });
|
|
});
|
|
|
|
it('should not fail for unresolved placeholders', () => {
|
|
const variables = { greeting: 'Hello, {{name}}!', name: '{{unknown}}' };
|
|
expect(resolveVariables(variables)).toEqual({
|
|
greeting: 'Hello, {{unknown}}!',
|
|
name: '{{unknown}}',
|
|
});
|
|
});
|
|
|
|
it('should handle Date objects as variables', () => {
|
|
const dateObj = new Date('2024-01-15T00:00:00.000Z');
|
|
const variables = {
|
|
greeting: 'Hello, {{name}}!',
|
|
name: 'John',
|
|
research_date: dateObj,
|
|
date_string: '{{ research_date }}',
|
|
};
|
|
const expected = {
|
|
greeting: 'Hello, John!',
|
|
name: 'John',
|
|
research_date: dateObj,
|
|
date_string: dateObj.toString(), // Date objects use toString() for template replacement
|
|
};
|
|
expect(resolveVariables(variables)).toEqual(expected);
|
|
});
|
|
|
|
it('should handle object variables in template references', () => {
|
|
const complexObj = { id: 123, name: 'Test Object' };
|
|
const variables = {
|
|
greeting: 'Hello, {{name}}!',
|
|
name: 'John',
|
|
config: complexObj,
|
|
config_ref: '{{ config }}',
|
|
};
|
|
const expected = {
|
|
greeting: 'Hello, John!',
|
|
name: 'John',
|
|
config: complexObj,
|
|
config_ref: '[object Object]', // When object is converted to string
|
|
};
|
|
expect(resolveVariables(variables)).toEqual(expected);
|
|
});
|
|
});
|
|
|
|
describe('runExtensionHook', () => {
|
|
beforeEach(() => {
|
|
// Reset the transform mock to return undefined by default
|
|
vi.mocked(transform).mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe('beforeAll', () => {
|
|
const hookName = 'beforeAll';
|
|
const context = {
|
|
suite: {
|
|
providers: [mockApiProvider],
|
|
prompts: [toPrompt('Test prompt {{ var1 }} {{ var2 }}')],
|
|
tests: [
|
|
{
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
},
|
|
],
|
|
} as TestSuite,
|
|
};
|
|
|
|
describe('no extensions provided', () => {
|
|
it('should not call transform', async () => {
|
|
await runExtensionHook([], hookName, context);
|
|
expect(transform).not.toHaveBeenCalled();
|
|
|
|
await runExtensionHook(undefined, hookName, context);
|
|
expect(transform).not.toHaveBeenCalled();
|
|
|
|
await runExtensionHook(null, hookName, context);
|
|
expect(transform).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return the original context', async () => {
|
|
const out = await runExtensionHook([], hookName, context);
|
|
expect(out).toEqual(context);
|
|
const out2 = await runExtensionHook(undefined, hookName, context);
|
|
expect(out2).toEqual(context);
|
|
const out3 = await runExtensionHook(null, hookName, context);
|
|
expect(out3).toEqual(context);
|
|
});
|
|
});
|
|
|
|
describe('extensions provided', () => {
|
|
it('should call transform for each extension using LEGACY convention', async () => {
|
|
// Non-file:// extensions use LEGACY calling convention: (hookName, context)
|
|
const extensions = ['ext1', 'ext2', 'ext3'];
|
|
await runExtensionHook(extensions, hookName, context);
|
|
expect(transform).toHaveBeenCalledTimes(3);
|
|
expect(transform).toHaveBeenNthCalledWith(1, 'ext1', hookName, context, false);
|
|
expect(transform).toHaveBeenNthCalledWith(2, 'ext2', hookName, context, false);
|
|
expect(transform).toHaveBeenNthCalledWith(3, 'ext3', hookName, context, false);
|
|
});
|
|
|
|
it('should return the original context when extension(s) do not return a value', async () => {
|
|
const out = await runExtensionHook(['ext1', 'ext2', 'ext3'], hookName, context);
|
|
expect(out).toEqual(context);
|
|
});
|
|
|
|
it('returned context should conform to the expected schema', async () => {
|
|
// Re-mock the transform function to return a valid context (with a new tag)
|
|
vi.mocked(transform).mockResolvedValue({
|
|
suite: {
|
|
providers: context.suite.providers,
|
|
prompts: context.suite.prompts,
|
|
tests: context.suite.tests,
|
|
},
|
|
});
|
|
|
|
const out = await runExtensionHook(['ext1', 'ext2', 'ext3'], hookName, context);
|
|
|
|
// Check the structure without relying on exact function name matching
|
|
expect(out.suite.providers).toHaveLength(1);
|
|
expect(out.suite.providers[0]).toHaveProperty('id');
|
|
expect(out.suite.providers[0]).toHaveProperty('callApi');
|
|
expect(typeof out.suite.providers[0].id).toBe('function');
|
|
expect(out.suite.providers[0].id()).toBe('test-provider');
|
|
expect(out.suite.prompts).toEqual(context.suite.prompts);
|
|
expect(out.suite.tests).toEqual(context.suite.tests);
|
|
});
|
|
|
|
it('should handle invalid context but not throw validation error', async () => {
|
|
// Re-mock the transform function to return a context with custom properties and valid mutable properties
|
|
vi.mocked(transform).mockResolvedValue({
|
|
suite: {
|
|
foo: 'bar', // This will be ignored as it's not a mutable property
|
|
tests: [{ vars: { newVar: 'newValue' } }], // This will be used as it's mutable
|
|
},
|
|
});
|
|
|
|
// The hook should handle this gracefully and return the modified context
|
|
const result = await runExtensionHook(['ext1', 'ext2', 'ext3'], hookName, context);
|
|
// Only mutable properties are updated, custom properties like 'foo' are ignored
|
|
expect(result.suite.providers).toBeDefined(); // Original providers preserved
|
|
expect(result.suite.tests).toEqual([{ vars: { newVar: 'newValue' } }]); // Tests updated
|
|
expect(result.suite).not.toHaveProperty('foo'); // Custom property ignored
|
|
});
|
|
|
|
it('should restore the prompt function dropped by JSON serialization (issue #9653)', async () => {
|
|
// A file://...:fn prompt carries a non-serializable `function`. When the
|
|
// suite round-trips through a Python/JS hook (JSON), `function` is lost.
|
|
const promptFn = vi.fn();
|
|
const fnContext = {
|
|
suite: {
|
|
providers: [mockApiProvider],
|
|
prompts: [
|
|
{
|
|
raw: 'def create_prompt(): ...',
|
|
label: 'prompt.py:create_prompt',
|
|
function: promptFn,
|
|
},
|
|
],
|
|
tests: [{ vars: {} }],
|
|
} as TestSuite,
|
|
};
|
|
|
|
// Simulate the serialized round-trip: the hook returns the same prompt
|
|
// without its `function`.
|
|
vi.mocked(transform).mockResolvedValue({
|
|
suite: {
|
|
providers: fnContext.suite.providers,
|
|
prompts: [{ raw: 'def create_prompt(): ...', label: 'prompt.py:create_prompt' }],
|
|
tests: fnContext.suite.tests,
|
|
},
|
|
});
|
|
|
|
const out = await runExtensionHook(['ext1'], hookName, fnContext);
|
|
|
|
expect(out.suite.prompts).toHaveLength(1);
|
|
expect(out.suite.prompts[0].function).toBe(promptFn);
|
|
});
|
|
|
|
it('should not restore a function when the hook rewrites the prompt raw source', async () => {
|
|
const promptFn = vi.fn();
|
|
const fnContext = {
|
|
suite: {
|
|
providers: [mockApiProvider],
|
|
prompts: [
|
|
{ raw: 'original source', label: 'prompt.py:create_prompt', function: promptFn },
|
|
],
|
|
tests: [{ vars: {} }],
|
|
} as TestSuite,
|
|
};
|
|
|
|
// The hook intentionally replaces the prompt with a new raw string and no
|
|
// function — its new behavior must be preserved.
|
|
vi.mocked(transform).mockResolvedValue({
|
|
suite: {
|
|
providers: fnContext.suite.providers,
|
|
prompts: [{ raw: 'rewritten source', label: 'prompt.py:create_prompt' }],
|
|
tests: fnContext.suite.tests,
|
|
},
|
|
});
|
|
|
|
const out = await runExtensionHook(['ext1'], hookName, fnContext);
|
|
|
|
expect(out.suite.prompts[0].raw).toBe('rewritten source');
|
|
expect(out.suite.prompts[0].function).toBeUndefined();
|
|
});
|
|
|
|
it('should not restore either function when two originals share a label, and should warn', async () => {
|
|
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
|
|
const fnA = vi.fn();
|
|
const fnB = vi.fn();
|
|
const fnContext = {
|
|
suite: {
|
|
providers: [mockApiProvider],
|
|
prompts: [
|
|
{ raw: 'source A', label: 'shared-label', function: fnA },
|
|
{ raw: 'source B', label: 'shared-label', function: fnB },
|
|
],
|
|
tests: [{ vars: {} }],
|
|
} as TestSuite,
|
|
};
|
|
|
|
vi.mocked(transform).mockResolvedValue({
|
|
suite: {
|
|
providers: fnContext.suite.providers,
|
|
prompts: [
|
|
{ raw: 'source A', label: 'shared-label' },
|
|
{ raw: 'source B', label: 'shared-label' },
|
|
],
|
|
tests: fnContext.suite.tests,
|
|
},
|
|
});
|
|
|
|
const out = await runExtensionHook(['ext1'], hookName, fnContext);
|
|
|
|
expect(out.suite.prompts[0].function).toBeUndefined();
|
|
expect(out.suite.prompts[1].function).toBeUndefined();
|
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('shared-label'));
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('should keep a function the hook itself attached instead of overwriting it', async () => {
|
|
const originalFn = vi.fn();
|
|
const hookFn = vi.fn();
|
|
const fnContext = {
|
|
suite: {
|
|
providers: [mockApiProvider],
|
|
prompts: [
|
|
{ raw: 'def create_prompt(): ...', label: 'prompt.py:fn', function: originalFn },
|
|
],
|
|
tests: [{ vars: {} }],
|
|
} as TestSuite,
|
|
};
|
|
|
|
// An in-process JS hook can return live objects; a function it attached
|
|
// deliberately must win over restoration.
|
|
vi.mocked(transform).mockResolvedValue({
|
|
suite: {
|
|
providers: fnContext.suite.providers,
|
|
prompts: [
|
|
{ raw: 'def create_prompt(): ...', label: 'prompt.py:fn', function: hookFn },
|
|
],
|
|
tests: fnContext.suite.tests,
|
|
},
|
|
});
|
|
|
|
const out = await runExtensionHook(['ext1'], hookName, fnContext);
|
|
|
|
expect(out.suite.prompts[0].function).toBe(hookFn);
|
|
});
|
|
|
|
it('should restore the function across multiple chained extensions', async () => {
|
|
const promptFn = vi.fn();
|
|
const fnContext = {
|
|
suite: {
|
|
providers: [mockApiProvider],
|
|
prompts: [
|
|
{ raw: 'def create_prompt(): ...', label: 'prompt.py:fn', function: promptFn },
|
|
],
|
|
tests: [{ vars: {} }],
|
|
} as TestSuite,
|
|
};
|
|
|
|
// Both extensions round-trip the suite through JSON, dropping the function
|
|
// each time. Restoration after the first hook feeds the second hook's
|
|
// originals, so the function must survive the whole chain.
|
|
const serializedSuite = {
|
|
suite: {
|
|
providers: fnContext.suite.providers,
|
|
prompts: [{ raw: 'def create_prompt(): ...', label: 'prompt.py:fn' }],
|
|
tests: fnContext.suite.tests,
|
|
},
|
|
};
|
|
vi.mocked(transform)
|
|
.mockResolvedValueOnce(serializedSuite)
|
|
.mockResolvedValueOnce(serializedSuite);
|
|
|
|
const out = await runExtensionHook(['ext1', 'ext2'], hookName, fnContext);
|
|
|
|
expect(transform).toHaveBeenCalledTimes(2);
|
|
expect(out.suite.prompts[0].function).toBe(promptFn);
|
|
});
|
|
|
|
it('should warn when the hook returns a function prompt under a different label', async () => {
|
|
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => logger);
|
|
const promptFn = vi.fn();
|
|
const fnContext = {
|
|
suite: {
|
|
providers: [mockApiProvider],
|
|
prompts: [
|
|
{ raw: 'def create_prompt(): ...', label: 'old-label', function: promptFn },
|
|
],
|
|
tests: [{ vars: {} }],
|
|
} as TestSuite,
|
|
};
|
|
|
|
vi.mocked(transform).mockResolvedValue({
|
|
suite: {
|
|
providers: fnContext.suite.providers,
|
|
prompts: [{ raw: 'def create_prompt(): ...', label: 'new-label' }],
|
|
tests: fnContext.suite.tests,
|
|
},
|
|
});
|
|
|
|
const out = await runExtensionHook(['ext1'], hookName, fnContext);
|
|
|
|
expect(out.suite.prompts[0].function).toBeUndefined();
|
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('old-label'));
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('should pass through malformed prompt entries without throwing', async () => {
|
|
const promptFn = vi.fn();
|
|
const fnContext = {
|
|
suite: {
|
|
providers: [mockApiProvider],
|
|
prompts: [
|
|
{ raw: 'def create_prompt(): ...', label: 'prompt.py:fn', function: promptFn },
|
|
],
|
|
tests: [{ vars: {} }],
|
|
} as TestSuite,
|
|
};
|
|
|
|
// Hooks return arbitrary deserialized JSON; malformed entries must pass
|
|
// through untouched while valid matches are still restored.
|
|
vi.mocked(transform).mockResolvedValue({
|
|
suite: {
|
|
providers: fnContext.suite.providers,
|
|
prompts: [
|
|
{ raw: 'def create_prompt(): ...', label: 'prompt.py:fn' },
|
|
null,
|
|
'just a string',
|
|
{ raw: 'no label here' },
|
|
],
|
|
tests: fnContext.suite.tests,
|
|
},
|
|
});
|
|
|
|
const out = await runExtensionHook(['ext1'], hookName, fnContext);
|
|
|
|
expect(out.suite.prompts).toHaveLength(4);
|
|
expect(out.suite.prompts[0].function).toBe(promptFn);
|
|
expect(out.suite.prompts[1]).toBeNull();
|
|
expect(out.suite.prompts[2]).toBe('just a string');
|
|
expect(out.suite.prompts[3]).toEqual({ raw: 'no label here' });
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('beforeEach', () => {
|
|
const hookName = 'beforeEach';
|
|
const context = {
|
|
test: {
|
|
vars: { var1: 'value1', var2: 'value2' },
|
|
assert: [{ type: 'equals', value: 'expected' }],
|
|
} as TestCase,
|
|
};
|
|
|
|
describe('no extensions provided', () => {
|
|
it('should not call transform', async () => {
|
|
await runExtensionHook([], hookName, context);
|
|
expect(transform).not.toHaveBeenCalled();
|
|
|
|
await runExtensionHook(undefined, hookName, context);
|
|
expect(transform).not.toHaveBeenCalled();
|
|
|
|
await runExtensionHook(null, hookName, context);
|
|
expect(transform).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return the original context', async () => {
|
|
const out = await runExtensionHook([], hookName, context);
|
|
expect(out).toEqual(context);
|
|
const out2 = await runExtensionHook(undefined, hookName, context);
|
|
expect(out2).toEqual(context);
|
|
const out3 = await runExtensionHook(null, hookName, context);
|
|
expect(out3).toEqual(context);
|
|
});
|
|
});
|
|
|
|
describe('extensions provided', () => {
|
|
it('should call transform for each extension using LEGACY convention', async () => {
|
|
// Extensions without file:// prefix or function name use LEGACY convention
|
|
const extensions = ['ext1', 'ext2', 'ext3'];
|
|
await runExtensionHook(extensions, hookName, context);
|
|
expect(transform).toHaveBeenCalledTimes(3);
|
|
// LEGACY convention: (extension, hookName, context, false)
|
|
expect(transform).toHaveBeenNthCalledWith(1, 'ext1', hookName, context, false);
|
|
expect(transform).toHaveBeenNthCalledWith(2, 'ext2', hookName, context, false);
|
|
expect(transform).toHaveBeenNthCalledWith(3, 'ext3', hookName, context, false);
|
|
});
|
|
|
|
it('should return the original context when extension(s) do not return a value', async () => {
|
|
const out = await runExtensionHook(['ext1', 'ext2', 'ext3'], hookName, context);
|
|
expect(out).toEqual(context);
|
|
});
|
|
|
|
it('returned context should conform to the expected schema', async () => {
|
|
// Re-mock the transform function to return a valid context (with modified test)
|
|
vi.mocked(transform).mockResolvedValue({
|
|
test: {
|
|
vars: { var1: 'modified_value1', var2: 'modified_value2' },
|
|
assert: [{ type: 'equals', value: 'modified_expected' }],
|
|
},
|
|
});
|
|
|
|
const out = await runExtensionHook(['ext1', 'ext2', 'ext3'], hookName, context);
|
|
|
|
expect(out.test.vars).toEqual({ var1: 'modified_value1', var2: 'modified_value2' });
|
|
expect(out.test.assert).toEqual([{ type: 'equals', value: 'modified_expected' }]);
|
|
});
|
|
|
|
it('should handle invalid context but not throw validation error', async () => {
|
|
// Re-mock the transform function to return an invalid context (test should be an object, not a string)
|
|
vi.mocked(transform).mockResolvedValue({
|
|
test: 'invalid_test_value',
|
|
});
|
|
|
|
// The hook should handle this gracefully and return the modified context
|
|
const result = await runExtensionHook(['ext1', 'ext2', 'ext3'], hookName, context);
|
|
expect(result.test).toBe('invalid_test_value');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('hook filtering with function names', () => {
|
|
const context = {
|
|
suite: {
|
|
providers: [mockApiProvider],
|
|
prompts: [{ raw: 'test prompt', label: 'test' } as Prompt],
|
|
tests: [{ vars: { var1: 'value1' } }],
|
|
} as TestSuite,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(transform).mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('should skip extension with hook name that does not match current hook', async () => {
|
|
// Extension specifies :afterAll but we're running beforeAll
|
|
await runExtensionHook(['file://path/to/hooks.js:afterAll'], 'beforeAll', context);
|
|
expect(transform).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should run extension with hook name that matches current hook', async () => {
|
|
// Extension specifies :beforeAll and we're running beforeAll
|
|
await runExtensionHook(['file://path/to/hooks.js:beforeAll'], 'beforeAll', context);
|
|
expect(transform).toHaveBeenCalledWith(
|
|
'file://path/to/hooks.js:beforeAll',
|
|
context,
|
|
{ hookName: 'beforeAll' },
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('should run extension with custom function name for ALL hooks using LEGACY convention', async () => {
|
|
// Extension specifies :myHandler (custom name) - should run for all hooks
|
|
// Uses LEGACY calling convention: (hookName, context)
|
|
const customExtension = 'file://path/to/hooks.js:myHandler';
|
|
|
|
await runExtensionHook([customExtension], 'beforeAll', context);
|
|
expect(transform).toHaveBeenCalledWith(
|
|
customExtension,
|
|
'beforeAll', // LEGACY: hookName as first arg
|
|
context, // LEGACY: context as second arg
|
|
false,
|
|
);
|
|
|
|
vi.mocked(transform).mockClear();
|
|
const beforeEachContext = { test: {} as TestCase };
|
|
await runExtensionHook([customExtension], 'beforeEach', beforeEachContext);
|
|
expect(transform).toHaveBeenCalledWith(
|
|
customExtension,
|
|
'beforeEach',
|
|
beforeEachContext,
|
|
false,
|
|
);
|
|
|
|
vi.mocked(transform).mockClear();
|
|
const afterEachContext = {
|
|
test: {} as TestCase,
|
|
result: {} as any,
|
|
};
|
|
await runExtensionHook([customExtension], 'afterEach', afterEachContext);
|
|
expect(transform).toHaveBeenCalledWith(
|
|
customExtension,
|
|
'afterEach',
|
|
afterEachContext,
|
|
false,
|
|
);
|
|
|
|
vi.mocked(transform).mockClear();
|
|
const afterAllContext = {
|
|
results: [],
|
|
suite: {} as TestSuite,
|
|
prompts: [],
|
|
evalId: 'test-eval-id',
|
|
config: {},
|
|
};
|
|
await runExtensionHook([customExtension], 'afterAll', afterAllContext);
|
|
expect(transform).toHaveBeenCalledWith(customExtension, 'afterAll', afterAllContext, false);
|
|
});
|
|
|
|
it('should run extension without function name for ALL hooks using LEGACY convention', async () => {
|
|
// Extension without function name - should run for all hooks
|
|
// Uses LEGACY calling convention: (hookName, context)
|
|
const noFunctionExtension = 'file://path/to/hooks.js';
|
|
|
|
await runExtensionHook([noFunctionExtension], 'beforeAll', context);
|
|
expect(transform).toHaveBeenCalledWith(
|
|
noFunctionExtension,
|
|
'beforeAll', // LEGACY: hookName as first arg
|
|
context, // LEGACY: context as second arg
|
|
false,
|
|
);
|
|
|
|
vi.mocked(transform).mockClear();
|
|
const afterEachContext = {
|
|
test: {} as TestCase,
|
|
result: {} as any,
|
|
};
|
|
await runExtensionHook([noFunctionExtension], 'afterEach', afterEachContext);
|
|
expect(transform).toHaveBeenCalledWith(
|
|
noFunctionExtension,
|
|
'afterEach',
|
|
afterEachContext,
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('should chain multiple beforeAll extensions so each sees prior changes', async () => {
|
|
const baseTests = [{ vars: { original: 'yes' } }];
|
|
const chainContext = {
|
|
suite: {
|
|
providers: [mockApiProvider],
|
|
prompts: [toPrompt('Test prompt')],
|
|
tests: baseTests,
|
|
} as TestSuite,
|
|
};
|
|
|
|
vi.mocked(transform)
|
|
.mockResolvedValueOnce({
|
|
suite: {
|
|
providers: chainContext.suite.providers,
|
|
prompts: chainContext.suite.prompts,
|
|
tests: [...baseTests, { vars: { added_by_ext1: 'yes' } }],
|
|
},
|
|
})
|
|
.mockImplementationOnce(async (_ext, context) => {
|
|
// Extension #2 should see the tests array modified by extension #1
|
|
const ctx = context as { suite: TestSuite };
|
|
expect(ctx.suite.tests).toHaveLength(2);
|
|
expect(ctx.suite.tests?.[1]).toEqual({ vars: { added_by_ext1: 'yes' } });
|
|
return {
|
|
suite: {
|
|
providers: ctx.suite.providers,
|
|
prompts: ctx.suite.prompts,
|
|
tests: [...(ctx.suite.tests || []), { vars: { added_by_ext2: 'yes' } }],
|
|
},
|
|
};
|
|
});
|
|
|
|
const out = await runExtensionHook(
|
|
['file://ext1.js:beforeAll', 'file://ext2.js:beforeAll'],
|
|
'beforeAll',
|
|
chainContext,
|
|
);
|
|
expect(out.suite.tests).toHaveLength(3);
|
|
expect(out.suite.tests?.[0]).toEqual({ vars: { original: 'yes' } });
|
|
expect(out.suite.tests?.[1]).toEqual({ vars: { added_by_ext1: 'yes' } });
|
|
expect(out.suite.tests?.[2]).toEqual({ vars: { added_by_ext2: 'yes' } });
|
|
});
|
|
|
|
it('should handle mixed extensions with correct calling conventions', async () => {
|
|
// Mix of hook-specific and generic extensions
|
|
const extensions = [
|
|
'file://hooks.js:beforeAll', // Only runs for beforeAll, NEW convention
|
|
'file://hooks.js:myHandler', // Runs for all hooks, LEGACY convention
|
|
'file://hooks.js:afterAll', // Only runs for afterAll (skipped here)
|
|
];
|
|
|
|
await runExtensionHook(extensions, 'beforeAll', context);
|
|
// Should call transform twice (beforeAll and myHandler, skip afterAll)
|
|
expect(transform).toHaveBeenCalledTimes(2);
|
|
|
|
// First call: :beforeAll uses NEW convention
|
|
expect(transform).toHaveBeenCalledWith(
|
|
'file://hooks.js:beforeAll',
|
|
context, // NEW: context as first arg
|
|
{ hookName: 'beforeAll' }, // NEW: hookName in object as second arg
|
|
false,
|
|
);
|
|
|
|
// Second call: :myHandler uses LEGACY convention
|
|
expect(transform).toHaveBeenCalledWith(
|
|
'file://hooks.js:myHandler',
|
|
'beforeAll', // LEGACY: hookName as first arg
|
|
context, // LEGACY: context as second arg
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('afterEach return value handling', () => {
|
|
const baseResult = {
|
|
provider: { id: () => 'test' } as any,
|
|
prompt: { raw: 'test', label: 'test' } as any,
|
|
vars: {},
|
|
response: { output: 'test output' } as any,
|
|
success: true,
|
|
score: 1,
|
|
latencyMs: 100,
|
|
namedScores: { existing_metric: 0.5 },
|
|
metadata: { existing_key: 'value' },
|
|
promptIdx: 0,
|
|
testIdx: 0,
|
|
testCase: {},
|
|
cost: 0,
|
|
} as any;
|
|
|
|
it('should merge returned namedScores into the result', async () => {
|
|
vi.mocked(transform).mockResolvedValue({
|
|
test: {} as TestCase,
|
|
result: {
|
|
namedScores: { num_turns: 3, cost_usd: 0.05 },
|
|
},
|
|
});
|
|
|
|
const context = {
|
|
test: {} as TestCase,
|
|
result: { ...baseResult },
|
|
};
|
|
|
|
const out = await runExtensionHook(['file://hooks.js:afterEach'], 'afterEach', context);
|
|
expect(out.result.namedScores).toEqual({
|
|
existing_metric: 0.5,
|
|
num_turns: 3,
|
|
cost_usd: 0.05,
|
|
});
|
|
});
|
|
|
|
it('should merge returned metadata into the result', async () => {
|
|
vi.mocked(transform).mockResolvedValue({
|
|
test: {} as TestCase,
|
|
result: {
|
|
metadata: { session_url: 'https://example.com', tool_calls: 5 },
|
|
},
|
|
});
|
|
|
|
const context = {
|
|
test: {} as TestCase,
|
|
result: { ...baseResult },
|
|
};
|
|
|
|
const out = await runExtensionHook(['file://hooks.js:afterEach'], 'afterEach', context);
|
|
expect(out.result.metadata).toEqual({
|
|
existing_key: 'value',
|
|
session_url: 'https://example.com',
|
|
tool_calls: 5,
|
|
});
|
|
});
|
|
|
|
it('should merge returned response.metadata into the result', async () => {
|
|
vi.mocked(transform).mockResolvedValue({
|
|
test: {} as TestCase,
|
|
result: {
|
|
response: { metadata: { session_viewer: 'https://viewer.example.com', tool_count: 3 } },
|
|
},
|
|
});
|
|
|
|
const context = {
|
|
test: {} as TestCase,
|
|
result: { ...baseResult },
|
|
};
|
|
|
|
const out = await runExtensionHook(['file://hooks.js:afterEach'], 'afterEach', context);
|
|
expect(out.result.response?.metadata).toEqual({
|
|
session_viewer: 'https://viewer.example.com',
|
|
tool_count: 3,
|
|
});
|
|
// Other response fields should be preserved
|
|
expect(out.result.response?.output).toBe('test output');
|
|
});
|
|
|
|
it('should preserve existing namedScores and metadata when extension returns none', async () => {
|
|
vi.mocked(transform).mockResolvedValue(undefined);
|
|
|
|
const context = {
|
|
test: {} as TestCase,
|
|
result: { ...baseResult },
|
|
};
|
|
|
|
const out = await runExtensionHook(['file://hooks.js:afterEach'], 'afterEach', context);
|
|
expect(out.result.namedScores).toEqual({ existing_metric: 0.5 });
|
|
expect(out.result.metadata).toEqual({ existing_key: 'value' });
|
|
});
|
|
|
|
it('should chain multiple extensions correctly', async () => {
|
|
vi.mocked(transform)
|
|
.mockResolvedValueOnce({
|
|
test: {} as TestCase,
|
|
result: {
|
|
namedScores: { metric_a: 1 },
|
|
metadata: { key_a: 'a' },
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
test: {} as TestCase,
|
|
result: {
|
|
namedScores: { metric_b: 2 },
|
|
metadata: { key_b: 'b' },
|
|
},
|
|
});
|
|
|
|
const context = {
|
|
test: {} as TestCase,
|
|
result: { ...baseResult },
|
|
};
|
|
|
|
const out = await runExtensionHook(
|
|
['file://hooks1.js:afterEach', 'file://hooks2.js:afterEach'],
|
|
'afterEach',
|
|
context,
|
|
);
|
|
expect(out.result.namedScores).toEqual({
|
|
existing_metric: 0.5,
|
|
metric_a: 1,
|
|
metric_b: 2,
|
|
});
|
|
expect(out.result.metadata).toEqual({
|
|
existing_key: 'value',
|
|
key_a: 'a',
|
|
key_b: 'b',
|
|
});
|
|
});
|
|
|
|
it('should allow later extensions to override earlier extension values', async () => {
|
|
vi.mocked(transform)
|
|
.mockResolvedValueOnce({
|
|
test: {} as TestCase,
|
|
result: {
|
|
namedScores: { shared_metric: 1 },
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
test: {} as TestCase,
|
|
result: {
|
|
namedScores: { shared_metric: 99 },
|
|
},
|
|
});
|
|
|
|
const context = {
|
|
test: {} as TestCase,
|
|
result: { ...baseResult },
|
|
};
|
|
|
|
const out = await runExtensionHook(
|
|
['file://hooks1.js:afterEach', 'file://hooks2.js:afterEach'],
|
|
'afterEach',
|
|
context,
|
|
);
|
|
expect(out.result.namedScores.shared_metric).toBe(99);
|
|
});
|
|
|
|
it('should filter non-numeric namedScores values', async () => {
|
|
vi.mocked(transform).mockResolvedValue({
|
|
test: {} as TestCase,
|
|
result: {
|
|
namedScores: {
|
|
valid_int: 42,
|
|
valid_float: 3.14,
|
|
valid_zero: 0,
|
|
valid_negative: -5,
|
|
string_val: 'not_a_number' as any,
|
|
null_val: null as any,
|
|
array_val: [1, 2] as any,
|
|
object_val: { nested: true } as any,
|
|
nan_val: NaN,
|
|
infinity_val: Infinity,
|
|
neg_infinity_val: -Infinity,
|
|
},
|
|
},
|
|
});
|
|
|
|
const context = {
|
|
test: {} as TestCase,
|
|
result: { ...baseResult },
|
|
};
|
|
|
|
const out = await runExtensionHook(['file://hooks.js:afterEach'], 'afterEach', context);
|
|
// Only finite numeric values should survive
|
|
expect(out.result.namedScores).toEqual({
|
|
existing_metric: 0.5,
|
|
valid_int: 42,
|
|
valid_float: 3.14,
|
|
valid_zero: 0,
|
|
valid_negative: -5,
|
|
});
|
|
});
|
|
|
|
it('should not allow overriding success, score, or response.output via return value', async () => {
|
|
vi.mocked(transform).mockResolvedValue({
|
|
test: {} as TestCase,
|
|
result: {
|
|
namedScores: { custom: 1 },
|
|
success: false,
|
|
score: 0,
|
|
response: { output: 'hacked', metadata: { injected: true } },
|
|
},
|
|
});
|
|
|
|
const context = {
|
|
test: {} as TestCase,
|
|
result: { ...baseResult },
|
|
};
|
|
|
|
const out = await runExtensionHook(['file://hooks.js:afterEach'], 'afterEach', context);
|
|
// success and score should remain unchanged
|
|
expect(out.result.success).toBe(true);
|
|
expect(out.result.score).toBe(1);
|
|
// response.output should remain unchanged
|
|
expect(out.result.response?.output).toBe('test output');
|
|
// but namedScores and response.metadata should be merged
|
|
expect(out.result.namedScores.custom).toBe(1);
|
|
expect(out.result.response?.metadata?.injected).toBe(true);
|
|
});
|
|
|
|
it('should pass accumulated context to subsequent extensions (chaining input)', async () => {
|
|
// Extension #2 should receive the context modified by extension #1
|
|
vi.mocked(transform)
|
|
.mockImplementationOnce(async (_ext, context) => {
|
|
// Extension #1: verify it gets the original context, return new namedScores
|
|
const ctx = context as AfterEachExtensionHookContext;
|
|
expect(ctx.result.namedScores).toEqual({ existing_metric: 0.5 });
|
|
return {
|
|
test: ctx.test,
|
|
result: {
|
|
namedScores: { from_ext1: 10 },
|
|
},
|
|
};
|
|
})
|
|
.mockImplementationOnce(async (_ext, context) => {
|
|
// Extension #2: should see extension #1's merged namedScores in input
|
|
const ctx = context as AfterEachExtensionHookContext;
|
|
expect(ctx.result.namedScores).toEqual({
|
|
existing_metric: 0.5,
|
|
from_ext1: 10,
|
|
});
|
|
return {
|
|
test: ctx.test,
|
|
result: {
|
|
namedScores: { from_ext2: 20 },
|
|
},
|
|
};
|
|
});
|
|
|
|
const context = {
|
|
test: {} as TestCase,
|
|
result: { ...baseResult },
|
|
};
|
|
|
|
const out = await runExtensionHook(
|
|
['file://hooks1.js:afterEach', 'file://hooks2.js:afterEach'],
|
|
'afterEach',
|
|
context,
|
|
);
|
|
expect(out.result.namedScores).toEqual({
|
|
existing_metric: 0.5,
|
|
from_ext1: 10,
|
|
from_ext2: 20,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getExtensionHookName', () => {
|
|
it('should extract function name from file:// path', () => {
|
|
expect(getExtensionHookName('file://path/to/hooks.js:beforeAll')).toBe('beforeAll');
|
|
expect(getExtensionHookName('file://path/to/hooks.py:afterEach')).toBe('afterEach');
|
|
expect(getExtensionHookName('file://hooks.js:myHandler')).toBe('myHandler');
|
|
});
|
|
|
|
it('should return undefined when no function name specified', () => {
|
|
expect(getExtensionHookName('file://path/to/hooks.js')).toBeUndefined();
|
|
expect(getExtensionHookName('file://hooks.py')).toBeUndefined();
|
|
});
|
|
|
|
it('should return undefined for non-file:// paths', () => {
|
|
expect(getExtensionHookName('path/to/hooks.js:beforeAll')).toBeUndefined();
|
|
expect(getExtensionHookName('hooks.js:beforeAll')).toBeUndefined();
|
|
});
|
|
|
|
it('should handle Windows drive letters correctly', () => {
|
|
// Position 7 is C:, position 8 is the colon after drive letter
|
|
// The function should not treat C: as function name
|
|
expect(getExtensionHookName('file://C:/path/to/hooks.js')).toBeUndefined();
|
|
expect(getExtensionHookName('file://C:/path/to/hooks.js:beforeAll')).toBe('beforeAll');
|
|
expect(getExtensionHookName('file://D:/hooks.js:myHandler')).toBe('myHandler');
|
|
});
|
|
|
|
it('should extract the last colon-separated segment', () => {
|
|
// Multiple colons - should get the last one
|
|
expect(getExtensionHookName('file://path:with:colons/hooks.js:functionName')).toBe(
|
|
'functionName',
|
|
);
|
|
});
|
|
|
|
it('should return undefined for trailing colon (empty function name)', () => {
|
|
// Edge case: "file://hooks.js:" should not return empty string
|
|
expect(getExtensionHookName('file://hooks.js:')).toBeUndefined();
|
|
expect(getExtensionHookName('file://path/to/hooks.py:')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('runExtensionHook error handling', () => {
|
|
const context = {
|
|
suite: {
|
|
providers: [mockApiProvider],
|
|
prompts: [{ raw: 'test prompt', label: 'test' } as Prompt],
|
|
tests: [{ vars: { var1: 'value1' } }],
|
|
} as TestSuite,
|
|
};
|
|
|
|
it('should wrap transform errors with hook context', async () => {
|
|
const originalError = new Error('Python script failed');
|
|
vi.mocked(transform).mockRejectedValue(originalError);
|
|
|
|
await expect(
|
|
runExtensionHook(['file://broken.py:handler'], 'beforeAll', context),
|
|
).rejects.toThrow('Extension hook "beforeAll" failed for file://broken.py:handler');
|
|
});
|
|
|
|
it('should preserve original error as cause', async () => {
|
|
const originalError = new Error('Original error message');
|
|
vi.mocked(transform).mockRejectedValue(originalError);
|
|
|
|
try {
|
|
await runExtensionHook(['file://broken.js:handler'], 'beforeAll', context);
|
|
expect.fail('Expected error to be thrown');
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).cause).toBe(originalError);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('collectFileMetadata', () => {
|
|
beforeEach(() => {
|
|
// Override mockPathResolve to return only the last path segment for these tests
|
|
mockPathResolve.mockImplementation((...paths: string[]) => {
|
|
return paths[paths.length - 1];
|
|
});
|
|
});
|
|
|
|
it('should identify image files correctly', () => {
|
|
const vars = {
|
|
image1: 'file://path/to/image.jpg',
|
|
image2: 'file://path/to/image.png',
|
|
image3: 'file://path/to/image.webp',
|
|
text: 'This is not a file',
|
|
otherFile: 'file://path/to/document.txt',
|
|
};
|
|
|
|
const metadata = collectFileMetadata(vars);
|
|
|
|
expect(metadata).toEqual({
|
|
image1: {
|
|
path: 'file://path/to/image.jpg',
|
|
type: 'image',
|
|
format: 'jpg',
|
|
},
|
|
image2: {
|
|
path: 'file://path/to/image.png',
|
|
type: 'image',
|
|
format: 'png',
|
|
},
|
|
image3: {
|
|
path: 'file://path/to/image.webp',
|
|
type: 'image',
|
|
format: 'webp',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should identify video files correctly', () => {
|
|
const vars = {
|
|
video1: 'file://path/to/video.mp4',
|
|
video2: 'file://path/to/video.webm',
|
|
video3: 'file://path/to/video.mkv',
|
|
text: 'This is not a file',
|
|
};
|
|
|
|
const metadata = collectFileMetadata(vars);
|
|
|
|
expect(metadata).toEqual({
|
|
video1: {
|
|
path: 'file://path/to/video.mp4',
|
|
type: 'video',
|
|
format: 'mp4',
|
|
},
|
|
video2: {
|
|
path: 'file://path/to/video.webm',
|
|
type: 'video',
|
|
format: 'webm',
|
|
},
|
|
video3: {
|
|
path: 'file://path/to/video.mkv',
|
|
type: 'video',
|
|
format: 'mkv',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should identify audio files correctly', () => {
|
|
const vars = {
|
|
audio1: 'file://path/to/audio.mp3',
|
|
audio2: 'file://path/to/audio.wav',
|
|
text: 'This is not a file',
|
|
};
|
|
|
|
const metadata = collectFileMetadata(vars);
|
|
|
|
expect(metadata).toEqual({
|
|
audio1: {
|
|
path: 'file://path/to/audio.mp3',
|
|
type: 'audio',
|
|
format: 'mp3',
|
|
},
|
|
audio2: {
|
|
path: 'file://path/to/audio.wav',
|
|
type: 'audio',
|
|
format: 'wav',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should return an empty object when no media files are found', () => {
|
|
const vars = {
|
|
text1: 'This is not a file',
|
|
text2: 'file://path/to/document.txt',
|
|
text3: 'file://path/to/document.pdf',
|
|
};
|
|
|
|
const metadata = collectFileMetadata(vars);
|
|
|
|
expect(metadata).toEqual({});
|
|
});
|
|
|
|
it('should handle non-string values correctly', () => {
|
|
const vars = {
|
|
text: 'file://path/to/image.jpg',
|
|
object: { key: 'value' },
|
|
array: ['file://path/to/video.mp4'],
|
|
number: 42 as unknown as string,
|
|
};
|
|
|
|
const metadata = collectFileMetadata(vars);
|
|
|
|
expect(metadata).toEqual({
|
|
text: {
|
|
path: 'file://path/to/image.jpg',
|
|
type: 'image',
|
|
format: 'jpg',
|
|
},
|
|
});
|
|
});
|
|
});
|
|
describe('Image Data URL Generation', () => {
|
|
beforeEach(() => {
|
|
// Override mockPathResolve to return only the last path segment for these tests
|
|
mockPathResolve.mockImplementation((...paths: string[]) => {
|
|
return paths[paths.length - 1];
|
|
});
|
|
|
|
// Mock fs.readFileSync to return predictable base64 data for different image types
|
|
vi.mocked(fs.readFileSync).mockImplementation((filePath: fs.PathOrFileDescriptor) => {
|
|
const pathStr = filePath.toString();
|
|
if (pathStr.includes('.jpg') || pathStr.includes('.jpeg')) {
|
|
// JPEG magic number: /9j/
|
|
return Buffer.from('/9j/4AAQSkZJRgABAQEASABIAAA', 'base64');
|
|
} else if (pathStr.includes('.png')) {
|
|
// PNG magic number: iVBORw0KGgo
|
|
return Buffer.from('iVBORw0KGgoAAAANSUhEUgAA', 'base64');
|
|
} else if (pathStr.includes('.gif')) {
|
|
// GIF magic number: R0lGODlh
|
|
return Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEA', 'base64');
|
|
} else if (pathStr.includes('.webp')) {
|
|
// WebP magic number: UklGR
|
|
return Buffer.from('UklGRh4AAABXRUJQVlA4TBEAAAAv', 'base64');
|
|
} else if (pathStr.includes('.bmp')) {
|
|
// BMP magic number: Qk0
|
|
return Buffer.from('Qk02AAAAAAAAADYAAAAoAAAAAQ', 'base64');
|
|
} else if (pathStr.includes('.svg')) {
|
|
return Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
|
|
} else {
|
|
return Buffer.from('test-file-content');
|
|
}
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('should generate data URL for JPEG files', async () => {
|
|
const prompt = toPrompt('Test prompt with image: {{image}}');
|
|
const renderedPrompt = await renderPrompt(prompt, {
|
|
image: 'file://test-image.jpg',
|
|
});
|
|
|
|
expect(renderedPrompt).toContain('data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAA=');
|
|
});
|
|
|
|
it('should generate data URL for PNG files', async () => {
|
|
const prompt = toPrompt('Test prompt with image: {{image}}');
|
|
const renderedPrompt = await renderPrompt(prompt, {
|
|
image: 'file://test-image.png',
|
|
});
|
|
|
|
expect(renderedPrompt).toContain('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA');
|
|
});
|
|
|
|
it('should generate data URL for GIF files', async () => {
|
|
const prompt = toPrompt('Test prompt with image: {{image}}');
|
|
const renderedPrompt = await renderPrompt(prompt, {
|
|
image: 'file://test-image.gif',
|
|
});
|
|
|
|
expect(renderedPrompt).toContain('data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEA');
|
|
});
|
|
|
|
it('should generate data URL for WebP files', async () => {
|
|
const prompt = toPrompt('Test prompt with image: {{image}}');
|
|
const renderedPrompt = await renderPrompt(prompt, {
|
|
image: 'file://test-image.webp',
|
|
});
|
|
|
|
expect(renderedPrompt).toContain('data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAv');
|
|
});
|
|
|
|
it('should generate data URL for BMP files', async () => {
|
|
const prompt = toPrompt('Test prompt with image: {{image}}');
|
|
const renderedPrompt = await renderPrompt(prompt, {
|
|
image: 'file://test-image.bmp',
|
|
});
|
|
|
|
expect(renderedPrompt).toContain('data:image/bmp;base64,Qk02AAAAAAAAADYAAAAoAAAAAQ');
|
|
});
|
|
|
|
it('should generate data URL for SVG files', async () => {
|
|
const prompt = toPrompt('Test prompt with image: {{image}}');
|
|
const renderedPrompt = await renderPrompt(prompt, {
|
|
image: 'file://test-image.svg',
|
|
});
|
|
|
|
expect(renderedPrompt).toContain('data:image/svg+xml;base64,');
|
|
expect(renderedPrompt).toContain(
|
|
'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
|
|
); // base64 of SVG content
|
|
});
|
|
|
|
it('should handle case-insensitive file extensions', async () => {
|
|
const prompt = toPrompt('Test prompt with image: {{image}}');
|
|
const renderedPrompt = await renderPrompt(prompt, {
|
|
image: 'file://test-image.JPG',
|
|
});
|
|
|
|
expect(renderedPrompt).toContain('data:image/jpeg;base64,');
|
|
});
|
|
|
|
it('should use magic number detection for accurate MIME type', async () => {
|
|
// Test file with wrong extension but correct magic number
|
|
const prompt = toPrompt('Test prompt with image: {{image}}');
|
|
|
|
// Mock a file with .jpg extension but PNG magic number
|
|
vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
|
|
return Buffer.from('iVBORw0KGgoAAAANSUhEUgAA', 'base64'); // PNG magic
|
|
});
|
|
|
|
const renderedPrompt = await renderPrompt(prompt, {
|
|
image: 'file://wrong-extension.jpg', // .jpg extension
|
|
});
|
|
|
|
// Should detect PNG from magic number, not extension
|
|
expect(renderedPrompt).toContain('data:image/png;base64,');
|
|
});
|
|
|
|
it('should maintain existing behavior for video files (raw base64)', async () => {
|
|
vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
|
|
return Buffer.from('test-video-content');
|
|
});
|
|
|
|
const prompt = toPrompt('Test prompt with video: {{video}}');
|
|
const renderedPrompt = await renderPrompt(prompt, {
|
|
video: 'file://test-video.mp4',
|
|
});
|
|
|
|
// Should NOT have data: prefix for videos
|
|
expect(renderedPrompt).not.toContain('data:video');
|
|
expect(renderedPrompt).toContain('dGVzdC12aWRlby1jb250ZW50'); // base64 of 'test-video-content'
|
|
});
|
|
|
|
it('should maintain existing behavior for audio files (raw base64)', async () => {
|
|
vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
|
|
return Buffer.from('test-audio-content');
|
|
});
|
|
|
|
const prompt = toPrompt('Test prompt with audio: {{audio}}');
|
|
const renderedPrompt = await renderPrompt(prompt, {
|
|
audio: 'file://test-audio.mp3',
|
|
});
|
|
|
|
// Should NOT have data: prefix for audio
|
|
expect(renderedPrompt).not.toContain('data:audio');
|
|
expect(renderedPrompt).toContain('dGVzdC1hdWRpby1jb250ZW50'); // base64 of 'test-audio-content'
|
|
});
|
|
|
|
it('should handle Azure Vision prompt structure correctly', async () => {
|
|
const azureVisionPrompt = toPrompt(`[
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": "What do you see in this image?"
|
|
},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": "{{image_url}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]`);
|
|
|
|
const renderedPrompt = await renderPrompt(azureVisionPrompt, {
|
|
image_url: 'file://test-image.jpg',
|
|
});
|
|
|
|
const parsed = JSON.parse(renderedPrompt);
|
|
const imageUrl = parsed[0].content[1].image_url.url;
|
|
|
|
expect(imageUrl).toMatch(/^data:image\/jpeg;base64,/);
|
|
expect(imageUrl).toContain('/9j/4AAQSkZJRgABAQEASABIAAA=');
|
|
});
|
|
|
|
it('should work with existing data URLs (no double processing)', async () => {
|
|
const prompt = toPrompt('Test prompt with image: {{image}}');
|
|
const existingDataUrl = 'data:image/jpeg;base64,/9j/existingimage';
|
|
|
|
// Should not process files that don't start with file://
|
|
const renderedPrompt = await renderPrompt(prompt, {
|
|
image: existingDataUrl,
|
|
});
|
|
|
|
expect(renderedPrompt).toContain(existingDataUrl);
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should work with HTTP URLs (no processing)', async () => {
|
|
const prompt = toPrompt('Test prompt with image: {{image}}');
|
|
const httpUrl = 'https://example.com/image.jpg';
|
|
|
|
const renderedPrompt = await renderPrompt(prompt, {
|
|
image: httpUrl,
|
|
});
|
|
|
|
expect(renderedPrompt).toContain(httpUrl);
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|