Files
promptfoo--promptfoo/test/esm.test.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:24:08 +08:00

451 lines
16 KiB
TypeScript

import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearWrapperDirCache,
getWrapperDir,
importModule,
isCjsInEsmError,
resolvePackageEntryPoint,
} from '../src/esm';
import logger from '../src/logger';
// Use __dirname directly since tests run in CommonJS mode
const testDir = __dirname;
vi.mock('../src/logger', () => ({
__esModule: true,
default: {
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
describe('ESM utilities', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
clearWrapperDirCache();
});
describe('getWrapperDir', () => {
afterEach(() => {
clearWrapperDirCache();
});
it('returns python wrapper directory', () => {
const result = getWrapperDir('python');
expect(result).toContain('python');
expect(result).toMatch(/python$/);
});
it('returns ruby wrapper directory', () => {
const result = getWrapperDir('ruby');
expect(result).toContain('ruby');
expect(result).toMatch(/ruby$/);
});
it('returns golang wrapper directory', () => {
const result = getWrapperDir('golang');
expect(result).toContain('golang');
expect(result).toMatch(/golang$/);
});
it('caches wrapper directory paths', () => {
const first = getWrapperDir('python');
const second = getWrapperDir('python');
expect(first).toBe(second);
});
it('clears cache when clearWrapperDirCache is called', () => {
const first = getWrapperDir('python');
clearWrapperDirCache();
// After clearing, the next call should still return the same path
// (the path computation is deterministic)
const second = getWrapperDir('python');
expect(first).toBe(second);
});
});
describe('importModule', () => {
it('imports JavaScript modules', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModule.js');
const result = await importModule(modulePath);
// importModule extracts the nested default from CommonJS modules
expect(result).toEqual({
testFunction: expect.any(Function),
});
expect(result.testFunction()).toBe('js default test result');
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Successfully imported module'),
);
});
it('imports TypeScript modules', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModule.ts');
const result = await importModule(modulePath);
expect(result).toEqual({
testFunction: expect.any(Function),
defaultProp: 'ts default property',
});
expect(result.testFunction()).toBe('ts default test result');
});
it.each([
{ extension: 'JS', source: "module.exports = { value: 'js' };", value: 'js' },
{ extension: 'MJS', source: "export const value = 'mjs';", value: 'mjs' },
{ extension: 'CJS', source: "module.exports = { value: 'cjs' };", value: 'cjs' },
{ extension: 'TS', source: "export const value = 'ts';", value: 'ts' },
{ extension: 'MTS', source: "export const value = 'mts';", value: 'mts' },
{ extension: 'CTS', source: "export const value = 'cts';", value: 'cts' },
])('imports modules referenced with an uppercase .$extension extension', async ({
extension,
source,
value,
}) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-esm-case-test-'));
const lowerCasePath = path.join(tempDir, `provider.${extension.toLowerCase()}`);
const upperCasePath = path.join(tempDir, `provider.${extension}`);
fs.writeFileSync(lowerCasePath, source);
try {
const result = await importModule(upperCasePath);
expect(result.value).toBe(value);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('does not substitute a different module on case-sensitive file systems', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-esm-case-test-'));
const lowerCasePath = path.join(tempDir, 'provider.js');
const upperCasePath = path.join(tempDir, 'provider.JS');
fs.writeFileSync(lowerCasePath, "module.exports = { value: 'lower' };");
try {
if (fs.existsSync(upperCasePath)) {
expect(fs.statSync(upperCasePath).ino).toBe(fs.statSync(lowerCasePath).ino);
return;
}
fs.writeFileSync(upperCasePath, "module.exports = { value: 'upper' };");
expect(fs.statSync(upperCasePath).ino).not.toBe(fs.statSync(lowerCasePath).ino);
try {
const result = await importModule(upperCasePath);
expect(result).toEqual({ value: 'upper' });
} catch (error) {
expect(error).toMatchObject({ code: 'ERR_UNKNOWN_FILE_EXTENSION' });
}
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('imports TypeScript modules with transitive TypeScript dependencies', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModuleWithTransitiveTsImport.ts');
const result = await importModule(modulePath);
expect(result.testFunction).toEqual(expect.any(Function));
expect(result.testFunction()).toBe('transitive TypeScript helper result');
});
it('imports package-less TypeScript modules with extensionless transitive imports', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-esm-test-'));
const helperPath = path.join(tempDir, 'helper.ts');
const modulePath = path.join(tempDir, 'provider.ts');
fs.writeFileSync(
helperPath,
'export function formatValue(value: string): string {\n return `package-less ${value}`;\n}\n',
);
fs.writeFileSync(
modulePath,
"import { formatValue } from './helper';\n\nexport const testFunction = () => formatValue('result');\n",
);
try {
const result = await importModule(modulePath);
expect(result.testFunction).toEqual(expect.any(Function));
expect(result.testFunction()).toBe('package-less result');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('imports CommonJS modules', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModule.cjs');
const result = await importModule(modulePath);
// importModule extracts the nested default from CommonJS modules
expect(result).toEqual({
testFunction: expect.any(Function),
});
expect(result.testFunction()).toBe('cjs default test result');
});
it('imports ESM modules', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModule.mjs');
const result = await importModule(modulePath);
expect(result).toEqual({
testFunction: expect.any(Function),
defaultProp: 'esm default property',
});
expect(result.testFunction()).toBe('esm default test result');
});
it('imports simple modules without nested defaults', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModuleSimple.js');
const result = await importModule(modulePath);
expect(result).toEqual(expect.any(Function));
expect(result()).toBe('simple function result');
});
it('returns named function when functionName is specified', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModule.js');
const result = await importModule(modulePath, 'testFunction');
expect(result).toEqual(expect.any(Function));
expect(result()).toBe('js default test result');
expect(logger.debug).toHaveBeenCalledWith('Returning named export: testFunction');
});
it('returns named function from TypeScript module', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModule.ts');
const result = await importModule(modulePath, 'testFunction');
expect(result).toEqual(expect.any(Function));
expect(result()).toBe('ts default test result');
});
it('handles absolute paths', async () => {
const absolutePath = path.resolve(__dirname, '__fixtures__/testModule.js');
const result = await importModule(absolutePath);
expect(result).toEqual({
testFunction: expect.any(Function),
});
expect(result.testFunction()).toBe('js default test result');
});
it('throws ENOENT error for non-existent module (normalized from ERR_MODULE_NOT_FOUND)', async () => {
const nonExistentPath = path.resolve(__dirname, '__fixtures__/nonExistent.js');
// importModule normalizes ERR_MODULE_NOT_FOUND to ENOENT for missing files
const error = await importModule(nonExistentPath).catch((e) => e);
expect(error).toBeInstanceOf(Error);
expect((error as NodeJS.ErrnoException).code).toBe('ENOENT');
expect((error as NodeJS.ErrnoException).path).toBe(nonExistentPath);
// Should NOT log error for missing files - this is expected during config discovery
expect(logger.error).not.toHaveBeenCalled();
});
it('logs debug information during import process', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModule.js');
await importModule(modulePath);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Attempting to import module'),
);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Attempting ESM import from'),
);
});
it('imports CommonJS modules via ESM', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModule.cjs');
const result = await importModule(modulePath);
expect(result).toEqual({
testFunction: expect.any(Function),
});
// In Vitest's ESM environment, .cjs files are imported successfully via ESM
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('Successfully imported module'),
);
});
it('extracts named export from ESM module', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModule.mjs');
const result = await importModule(modulePath, 'testFunction');
expect(result).toEqual(expect.any(Function));
expect(result()).toBe('esm default test result');
});
it('sets error.cause with both ESM and CJS errors when combined error is thrown', () => {
const esmError = new Error('require is not defined');
const cjsError = new Error('Cannot find module');
const combinedError = new Error(
'Failed to load module test.js:\n' +
' ESM import error: require is not defined\n' +
' CJS fallback error: Cannot find module',
{ cause: { esmError, cjsError } },
);
expect(combinedError).toBeInstanceOf(Error);
expect(combinedError.message).toContain('Failed to load module');
expect(combinedError.message).toContain('ESM import error');
expect(combinedError.message).toContain('CJS fallback error');
expect(combinedError.cause).toBeDefined();
const cause = combinedError.cause as { esmError: Error; cjsError: Error };
expect(cause.esmError).toBe(esmError);
expect(cause.cjsError).toBe(cjsError);
});
describe('CJS fallback for .js files', () => {
it('loads CommonJS .js files via CJS fallback when ESM fails', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModuleCjsFallback.js');
const result = await importModule(modulePath);
expect(result).toEqual({
testValue: 'cjs-fallback-test',
testFunction: expect.any(Function),
});
expect(result.testFunction()).toBe('cjs fallback result');
});
it('returns named export via CJS fallback', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModuleCjsFallback.js');
const result = await importModule(modulePath, 'testFunction');
expect(result).toEqual(expect.any(Function));
expect(result()).toBe('cjs fallback result');
});
it('returns named value via CJS fallback', async () => {
const modulePath = path.resolve(testDir, '__fixtures__/testModuleCjsFallback.js');
const result = await importModule(modulePath, 'testValue');
expect(result).toBe('cjs-fallback-test');
});
});
});
describe('isCjsInEsmError', () => {
it('detects "require is not defined" error', () => {
expect(isCjsInEsmError('ReferenceError: require is not defined')).toBe(true);
});
it('detects "module is not defined" error', () => {
expect(isCjsInEsmError('ReferenceError: module is not defined in ES module scope')).toBe(
true,
);
});
it('detects "exports is not defined" error', () => {
expect(isCjsInEsmError('ReferenceError: exports is not defined')).toBe(true);
});
it('detects "__dirname is not defined" error', () => {
expect(isCjsInEsmError('ReferenceError: __dirname is not defined in ES module scope')).toBe(
true,
);
});
it('detects "__filename is not defined" error', () => {
expect(isCjsInEsmError('ReferenceError: __filename is not defined')).toBe(true);
});
it('detects ERR_REQUIRE_ESM error', () => {
expect(isCjsInEsmError('Error [ERR_REQUIRE_ESM]: require() of ES Module not supported')).toBe(
true,
);
});
it('returns false for unrelated errors', () => {
expect(isCjsInEsmError('SyntaxError: Unexpected token')).toBe(false);
expect(isCjsInEsmError('TypeError: Cannot read property')).toBe(false);
expect(isCjsInEsmError('Module not found')).toBe(false);
});
});
describe('resolvePackageEntryPoint', () => {
const mockPackagesDir = path.resolve(testDir, '__fixtures__/mock-packages');
it('resolves ESM-only package with exports field', () => {
const result = resolvePackageEntryPoint('@test/esm-only-pkg', mockPackagesDir);
expect(result).toBe(
path.join(mockPackagesDir, 'node_modules/@test/esm-only-pkg/dist/index.js'),
);
});
it('resolves package with main field', () => {
const result = resolvePackageEntryPoint('@test/module-field-pkg', mockPackagesDir);
expect(result).toBe(
path.join(mockPackagesDir, 'node_modules/@test/module-field-pkg/esm/index.js'),
);
});
it('resolves CommonJS package with main field', () => {
const result = resolvePackageEntryPoint('@test/cjs-pkg', mockPackagesDir);
// CommonJS packages are resolved via require.resolve, which returns the main field
expect(result).toBe(path.join(mockPackagesDir, 'node_modules/@test/cjs-pkg/lib/index.js'));
});
it('returns null for non-existent package', () => {
const result = resolvePackageEntryPoint('@test/non-existent-pkg', mockPackagesDir);
expect(result).toBeNull();
});
it('returns null when base directory does not exist', () => {
const result = resolvePackageEntryPoint('@test/esm-only-pkg', '/non/existent/dir');
expect(result).toBeNull();
});
it('handles scoped packages correctly', () => {
// Test that scoped package names (@org/pkg) are split correctly
const result = resolvePackageEntryPoint('@test/esm-only-pkg', mockPackagesDir);
expect(result).not.toBeNull();
expect(result).toContain('@test');
expect(result).toContain('esm-only-pkg');
});
it('resolves package with direct string exports field', () => {
// Tests: "exports": "./index.js"
const result = resolvePackageEntryPoint('@test/string-exports-pkg', mockPackagesDir);
expect(result).toBe(
path.join(mockPackagesDir, 'node_modules/@test/string-exports-pkg/index.js'),
);
});
it('resolves package with shorthand object exports field', () => {
// Tests: "exports": { ".": "./index.js" }
const result = resolvePackageEntryPoint('@test/shorthand-exports-pkg', mockPackagesDir);
expect(result).toBe(
path.join(mockPackagesDir, 'node_modules/@test/shorthand-exports-pkg/index.js'),
);
});
it('resolves package with default conditional exports field', () => {
// Tests: "exports": { ".": { "default": "./index.js" } }
const result = resolvePackageEntryPoint('@test/default-exports-pkg', mockPackagesDir);
expect(result).toBe(
path.join(mockPackagesDir, 'node_modules/@test/default-exports-pkg/index.js'),
);
});
});
});