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
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { importModule } from '../../../src/esm';
|
|
import { runPython } from '../../../src/python/pythonUtils';
|
|
import {
|
|
functionCache,
|
|
loadFunction,
|
|
parseFileUrl,
|
|
} from '../../../src/util/functions/loadFunction';
|
|
|
|
vi.mock('../../../src/esm', () => ({
|
|
importModule: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../../src/python/pythonUtils', () => ({
|
|
runPython: vi.fn(),
|
|
}));
|
|
|
|
// Use hoisted mock values that work on all platforms
|
|
const { TEST_JS_PATH, TEST_PY_PATH, TEST_TXT_PATH, mockResolve } = vi.hoisted(() => {
|
|
const TEST_JS_PATH = '/test/resolved/function.js';
|
|
const TEST_PY_PATH = '/test/resolved/function.py';
|
|
const TEST_TXT_PATH = '/test/resolved/function.txt';
|
|
const mockResolve = vi.fn((...args: string[]): string => {
|
|
const lastArg = args[args.length - 1] || '';
|
|
if (lastArg.endsWith('.py')) {
|
|
return TEST_PY_PATH;
|
|
}
|
|
if (lastArg.endsWith('.txt')) {
|
|
return TEST_TXT_PATH;
|
|
}
|
|
return TEST_JS_PATH;
|
|
});
|
|
return { TEST_JS_PATH, TEST_PY_PATH, TEST_TXT_PATH, mockResolve };
|
|
});
|
|
|
|
vi.mock('path', async () => {
|
|
const actual = await vi.importActual<typeof import('path')>('path');
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual,
|
|
resolve: mockResolve,
|
|
},
|
|
resolve: mockResolve,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../../src/cliState', () => ({
|
|
default: {
|
|
basePath: '/base/path',
|
|
},
|
|
}));
|
|
|
|
describe('loadFunction', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockResolve.mockReset();
|
|
mockResolve.mockImplementation((...args: string[]) => {
|
|
const lastArg = args[args.length - 1] || '';
|
|
if (lastArg.endsWith('.py')) {
|
|
return TEST_PY_PATH;
|
|
}
|
|
if (lastArg.endsWith('.txt')) {
|
|
return TEST_TXT_PATH;
|
|
}
|
|
return TEST_JS_PATH;
|
|
});
|
|
// Clear the function cache
|
|
Object.keys(functionCache).forEach((key) => delete functionCache[key]);
|
|
});
|
|
|
|
describe('JavaScript functions', () => {
|
|
it('should load a JavaScript function with explicit function name', async () => {
|
|
const mockFn = vi.fn();
|
|
vi.mocked(importModule).mockResolvedValue(mockFn);
|
|
|
|
const result = await loadFunction({
|
|
filePath: 'function.js',
|
|
functionName: 'customFunction',
|
|
});
|
|
|
|
expect(importModule).toHaveBeenCalledWith(TEST_JS_PATH, 'customFunction');
|
|
expect(result).toBe(mockFn);
|
|
});
|
|
|
|
it('should load a JavaScript function with default export', async () => {
|
|
const mockFn = vi.fn();
|
|
vi.mocked(importModule).mockResolvedValue(mockFn);
|
|
|
|
const result = await loadFunction({
|
|
filePath: 'function.js',
|
|
});
|
|
|
|
expect(importModule).toHaveBeenCalledWith(TEST_JS_PATH, undefined);
|
|
expect(result).toBe(mockFn);
|
|
});
|
|
|
|
it('should load a JavaScript function from default.default export', async () => {
|
|
const mockFn = vi.fn();
|
|
vi.mocked(importModule).mockResolvedValue({ default: { default: mockFn } });
|
|
|
|
const result = await loadFunction({
|
|
filePath: 'function.js',
|
|
});
|
|
|
|
expect(importModule).toHaveBeenCalledWith(TEST_JS_PATH, undefined);
|
|
expect(result).toBe(mockFn);
|
|
});
|
|
|
|
it('should throw error if JavaScript file does not export a function', async () => {
|
|
vi.mocked(importModule).mockResolvedValue({ notAFunction: 'string' });
|
|
|
|
await expect(
|
|
loadFunction({
|
|
filePath: 'function.js',
|
|
functionName: 'customFunction',
|
|
}),
|
|
).rejects.toThrow('JavaScript file must export a "customFunction" function');
|
|
});
|
|
|
|
it('should use function cache when enabled', async () => {
|
|
const mockFn = vi.fn();
|
|
vi.mocked(importModule).mockResolvedValue(mockFn);
|
|
|
|
// First call
|
|
const result1 = await loadFunction({
|
|
filePath: 'function.js',
|
|
useCache: true,
|
|
});
|
|
|
|
// Second call - should use cache
|
|
const result2 = await loadFunction({
|
|
filePath: 'function.js',
|
|
useCache: true,
|
|
});
|
|
|
|
expect(importModule).toHaveBeenCalledTimes(1);
|
|
expect(result1).toBe(result2);
|
|
expect(result1).toBe(mockFn);
|
|
});
|
|
|
|
it('should keep cached functions isolated by resolved path', async () => {
|
|
const mockFn1 = vi.fn();
|
|
const mockFn2 = vi.fn();
|
|
mockResolve.mockImplementation((basePath, filePath) => `${basePath}/${filePath}`);
|
|
vi.mocked(importModule).mockResolvedValueOnce(mockFn1).mockResolvedValueOnce(mockFn2);
|
|
|
|
const result1 = await loadFunction({
|
|
filePath: 'function.js',
|
|
basePath: '/base/one',
|
|
useCache: true,
|
|
});
|
|
const result2 = await loadFunction({
|
|
filePath: 'function.js',
|
|
basePath: '/base/two',
|
|
useCache: true,
|
|
});
|
|
|
|
expect(importModule).toHaveBeenCalledTimes(2);
|
|
expect(importModule).toHaveBeenNthCalledWith(1, '/base/one/function.js', undefined);
|
|
expect(importModule).toHaveBeenNthCalledWith(2, '/base/two/function.js', undefined);
|
|
expect(result1).toBe(mockFn1);
|
|
expect(result2).toBe(mockFn2);
|
|
});
|
|
|
|
it('should keep cached functions isolated by default function name', async () => {
|
|
const mockFn1 = vi.fn();
|
|
const mockFn2 = vi.fn();
|
|
vi.mocked(importModule).mockResolvedValue({ first: mockFn1, second: mockFn2 });
|
|
|
|
const result1 = await loadFunction({
|
|
filePath: 'function.js',
|
|
defaultFunctionName: 'first',
|
|
useCache: true,
|
|
});
|
|
const result2 = await loadFunction({
|
|
filePath: 'function.js',
|
|
defaultFunctionName: 'second',
|
|
useCache: true,
|
|
});
|
|
|
|
expect(importModule).toHaveBeenCalledTimes(2);
|
|
expect(result1).toBe(mockFn1);
|
|
expect(result2).toBe(mockFn2);
|
|
});
|
|
|
|
it('should not use cache when disabled', async () => {
|
|
const mockFn1 = vi.fn();
|
|
const mockFn2 = vi.fn();
|
|
vi.mocked(importModule).mockResolvedValueOnce(mockFn1).mockResolvedValueOnce(mockFn2);
|
|
|
|
// First call
|
|
const result1 = await loadFunction({
|
|
filePath: 'function.js',
|
|
useCache: false,
|
|
});
|
|
|
|
// Second call
|
|
const result2 = await loadFunction({
|
|
filePath: 'function.js',
|
|
useCache: false,
|
|
});
|
|
|
|
expect(importModule).toHaveBeenCalledTimes(2);
|
|
expect(result1).not.toBe(result2);
|
|
});
|
|
});
|
|
|
|
describe('Python functions', () => {
|
|
it('should load a Python function with explicit function name', async () => {
|
|
const mockPythonResult = vi.fn();
|
|
vi.mocked(runPython).mockImplementation((...args) => mockPythonResult(...args));
|
|
|
|
const result = await loadFunction({
|
|
filePath: 'function.py',
|
|
functionName: 'custom_function',
|
|
});
|
|
|
|
expect(typeof result).toBe('function');
|
|
await result('test input');
|
|
expect(runPython).toHaveBeenCalledWith(TEST_PY_PATH, 'custom_function', ['test input']);
|
|
});
|
|
|
|
it('should use default function name for Python when none specified', async () => {
|
|
const mockPythonResult = vi.fn();
|
|
vi.mocked(runPython).mockImplementation((...args) => mockPythonResult(...args));
|
|
|
|
const result = await loadFunction({
|
|
filePath: 'function.py',
|
|
});
|
|
|
|
expect(typeof result).toBe('function');
|
|
await result('test input');
|
|
expect(runPython).toHaveBeenCalledWith(TEST_PY_PATH, 'func', ['test input']);
|
|
});
|
|
|
|
it('should keep cached Python functions isolated by default function name', async () => {
|
|
const result1 = await loadFunction({
|
|
filePath: 'function.py',
|
|
defaultFunctionName: 'first',
|
|
useCache: true,
|
|
});
|
|
const result2 = await loadFunction({
|
|
filePath: 'function.py',
|
|
defaultFunctionName: 'second',
|
|
useCache: true,
|
|
});
|
|
|
|
expect(result1).not.toBe(result2);
|
|
|
|
await result1('first input');
|
|
await result2('second input');
|
|
|
|
expect(runPython).toHaveBeenNthCalledWith(1, TEST_PY_PATH, 'first', ['first input']);
|
|
expect(runPython).toHaveBeenNthCalledWith(2, TEST_PY_PATH, 'second', ['second input']);
|
|
});
|
|
});
|
|
|
|
describe('Error handling', () => {
|
|
it('should throw error for unsupported file types', async () => {
|
|
await expect(
|
|
loadFunction({
|
|
filePath: 'function.txt',
|
|
}),
|
|
).rejects.toThrow(
|
|
'File must be a JavaScript (js, cjs, mjs, ts, cts, mts) or Python (.py) file',
|
|
);
|
|
});
|
|
|
|
it('should handle import errors', async () => {
|
|
const error = new Error('Import failed');
|
|
vi.mocked(importModule).mockRejectedValue(error);
|
|
|
|
await expect(
|
|
loadFunction({
|
|
filePath: 'function.js',
|
|
}),
|
|
).rejects.toThrow('Import failed');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('parseFileUrl', () => {
|
|
it('should parse file URL with function name', () => {
|
|
const result = parseFileUrl('file:///path/to/file.js:functionName');
|
|
expect(result).toEqual({
|
|
filePath: '/path/to/file.js',
|
|
functionName: 'functionName',
|
|
});
|
|
});
|
|
|
|
it('should parse file URL without function name', () => {
|
|
const result = parseFileUrl('file:///path/to/file.js');
|
|
expect(result).toEqual({
|
|
filePath: '/path/to/file.js',
|
|
});
|
|
});
|
|
|
|
it('should throw error for invalid file URL', () => {
|
|
expect(() => parseFileUrl('/path/to/file.js')).toThrow('URL must start with file://');
|
|
});
|
|
|
|
it('should handle Windows-style paths', () => {
|
|
const result = parseFileUrl('file://C:/path/to/file.js:functionName');
|
|
expect(result).toEqual({
|
|
filePath: 'C:/path/to/file.js',
|
|
functionName: 'functionName',
|
|
});
|
|
});
|
|
|
|
it('should handle standard Windows file URLs on Windows', () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
|
|
try {
|
|
expect(parseFileUrl('file:///C:/path/to/file.js')).toEqual({
|
|
filePath: 'C:/path/to/file.js',
|
|
});
|
|
expect(parseFileUrl('file:///C:/path/to/file.js:functionName')).toEqual({
|
|
filePath: 'C:/path/to/file.js',
|
|
functionName: 'functionName',
|
|
});
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: originalPlatform,
|
|
configurable: true,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('should preserve standard Windows-looking file URLs as POSIX paths on POSIX', () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
|
|
try {
|
|
expect(parseFileUrl('file:///C:/path/to/file.js')).toEqual({
|
|
filePath: '/C:/path/to/file.js',
|
|
});
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: originalPlatform,
|
|
configurable: true,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('should handle relative paths', () => {
|
|
const result = parseFileUrl('file://./path/to/file.js:functionName');
|
|
expect(result).toEqual({
|
|
filePath: './path/to/file.js',
|
|
functionName: 'functionName',
|
|
});
|
|
});
|
|
|
|
it('should parse Python file URLs with function names', () => {
|
|
const result = parseFileUrl('file://./path/to/file.py:function_name');
|
|
expect(result).toEqual({
|
|
filePath: './path/to/file.py',
|
|
functionName: 'function_name',
|
|
});
|
|
});
|
|
|
|
it('should preserve colons in default-export file paths', () => {
|
|
const result = parseFileUrl('file://./path/to/file:default.js');
|
|
expect(result).toEqual({
|
|
filePath: './path/to/file:default.js',
|
|
});
|
|
});
|
|
|
|
it('should parse named exports from file paths that contain colons', () => {
|
|
const result = parseFileUrl('file://./path/to/file:default.js:functionName');
|
|
expect(result).toEqual({
|
|
filePath: './path/to/file:default.js',
|
|
functionName: 'functionName',
|
|
});
|
|
});
|
|
|
|
it('should parse named exports from directory paths that contain colons', () => {
|
|
const result = parseFileUrl('file://path:with:colons/hooks.js:functionName');
|
|
expect(result).toEqual({
|
|
filePath: 'path:with:colons/hooks.js',
|
|
functionName: 'functionName',
|
|
});
|
|
});
|
|
});
|