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
1893 lines
66 KiB
TypeScript
1893 lines
66 KiB
TypeScript
import * as fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import { globSync, hasMagic } from 'glob';
|
|
import * as yaml from 'js-yaml';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import cliState from '../../src/cliState';
|
|
import {
|
|
getResolvedRelativePath,
|
|
maybeLoadConfigFromExternalFile,
|
|
maybeLoadFromExternalFile,
|
|
maybeLoadFromExternalFileWithVars,
|
|
maybeLoadResponseFormatFromExternalFile,
|
|
maybeLoadToolsFromExternalFile,
|
|
parsePathOrGlob,
|
|
pathExists,
|
|
readFilters,
|
|
readOutput,
|
|
} from '../../src/util/file';
|
|
import {
|
|
isAudioFile,
|
|
isImageFile,
|
|
isJavascriptFile,
|
|
isVideoFile,
|
|
} from '../../src/util/fileExtensions';
|
|
import { mockProcessEnv } from './utils';
|
|
|
|
const hasGlobMagic = (candidatePath: string) => {
|
|
return /[*?[\]{}()!+@]/.test(candidatePath) || candidatePath.includes('\\');
|
|
};
|
|
|
|
vi.mock('proxy-agent', () => ({
|
|
ProxyAgent: vi.fn().mockImplementation(() => ({
|
|
isMockProxyAgent: true,
|
|
addRequest: vi.fn(),
|
|
connect: vi.fn(),
|
|
})),
|
|
}));
|
|
|
|
vi.mock('fs', async () => {
|
|
const actual = await vi.importActual<typeof import('fs')>('fs');
|
|
return {
|
|
...actual,
|
|
readFileSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
statSync: vi.fn(),
|
|
readdirSync: vi.fn(),
|
|
existsSync: vi.fn(),
|
|
mkdirSync: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('fs/promises', async () => {
|
|
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises');
|
|
return {
|
|
...actual,
|
|
access: vi.fn(actual.access),
|
|
};
|
|
});
|
|
|
|
vi.mock('glob', () => ({
|
|
globSync: vi.fn(),
|
|
hasMagic: vi.fn((candidatePath: string) => hasGlobMagic(candidatePath)),
|
|
}));
|
|
|
|
vi.mock('../../src/esm', () => ({
|
|
importModule: vi.fn(),
|
|
}));
|
|
|
|
// Import after mocking
|
|
import { importModule } from '../../src/esm';
|
|
|
|
vi.mock('../../src/python/pythonUtils', () => ({
|
|
runPython: vi.fn(),
|
|
}));
|
|
|
|
describe('file utilities', () => {
|
|
describe('isJavascriptFile', () => {
|
|
it('identifies JavaScript and TypeScript files', () => {
|
|
expect(isJavascriptFile('test.js')).toBe(true);
|
|
expect(isJavascriptFile('test.cjs')).toBe(true);
|
|
expect(isJavascriptFile('test.mjs')).toBe(true);
|
|
expect(isJavascriptFile('test.ts')).toBe(true);
|
|
expect(isJavascriptFile('test.cts')).toBe(true);
|
|
expect(isJavascriptFile('test.mts')).toBe(true);
|
|
expect(isJavascriptFile('test.txt')).toBe(false);
|
|
expect(isJavascriptFile('test.py')).toBe(false);
|
|
});
|
|
|
|
it('matches extensions case-insensitively', () => {
|
|
expect(isJavascriptFile('test.JS')).toBe(true);
|
|
expect(isJavascriptFile('test.Ts')).toBe(true);
|
|
expect(isJavascriptFile('test.MJS')).toBe(true);
|
|
expect(isJavascriptFile('test.CTS')).toBe(true);
|
|
expect(isJavascriptFile('test.TXT')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isImageFile', () => {
|
|
it('identifies image files correctly', () => {
|
|
expect(isImageFile('photo.jpg')).toBe(true);
|
|
expect(isImageFile('image.jpeg')).toBe(true);
|
|
expect(isImageFile('icon.png')).toBe(true);
|
|
expect(isImageFile('anim.gif')).toBe(true);
|
|
expect(isImageFile('image.bmp')).toBe(true);
|
|
expect(isImageFile('photo.webp')).toBe(true);
|
|
expect(isImageFile('icon.svg')).toBe(true);
|
|
expect(isImageFile('doc.pdf')).toBe(false);
|
|
expect(isImageFile('noextension')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isVideoFile', () => {
|
|
it('identifies video files correctly', () => {
|
|
expect(isVideoFile('video.mp4')).toBe(true);
|
|
expect(isVideoFile('clip.webm')).toBe(true);
|
|
expect(isVideoFile('video.ogg')).toBe(true);
|
|
expect(isVideoFile('movie.mov')).toBe(true);
|
|
expect(isVideoFile('video.avi')).toBe(true);
|
|
expect(isVideoFile('clip.wmv')).toBe(true);
|
|
expect(isVideoFile('movie.mkv')).toBe(true);
|
|
expect(isVideoFile('video.m4v')).toBe(true);
|
|
expect(isVideoFile('doc.pdf')).toBe(false);
|
|
expect(isVideoFile('noextension')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isAudioFile', () => {
|
|
it('identifies audio files correctly', () => {
|
|
expect(isAudioFile('sound.wav')).toBe(true);
|
|
expect(isAudioFile('music.mp3')).toBe(true);
|
|
expect(isAudioFile('audio.ogg')).toBe(true);
|
|
expect(isAudioFile('sound.aac')).toBe(true);
|
|
expect(isAudioFile('music.m4a')).toBe(true);
|
|
expect(isAudioFile('audio.flac')).toBe(true);
|
|
expect(isAudioFile('sound.wma')).toBe(true);
|
|
expect(isAudioFile('music.aiff')).toBe(true);
|
|
expect(isAudioFile('voice.opus')).toBe(true);
|
|
expect(isAudioFile('doc.pdf')).toBe(false);
|
|
expect(isAudioFile('noextension')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('maybeLoadFromExternalFile', () => {
|
|
const mockFileContent = 'test content';
|
|
const originalBasePath = cliState.basePath;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(mockFileContent);
|
|
vi.mocked(hasMagic).mockImplementation((pattern: string | string[]) => {
|
|
const p = Array.isArray(pattern) ? pattern.join('') : pattern;
|
|
return hasGlobMagic(p);
|
|
});
|
|
cliState.basePath = '/mock/base/path';
|
|
});
|
|
|
|
afterEach(() => {
|
|
cliState.basePath = originalBasePath;
|
|
});
|
|
|
|
it('should return non-string inputs as-is', () => {
|
|
expect(maybeLoadFromExternalFile({ foo: 'bar' })).toEqual({ foo: 'bar' });
|
|
expect(maybeLoadFromExternalFile(null)).toBeNull();
|
|
expect(maybeLoadFromExternalFile(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('should return strings that do not start with file:// as-is', () => {
|
|
expect(maybeLoadFromExternalFile('just a string')).toBe('just a string');
|
|
expect(maybeLoadFromExternalFile('/path/to/file')).toBe('/path/to/file');
|
|
});
|
|
|
|
it('should load JSON files', () => {
|
|
const mockData = { key: 'value' };
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData));
|
|
|
|
const result = maybeLoadFromExternalFile('file://test.json');
|
|
expect(result).toEqual(mockData);
|
|
});
|
|
|
|
it('should load YAML files', () => {
|
|
const mockData = { key: 'value' };
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(yaml.dump(mockData));
|
|
|
|
const result = maybeLoadFromExternalFile('file://test.yaml');
|
|
expect(result).toEqual(mockData);
|
|
});
|
|
|
|
it('should load CSV files', () => {
|
|
const csvContent = 'name,age\nJohn,30\nJane,25';
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(csvContent);
|
|
|
|
const result = maybeLoadFromExternalFile('file://test.csv');
|
|
expect(result).toEqual([
|
|
{ name: 'John', age: '30' },
|
|
{ name: 'Jane', age: '25' },
|
|
]);
|
|
});
|
|
|
|
it('should handle glob patterns for YAML files', () => {
|
|
const mockFiles = ['/mock/base/path/scenario1.yaml', '/mock/base/path/scenario2.yaml'];
|
|
const mockData1 = { test: 'scenario1' };
|
|
const mockData2 = { test: 'scenario2' };
|
|
|
|
vi.mocked(globSync).mockReturnValue(mockFiles);
|
|
vi.mocked(fs.readFileSync)
|
|
.mockReturnValueOnce(yaml.dump(mockData1))
|
|
.mockReturnValueOnce(yaml.dump(mockData2));
|
|
|
|
const result = maybeLoadFromExternalFile('file://scenarios/*.yaml');
|
|
expect(result).toEqual([mockData1, mockData2]);
|
|
expect(globSync).toHaveBeenCalledWith(path.resolve('/mock/base/path', 'scenarios/*.yaml'), {
|
|
windowsPathsNoEscape: true,
|
|
});
|
|
});
|
|
|
|
it('should handle glob patterns for JSON files', () => {
|
|
const mockFiles = ['/mock/base/path/data1.json', '/mock/base/path/data2.json'];
|
|
const mockData1 = { id: 1 };
|
|
const mockData2 = { id: 2 };
|
|
|
|
vi.mocked(globSync).mockReturnValue(mockFiles);
|
|
vi.mocked(fs.readFileSync)
|
|
.mockReturnValueOnce(JSON.stringify(mockData1))
|
|
.mockReturnValueOnce(JSON.stringify(mockData2));
|
|
|
|
const result = maybeLoadFromExternalFile('file://data*.json');
|
|
expect(result).toEqual([mockData1, mockData2]);
|
|
});
|
|
|
|
it('should handle glob patterns with arrays in files', () => {
|
|
const mockFiles = ['/mock/base/path/tests1.yaml', '/mock/base/path/tests2.yaml'];
|
|
const mockData1 = [{ test: 'a' }, { test: 'b' }];
|
|
const mockData2 = [{ test: 'c' }, { test: 'd' }];
|
|
|
|
vi.mocked(globSync).mockReturnValue(mockFiles);
|
|
vi.mocked(fs.readFileSync)
|
|
.mockReturnValueOnce(yaml.dump(mockData1))
|
|
.mockReturnValueOnce(yaml.dump(mockData2));
|
|
|
|
const result = maybeLoadFromExternalFile('file://tests*.yaml');
|
|
expect(result).toEqual([{ test: 'a' }, { test: 'b' }, { test: 'c' }, { test: 'd' }]);
|
|
});
|
|
|
|
it('should handle single-column CSV files consistently with glob patterns', () => {
|
|
const mockFiles = ['/mock/base/path/data1.csv', '/mock/base/path/data2.csv'];
|
|
const csvContent1 = 'name\nAlice\nBob';
|
|
const csvContent2 = 'name\nCharlie\nDavid';
|
|
|
|
vi.mocked(globSync).mockReturnValue(mockFiles);
|
|
vi.mocked(fs.readFileSync).mockReturnValueOnce(csvContent1).mockReturnValueOnce(csvContent2);
|
|
|
|
const result = maybeLoadFromExternalFile('file://data*.csv');
|
|
// Should return array of values, not objects
|
|
expect(result).toEqual(['Alice', 'Bob', 'Charlie', 'David']);
|
|
});
|
|
|
|
it('should handle multi-column CSV files with glob patterns', () => {
|
|
const mockFiles = ['/mock/base/path/users.csv'];
|
|
const csvContent = 'name,age\nAlice,30\nBob,25';
|
|
|
|
vi.mocked(globSync).mockReturnValue(mockFiles);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(csvContent);
|
|
|
|
const result = maybeLoadFromExternalFile('file://users*.csv');
|
|
// Should return array of objects for multi-column CSV
|
|
expect(result).toEqual([
|
|
{ name: 'Alice', age: '30' },
|
|
{ name: 'Bob', age: '25' },
|
|
]);
|
|
});
|
|
|
|
it('should handle empty YAML files in glob patterns', () => {
|
|
const mockFiles = ['/mock/base/path/empty.yaml', '/mock/base/path/data.yaml'];
|
|
const mockData = { test: 'data' };
|
|
|
|
vi.mocked(globSync).mockReturnValue(mockFiles);
|
|
vi.mocked(fs.readFileSync)
|
|
.mockReturnValueOnce('') // Empty file
|
|
.mockReturnValueOnce(yaml.dump(mockData));
|
|
|
|
const result = maybeLoadFromExternalFile('file://**.yaml');
|
|
// Should skip empty file and only include valid data
|
|
expect(result).toEqual([mockData]);
|
|
});
|
|
|
|
it('should throw error when glob pattern matches no files', () => {
|
|
vi.mocked(globSync).mockReturnValue([]);
|
|
|
|
expect(() => maybeLoadFromExternalFile('file://nonexistent/*.yaml')).toThrow(
|
|
`No files found matching pattern: ${path.resolve('/mock/base/path', 'nonexistent/*.yaml')}`,
|
|
);
|
|
});
|
|
|
|
it('should throw error when file does not exist', () => {
|
|
const enoentError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException;
|
|
enoentError.code = 'ENOENT';
|
|
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
|
throw enoentError;
|
|
});
|
|
|
|
expect(() => maybeLoadFromExternalFile('file://nonexistent.yaml')).toThrow(
|
|
`File does not exist: ${path.resolve('/mock/base/path', 'nonexistent.yaml')}`,
|
|
);
|
|
});
|
|
|
|
it('should handle arrays of file paths', () => {
|
|
const mockData1 = { key: 'value1' };
|
|
const mockData2 = { key: 'value2' };
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync)
|
|
.mockReturnValueOnce(JSON.stringify(mockData1))
|
|
.mockReturnValueOnce(JSON.stringify(mockData2));
|
|
|
|
const result = maybeLoadFromExternalFile(['file://test1.json', 'file://test2.json']);
|
|
expect(result).toEqual([mockData1, mockData2]);
|
|
});
|
|
|
|
it('should use basePath when resolving file paths', () => {
|
|
const basePath = '/base/path';
|
|
cliState.basePath = basePath;
|
|
vi.mocked(fs.readFileSync).mockReturnValue(mockFileContent);
|
|
|
|
maybeLoadFromExternalFile('file://test.txt');
|
|
|
|
const expectedPath = path.resolve(basePath, 'test.txt');
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expectedPath, 'utf8');
|
|
|
|
cliState.basePath = undefined;
|
|
});
|
|
|
|
it('should handle relative paths correctly', () => {
|
|
const basePath = './relative/path';
|
|
cliState.basePath = basePath;
|
|
vi.mocked(fs.readFileSync).mockReturnValue(mockFileContent);
|
|
|
|
maybeLoadFromExternalFile('file://test.txt');
|
|
|
|
const expectedPath = path.resolve(basePath, 'test.txt');
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expectedPath, 'utf8');
|
|
|
|
cliState.basePath = undefined;
|
|
});
|
|
|
|
it('should handle a path with environment variables in Nunjucks template', () => {
|
|
mockProcessEnv({ TEST_ROOT_PATH: '/root/dir' });
|
|
const input = 'file://{{ env.TEST_ROOT_PATH }}/test.txt';
|
|
|
|
const expectedPath = path.resolve(`${process.env.TEST_ROOT_PATH}/test.txt`);
|
|
maybeLoadFromExternalFile(input);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expectedPath, 'utf8');
|
|
|
|
mockProcessEnv({ TEST_ROOT_PATH: undefined });
|
|
});
|
|
|
|
it('should ignore basePath when file path is absolute', () => {
|
|
const basePath = '/base/path';
|
|
cliState.basePath = basePath;
|
|
vi.mocked(fs.readFileSync).mockReturnValue(mockFileContent);
|
|
|
|
maybeLoadFromExternalFile('file:///absolute/path/test.txt');
|
|
|
|
const expectedPath = path.resolve('/absolute/path/test.txt');
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expectedPath, 'utf8');
|
|
|
|
cliState.basePath = undefined;
|
|
});
|
|
|
|
it('should handle list of paths', () => {
|
|
const basePath = './relative/path';
|
|
cliState.basePath = basePath;
|
|
const input = ['file://test1.txt', 'file://test2.txt', 'file://test3.txt'];
|
|
|
|
// Mock readFileSync to return consistent data
|
|
const mockFileData = 'test content';
|
|
vi.mocked(fs.readFileSync).mockReturnValue(mockFileData);
|
|
|
|
maybeLoadFromExternalFile(input);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(3);
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(path.resolve(basePath, 'test1.txt'), 'utf8');
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(path.resolve(basePath, 'test2.txt'), 'utf8');
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(path.resolve(basePath, 'test3.txt'), 'utf8');
|
|
|
|
cliState.basePath = undefined;
|
|
});
|
|
|
|
it('should recursively load file references in objects', () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValueOnce('{"foo": 1}').mockReturnValueOnce('bar');
|
|
|
|
const config = {
|
|
data: 'file://data.json',
|
|
nested: {
|
|
text: 'file://note.txt',
|
|
},
|
|
};
|
|
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(2);
|
|
expect(result).toEqual({ data: { foo: 1 }, nested: { text: 'bar' } });
|
|
});
|
|
|
|
it('should handle arrays and nested structures', () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValueOnce('test content');
|
|
|
|
const config = {
|
|
items: ['file://test.txt', 'normal string'],
|
|
nullValue: null,
|
|
emptyArray: [],
|
|
nested: {
|
|
deep: {
|
|
value: 'file://test.txt',
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(2);
|
|
expect(result).toEqual({
|
|
items: ['test content', 'normal string'],
|
|
nullValue: null,
|
|
emptyArray: [],
|
|
nested: {
|
|
deep: {
|
|
value: 'test content',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should handle primitive values safely', () => {
|
|
expect(maybeLoadConfigFromExternalFile('normal string')).toBe('normal string');
|
|
expect(maybeLoadConfigFromExternalFile(42)).toBe(42);
|
|
expect(maybeLoadConfigFromExternalFile(true)).toBe(true);
|
|
expect(maybeLoadConfigFromExternalFile(null)).toBeNull();
|
|
expect(maybeLoadConfigFromExternalFile(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('should handle deeply nested objects with multiple file references', () => {
|
|
vi.mocked(fs.readFileSync)
|
|
.mockReturnValueOnce('{"nested": {"value": 123}}')
|
|
.mockReturnValueOnce('["item1", "item2"]')
|
|
.mockReturnValueOnce('deeply nested content');
|
|
|
|
const config = {
|
|
level1: {
|
|
level2: {
|
|
level3: {
|
|
data: 'file://deep.json',
|
|
items: 'file://items.json',
|
|
level4: {
|
|
content: 'file://content.txt',
|
|
static: 'unchanged',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
topLevel: 'unchanged',
|
|
};
|
|
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(3);
|
|
expect(result).toEqual({
|
|
level1: {
|
|
level2: {
|
|
level3: {
|
|
data: { nested: { value: 123 } },
|
|
items: ['item1', 'item2'],
|
|
level4: {
|
|
content: 'deeply nested content',
|
|
static: 'unchanged',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
topLevel: 'unchanged',
|
|
});
|
|
});
|
|
|
|
it('should handle arrays with mixed content types including file references', () => {
|
|
vi.mocked(fs.readFileSync)
|
|
.mockReturnValueOnce('{"config": "loaded"}')
|
|
.mockReturnValueOnce('text content')
|
|
.mockReturnValueOnce('{"config": "loaded"}');
|
|
|
|
const config = {
|
|
mixedArray: [
|
|
'string value',
|
|
42,
|
|
true,
|
|
{ normal: 'object' },
|
|
'file://config.json',
|
|
['nested', 'array', 'file://text.txt'],
|
|
null,
|
|
{ nested: { file: 'file://config.json' } },
|
|
],
|
|
};
|
|
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
|
|
expect(result).toEqual({
|
|
mixedArray: [
|
|
'string value',
|
|
42,
|
|
true,
|
|
{ normal: 'object' },
|
|
{ config: 'loaded' },
|
|
['nested', 'array', 'text content'],
|
|
null,
|
|
{ nested: { file: { config: 'loaded' } } },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('should handle edge cases with empty objects and arrays', () => {
|
|
const config = {
|
|
emptyObject: {},
|
|
emptyArray: [],
|
|
arrayWithEmpties: [{}, [], null, undefined, ''],
|
|
nested: {
|
|
empty: {},
|
|
moreNested: {
|
|
stillEmpty: {},
|
|
emptyArray: [],
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
|
|
expect(result).toEqual(config);
|
|
});
|
|
|
|
it('should preserve object keys that contain special characters', () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValueOnce('special content');
|
|
|
|
const config = {
|
|
'key-with-dashes': 'file://special.txt',
|
|
'key.with.dots': 'normal value',
|
|
'key with spaces': { nested: 'value' },
|
|
'key@with#symbols': ['array', 'value'],
|
|
123: 'numeric key',
|
|
'': 'empty key',
|
|
};
|
|
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
|
|
expect(result).toEqual({
|
|
'key-with-dashes': 'special content',
|
|
'key.with.dots': 'normal value',
|
|
'key with spaces': { nested: 'value' },
|
|
'key@with#symbols': ['array', 'value'],
|
|
123: 'numeric key',
|
|
'': 'empty key',
|
|
});
|
|
});
|
|
|
|
it('should handle objects with prototype pollution attempts safely', () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValueOnce('malicious content');
|
|
|
|
const protoKey = '__proto__';
|
|
const config = {
|
|
[protoKey]: 'file://malicious.txt',
|
|
constructor: { normal: 'value' },
|
|
prototype: ['safe', 'array'],
|
|
normal: 'safe value',
|
|
};
|
|
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
|
|
expect(result).toEqual({
|
|
[protoKey]: 'malicious content',
|
|
constructor: { normal: 'value' },
|
|
prototype: ['safe', 'array'],
|
|
normal: 'safe value',
|
|
});
|
|
expect(Object.getPrototypeOf(result)).toBe(Object.prototype);
|
|
});
|
|
|
|
it('should handle very large nested structures efficiently', () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue('file content');
|
|
|
|
// Create a large nested structure
|
|
const createNestedConfig = (depth: number): any => {
|
|
if (depth === 0) {
|
|
return 'file://test.txt';
|
|
}
|
|
return {
|
|
[`level${depth}`]: createNestedConfig(depth - 1),
|
|
[`static${depth}`]: `value at depth ${depth}`,
|
|
};
|
|
};
|
|
|
|
const config = createNestedConfig(10);
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
|
|
// Should have loaded the file reference at the deepest level
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(1);
|
|
|
|
// Verify the structure is preserved
|
|
let current = result;
|
|
for (let i = 10; i > 0; i--) {
|
|
expect(current).toHaveProperty(`level${i}`);
|
|
expect(current).toHaveProperty(`static${i}`, `value at depth ${i}`);
|
|
current = current[`level${i}`];
|
|
}
|
|
expect(current).toBe('file content');
|
|
});
|
|
|
|
it('should handle functions and undefined values in objects gracefully', () => {
|
|
const testFunction = () => 'test';
|
|
|
|
const config = {
|
|
func: testFunction,
|
|
undef: undefined,
|
|
normal: 'value',
|
|
nested: {
|
|
func2: testFunction,
|
|
undef2: undefined,
|
|
},
|
|
};
|
|
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
|
|
expect(result).toEqual(config);
|
|
expect(result.func).toBe(testFunction);
|
|
expect(result.nested.func2).toBe(testFunction);
|
|
});
|
|
|
|
it('should return original string for Python files with function names', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
|
|
const result = maybeLoadFromExternalFile('file://assert.py:my_function');
|
|
|
|
// Should return the original string, not attempt to load file
|
|
expect(result).toBe('file://assert.py:my_function');
|
|
expect(fs.existsSync).not.toHaveBeenCalled();
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return original string for JavaScript files with function names', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
|
|
const jsFiles = ['file://test.js:myFunc', 'file://test.ts:myFunc', 'file://test.mjs:myFunc'];
|
|
|
|
for (const fileRef of jsFiles) {
|
|
const result = maybeLoadFromExternalFile(fileRef);
|
|
expect(result).toBe(fileRef);
|
|
}
|
|
|
|
expect(fs.existsSync).not.toHaveBeenCalled();
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should load Python/JS files normally when no function name specified', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue('file contents');
|
|
|
|
const result = maybeLoadFromExternalFile('file://test.py');
|
|
|
|
expect(result).toBe('file contents');
|
|
expect(fs.readFileSync).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle Windows drive letters correctly', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
|
|
// Drive letter colon should not be treated as function separator
|
|
const result = maybeLoadFromExternalFile('file://C:/path/test.py:myFunc');
|
|
|
|
expect(result).toBe('file://C:/path/test.py:myFunc');
|
|
});
|
|
|
|
it('should handle non-Python/JS files with colons normally', () => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue('{"data": "test"}');
|
|
|
|
// JSON file with colon should still load normally (not treated as function)
|
|
const result = maybeLoadFromExternalFile('file://data:test.json');
|
|
|
|
expect(result).toEqual({ data: 'test' });
|
|
expect(fs.readFileSync).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should preserve function references in config objects (integration test)', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
|
|
// Mock config object similar to what would be loaded from YAML tests
|
|
const config = {
|
|
assert: [
|
|
{
|
|
type: 'python',
|
|
value: 'file://assert.py:my_function',
|
|
},
|
|
{
|
|
type: 'javascript',
|
|
value: 'file://assert.js:my_function',
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
|
|
// Function references should be preserved unchanged
|
|
expect(result.assert[0].value).toBe('file://assert.py:my_function');
|
|
expect(result.assert[1].value).toBe('file://assert.js:my_function');
|
|
|
|
// No file system calls should have been made for function references
|
|
expect(fs.existsSync).not.toHaveBeenCalled();
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should still support glob patterns after refactor (regression test)', () => {
|
|
const mockFiles = ['/mock/base/path/test1.yaml', '/mock/base/path/test2.yaml'];
|
|
const mockData1 = { test: 'data1' };
|
|
const mockData2 = { test: 'data2' };
|
|
|
|
vi.mocked(globSync).mockReturnValue(mockFiles);
|
|
vi.mocked(fs.readFileSync)
|
|
.mockReturnValueOnce(yaml.dump(mockData1))
|
|
.mockReturnValueOnce(yaml.dump(mockData2));
|
|
|
|
const result = maybeLoadFromExternalFile('file://test*.yaml');
|
|
|
|
// Glob expansion should still work correctly
|
|
expect(result).toEqual([mockData1, mockData2]);
|
|
expect(globSync).toHaveBeenCalledWith(path.resolve('/mock/base/path', 'test*.yaml'), {
|
|
windowsPathsNoEscape: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getResolvedRelativePath', () => {
|
|
const originalCwd = process.cwd();
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(process, 'cwd').mockReturnValue('/mock/cwd');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.spyOn(process, 'cwd').mockReturnValue(originalCwd);
|
|
});
|
|
|
|
it('returns absolute path unchanged', () => {
|
|
const absolutePath = path.resolve('/absolute/path/file.txt');
|
|
expect(getResolvedRelativePath(absolutePath, false)).toBe(absolutePath);
|
|
});
|
|
|
|
it('uses process.cwd() when isCloudConfig is true', () => {
|
|
expect(getResolvedRelativePath('relative/file.txt', true)).toBe(
|
|
path.join('/mock/cwd', 'relative/file.txt'),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('context-aware file loading', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
(fs.existsSync as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
|
(fs.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue('file content');
|
|
vi.mocked(hasMagic).mockImplementation((pattern: string | string[]) => {
|
|
const p = Array.isArray(pattern) ? pattern.join('') : pattern;
|
|
return p.includes('*') || p.includes('?') || p.includes('[') || p.includes('{');
|
|
});
|
|
cliState.basePath = '/test';
|
|
});
|
|
|
|
describe('maybeLoadFromExternalFile with context', () => {
|
|
it('should preserve Python files in assertion context', () => {
|
|
const result = maybeLoadFromExternalFile('file://assert.py', 'assertion');
|
|
expect(result).toBe('file://assert.py');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should preserve Python files with function names in assertion context', () => {
|
|
const result = maybeLoadFromExternalFile('file://assert.py:get_assert', 'assertion');
|
|
expect(result).toBe('file://assert.py:get_assert');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should preserve JavaScript files in assertion context', () => {
|
|
const result = maybeLoadFromExternalFile('file://assert.js', 'assertion');
|
|
expect(result).toBe('file://assert.js');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should preserve TypeScript files in assertion context', () => {
|
|
const result = maybeLoadFromExternalFile('file://assert.ts', 'assertion');
|
|
expect(result).toBe('file://assert.ts');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should load Python files normally in general context', () => {
|
|
(fs.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue('def test(): pass');
|
|
const result = maybeLoadFromExternalFile('file://script.py', 'general');
|
|
expect(result).toBe('def test(): pass');
|
|
expect(fs.readFileSync).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should load JavaScript files normally in general context', () => {
|
|
(fs.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue('module.exports = {};');
|
|
const result = maybeLoadFromExternalFile('file://script.js', 'general');
|
|
expect(result).toBe('module.exports = {};');
|
|
expect(fs.readFileSync).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should load Python files normally when no context provided', () => {
|
|
(fs.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue('def test(): pass');
|
|
const result = maybeLoadFromExternalFile('file://script.py');
|
|
expect(result).toBe('def test(): pass');
|
|
expect(fs.readFileSync).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should load non-code files normally in assertion context', () => {
|
|
(fs.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue('test data');
|
|
const result = maybeLoadFromExternalFile('file://data.txt', 'assertion');
|
|
expect(result).toBe('test data');
|
|
expect(fs.readFileSync).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle Windows paths correctly in assertion context', () => {
|
|
const result = maybeLoadFromExternalFile('file://C:/test/assert.py', 'assertion');
|
|
expect(result).toBe('file://C:/test/assert.py');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle arrays in assertion context', () => {
|
|
const files = ['file://assert1.py', 'file://assert2.js', 'file://data.txt'];
|
|
(fs.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue('file content');
|
|
const result = maybeLoadFromExternalFile(files, 'assertion');
|
|
|
|
expect(result).toEqual(['file://assert1.py', 'file://assert2.js', 'file content']);
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(1); // Only data.txt should be loaded
|
|
});
|
|
});
|
|
|
|
describe('maybeLoadConfigFromExternalFile with assertion detection', () => {
|
|
it('should preserve Python assertion file references', () => {
|
|
const config = {
|
|
assert: [
|
|
{
|
|
type: 'python',
|
|
value: 'file://good_assertion.py',
|
|
},
|
|
],
|
|
};
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
expect(result.assert[0].value).toBe('file://good_assertion.py');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should preserve JavaScript assertion file references', () => {
|
|
const config = {
|
|
assert: [
|
|
{
|
|
type: 'javascript',
|
|
value: 'file://assertion.js:checkResult',
|
|
},
|
|
],
|
|
};
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
expect(result.assert[0].value).toBe('file://assertion.js:checkResult');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should load non-assertion Python files normally', () => {
|
|
(fs.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue('def utility(): pass');
|
|
const config = {
|
|
util: 'file://helper.py',
|
|
};
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
expect(result.util).toBe('def utility(): pass');
|
|
expect(fs.readFileSync).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle nested assertion objects', () => {
|
|
const config = {
|
|
tests: [
|
|
{
|
|
assert: [
|
|
{
|
|
type: 'python',
|
|
value: 'file://nested_assertion.py',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
expect(result.tests[0].assert[0].value).toBe('file://nested_assertion.py');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle mixed assertion types', () => {
|
|
(fs.readFileSync as ReturnType<typeof vi.fn>).mockReturnValue('some data');
|
|
const config = {
|
|
assert: [
|
|
{
|
|
type: 'python',
|
|
value: 'file://python_assert.py',
|
|
},
|
|
{
|
|
type: 'contains',
|
|
value: 'file://expected.txt',
|
|
},
|
|
{
|
|
type: 'javascript',
|
|
value: 'file://js_assert.js',
|
|
},
|
|
],
|
|
};
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
expect(result.assert[0].value).toBe('file://python_assert.py');
|
|
expect(result.assert[1].value).toBe('some data'); // contains assertion loads file
|
|
expect(result.assert[2].value).toBe('file://js_assert.js');
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(1); // Only expected.txt
|
|
});
|
|
});
|
|
|
|
describe('maybeLoadConfigFromExternalFile with vars context', () => {
|
|
it('should preserve glob patterns in vars field for test case expansion', () => {
|
|
const config = {
|
|
vars: {
|
|
text: 'file://./resources/tests/*.json',
|
|
},
|
|
assert: [
|
|
{
|
|
type: 'contains',
|
|
value: 'hello',
|
|
},
|
|
],
|
|
};
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
expect(result.vars.text).toBe('file://./resources/tests/*.json');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should preserve glob patterns in nested vars objects', () => {
|
|
const config = {
|
|
tests: [
|
|
{
|
|
vars: {
|
|
input: 'file://inputs/*.txt',
|
|
data: 'file://data/test-*.json',
|
|
patterns: 'file://data/test-{a,b}.yaml',
|
|
optional: 'file://data/file?.json',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
expect(result.tests[0].vars.input).toBe('file://inputs/*.txt');
|
|
expect(result.tests[0].vars.data).toBe('file://data/test-*.json');
|
|
expect(result.tests[0].vars.patterns).toBe('file://data/test-{a,b}.yaml');
|
|
expect(result.tests[0].vars.optional).toBe('file://data/file?.json');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should preserve non-glob file references in vars for runtime loading by renderPrompt', () => {
|
|
const config = {
|
|
vars: {
|
|
content: 'file://content.txt', // No glob pattern - still preserved
|
|
},
|
|
};
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
// File references in vars should be preserved for runtime loading
|
|
// JS/Python files will be executed by renderPrompt in evaluatorHelpers.ts
|
|
expect(result.vars.content).toBe('file://content.txt');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should preserve JS file references in vars for runtime execution', () => {
|
|
const config = {
|
|
vars: {
|
|
dynamicContent: 'file://generateContent.js',
|
|
},
|
|
};
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
expect(result.vars.dynamicContent).toBe('file://generateContent.js');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should preserve Python file references in vars for runtime execution', () => {
|
|
const config = {
|
|
vars: {
|
|
dynamicContent: 'file://generateContent.py',
|
|
},
|
|
};
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
expect(result.vars.dynamicContent).toBe('file://generateContent.py');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should preserve all file references in nested test cases loaded from external files', () => {
|
|
// This tests the scenario from PR #6393 where test cases are loaded
|
|
// from an external YAML file and contain JS/Python file references in vars
|
|
const testCasesFromExternalFile = [
|
|
{
|
|
vars: {
|
|
question: 'What is the policy?',
|
|
context: 'file://load_context.py',
|
|
},
|
|
},
|
|
{
|
|
vars: {
|
|
question: 'How does this work?',
|
|
context: 'file://load_context.js',
|
|
},
|
|
},
|
|
];
|
|
|
|
const result = maybeLoadConfigFromExternalFile(testCasesFromExternalFile);
|
|
|
|
// Both Python and JS file references should be preserved for runtime execution
|
|
expect(result[0].vars.context).toBe('file://load_context.py');
|
|
expect(result[1].vars.context).toBe('file://load_context.js');
|
|
// Static values should remain unchanged
|
|
expect(result[0].vars.question).toBe('What is the policy?');
|
|
expect(result[1].vars.question).toBe('How does this work?');
|
|
// No files should have been read
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should preserve plain text file references in vars for consistent runtime loading', () => {
|
|
// Plain text files are also preserved for runtime loading by renderPrompt
|
|
// This ensures consistent behavior across all file types
|
|
const config = {
|
|
tests: [
|
|
{
|
|
vars: {
|
|
content: 'file://content.txt',
|
|
data: 'file://data.json',
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = maybeLoadConfigFromExternalFile(config);
|
|
|
|
expect(result.tests[0].vars.content).toBe('file://content.txt');
|
|
expect(result.tests[0].vars.data).toBe('file://data.json');
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('maybeLoadToolsFromExternalFile', () => {
|
|
const mockFileContent = '{"name": "calculator", "parameters": {"type": "object"}}';
|
|
const mockToolsArray = [
|
|
{ type: 'function', function: { name: 'calculator', parameters: { type: 'object' } } },
|
|
];
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(mockFileContent);
|
|
});
|
|
|
|
it('should process tool objects directly', async () => {
|
|
const tools = mockToolsArray;
|
|
const vars = { api_key: '123456' };
|
|
expect(await maybeLoadToolsFromExternalFile(tools, vars)).toEqual(tools);
|
|
});
|
|
|
|
it('should load tools from external file', async () => {
|
|
const tools = 'file://tools.json';
|
|
expect(await maybeLoadToolsFromExternalFile(tools)).toEqual(JSON.parse(mockFileContent));
|
|
});
|
|
|
|
it('should render variables in tools object', async () => {
|
|
const tools = [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'calculator',
|
|
parameters: { type: 'object' },
|
|
apiKey: '{{ api_key }}',
|
|
},
|
|
},
|
|
];
|
|
const vars = { api_key: '123456' };
|
|
|
|
const expected = [
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'calculator',
|
|
parameters: { type: 'object' },
|
|
apiKey: '123456',
|
|
},
|
|
},
|
|
];
|
|
|
|
expect(await maybeLoadToolsFromExternalFile(tools, vars)).toEqual(expected);
|
|
});
|
|
|
|
it('should render variables and load from external file', async () => {
|
|
const tools = 'file://{{ file_path }}.json';
|
|
const vars = { file_path: 'tools' };
|
|
|
|
maybeLoadToolsFromExternalFile(tools, vars);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('tools.json'), 'utf8');
|
|
});
|
|
|
|
it('should handle array of file paths', async () => {
|
|
const tools = ['file://tools1.json', 'file://tools2.json'];
|
|
|
|
await maybeLoadToolsFromExternalFile(tools);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
describe('validation', () => {
|
|
it('should throw error for Python/JS/TS file with function name when file not found', async () => {
|
|
const pythonTools = 'file://nonexistent.py:get_tools';
|
|
await expect(maybeLoadToolsFromExternalFile(pythonTools)).rejects.toThrow(
|
|
/Failed to load tools/,
|
|
);
|
|
|
|
const jsTools = 'file://nonexistent.js:getTools';
|
|
await expect(maybeLoadToolsFromExternalFile(jsTools)).rejects.toThrow(
|
|
/Failed to load tools/,
|
|
);
|
|
|
|
const tsTools = 'file://nonexistent.ts:getTools';
|
|
await expect(maybeLoadToolsFromExternalFile(tsTools)).rejects.toThrow(
|
|
/Failed to load tools/,
|
|
);
|
|
});
|
|
|
|
it('should throw error for Python file without function name', async () => {
|
|
const tools = 'file://tools.py';
|
|
|
|
await expect(maybeLoadToolsFromExternalFile(tools)).rejects.toThrow(
|
|
/Python files require a function name/,
|
|
);
|
|
await expect(maybeLoadToolsFromExternalFile(tools)).rejects.toThrow(
|
|
/file:\/\/tools\.py:get_tools/,
|
|
);
|
|
});
|
|
|
|
it('should throw error for JavaScript file without function name', async () => {
|
|
const tools = 'file://tools.js';
|
|
|
|
await expect(maybeLoadToolsFromExternalFile(tools)).rejects.toThrow(
|
|
/JavaScript files require a function name/,
|
|
);
|
|
});
|
|
|
|
it('should throw error for invalid string content', async () => {
|
|
const textContent = 'this is not valid JSON or YAML';
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(textContent);
|
|
|
|
const tools = 'file://tools.txt';
|
|
|
|
await expect(maybeLoadToolsFromExternalFile(tools)).rejects.toThrow(
|
|
/expected an array or object, but got a string/,
|
|
);
|
|
});
|
|
|
|
it('should accept valid YAML tools', async () => {
|
|
const yamlContent = `- type: function
|
|
function:
|
|
name: test
|
|
parameters:
|
|
type: object`;
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(yamlContent);
|
|
|
|
const tools = 'file://tools.yaml';
|
|
const result = await maybeLoadToolsFromExternalFile(tools);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
expect(result[0].type).toBe('function');
|
|
});
|
|
|
|
it('should accept valid JSON tools', async () => {
|
|
const jsonContent = JSON.stringify([
|
|
{ type: 'function', function: { name: 'test', parameters: { type: 'object' } } },
|
|
]);
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
vi.mocked(fs.readFileSync).mockReturnValue(jsonContent);
|
|
|
|
const tools = 'file://tools.json';
|
|
const result = await maybeLoadToolsFromExternalFile(tools);
|
|
|
|
expect(Array.isArray(result)).toBe(true);
|
|
expect(result[0].type).toBe('function');
|
|
});
|
|
|
|
it('should accept inline tool objects', async () => {
|
|
const tools = [{ type: 'function', function: { name: 'test' } }];
|
|
const result = await maybeLoadToolsFromExternalFile(tools);
|
|
expect(result).toEqual(tools);
|
|
});
|
|
|
|
it('should return undefined for undefined input', async () => {
|
|
const result = await maybeLoadToolsFromExternalFile(undefined);
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it('should return null for null input', async () => {
|
|
const result = await maybeLoadToolsFromExternalFile(null);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should reject number return types from functions', async () => {
|
|
const mockedImportModule = vi.mocked(importModule);
|
|
mockedImportModule.mockResolvedValue({
|
|
getTools: () => 42,
|
|
});
|
|
|
|
const tools = 'file://tools.js:getTools';
|
|
await expect(maybeLoadToolsFromExternalFile(tools)).rejects.toThrow(
|
|
/must return an array or object/,
|
|
);
|
|
});
|
|
|
|
it('should reject boolean return types from functions', async () => {
|
|
const mockedImportModule = vi.mocked(importModule);
|
|
mockedImportModule.mockResolvedValue({
|
|
getTools: () => true,
|
|
});
|
|
|
|
const tools = 'file://tools.js:getTools';
|
|
await expect(maybeLoadToolsFromExternalFile(tools)).rejects.toThrow(
|
|
/must return an array or object/,
|
|
);
|
|
});
|
|
|
|
it('should handle async function exports from JS files', async () => {
|
|
const mockedImportModule = vi.mocked(importModule);
|
|
const expectedTools = [{ type: 'function', function: { name: 'asyncTool' } }];
|
|
mockedImportModule.mockResolvedValue({
|
|
getTools: async () => expectedTools,
|
|
});
|
|
|
|
const tools = 'file://tools.js:getTools';
|
|
const result = await maybeLoadToolsFromExternalFile(tools);
|
|
|
|
expect(result).toEqual(expectedTools);
|
|
});
|
|
|
|
it('should show empty exports message when no functions available', async () => {
|
|
const mockedImportModule = vi.mocked(importModule);
|
|
mockedImportModule.mockResolvedValue({
|
|
default: {},
|
|
});
|
|
|
|
const tools = 'file://tools.js:getTools';
|
|
await expect(maybeLoadToolsFromExternalFile(tools)).rejects.toThrow(
|
|
/Available exports: \(none\)/,
|
|
);
|
|
});
|
|
|
|
it('should handle JavaScript syntax errors gracefully', async () => {
|
|
const mockedImportModule = vi.mocked(importModule);
|
|
const syntaxError = new SyntaxError('Unexpected token )');
|
|
mockedImportModule.mockRejectedValue(syntaxError);
|
|
|
|
const tools = 'file://tools.js:getTools';
|
|
await expect(maybeLoadToolsFromExternalFile(tools)).rejects.toThrow(/Failed to load tools/);
|
|
await expect(maybeLoadToolsFromExternalFile(tools)).rejects.toThrow(/Unexpected token/);
|
|
});
|
|
|
|
it('should handle Python syntax errors gracefully', async () => {
|
|
const { runPython } = await import('../../src/python/pythonUtils');
|
|
vi.mocked(runPython).mockRejectedValue(
|
|
new Error('SyntaxError: invalid syntax (tools.py, line 2)'),
|
|
);
|
|
|
|
const tools = 'file://tools.py:get_tools';
|
|
await expect(maybeLoadToolsFromExternalFile(tools)).rejects.toThrow(/Failed to load tools/);
|
|
await expect(maybeLoadToolsFromExternalFile(tools)).rejects.toThrow(/SyntaxError/);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('maybeLoadFromExternalFileWithVars', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
});
|
|
|
|
it('should render variables and load from file', () => {
|
|
const mockContent = JSON.stringify({ name: 'test', value: 123 });
|
|
vi.mocked(fs.readFileSync).mockReturnValue(mockContent);
|
|
|
|
const result = maybeLoadFromExternalFileWithVars('file://{{ filename }}.json', {
|
|
filename: 'config',
|
|
});
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('config.json'), 'utf8');
|
|
expect(result).toEqual({ name: 'test', value: 123 });
|
|
});
|
|
|
|
it('should render variables in object', () => {
|
|
const config = {
|
|
name: '{{ name }}',
|
|
apiKey: '{{ api_key }}',
|
|
};
|
|
const vars = { name: 'test-function', api_key: 'sk-123' };
|
|
|
|
const result = maybeLoadFromExternalFileWithVars(config, vars);
|
|
|
|
expect(result).toEqual({
|
|
name: 'test-function',
|
|
apiKey: 'sk-123',
|
|
});
|
|
});
|
|
|
|
it('should pass through non-file values unchanged', () => {
|
|
const config = { type: 'function', name: 'calculator' };
|
|
const result = maybeLoadFromExternalFileWithVars(config, {});
|
|
|
|
expect(result).toEqual(config);
|
|
});
|
|
|
|
it('should handle undefined and null', () => {
|
|
expect(maybeLoadFromExternalFileWithVars(undefined, {})).toBeUndefined();
|
|
expect(maybeLoadFromExternalFileWithVars(null, {})).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('maybeLoadResponseFormatFromExternalFile', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
});
|
|
|
|
it('should return undefined for undefined input', () => {
|
|
expect(maybeLoadResponseFormatFromExternalFile(undefined, {})).toBeUndefined();
|
|
});
|
|
|
|
it('should return null for null input', () => {
|
|
expect(maybeLoadResponseFormatFromExternalFile(null, {})).toBeNull();
|
|
});
|
|
|
|
it('should load response_format from file', () => {
|
|
const mockFormat = JSON.stringify({
|
|
type: 'json_schema',
|
|
json_schema: { name: 'my_schema', schema: { type: 'object' } },
|
|
});
|
|
vi.mocked(fs.readFileSync).mockReturnValue(mockFormat);
|
|
|
|
const result = maybeLoadResponseFormatFromExternalFile('file://format.json', {});
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('format.json'), 'utf8');
|
|
expect(result).toEqual({
|
|
type: 'json_schema',
|
|
json_schema: { name: 'my_schema', schema: { type: 'object' } },
|
|
});
|
|
});
|
|
|
|
it('should render variables in response_format', () => {
|
|
const format = {
|
|
type: 'json_schema',
|
|
name: '{{ schema_name }}',
|
|
schema: { type: 'object' },
|
|
};
|
|
const vars = { schema_name: 'my_custom_schema' };
|
|
|
|
const result = maybeLoadResponseFormatFromExternalFile(format, vars);
|
|
|
|
expect(result).toEqual({
|
|
type: 'json_schema',
|
|
name: 'my_custom_schema',
|
|
schema: { type: 'object' },
|
|
});
|
|
});
|
|
|
|
it('should load nested schema from file when schema is a file reference', () => {
|
|
const nestedSchema = JSON.stringify({
|
|
type: 'object',
|
|
properties: { name: { type: 'string' } },
|
|
});
|
|
vi.mocked(fs.readFileSync).mockReturnValue(nestedSchema);
|
|
|
|
const format = {
|
|
type: 'json_schema',
|
|
name: 'test_schema',
|
|
schema: 'file://schema.json',
|
|
};
|
|
|
|
const result = maybeLoadResponseFormatFromExternalFile(format, {});
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('schema.json'), 'utf8');
|
|
expect(result).toEqual({
|
|
type: 'json_schema',
|
|
name: 'test_schema',
|
|
schema: {
|
|
type: 'object',
|
|
properties: { name: { type: 'string' } },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should load nested json_schema.schema from file', () => {
|
|
const nestedSchema = JSON.stringify({
|
|
type: 'object',
|
|
required: ['id'],
|
|
});
|
|
vi.mocked(fs.readFileSync).mockReturnValue(nestedSchema);
|
|
|
|
const format = {
|
|
type: 'json_schema',
|
|
json_schema: {
|
|
name: 'nested_schema',
|
|
schema: 'file://nested.json',
|
|
},
|
|
};
|
|
|
|
const result = maybeLoadResponseFormatFromExternalFile(format, {});
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('nested.json'), 'utf8');
|
|
expect(result).toEqual({
|
|
type: 'json_schema',
|
|
json_schema: {
|
|
name: 'nested_schema',
|
|
schema: {
|
|
type: 'object',
|
|
required: ['id'],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should render variables in nested schema file path', () => {
|
|
const nestedSchema = JSON.stringify({ type: 'object' });
|
|
vi.mocked(fs.readFileSync).mockReturnValue(nestedSchema);
|
|
|
|
const format = {
|
|
type: 'json_schema',
|
|
name: 'dynamic_schema',
|
|
schema: 'file://{{ schema_file }}.json',
|
|
};
|
|
const vars = { schema_file: 'my-schema' };
|
|
|
|
maybeLoadResponseFormatFromExternalFile(format, vars);
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(
|
|
expect.stringContaining('my-schema.json'),
|
|
'utf8',
|
|
);
|
|
});
|
|
|
|
it('should pass through json_object type without modification', () => {
|
|
const format = { type: 'json_object' };
|
|
const result = maybeLoadResponseFormatFromExternalFile(format, {});
|
|
|
|
expect(result).toEqual({ type: 'json_object' });
|
|
});
|
|
|
|
it('should pass through non-json_schema types without nested loading', () => {
|
|
const format = { type: 'text' };
|
|
const result = maybeLoadResponseFormatFromExternalFile(format, {});
|
|
|
|
expect(result).toEqual({ type: 'text' });
|
|
});
|
|
|
|
it('should handle inline schema without file loading', () => {
|
|
const format = {
|
|
type: 'json_schema',
|
|
name: 'inline_schema',
|
|
schema: {
|
|
type: 'object',
|
|
properties: { count: { type: 'number' } },
|
|
},
|
|
};
|
|
|
|
const result = maybeLoadResponseFormatFromExternalFile(format, {});
|
|
|
|
expect(fs.readFileSync).not.toHaveBeenCalled();
|
|
expect(result).toEqual(format);
|
|
});
|
|
|
|
it('should handle chained file references (outer file contains nested file reference)', () => {
|
|
// This tests the critical case where:
|
|
// 1. response_format: file://format.json
|
|
// 2. format.json contains { type: 'json_schema', schema: 'file://schema.json' }
|
|
// Both files should be loaded correctly
|
|
const outerFormat = JSON.stringify({
|
|
type: 'json_schema',
|
|
name: 'chained_schema',
|
|
schema: 'file://nested-schema.json',
|
|
});
|
|
const nestedSchema = JSON.stringify({
|
|
type: 'object',
|
|
properties: { id: { type: 'string' } },
|
|
required: ['id'],
|
|
});
|
|
|
|
// Mock fs.readFileSync to return different content based on the file path
|
|
vi.mocked(fs.readFileSync).mockImplementation((filePath: any) => {
|
|
if (filePath.includes('format.json')) {
|
|
return outerFormat;
|
|
}
|
|
if (filePath.includes('nested-schema.json')) {
|
|
return nestedSchema;
|
|
}
|
|
throw new Error(`Unexpected file: ${filePath}`);
|
|
});
|
|
|
|
const result = maybeLoadResponseFormatFromExternalFile('file://format.json', {});
|
|
|
|
expect(fs.readFileSync).toHaveBeenCalledTimes(2);
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('format.json'), 'utf8');
|
|
expect(fs.readFileSync).toHaveBeenCalledWith(
|
|
expect.stringContaining('nested-schema.json'),
|
|
'utf8',
|
|
);
|
|
expect(result).toEqual({
|
|
type: 'json_schema',
|
|
name: 'chained_schema',
|
|
schema: {
|
|
type: 'object',
|
|
properties: { id: { type: 'string' } },
|
|
required: ['id'],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should propagate errors when nested schema file does not exist', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
|
throw new Error('ENOENT: no such file or directory');
|
|
});
|
|
|
|
const format = {
|
|
type: 'json_schema',
|
|
name: 'test_schema',
|
|
schema: 'file://nonexistent.json',
|
|
};
|
|
|
|
expect(() => maybeLoadResponseFormatFromExternalFile(format, {})).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('readOutput', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset fs.readFileSync mock to clear any mockReturnValue from other tests
|
|
vi.mocked(fs.readFileSync).mockReset();
|
|
});
|
|
|
|
it('reads JSON output', () => {
|
|
const outputPath = 'output.json';
|
|
vi.mocked(fs.readFileSync).mockReturnValue('{}');
|
|
const output = readOutput(outputPath);
|
|
expect(output).toEqual({});
|
|
});
|
|
|
|
it('fails for csv output', () => {
|
|
expect(() => readOutput('output.csv')).toThrow(
|
|
'Unsupported output file format: csv currently only supports json',
|
|
);
|
|
});
|
|
|
|
it('fails for yaml output', () => {
|
|
expect(() => readOutput('output.yaml')).toThrow(
|
|
'Unsupported output file format: yaml currently only supports json',
|
|
);
|
|
|
|
expect(() => readOutput('output.yml')).toThrow(
|
|
'Unsupported output file format: yml currently only supports json',
|
|
);
|
|
});
|
|
|
|
it('fails for xml output', () => {
|
|
expect(() => readOutput('output.xml')).toThrow(
|
|
'Unsupported output file format: xml currently only supports json',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('readFilters', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('loads filter from file', async () => {
|
|
const mockFilter = vi.fn();
|
|
const mockedImportModule = vi.mocked(importModule);
|
|
|
|
vi.mocked(globSync).mockImplementation((pathOrGlob) => [pathOrGlob].flat());
|
|
mockedImportModule.mockResolvedValueOnce(mockFilter);
|
|
|
|
const filters = await readFilters({ testFilter: 'filter.js' });
|
|
|
|
expect(filters.testFilter).toBe(mockFilter);
|
|
});
|
|
|
|
it('returns empty object when no filters provided', async () => {
|
|
const filters = await readFilters({});
|
|
expect(filters).toEqual({});
|
|
});
|
|
|
|
it('handles non-existent filter file gracefully', async () => {
|
|
vi.mocked(globSync).mockReturnValue([]);
|
|
const filters = await readFilters({ testFilter: 'nonexistent.js' });
|
|
expect(filters.testFilter).toBeUndefined();
|
|
});
|
|
|
|
it('throws when importModule fails', async () => {
|
|
vi.mocked(globSync).mockImplementation((pathOrGlob) => [pathOrGlob].flat());
|
|
vi.mocked(importModule).mockRejectedValueOnce(new Error('Module load failed'));
|
|
|
|
await expect(readFilters({ testFilter: 'invalid.js' })).rejects.toThrow('Module load failed');
|
|
});
|
|
});
|
|
|
|
describe('parsePathOrGlob', () => {
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should parse a simple file path with extension', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('/base', 'file.txt')).toEqual({
|
|
extension: '.txt',
|
|
functionName: undefined,
|
|
isPathPattern: false,
|
|
filePath: path.join('/base', 'file.txt'),
|
|
});
|
|
});
|
|
|
|
it('should parse a file path with function name', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('/base', 'file.py:myFunction')).toEqual({
|
|
extension: '.py',
|
|
functionName: 'myFunction',
|
|
isPathPattern: false,
|
|
filePath: path.join('/base', 'file.py'),
|
|
});
|
|
});
|
|
|
|
it('should parse a Go file path with function name', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('/base', 'script.go:CallApi')).toEqual({
|
|
extension: '.go',
|
|
functionName: 'CallApi',
|
|
isPathPattern: false,
|
|
filePath: path.join('/base', 'script.go'),
|
|
});
|
|
});
|
|
|
|
it('should parse a directory path', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => true } as fs.Stats);
|
|
expect(parsePathOrGlob('/base', 'dir')).toEqual({
|
|
extension: undefined,
|
|
functionName: undefined,
|
|
isPathPattern: true,
|
|
filePath: path.join('/base', 'dir'),
|
|
});
|
|
});
|
|
|
|
it('should handle non-existent file path gracefully when PROMPTFOO_STRICT_FILES is false', async () => {
|
|
vi.spyOn(fs, 'statSync').mockImplementation(() => {
|
|
throw new Error('File does not exist');
|
|
});
|
|
expect(parsePathOrGlob('/base', 'nonexistent.js')).toEqual({
|
|
extension: '.js',
|
|
functionName: undefined,
|
|
isPathPattern: false,
|
|
filePath: path.join('/base', 'nonexistent.js'),
|
|
});
|
|
});
|
|
|
|
it('should throw an error for non-existent file path when PROMPTFOO_STRICT_FILES is true', async () => {
|
|
mockProcessEnv({ PROMPTFOO_STRICT_FILES: 'true' });
|
|
vi.spyOn(fs, 'statSync').mockImplementation(() => {
|
|
throw new Error('File does not exist');
|
|
});
|
|
expect(() => parsePathOrGlob('/base', 'nonexistent.js')).toThrow('File does not exist');
|
|
mockProcessEnv({ PROMPTFOO_STRICT_FILES: undefined });
|
|
});
|
|
|
|
it('should properly test file existence when function name in the path', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
parsePathOrGlob('/base', 'script.py:myFunction');
|
|
expect(fs.statSync).toHaveBeenCalledWith(path.join('/base', 'script.py'));
|
|
});
|
|
|
|
it('should return empty extension for files without extension', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('/base', 'file')).toEqual({
|
|
extension: '',
|
|
functionName: undefined,
|
|
isPathPattern: false,
|
|
filePath: path.join('/base', 'file'),
|
|
});
|
|
});
|
|
|
|
it('should handle relative paths', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('./base', 'file.txt')).toEqual({
|
|
extension: '.txt',
|
|
functionName: undefined,
|
|
isPathPattern: false,
|
|
filePath: path.join('./base', 'file.txt'),
|
|
});
|
|
});
|
|
|
|
it('should handle paths with environment variables', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
mockProcessEnv({ FILE_PATH: 'file.txt' });
|
|
const filePath = process.env.FILE_PATH as string;
|
|
expect(parsePathOrGlob('/base', filePath)).toEqual({
|
|
extension: '.txt',
|
|
functionName: undefined,
|
|
isPathPattern: false,
|
|
filePath: path.join('/base', 'file.txt'),
|
|
});
|
|
mockProcessEnv({ FILE_PATH: undefined });
|
|
});
|
|
|
|
it('should handle glob patterns in file path', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('/base', '*.js')).toEqual({
|
|
extension: undefined,
|
|
functionName: undefined,
|
|
isPathPattern: true,
|
|
filePath: path.join('/base', '*.js'),
|
|
});
|
|
});
|
|
|
|
it('should handle complex file paths', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('/base', 'dir/subdir/file.py:func')).toEqual({
|
|
extension: '.py',
|
|
functionName: 'func',
|
|
isPathPattern: false,
|
|
filePath: path.join('/base', 'dir/subdir/file.py'),
|
|
});
|
|
});
|
|
|
|
it('should handle non-standard file extensions', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('/base', 'file.customext')).toEqual({
|
|
extension: '.customext',
|
|
functionName: undefined,
|
|
isPathPattern: false,
|
|
filePath: path.join('/base', 'file.customext'),
|
|
});
|
|
});
|
|
|
|
it('should handle deeply nested file paths', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('/base', 'a/b/c/d/e/f/g/file.py:func')).toEqual({
|
|
extension: '.py',
|
|
functionName: 'func',
|
|
isPathPattern: false,
|
|
filePath: path.join('/base', 'a/b/c/d/e/f/g/file.py'),
|
|
});
|
|
});
|
|
|
|
it('should handle complex directory paths', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => true } as fs.Stats);
|
|
expect(parsePathOrGlob('/base', 'a/b/c/d/e/f/g')).toEqual({
|
|
extension: undefined,
|
|
functionName: undefined,
|
|
isPathPattern: true,
|
|
filePath: path.join('/base', 'a/b/c/d/e/f/g'),
|
|
});
|
|
});
|
|
|
|
it('should join basePath and safeFilename correctly', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
const basePath = 'base';
|
|
const relativePath = 'relative/path/to/file.txt';
|
|
expect(parsePathOrGlob(basePath, relativePath)).toEqual({
|
|
extension: '.txt',
|
|
functionName: undefined,
|
|
isPathPattern: false,
|
|
filePath: expect.stringMatching(/base[\\\/]relative[\\\/]path[\\\/]to[\\\/]file.txt/),
|
|
});
|
|
});
|
|
|
|
it('should handle empty basePath', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('', 'file.txt')).toEqual({
|
|
extension: '.txt',
|
|
functionName: undefined,
|
|
isPathPattern: false,
|
|
filePath: 'file.txt',
|
|
});
|
|
});
|
|
|
|
it('should handle file:// prefix', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('', 'file://file.txt')).toEqual({
|
|
extension: '.txt',
|
|
functionName: undefined,
|
|
isPathPattern: false,
|
|
filePath: 'file.txt',
|
|
});
|
|
});
|
|
|
|
it('should handle file://./... with absolute base path', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('/absolute/base', 'file://./prompts/file.txt')).toEqual({
|
|
extension: '.txt',
|
|
functionName: undefined,
|
|
isPathPattern: false,
|
|
filePath: expect.stringMatching(/^[/\\]absolute[/\\]base[/\\]prompts[/\\]file\.txt$/),
|
|
});
|
|
});
|
|
|
|
it('should handle file://./... with relative base path', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('relative/base', 'file://file.txt')).toEqual({
|
|
extension: '.txt',
|
|
functionName: undefined,
|
|
isPathPattern: false,
|
|
filePath: expect.stringMatching(/^relative[/\\]base[/\\]file\.txt$/),
|
|
});
|
|
});
|
|
|
|
it('should handle file:// prefix with Go function', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('/base', 'file://script.go:CallApi')).toEqual({
|
|
extension: '.go',
|
|
functionName: 'CallApi',
|
|
isPathPattern: false,
|
|
filePath: path.join('/base', 'script.go'),
|
|
});
|
|
});
|
|
|
|
it('should handle file:// prefix with absolute path and Go function', async () => {
|
|
vi.spyOn(fs, 'statSync').mockReturnValue({ isDirectory: () => false } as fs.Stats);
|
|
expect(parsePathOrGlob('/base', 'file:///absolute/path/script.go:CallApi')).toEqual({
|
|
extension: '.go',
|
|
functionName: 'CallApi',
|
|
isPathPattern: false,
|
|
filePath: expect.stringMatching(/^[/\\]absolute[/\\]path[/\\]script\.go$/),
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('pathExists', () => {
|
|
let tmpRoot: string;
|
|
|
|
beforeEach(async () => {
|
|
const fsp = await import('fs/promises');
|
|
tmpRoot = await fsp.mkdtemp(path.join((await import('os')).tmpdir(), 'pf-pathExists-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
const fsp = await import('fs/promises');
|
|
await fsp.rm(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('returns true for an existing file', async () => {
|
|
const fsp = await import('fs/promises');
|
|
const target = path.join(tmpRoot, 'present.txt');
|
|
await fsp.writeFile(target, 'hi');
|
|
await expect(pathExists(target)).resolves.toBe(true);
|
|
});
|
|
|
|
it('returns true for an existing directory', async () => {
|
|
await expect(pathExists(tmpRoot)).resolves.toBe(true);
|
|
});
|
|
|
|
it('returns false for a missing path', async () => {
|
|
await expect(pathExists(path.join(tmpRoot, 'missing.txt'))).resolves.toBe(false);
|
|
});
|
|
|
|
it('returns false when an intermediate component is not a directory (ENOTDIR)', async () => {
|
|
const fsp = await import('fs/promises');
|
|
const file = path.join(tmpRoot, 'a.txt');
|
|
await fsp.writeFile(file, 'x');
|
|
await expect(pathExists(path.join(file, 'nested'))).resolves.toBe(false);
|
|
});
|
|
|
|
it('rethrows access errors other than missing paths', async () => {
|
|
const fsp = await import('fs/promises');
|
|
const accessError = Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
|
vi.mocked(fsp.access).mockRejectedValueOnce(accessError);
|
|
|
|
await expect(pathExists(path.join(tmpRoot, 'locked.txt'))).rejects.toBe(accessError);
|
|
});
|
|
});
|
|
});
|