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
439 lines
13 KiB
TypeScript
439 lines
13 KiB
TypeScript
/**
|
|
* Smoke tests for CLI binary operations.
|
|
*
|
|
* These tests verify the built CLI binary works correctly.
|
|
* They run against dist/src/main.js (the built package).
|
|
*
|
|
* @see docs/plans/smoke-tests.md for the full checklist
|
|
*/
|
|
import { spawn, spawnSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import { createServer, type Server as HttpServer } from 'http';
|
|
import * as path from 'path';
|
|
|
|
import { Server as SocketIOServer } from 'socket.io';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
// Path to the built CLI binaries
|
|
const CLI_PATH = path.resolve(__dirname, '../../dist/src/main.js');
|
|
const ENTRYPOINT_PATH = path.resolve(__dirname, '../../dist/src/entrypoint.js');
|
|
const ROOT_DIR = path.resolve(__dirname, '../..');
|
|
|
|
/**
|
|
* Helper to run the CLI and capture output
|
|
*/
|
|
function runCli(
|
|
args: string[],
|
|
options: { cwd?: string; expectError?: boolean; env?: NodeJS.ProcessEnv } = {},
|
|
): { stdout: string; stderr: string; exitCode: number } {
|
|
const result = spawnSync('node', [CLI_PATH, ...args], {
|
|
cwd: options.cwd || ROOT_DIR,
|
|
encoding: 'utf-8',
|
|
env: { ...process.env, ...options.env, NO_COLOR: '1' },
|
|
timeout: 30000,
|
|
});
|
|
|
|
return {
|
|
stdout: result.stdout || '',
|
|
stderr: result.stderr || '',
|
|
exitCode: result.status ?? 1,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Helper to run the CLI via entrypoint.js (the actual bin entry)
|
|
*/
|
|
function runEntrypoint(
|
|
args: string[],
|
|
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
|
): { stdout: string; stderr: string; exitCode: number } {
|
|
const result = spawnSync('node', [ENTRYPOINT_PATH, ...args], {
|
|
cwd: options.cwd || ROOT_DIR,
|
|
encoding: 'utf-8',
|
|
env: { ...process.env, ...options.env, NO_COLOR: '1' },
|
|
timeout: 30000,
|
|
});
|
|
|
|
return {
|
|
stdout: result.stdout || '',
|
|
stderr: result.stderr || '',
|
|
exitCode: result.status ?? 1,
|
|
};
|
|
}
|
|
|
|
function runEntrypointAsync(
|
|
args: string[],
|
|
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn('node', [ENTRYPOINT_PATH, ...args], {
|
|
cwd: options.cwd || ROOT_DIR,
|
|
env: { ...process.env, ...options.env, NO_COLOR: '1' },
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
const timeoutId = setTimeout(() => {
|
|
child.kill('SIGTERM');
|
|
reject(new Error(`CLI entrypoint timed out for: ${args.join(' ')}`));
|
|
}, 30000);
|
|
|
|
child.stdout.setEncoding('utf8');
|
|
child.stdout.on('data', (chunk: string) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.setEncoding('utf8');
|
|
child.stderr.on('data', (chunk: string) => {
|
|
stderr += chunk;
|
|
});
|
|
child.once('error', (error) => {
|
|
clearTimeout(timeoutId);
|
|
reject(error);
|
|
});
|
|
child.once('close', (code) => {
|
|
clearTimeout(timeoutId);
|
|
resolve({
|
|
stdout,
|
|
stderr,
|
|
exitCode: code ?? 1,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function runGit(args: string[], cwd: string): void {
|
|
const result = spawnSync('git', args, {
|
|
cwd,
|
|
encoding: 'utf-8',
|
|
});
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(
|
|
`git ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
describe('CLI Smoke Tests', () => {
|
|
beforeAll(() => {
|
|
// Verify the built binaries exist
|
|
if (!fs.existsSync(CLI_PATH)) {
|
|
throw new Error(`Built CLI not found at ${CLI_PATH}. Run 'npm run build' first.`);
|
|
}
|
|
if (!fs.existsSync(ENTRYPOINT_PATH)) {
|
|
throw new Error(
|
|
`Built entrypoint not found at ${ENTRYPOINT_PATH}. Run 'npm run build' first.`,
|
|
);
|
|
}
|
|
});
|
|
|
|
describe('1.1 Basic CLI Operations', () => {
|
|
it('1.1.1 - outputs version with --version', () => {
|
|
const { stdout, exitCode } = runCli(['--version']);
|
|
|
|
expect(exitCode).toBe(0);
|
|
// Version should contain semver pattern (e.g., 1.2.3 or 1.2.3-beta.1)
|
|
// Note: Output may include warnings if invalid config files exist in cwd
|
|
expect(stdout).toMatch(/\d+\.\d+\.\d+(-[\w.]+)?/);
|
|
});
|
|
|
|
it('1.1.2 - outputs help with --help', () => {
|
|
const { stdout, exitCode } = runCli(['--help']);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('promptfoo');
|
|
expect(stdout).toContain('eval');
|
|
expect(stdout).toContain('Commands:');
|
|
});
|
|
|
|
it('1.1.3 - outputs subcommand help with eval --help', () => {
|
|
const { stdout, exitCode } = runCli(['eval', '--help']);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('--config');
|
|
expect(stdout).toContain('--output');
|
|
expect(stdout).toContain('--no-cache');
|
|
});
|
|
|
|
it('1.1.4 - handles unknown command gracefully', () => {
|
|
const { stderr, exitCode } = runCli(['unknowncommand123']);
|
|
|
|
expect(exitCode).toBe(1);
|
|
// Commander outputs "error: unknown command" (lowercase)
|
|
expect(stderr.toLowerCase()).toContain('unknown command');
|
|
});
|
|
|
|
it('1.1.5 - handles missing config file gracefully', () => {
|
|
const { stdout, stderr, exitCode } = runCli(['eval', '-c', 'nonexistent-config-file.yaml']);
|
|
|
|
expect(exitCode).toBe(1);
|
|
// Error may appear in stdout (uncaught exception) or stderr
|
|
const output = stdout + stderr;
|
|
// Should indicate the file wasn't found
|
|
expect(output.toLowerCase()).toMatch(
|
|
/not found|no such file|does not exist|cannot find|no configuration file/i,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('1.2 Init Command', () => {
|
|
let tempDir: string;
|
|
|
|
beforeAll(() => {
|
|
// Create a temp directory for init tests
|
|
tempDir = path.join(ROOT_DIR, 'test/smoke/.temp-init-test');
|
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
});
|
|
|
|
afterAll(() => {
|
|
// Clean up temp directory
|
|
if (fs.existsSync(tempDir)) {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('1.2.1 - init --no-interactive creates config file', () => {
|
|
const initDir = path.join(tempDir, 'init-test');
|
|
fs.mkdirSync(initDir, { recursive: true });
|
|
|
|
const { exitCode } = runCli(['init', '--no-interactive'], { cwd: initDir });
|
|
|
|
expect(exitCode).toBe(0);
|
|
// Should create a promptfooconfig file
|
|
const configExists =
|
|
fs.existsSync(path.join(initDir, 'promptfooconfig.yaml')) ||
|
|
fs.existsSync(path.join(initDir, 'promptfooconfig.yml'));
|
|
expect(configExists).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('1.3 Validate Command', () => {
|
|
const fixturesDir = path.join(__dirname, 'fixtures/configs');
|
|
|
|
it('1.3.1 - validates a correct config file', () => {
|
|
const { stdout, exitCode } = runCli(['validate', '-c', path.join(fixturesDir, 'basic.yaml')]);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout.toLowerCase()).toContain('valid');
|
|
});
|
|
|
|
it('1.3.2 - rejects an invalid config file', () => {
|
|
const { stdout, stderr, exitCode } = runCli([
|
|
'validate',
|
|
'-c',
|
|
path.join(fixturesDir, 'invalid.yaml'),
|
|
]);
|
|
|
|
expect(exitCode).toBe(1);
|
|
// Error may appear in stdout or stderr
|
|
const output = stdout + stderr;
|
|
expect(output.length).toBeGreaterThan(0);
|
|
// Should indicate a validation error (missing providers)
|
|
expect(output.toLowerCase()).toMatch(/provider|invalid|error/i);
|
|
});
|
|
});
|
|
|
|
describe('1.6 Cache Commands', () => {
|
|
it('1.6.1 - cache clear executes without error', () => {
|
|
const { exitCode } = runCli(['cache', 'clear']);
|
|
|
|
// Should succeed (exit 0) even if cache is empty
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('1.5 List Commands', () => {
|
|
it('1.5.1 - list evals executes without error', () => {
|
|
const { exitCode } = runCli(['list', 'evals']);
|
|
|
|
// Should succeed even if no evals exist
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
|
|
it('1.5.2 - list datasets executes without error', () => {
|
|
const { exitCode } = runCli(['list', 'datasets']);
|
|
|
|
// Should succeed even if no datasets exist
|
|
expect(exitCode).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('1.7 Entrypoint Wrapper', () => {
|
|
/**
|
|
* These tests verify that entrypoint.js (the actual bin entry) works correctly.
|
|
* The entrypoint provides a Node.js version check before loading dependencies.
|
|
*/
|
|
|
|
it('1.7.1 - entrypoint outputs version with --version', () => {
|
|
const { stdout, exitCode } = runEntrypoint(['--version']);
|
|
|
|
expect(exitCode).toBe(0);
|
|
// Version should contain semver pattern
|
|
expect(stdout).toMatch(/\d+\.\d+\.\d+(-[\w.]+)?/);
|
|
});
|
|
|
|
it('1.7.2 - entrypoint outputs help with --help', () => {
|
|
const { stdout, exitCode } = runEntrypoint(['--help']);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('promptfoo');
|
|
expect(stdout).toContain('eval');
|
|
expect(stdout).toContain('Commands:');
|
|
});
|
|
|
|
it('1.7.3 - entrypoint handles subcommands correctly', () => {
|
|
const { stdout, exitCode } = runEntrypoint(['eval', '--help']);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stdout).toContain('--config');
|
|
expect(stdout).toContain('--output');
|
|
});
|
|
|
|
it('1.7.4 - entrypoint produces identical output to main.js', () => {
|
|
// Verify entrypoint delegates to main.js correctly
|
|
const entrypointResult = runEntrypoint(['--version']);
|
|
const mainResult = runCli(['--version']);
|
|
|
|
expect(entrypointResult.exitCode).toBe(mainResult.exitCode);
|
|
expect(entrypointResult.stdout.trim()).toBe(mainResult.stdout.trim());
|
|
});
|
|
});
|
|
|
|
describe('1.8 Code Scan Structured Output', () => {
|
|
let repoDir: string;
|
|
let httpServer: HttpServer;
|
|
let socketServer: SocketIOServer;
|
|
let apiHost: string;
|
|
|
|
beforeAll(async () => {
|
|
repoDir = path.join(ROOT_DIR, 'test/smoke/.temp-code-scan-structured-output');
|
|
fs.rmSync(repoDir, { recursive: true, force: true });
|
|
fs.mkdirSync(repoDir, { recursive: true });
|
|
|
|
runGit(['init', '-b', 'main'], repoDir);
|
|
runGit(['config', 'user.email', 'smoke@example.com'], repoDir);
|
|
runGit(['config', 'user.name', 'Promptfoo Smoke Test'], repoDir);
|
|
runGit(['commit', '--allow-empty', '-m', 'baseline'], repoDir);
|
|
|
|
httpServer = createServer((_request, response) => {
|
|
response.writeHead(200, { 'content-type': 'text/plain' });
|
|
response.end('ok');
|
|
});
|
|
socketServer = new SocketIOServer(httpServer, {
|
|
cors: { origin: '*' },
|
|
});
|
|
socketServer.on('connection', (socket) => {
|
|
socket.on('agent:join', () => {});
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
httpServer.once('error', reject);
|
|
httpServer.listen(0, '127.0.0.1', () => {
|
|
httpServer.off('error', reject);
|
|
const address = httpServer.address();
|
|
if (!address || typeof address === 'string') {
|
|
reject(new Error('Failed to resolve mock code-scan server port'));
|
|
return;
|
|
}
|
|
apiHost = `http://127.0.0.1:${address.port}`;
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await new Promise<void>((resolve) => {
|
|
socketServer.close(() => {
|
|
httpServer.close(() => resolve());
|
|
});
|
|
});
|
|
fs.rmSync(repoDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
'JSON',
|
|
['--json'],
|
|
(payload: Record<string, unknown>) => {
|
|
expect(payload).toMatchObject({
|
|
success: true,
|
|
comments: [],
|
|
review: 'No files to scan',
|
|
});
|
|
},
|
|
],
|
|
[
|
|
'SARIF',
|
|
['--format', 'sarif'],
|
|
(payload: Record<string, unknown>) => {
|
|
expect(payload).toMatchObject({
|
|
version: '2.1.0',
|
|
runs: [expect.any(Object)],
|
|
});
|
|
},
|
|
],
|
|
])('1.8.%# - keeps %s stdout machine-readable under --verbose and LOG_LEVEL=debug', async (_label, outputArgs, assertPayload) => {
|
|
const { stdout, stderr, exitCode } = await runEntrypointAsync(
|
|
[
|
|
'code-scans',
|
|
'run',
|
|
repoDir,
|
|
'--diffs-only',
|
|
'--api-host',
|
|
apiHost,
|
|
'--base',
|
|
'main',
|
|
'--compare',
|
|
'HEAD',
|
|
...outputArgs,
|
|
'--verbose',
|
|
],
|
|
{
|
|
env: {
|
|
LOG_LEVEL: 'debug',
|
|
NODE_NO_WARNINGS: '1',
|
|
PROMPTFOO_DISABLE_UPDATE: 'true',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(exitCode).toBe(0);
|
|
expect(stderr).toBe('');
|
|
|
|
const payload = JSON.parse(stdout) as Record<string, unknown>;
|
|
assertPayload(payload);
|
|
});
|
|
|
|
it('1.8.2 - keeps structured config failures off stdout', async () => {
|
|
const missingConfigPath = path.join(repoDir, 'definitely-missing-code-scan-config.yaml');
|
|
const { stdout, stderr, exitCode } = await runEntrypointAsync(
|
|
['code-scans', 'run', repoDir, '--json', '--config', missingConfigPath],
|
|
{
|
|
env: {
|
|
NODE_NO_WARNINGS: '1',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(stdout).toBe('');
|
|
expect(stderr).toContain(`Scan failed: Configuration file not found: ${missingConfigPath}`);
|
|
});
|
|
|
|
it('1.8.3 - keeps structured output flag conflicts off stdout', async () => {
|
|
const { stdout, stderr, exitCode } = await runEntrypointAsync(
|
|
['code-scans', 'run', repoDir, '--json', '--format', 'sarif'],
|
|
{
|
|
env: {
|
|
NODE_NO_WARNINGS: '1',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(exitCode).toBe(1);
|
|
expect(stdout).toBe('');
|
|
expect(stderr).toContain('Scan failed: Cannot combine --json with --format sarif');
|
|
});
|
|
});
|
|
});
|