Files
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

655 lines
21 KiB
TypeScript

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { Command } from 'commander';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// Hoisted mocks for shutdown tests
const mockSetupEnv = vi.hoisted(() => vi.fn());
const mockSetLogLevel = vi.hoisted(() => vi.fn());
const mockTelemetryRecord = vi.hoisted(() => vi.fn());
const mockTelemetryInitialize = vi.hoisted(() => vi.fn());
const mockTelemetryShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockCloseLogger = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockCloseDbIfOpen = vi.hoisted(() => vi.fn());
const mockDispatcherDestroy = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockGetGlobalDispatcher = vi.hoisted(() =>
vi.fn().mockReturnValue({ destroy: mockDispatcherDestroy }),
);
// Import actual undici to preserve other exports (Agent, ProxyAgent, setGlobalDispatcher, etc.)
const actualUndici = vi.hoisted(() => vi.importActual<typeof import('undici')>('undici'));
// Mock the dependencies
vi.mock('../src/util', () => ({
setupEnv: mockSetupEnv,
}));
vi.mock('../src/logger', () => ({
__esModule: true,
default: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
setLogLevel: mockSetLogLevel,
closeLogger: mockCloseLogger,
}));
vi.mock('../src/telemetry', () => ({
__esModule: true,
default: {
initialize: mockTelemetryInitialize,
record: mockTelemetryRecord,
shutdown: mockTelemetryShutdown,
},
}));
vi.mock('../src/database/index', () => ({
closeDbIfOpen: mockCloseDbIfOpen,
}));
vi.mock('undici', async () => ({
...(await actualUndici),
getGlobalDispatcher: mockGetGlobalDispatcher,
}));
// Mock code scan commands to avoid ESM import issues with execa
vi.mock('../src/codeScan', () => ({
codeScansCommand: vi.fn(),
}));
let addCommonOptionsRecursively: typeof import('../src/mainUtils').addCommonOptionsRecursively;
let isMainModule: typeof import('../src/mainUtils').isMainModule;
let shouldSkipDefaultConfigLoading: typeof import('../src/mainUtils').shouldSkipDefaultConfigLoading;
let setupEnvFilesFromArgv: typeof import('../src/mainUtils').setupEnvFilesFromArgv;
let shutdownGracefully: typeof import('../src/mainUtils').shutdownGracefully;
async function loadMainModule() {
vi.resetModules();
({
addCommonOptionsRecursively,
isMainModule,
shouldSkipDefaultConfigLoading,
setupEnvFilesFromArgv,
shutdownGracefully,
} = await import('../src/mainUtils'));
}
describe('setupEnvFilesFromArgv', () => {
beforeEach(async () => {
await loadMainModule();
mockSetupEnv.mockReset();
mockTelemetryInitialize.mockReset();
});
it('should load env files before command actions run', () => {
setupEnvFilesFromArgv(['eval', '--env-file', '.env.local']);
expect(mockSetupEnv).toHaveBeenCalledWith('.env.local', { refreshConfigDirectory: true });
expect(mockTelemetryInitialize).toHaveBeenCalledOnce();
});
it('should support repeated and comma-separated env file args', () => {
setupEnvFilesFromArgv(['eval', '--env-file', '.env.one', '--env-path=.env.two,.env.three']);
expect(mockSetupEnv).toHaveBeenCalledWith(['.env.one', '.env.two', '.env.three'], {
refreshConfigDirectory: true,
});
});
it('should ignore flags after --', () => {
setupEnvFilesFromArgv(['eval', '--', '--env-file', '.env.local']);
expect(mockSetupEnv).not.toHaveBeenCalled();
expect(mockTelemetryInitialize).toHaveBeenCalledOnce();
});
it('should recognize the --env-path alias', () => {
setupEnvFilesFromArgv(['eval', '--env-path', '.env.staging']);
expect(mockSetupEnv).toHaveBeenCalledWith('.env.staging', { refreshConfigDirectory: true });
});
it('should be a no-op when no env flags are present', () => {
setupEnvFilesFromArgv(['eval', '--verbose', '--no-cache']);
expect(mockSetupEnv).not.toHaveBeenCalled();
});
it('should ignore --env-file= with empty value', () => {
setupEnvFilesFromArgv(['eval', '--env-file=']);
expect(mockSetupEnv).not.toHaveBeenCalled();
});
it('should skip --env-file when next arg starts with -', () => {
setupEnvFilesFromArgv(['eval', '--env-file', '--verbose']);
expect(mockSetupEnv).not.toHaveBeenCalled();
});
});
describe('shouldSkipDefaultConfigLoading', () => {
beforeEach(async () => {
await loadMainModule();
});
it('skips unrelated default config discovery for code-scans commands', () => {
expect(shouldSkipDefaultConfigLoading(['code-scans', 'run'])).toBe(true);
expect(shouldSkipDefaultConfigLoading(['--verbose', 'code-scans', 'run', '--help'])).toBe(true);
expect(
shouldSkipDefaultConfigLoading(['--env-file', '.env.local', 'code-scans', 'run', '--help']),
).toBe(true);
expect(shouldSkipDefaultConfigLoading(['--env-path=.env.local', 'code-scans', 'run'])).toBe(
true,
);
});
it('keeps default config discovery for other commands and post-separator arguments', () => {
expect(shouldSkipDefaultConfigLoading(['eval', '--help'])).toBe(false);
expect(shouldSkipDefaultConfigLoading(['--', 'code-scans', 'run'])).toBe(false);
});
});
describe('addCommonOptionsRecursively', () => {
const originalExit = process.exit;
let program: Command;
let subCommand: Command;
beforeAll(() => {
process.exit = vi.fn() as any;
});
beforeEach(async () => {
await loadMainModule();
program = new Command('promptfoo');
program.action(() => {});
subCommand = program.command('subcommand');
subCommand.action(() => {});
mockTelemetryRecord.mockReset();
vi.clearAllMocks();
});
afterAll(() => {
process.exit = originalExit;
});
it('should add both verbose and env-file options to a command', () => {
addCommonOptionsRecursively(program);
const hasVerboseOption = program.options.some(
(option) => option.short === '-v' || option.long === '--verbose',
);
const hasEnvFileOption = program.options.some(
(option) => option.long === '--env-file' || option.long === '--env-path',
);
expect(hasVerboseOption).toBe(true);
expect(hasEnvFileOption).toBe(true);
});
it('should not add duplicate options if they already exist', () => {
program.option('--env-file, --env-path <path>', 'Path to .env file');
program.option('-v, --verbose', 'Show debug logs', false);
// Count options before
const envFileOptionsBefore = program.options.filter(
(option) => option.long === '--env-file' || option.long === '--env-path',
).length;
const verboseOptionsBefore = program.options.filter(
(option) => option.short === '-v' || option.long === '--verbose',
).length;
addCommonOptionsRecursively(program);
// Count options after
const envFileOptionsAfter = program.options.filter(
(option) => option.long === '--env-file' || option.long === '--env-path',
).length;
const verboseOptionsAfter = program.options.filter(
(option) => option.short === '-v' || option.long === '--verbose',
).length;
// Should still have the same number of options
expect(envFileOptionsAfter).toBe(envFileOptionsBefore);
expect(verboseOptionsAfter).toBe(verboseOptionsBefore);
});
it('should add options to subcommands', () => {
addCommonOptionsRecursively(program);
const hasSubcommandVerboseOption = subCommand.options.some(
(option) => option.short === '-v' || option.long === '--verbose',
);
const hasSubcommandEnvFileOption = subCommand.options.some(
(option) => option.long === '--env-file' || option.long === '--env-path',
);
expect(hasSubcommandVerboseOption).toBe(true);
expect(hasSubcommandEnvFileOption).toBe(true);
});
it('should add options to nested subcommands', () => {
// Create a deeper command structure
const subSubCommand = subCommand.command('subsubcommand');
subSubCommand.action(() => {});
const level3Command = subSubCommand.command('subsubsubcommand');
level3Command.action(() => {});
addCommonOptionsRecursively(program);
// Check all levels of commands have the options
const hasMainVerboseOption = program.options.some(
(option) => option.short === '-v' || option.long === '--verbose',
);
const hasMainEnvFileOption = program.options.some(
(option) => option.long === '--env-file' || option.long === '--env-path',
);
const hasSubCommandVerboseOption = subCommand.options.some(
(option) => option.short === '-v' || option.long === '--verbose',
);
const hasSubCommandEnvFileOption = subCommand.options.some(
(option) => option.long === '--env-file' || option.long === '--env-path',
);
const hasSubSubCommandVerboseOption = subSubCommand.options.some(
(option) => option.short === '-v' || option.long === '--verbose',
);
const hasSubSubCommandEnvFileOption = subSubCommand.options.some(
(option) => option.long === '--env-file' || option.long === '--env-path',
);
const hasSubSubSubCommandVerboseOption = level3Command.options.some(
(option) => option.short === '-v' || option.long === '--verbose',
);
const hasSubSubSubCommandEnvFileOption = level3Command.options.some(
(option) => option.long === '--env-file' || option.long === '--env-path',
);
expect(hasMainVerboseOption).toBe(true);
expect(hasMainEnvFileOption).toBe(true);
expect(hasSubCommandVerboseOption).toBe(true);
expect(hasSubCommandEnvFileOption).toBe(true);
expect(hasSubSubCommandVerboseOption).toBe(true);
expect(hasSubSubCommandEnvFileOption).toBe(true);
expect(hasSubSubSubCommandVerboseOption).toBe(true);
expect(hasSubSubSubCommandEnvFileOption).toBe(true);
});
it('should register a single hook that handles both options', () => {
// Create a fake action that manually mocks the Commander hook system
const mockHookRegister = vi.fn();
(program as any).hook = mockHookRegister;
// Apply common options
addCommonOptionsRecursively(program);
// Verify the hook was registered only once
expect(mockHookRegister).toHaveBeenCalledTimes(1);
expect(mockHookRegister).toHaveBeenCalledWith('preAction', expect.any(Function));
// Get the hook function
const preActionFn = mockHookRegister.mock.calls[0][1];
// Create mock command object with name() and parent for telemetry
const createMockCommand = (opts: Record<string, unknown>, commandName = 'test') => ({
opts: () => opts,
name: () => commandName,
parent: null,
});
// Test verbose option
preActionFn(createMockCommand({ verbose: true }));
expect(mockSetLogLevel).toHaveBeenCalledWith('debug');
// Test env-file option
preActionFn(createMockCommand({ envFile: '.env.test' }));
expect(mockSetupEnv).toHaveBeenCalledWith('.env.test');
// Test both options together
preActionFn(createMockCommand({ verbose: true, envFile: '.env.combined' }));
expect(mockSetLogLevel).toHaveBeenCalledWith('debug');
expect(mockSetupEnv).toHaveBeenCalledWith('.env.combined');
});
it('keeps structured code-scan output muted even when --verbose is present', () => {
const mockHookRegister = vi.fn();
(program as any).hook = mockHookRegister;
addCommonOptionsRecursively(program);
const preActionFn = mockHookRegister.mock.calls[0][1];
const originalArgv = process.argv;
process.argv = ['node', 'promptfoo', 'code-scans', 'run', '.', '--json', '--verbose'];
try {
preActionFn({
opts: () => ({ verbose: true }),
name: () => 'run',
parent: {
name: () => 'code-scans',
parent: null,
},
});
} finally {
process.argv = originalArgv;
}
expect(mockSetLogLevel).not.toHaveBeenCalledWith('debug');
});
it('should parse --env-file without consuming positional subcommand arguments', async () => {
const action = vi.fn();
const documentCommand = program.command('scan-model').argument('<model>').action(action);
addCommonOptionsRecursively(program);
await program.parseAsync(['scan-model', '--env-file', '.env.local', 'llama-3'], {
from: 'user',
});
expect(action).toHaveBeenCalledWith(
'llama-3',
expect.objectContaining({ verbose: false }),
documentCommand,
);
expect(mockSetupEnv).toHaveBeenCalledWith('.env.local');
});
it('should support repeated and comma-separated --env-file values', async () => {
addCommonOptionsRecursively(program);
await program.parseAsync(
['subcommand', '--env-file', '.env.one', '--env-file', '.env.two,.env.three'],
{ from: 'user' },
);
expect(mockSetupEnv).toHaveBeenCalledWith(['.env.one', '.env.two', '.env.three']);
});
it('should record telemetry once for nested subcommands', async () => {
const nestedParent = program.command('redteam');
const nestedAction = vi.fn();
nestedParent.command('run').action(nestedAction);
addCommonOptionsRecursively(program);
await program.parseAsync(['redteam', 'run'], { from: 'user' });
expect(nestedAction).toHaveBeenCalledTimes(1);
expect(mockTelemetryRecord).toHaveBeenCalledTimes(1);
expect(mockTelemetryRecord).toHaveBeenCalledWith('command_used', { name: 'redteam run' });
});
});
describe('isMainModule', () => {
let tempDir: string;
let realFilePath: string;
let symlinkPath: string;
beforeAll(async () => {
await loadMainModule();
// Create a temporary directory with a real file and a symlink
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'main-test-'));
realFilePath = path.join(tempDir, 'real-file.js');
symlinkPath = path.join(tempDir, 'symlink-file.js');
// Create a real file
fs.writeFileSync(realFilePath, '// test file');
// Create a symlink pointing to the real file
fs.symlinkSync(realFilePath, symlinkPath);
});
afterAll(() => {
// Clean up temporary files
try {
fs.unlinkSync(symlinkPath);
fs.unlinkSync(realFilePath);
fs.rmdirSync(tempDir);
} catch {
// Ignore cleanup errors
}
});
it('should return false when processArgv1 is undefined', () => {
const result = isMainModule('file:///some/path/main.js', undefined);
expect(result).toBe(false);
});
it('should return false when processArgv1 is empty string', () => {
const result = isMainModule('file:///some/path/main.js', '');
expect(result).toBe(false);
});
it('should return true when paths match directly', () => {
const fileUrl = pathToFileURL(realFilePath).href;
const result = isMainModule(fileUrl, realFilePath);
expect(result).toBe(true);
});
it('should return true when processArgv1 is a symlink pointing to the module', () => {
// This is the key test case for npm global bin symlinks
const fileUrl = pathToFileURL(realFilePath).href;
const result = isMainModule(fileUrl, symlinkPath);
expect(result).toBe(true);
});
it('should return false when paths do not match', () => {
const fileUrl = pathToFileURL(realFilePath).href;
const otherPath = path.join(tempDir, 'other-file.js');
fs.writeFileSync(otherPath, '// other file');
try {
const result = isMainModule(fileUrl, otherPath);
expect(result).toBe(false);
} finally {
fs.unlinkSync(otherPath);
}
});
it('should return false when processArgv1 points to non-existent file', () => {
const fileUrl = pathToFileURL(realFilePath).href;
const nonExistentPath = path.join(tempDir, 'non-existent.js');
const result = isMainModule(fileUrl, nonExistentPath);
expect(result).toBe(false);
});
it('should handle relative paths correctly', () => {
// Save current directory
const originalCwd = process.cwd();
try {
// Change to temp directory
process.chdir(tempDir);
const fileUrl = pathToFileURL(realFilePath).href;
const result = isMainModule(fileUrl, './real-file.js');
expect(result).toBe(true);
} finally {
// Restore original directory
process.chdir(originalCwd);
}
});
it('should handle symlink to symlink chains', () => {
const secondSymlinkPath = path.join(tempDir, 'second-symlink.js');
try {
// Create a symlink pointing to the first symlink
fs.symlinkSync(symlinkPath, secondSymlinkPath);
const fileUrl = pathToFileURL(realFilePath).href;
const result = isMainModule(fileUrl, secondSymlinkPath);
expect(result).toBe(true);
} finally {
try {
fs.unlinkSync(secondSymlinkPath);
} catch {
// Ignore cleanup errors
}
}
});
});
describe('shutdownGracefully', () => {
const originalExit = process.exit;
beforeEach(async () => {
await loadMainModule();
vi.useFakeTimers();
process.exit = vi.fn() as never;
// Reset all mocks to default behavior
mockSetupEnv.mockReset();
mockSetLogLevel.mockReset();
mockTelemetryShutdown.mockReset().mockResolvedValue(undefined);
mockCloseLogger.mockReset().mockResolvedValue(undefined);
mockCloseDbIfOpen.mockReset();
mockDispatcherDestroy.mockReset().mockResolvedValue(undefined);
mockGetGlobalDispatcher.mockReset().mockReturnValue({ destroy: mockDispatcherDestroy });
});
afterEach(() => {
vi.useRealTimers();
vi.resetAllMocks();
process.exit = originalExit;
});
it('should complete gracefully when all operations succeed quickly', async () => {
const shutdownPromise = shutdownGracefully();
// Advance past any internal timeouts
await vi.runAllTimersAsync();
await shutdownPromise;
expect(mockTelemetryShutdown).toHaveBeenCalled();
expect(mockCloseLogger).toHaveBeenCalled();
expect(mockCloseDbIfOpen).toHaveBeenCalled();
expect(mockDispatcherDestroy).toHaveBeenCalled();
});
it('should handle telemetry shutdown timeout', async () => {
// Make telemetry.shutdown() hang forever
mockTelemetryShutdown.mockImplementation(() => new Promise(() => {}));
const shutdownPromise = shutdownGracefully();
// Advance time to trigger the individual operation timeout (1s)
await vi.advanceTimersByTimeAsync(1100);
// Advance remaining timers
await vi.runAllTimersAsync();
await shutdownPromise;
// Should still have called other cleanup operations
expect(mockCloseLogger).toHaveBeenCalled();
expect(mockCloseDbIfOpen).toHaveBeenCalled();
});
it('should handle closeLogger timeout', async () => {
// Make closeLogger() hang forever
mockCloseLogger.mockImplementation(() => new Promise(() => {}));
const shutdownPromise = shutdownGracefully();
// Advance time to trigger all timeouts
await vi.runAllTimersAsync();
await shutdownPromise;
// Other operations should still complete
expect(mockTelemetryShutdown).toHaveBeenCalled();
expect(mockCloseDbIfOpen).toHaveBeenCalled();
expect(mockDispatcherDestroy).toHaveBeenCalled();
});
it('should handle dispatcher.destroy() timeout', async () => {
// Make dispatcher.destroy() hang forever
mockDispatcherDestroy.mockImplementation(() => new Promise(() => {}));
const shutdownPromise = shutdownGracefully();
// Advance time to trigger all timeouts
await vi.runAllTimersAsync();
await shutdownPromise;
// Other operations should still complete
expect(mockTelemetryShutdown).toHaveBeenCalled();
expect(mockCloseLogger).toHaveBeenCalled();
expect(mockCloseDbIfOpen).toHaveBeenCalled();
});
it('should handle telemetry shutdown throwing error', async () => {
mockTelemetryShutdown.mockRejectedValue(new Error('Telemetry failed'));
const shutdownPromise = shutdownGracefully();
await vi.runAllTimersAsync();
// Should not throw - should handle error gracefully
await expect(shutdownPromise).resolves.toBeUndefined();
// Other operations should still complete
expect(mockCloseLogger).toHaveBeenCalled();
expect(mockCloseDbIfOpen).toHaveBeenCalled();
});
it('should handle dispatcher.destroy() throwing error', async () => {
mockDispatcherDestroy.mockRejectedValue(new Error('Dispatcher failed'));
const shutdownPromise = shutdownGracefully();
await vi.runAllTimersAsync();
// Should not throw
await expect(shutdownPromise).resolves.toBeUndefined();
});
it('should force exit when all cleanup operations hang', async () => {
// Make all operations hang
mockTelemetryShutdown.mockImplementation(() => new Promise(() => {}));
mockCloseLogger.mockImplementation(() => new Promise(() => {}));
mockDispatcherDestroy.mockImplementation(() => new Promise(() => {}));
// Start shutdown but don't await yet
void shutdownGracefully();
// The force exit timeout is 3000ms
await vi.advanceTimersByTimeAsync(3000);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should clear force exit timeout when cleanup completes normally', async () => {
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
const shutdownPromise = shutdownGracefully();
await vi.runAllTimersAsync();
await shutdownPromise;
const forceExitTimeout = setTimeoutSpy.mock.results[0]?.value;
expect(clearTimeoutSpy).toHaveBeenCalledWith(forceExitTimeout);
expect(process.exit).toHaveBeenCalled();
});
it('should handle getGlobalDispatcher throwing', async () => {
mockGetGlobalDispatcher.mockImplementation(() => {
throw new Error('No dispatcher');
});
const shutdownPromise = shutdownGracefully();
await vi.runAllTimersAsync();
// Should complete without throwing
await expect(shutdownPromise).resolves.toBeUndefined();
});
});