0d3cb498a3
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
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
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) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
245 lines
7.9 KiB
TypeScript
245 lines
7.9 KiB
TypeScript
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
import * as yaml from 'js-yaml';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import cliState from '../../../src/cliState';
|
|
import logger from '../../../src/logger';
|
|
import {
|
|
getConfigDirectoryPath,
|
|
refreshConfigDirectoryPathFromEnv,
|
|
setConfigDirectoryPath,
|
|
} from '../../../src/util/config/manage';
|
|
import { writePromptfooConfig } from '../../../src/util/config/writer';
|
|
import { mockProcessEnv } from '../../util/utils';
|
|
|
|
import type { UnifiedConfig } from '../../../src/types/index';
|
|
|
|
// Create hoisted mock functions for fs
|
|
const mockFs = vi.hoisted(() => ({
|
|
existsSync: vi.fn(),
|
|
mkdirSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
readFileSync: vi.fn(),
|
|
readdirSync: vi.fn(),
|
|
statSync: vi.fn(),
|
|
unlinkSync: vi.fn(),
|
|
rmdirSync: vi.fn(),
|
|
copyFileSync: vi.fn(),
|
|
renameSync: vi.fn(),
|
|
accessSync: vi.fn(),
|
|
chmodSync: vi.fn(),
|
|
constants: {},
|
|
}));
|
|
|
|
// Mock both 'fs' and 'node:fs' to cover all import patterns
|
|
vi.mock('fs', () => ({
|
|
...mockFs,
|
|
default: mockFs,
|
|
}));
|
|
|
|
vi.mock('node:fs', () => ({
|
|
...mockFs,
|
|
default: mockFs,
|
|
}));
|
|
|
|
vi.mock('os');
|
|
// js-yaml v5 is native ESM with a sealed namespace, so vi.spyOn cannot patch it.
|
|
// Wrap dump in a spy-able mock that keeps the real implementation.
|
|
vi.mock('js-yaml', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('js-yaml')>();
|
|
return { ...actual, dump: vi.fn(actual.dump) };
|
|
});
|
|
vi.mock('../../../src/logger', () => ({
|
|
default: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
describe('config', () => {
|
|
const mockHomedir = '/mock/home';
|
|
const defaultConfigPath = path.join(mockHomedir, '.promptfoo');
|
|
let restoreEnv: () => void;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(os.homedir).mockReturnValue(mockHomedir);
|
|
mockFs.existsSync.mockReturnValue(false);
|
|
restoreEnv = mockProcessEnv({ PROMPTFOO_CONFIG_DIR: undefined });
|
|
refreshConfigDirectoryPathFromEnv();
|
|
setConfigDirectoryPath(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreEnv();
|
|
refreshConfigDirectoryPathFromEnv();
|
|
setConfigDirectoryPath(undefined);
|
|
});
|
|
|
|
describe('getConfigDirectoryPath', () => {
|
|
it('returns default path when no custom path is set', () => {
|
|
expect(getConfigDirectoryPath()).toBe(defaultConfigPath);
|
|
});
|
|
|
|
it('reads a config directory refreshed after early env-file loading', () => {
|
|
const restoreConfigDir = mockProcessEnv({ PROMPTFOO_CONFIG_DIR: '/env-file/config' });
|
|
try {
|
|
refreshConfigDirectoryPathFromEnv();
|
|
expect(getConfigDirectoryPath()).toBe('/env-file/config');
|
|
} finally {
|
|
restoreConfigDir();
|
|
refreshConfigDirectoryPathFromEnv();
|
|
}
|
|
});
|
|
|
|
it('does not let eval config env overrides move the global config directory', () => {
|
|
const originalConfig = cliState.config;
|
|
cliState.config = { env: { PROMPTFOO_CONFIG_DIR: '/eval-config/path' } };
|
|
try {
|
|
expect(getConfigDirectoryPath()).toBe(defaultConfigPath);
|
|
} finally {
|
|
cliState.config = originalConfig;
|
|
}
|
|
});
|
|
|
|
it('does not move after later process environment changes', () => {
|
|
const restoreConfigDir = mockProcessEnv({ PROMPTFOO_CONFIG_DIR: '/late/config' });
|
|
try {
|
|
expect(getConfigDirectoryPath()).toBe(defaultConfigPath);
|
|
} finally {
|
|
restoreConfigDir();
|
|
}
|
|
});
|
|
|
|
it('does not create directory when createIfNotExists is false', () => {
|
|
getConfigDirectoryPath(false);
|
|
expect(mockFs.mkdirSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Note: The directory creation test cannot verify mkdirSync was called because
|
|
// the source uses require('fs') inside the function which bypasses Vitest's ESM mocking.
|
|
// We verify the function completes successfully with the correct return value instead.
|
|
it('handles createIfNotExists flag and returns correct path', () => {
|
|
const result = getConfigDirectoryPath(true);
|
|
expect(result).toBe(defaultConfigPath);
|
|
});
|
|
|
|
it('does not create directory when it already exists', () => {
|
|
mockFs.existsSync.mockReturnValue(true);
|
|
const result = getConfigDirectoryPath(true);
|
|
expect(result).toBe(defaultConfigPath);
|
|
});
|
|
});
|
|
|
|
describe('setConfigDirectoryPath', () => {
|
|
it('updates the config directory path', () => {
|
|
const newPath = '/new/config/path';
|
|
setConfigDirectoryPath(newPath);
|
|
expect(getConfigDirectoryPath()).toBe(newPath);
|
|
});
|
|
|
|
it('overrides the environment variable', () => {
|
|
const envPath = '/env/path';
|
|
const newPath = '/new/path';
|
|
const restoreConfigDir = mockProcessEnv({ PROMPTFOO_CONFIG_DIR: envPath });
|
|
try {
|
|
setConfigDirectoryPath(newPath);
|
|
expect(getConfigDirectoryPath()).toBe(newPath);
|
|
} finally {
|
|
restoreConfigDir();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('writePromptfooConfig', () => {
|
|
const mockOutputPath = '/mock/output/path.yaml';
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
});
|
|
|
|
it('writes a basic config to the specified path', () => {
|
|
const mockConfig: Partial<UnifiedConfig> = { description: 'Test config' };
|
|
const mockYaml = 'description: Test config\n';
|
|
vi.mocked(yaml.dump).mockReturnValue(mockYaml);
|
|
|
|
writePromptfooConfig(mockConfig, mockOutputPath);
|
|
|
|
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
|
mockOutputPath,
|
|
`# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json\n${mockYaml}`,
|
|
);
|
|
});
|
|
|
|
it('orders the keys of the config correctly', () => {
|
|
const mockConfig: Partial<UnifiedConfig> = {
|
|
tests: [{ assert: [{ type: 'equals', value: 'test assertion' }] }],
|
|
description: 'Test config',
|
|
prompts: ['prompt1'],
|
|
providers: ['provider1'],
|
|
defaultTest: { assert: [{ type: 'equals', value: 'default assertion' }] },
|
|
};
|
|
|
|
writePromptfooConfig(mockConfig, mockOutputPath);
|
|
|
|
const dumpCall = vi.mocked(yaml.dump).mock.calls[0][0];
|
|
const keys = Object.keys(dumpCall);
|
|
expect(keys).toEqual(['description', 'prompts', 'providers', 'defaultTest', 'tests']);
|
|
});
|
|
|
|
it('uses js-yaml to dump the config with skipInvalid option', () => {
|
|
const mockConfig: Partial<UnifiedConfig> = { description: 'Test config' };
|
|
|
|
writePromptfooConfig(mockConfig, mockOutputPath);
|
|
|
|
expect(yaml.dump).toHaveBeenCalledWith(expect.anything(), { skipInvalid: true });
|
|
});
|
|
|
|
it('handles empty config', () => {
|
|
vi.mocked(yaml.dump).mockReturnValueOnce('');
|
|
const mockConfig: Partial<UnifiedConfig> = {};
|
|
|
|
writePromptfooConfig(mockConfig, mockOutputPath);
|
|
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
|
|
expect(logger.warn).toHaveBeenCalledWith('Warning: config is empty, skipping write');
|
|
});
|
|
|
|
it('preserves all fields of the UnifiedConfig', () => {
|
|
const mockConfig: Partial<UnifiedConfig> = {
|
|
description: 'Full config test',
|
|
prompts: ['prompt1', 'prompt2'],
|
|
providers: ['provider1', 'provider2'],
|
|
defaultTest: { assert: [{ type: 'equals', value: 'default assertion' }] },
|
|
tests: [
|
|
{ assert: [{ type: 'equals', value: 'test assertion 1' }] },
|
|
{ assert: [{ type: 'equals', value: 'test assertion 2' }] },
|
|
],
|
|
outputPath: './output',
|
|
};
|
|
|
|
writePromptfooConfig(mockConfig, mockOutputPath);
|
|
|
|
const dumpCall = vi.mocked(yaml.dump).mock.calls[0][0];
|
|
expect(dumpCall).toEqual(expect.objectContaining(mockConfig));
|
|
});
|
|
|
|
it('handles config with undefined values', () => {
|
|
const mockConfig: Partial<UnifiedConfig> = {
|
|
description: 'Config with undefined',
|
|
prompts: undefined,
|
|
providers: ['provider1'],
|
|
};
|
|
|
|
writePromptfooConfig(mockConfig, mockOutputPath);
|
|
|
|
const dumpCall = vi.mocked(yaml.dump).mock.calls[0][0];
|
|
expect(dumpCall).toHaveProperty('description', 'Config with undefined');
|
|
expect(dumpCall).toHaveProperty('providers', ['provider1']);
|
|
expect(dumpCall).not.toHaveProperty('prompts');
|
|
});
|
|
});
|