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
212 lines
7.2 KiB
TypeScript
212 lines
7.2 KiB
TypeScript
import * as fs from 'fs';
|
|
|
|
import dotenv from 'dotenv';
|
|
import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest';
|
|
import logger from '../../src/logger';
|
|
import * as configManage from '../../src/util/config/manage';
|
|
import {
|
|
getConfigDirectoryPath,
|
|
refreshConfigDirectoryPathFromEnv,
|
|
setConfigDirectoryPath,
|
|
} from '../../src/util/config/manage';
|
|
import { setupEnv } from '../../src/util/env';
|
|
import { mockProcessEnv } from './utils';
|
|
|
|
vi.mock('fs', async () => {
|
|
const actual = await vi.importActual<typeof import('fs')>('fs');
|
|
return {
|
|
...actual,
|
|
existsSync: vi.fn(),
|
|
};
|
|
});
|
|
|
|
describe('setupEnv', () => {
|
|
let originalEnv: typeof process.env;
|
|
let dotenvConfigSpy: MockInstance<
|
|
(options?: dotenv.DotenvConfigOptions) => dotenv.DotenvConfigOutput
|
|
>;
|
|
let loggerInfoSpy: MockInstance;
|
|
|
|
beforeEach(() => {
|
|
originalEnv = { ...process.env };
|
|
// Ensure NODE_ENV is not set at the start of each test
|
|
mockProcessEnv({ NODE_ENV: undefined });
|
|
mockProcessEnv({ PROMPTFOO_CONFIG_DIR: undefined });
|
|
refreshConfigDirectoryPathFromEnv();
|
|
setConfigDirectoryPath(undefined);
|
|
// Spy on dotenv.config to verify it's called with the right parameters
|
|
dotenvConfigSpy = vi.spyOn(dotenv, 'config').mockImplementation(() => ({ parsed: {} }));
|
|
loggerInfoSpy = vi.spyOn(logger, 'info').mockImplementation(() => logger);
|
|
// Mock file existence check - default to true for backward compat with existing tests
|
|
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockProcessEnv(originalEnv, { clear: true });
|
|
refreshConfigDirectoryPathFromEnv();
|
|
setConfigDirectoryPath(undefined);
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
it('should call dotenv.config with quiet=true when envPath is undefined', () => {
|
|
setupEnv(undefined);
|
|
|
|
expect(dotenvConfigSpy).toHaveBeenCalledTimes(1);
|
|
expect(dotenvConfigSpy).toHaveBeenCalledWith({ quiet: true });
|
|
});
|
|
|
|
it('should call dotenv.config with path, override=true, and quiet=true when envPath is specified', () => {
|
|
const testEnvPath = '.env.test';
|
|
|
|
setupEnv(testEnvPath);
|
|
|
|
expect(dotenvConfigSpy).toHaveBeenCalledTimes(1);
|
|
expect(dotenvConfigSpy).toHaveBeenCalledWith({
|
|
path: testEnvPath,
|
|
override: true,
|
|
quiet: true,
|
|
});
|
|
expect(loggerInfoSpy).toHaveBeenCalledWith('Loading environment variables from .env.test');
|
|
});
|
|
|
|
it('should load environment variables with override when specified env file has conflicting values', () => {
|
|
// Mock dotenv.config to simulate loading variables
|
|
dotenvConfigSpy.mockImplementation((options?: dotenv.DotenvConfigOptions) => {
|
|
if (options?.path === '.env.production') {
|
|
if (options.override) {
|
|
mockProcessEnv({ NODE_ENV: 'production' });
|
|
} else if (!process.env.NODE_ENV) {
|
|
mockProcessEnv({ NODE_ENV: 'production' });
|
|
}
|
|
} else {
|
|
// Default .env file
|
|
if (!process.env.NODE_ENV) {
|
|
mockProcessEnv({ NODE_ENV: 'development' });
|
|
}
|
|
}
|
|
return { parsed: {} };
|
|
});
|
|
|
|
// First load the default .env (setting NODE_ENV to 'development')
|
|
setupEnv(undefined);
|
|
expect(process.env.NODE_ENV).toBe('development');
|
|
|
|
// Then load .env.production with override (should change NODE_ENV to 'production')
|
|
setupEnv('.env.production');
|
|
expect(process.env.NODE_ENV).toBe('production');
|
|
});
|
|
|
|
it('should refresh the config directory after early env loading and freeze later changes', () => {
|
|
const refreshConfigDirectorySpy = vi.spyOn(configManage, 'refreshConfigDirectoryPathFromEnv');
|
|
dotenvConfigSpy.mockImplementation(() => {
|
|
mockProcessEnv({ PROMPTFOO_CONFIG_DIR: '/early/config' });
|
|
return { parsed: {} };
|
|
});
|
|
|
|
setupEnv('.env.early', { refreshConfigDirectory: true });
|
|
expect(refreshConfigDirectorySpy).toHaveBeenCalledTimes(1);
|
|
expect(getConfigDirectoryPath()).toBe('/early/config');
|
|
|
|
mockProcessEnv({ PROMPTFOO_CONFIG_DIR: '/late/config' });
|
|
expect(getConfigDirectoryPath()).toBe('/early/config');
|
|
});
|
|
|
|
describe('multi-file support', () => {
|
|
it('should call dotenv.config with array of paths when multiple files specified', () => {
|
|
const paths = ['.env', '.env.local'];
|
|
|
|
setupEnv(paths);
|
|
|
|
expect(dotenvConfigSpy).toHaveBeenCalledTimes(1);
|
|
expect(dotenvConfigSpy).toHaveBeenCalledWith({
|
|
path: paths,
|
|
override: true,
|
|
quiet: true,
|
|
});
|
|
expect(loggerInfoSpy).toHaveBeenCalledWith(
|
|
'Loading environment variables from: .env, .env.local',
|
|
);
|
|
});
|
|
|
|
it('should call dotenv.config with single path (not array) when one file specified as array', () => {
|
|
const paths = ['.env'];
|
|
|
|
setupEnv(paths);
|
|
|
|
expect(dotenvConfigSpy).toHaveBeenCalledTimes(1);
|
|
expect(dotenvConfigSpy).toHaveBeenCalledWith({
|
|
path: '.env',
|
|
override: true,
|
|
quiet: true,
|
|
});
|
|
expect(loggerInfoSpy).toHaveBeenCalledWith('Loading environment variables from .env');
|
|
});
|
|
|
|
it('should throw error when specified file does not exist', () => {
|
|
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
|
|
expect(() => setupEnv('.env.missing')).toThrow('Environment file not found: .env.missing');
|
|
});
|
|
|
|
it('should throw error when any file in array does not exist', () => {
|
|
vi.mocked(fs.existsSync).mockImplementation((p) => p === '.env');
|
|
|
|
expect(() => setupEnv(['.env', '.env.missing'])).toThrow(
|
|
'Environment file not found: .env.missing',
|
|
);
|
|
});
|
|
|
|
it('should validate all files exist before calling dotenv.config', () => {
|
|
vi.mocked(fs.existsSync).mockImplementation((p) => p === '.env');
|
|
|
|
expect(() => setupEnv(['.env', '.env.missing'])).toThrow();
|
|
expect(dotenvConfigSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should filter out empty strings from array', () => {
|
|
setupEnv(['', '.env', ' ']);
|
|
|
|
expect(dotenvConfigSpy).toHaveBeenCalledWith({
|
|
path: '.env',
|
|
override: true,
|
|
quiet: true,
|
|
});
|
|
expect(loggerInfoSpy).toHaveBeenCalledWith('Loading environment variables from .env');
|
|
});
|
|
|
|
it('should call default dotenv.config when array contains only empty strings', () => {
|
|
setupEnv(['', ' ', '']);
|
|
|
|
expect(dotenvConfigSpy).toHaveBeenCalledWith({ quiet: true });
|
|
});
|
|
|
|
it('should call default dotenv.config when given empty array', () => {
|
|
setupEnv([]);
|
|
|
|
expect(dotenvConfigSpy).toHaveBeenCalledWith({ quiet: true });
|
|
});
|
|
|
|
it('should expand comma-separated values within array elements', () => {
|
|
// This simulates what Commander passes when using --env-file .env,.env.local
|
|
setupEnv(['.env,.env.local']);
|
|
|
|
expect(dotenvConfigSpy).toHaveBeenCalledTimes(1);
|
|
expect(dotenvConfigSpy).toHaveBeenCalledWith({
|
|
path: ['.env', '.env.local'],
|
|
override: true,
|
|
quiet: true,
|
|
});
|
|
});
|
|
|
|
it('should handle mixed array with some comma-separated and some individual paths', () => {
|
|
setupEnv(['.env,.env.local', '.env.production']);
|
|
|
|
expect(dotenvConfigSpy).toHaveBeenCalledWith({
|
|
path: ['.env', '.env.local', '.env.production'],
|
|
override: true,
|
|
quiet: true,
|
|
});
|
|
});
|
|
});
|
|
});
|