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

406 lines
13 KiB
TypeScript

/**
* End-to-end smoke tests for eval resume functionality.
*
* Tests the --resume flag and Ctrl+C (SIGINT) pause/resume flow:
* 1. Full eval completes successfully with echo provider
* 2. SIGINT mid-eval pauses and prints resume instructions
* 3. --resume resumes from where it left off
* 4. Second SIGINT force-exits the process
* 5. Flag conflict detection (--no-write, --retry-errors)
*
* Uses the echo provider for deterministic, zero-cost testing.
* Uses --delay to create a window for SIGINT interruption.
*/
import { spawn, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const CLI_PATH = path.resolve(__dirname, '../../dist/src/main.js');
const ROOT_DIR = path.resolve(__dirname, '../..');
const FIXTURES_DIR = path.resolve(__dirname, 'fixtures');
const CONFIGS_DIR = path.resolve(FIXTURES_DIR, 'configs');
const OUTPUT_DIR = path.resolve(__dirname, '.temp-output-resume');
// Use a dedicated absolute config dir for resume tests so DB persists between runs.
// The vitest setup sets PROMPTFOO_CONFIG_DIR to a relative path which can cause issues.
const RESUME_CONFIG_DIR = path.resolve(OUTPUT_DIR, '.promptfoo-resume-test');
/**
* Helper to run the CLI synchronously and capture output
*/
function runCli(
args: string[],
options: { cwd?: string; env?: NodeJS.ProcessEnv; timeout?: number } = {},
): { 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',
PROMPTFOO_CONFIG_DIR: RESUME_CONFIG_DIR,
// Override IS_TESTING from vitest.setup.ts — we need file-based DB for resume persistence
IS_TESTING: '',
},
timeout: options.timeout || 60000,
});
return {
stdout: result.stdout || '',
stderr: result.stderr || '',
exitCode: result.status ?? 1,
};
}
/**
* Helper to run CLI asynchronously (for SIGINT testing).
* Spawns a child process and returns control functions.
*/
function spawnCli(
args: string[],
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
): {
sendSignal: (signal: NodeJS.Signals) => void;
waitForOutput: (pattern: string | RegExp, timeoutMs?: number) => Promise<string>;
waitForExit: (
timeoutMs?: number,
) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
kill: () => void;
} {
let stdout = '';
let stderr = '';
const child = spawn('node', [CLI_PATH, ...args], {
cwd: options.cwd || ROOT_DIR,
env: {
...process.env,
...options.env,
NO_COLOR: '1',
PROMPTFOO_CONFIG_DIR: RESUME_CONFIG_DIR,
// Override IS_TESTING from vitest.setup.ts — we need file-based DB for resume persistence
IS_TESTING: '',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
const outputWaiters: Array<{
pattern: string | RegExp;
resolve: (output: string) => void;
reject: (error: Error) => void;
}> = [];
const checkWaiters = () => {
const combined = stdout + stderr;
for (let i = outputWaiters.length - 1; i >= 0; i--) {
const waiter = outputWaiters[i];
const match =
typeof waiter.pattern === 'string'
? combined.includes(waiter.pattern)
: waiter.pattern.test(combined);
if (match) {
outputWaiters.splice(i, 1);
waiter.resolve(combined);
}
}
};
child.stdout?.on('data', (data) => {
stdout += data.toString();
checkWaiters();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
checkWaiters();
});
const sendSignal = (signal: NodeJS.Signals) => {
if (child.pid && !child.killed) {
child.kill(signal);
}
};
const waitForOutput = (pattern: string | RegExp, timeoutMs = 30000): Promise<string> => {
const combined = stdout + stderr;
const alreadyMatches =
typeof pattern === 'string' ? combined.includes(pattern) : pattern.test(combined);
if (alreadyMatches) {
return Promise.resolve(combined);
}
return new Promise((resolveWait, rejectWait) => {
const timer = setTimeout(() => {
const idx = outputWaiters.findIndex((w) => w.resolve === wrappedResolve);
if (idx >= 0) {
outputWaiters.splice(idx, 1);
}
rejectWait(
new Error(
`Timeout waiting for output matching "${pattern}".\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`,
),
);
}, timeoutMs);
const wrappedResolve = (output: string) => {
clearTimeout(timer);
resolveWait(output);
};
outputWaiters.push({
pattern,
resolve: wrappedResolve,
reject: rejectWait,
});
});
};
const waitForExit = (
timeoutMs = 30000,
): Promise<{ stdout: string; stderr: string; exitCode: number }> => {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(
new Error(
`Process did not exit within ${timeoutMs}ms.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`,
),
);
}, timeoutMs);
child.on('exit', (code) => {
clearTimeout(timer);
// Small delay to collect remaining output
setTimeout(() => {
resolve({ stdout, stderr, exitCode: code ?? 1 });
}, 100);
});
});
};
// Also handle exit for waiters
child.on('exit', (code) => {
// Small delay to let final output arrive
setTimeout(() => {
for (const waiter of outputWaiters) {
waiter.reject(
new Error(
`Process exited (code ${code}) before matching pattern.\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`,
),
);
}
outputWaiters.length = 0;
}, 200);
});
return {
sendSignal,
waitForOutput,
waitForExit,
kill: () => sendSignal('SIGKILL'),
};
}
/**
* Extract eval ID from pause output.
* Looks for "Evaluation paused. ID: <evalId>" pattern.
*/
function extractEvalId(output: string): string | null {
// Match "Evaluation paused. ID: eval-xxx-yyyy" (NO_COLOR strips ANSI)
const match = output.match(/Evaluation paused\. ID:\s*(\S+)/);
return match ? match[1] : null;
}
describe('Resume E2E Tests', () => {
beforeAll(() => {
if (!fs.existsSync(CLI_PATH)) {
throw new Error(`Built CLI not found at ${CLI_PATH}. Run 'npm run build' first.`);
}
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.mkdirSync(RESUME_CONFIG_DIR, { recursive: true });
});
afterAll(() => {
if (fs.existsSync(OUTPUT_DIR)) {
fs.rmSync(OUTPUT_DIR, { recursive: true, force: true });
}
});
describe('Regular eval resume', () => {
it('completes a full evaluation with echo provider', () => {
const configPath = path.join(CONFIGS_DIR, 'resume-many-tests.yaml');
const outputPath = path.join(OUTPUT_DIR, 'full-eval-output.json');
const { exitCode } = runCli(['eval', '-c', configPath, '-o', outputPath, '--no-cache']);
expect(exitCode).toBe(0);
const content = fs.readFileSync(outputPath, 'utf-8');
const parsed = JSON.parse(content);
// Should have 10 test results (10 tests x 1 provider x 1 prompt)
expect(parsed.results.results.length).toBe(10);
// Verify first and last names are present
const outputs = parsed.results.results.map(
(r: { response: { output: string } }) => r.response.output,
);
expect(outputs.some((o: string) => o.includes('Alice'))).toBe(true);
expect(outputs.some((o: string) => o.includes('Judy'))).toBe(true);
});
it('SIGINT pauses evaluation and shows eval ID', async () => {
const configPath = path.join(CONFIGS_DIR, 'resume-many-tests.yaml');
// Run with delay so we have time to send SIGINT
const cli = spawnCli(['eval', '-c', configPath, '--no-cache', '--delay', '200']);
try {
// Wait for eval to start
await cli.waitForOutput(/Running \d+ test cases/, 15000);
// Send SIGINT to pause
cli.sendSignal('SIGINT');
// Wait for the process to fully exit so all output is captured
const result = await cli.waitForExit(15000);
const combined = result.stdout + result.stderr;
// Should contain the pause message and eval ID
expect(combined).toContain('Evaluation paused');
const evalId = extractEvalId(combined);
expect(evalId).toBeTruthy();
// Should also contain the resume instructions
expect(combined).toContain('Resume with:');
expect(combined).toContain('--resume');
} catch (error) {
cli.kill();
throw error;
}
});
it('--resume completes remaining test cases from a paused eval', async () => {
const configPath = path.join(CONFIGS_DIR, 'resume-many-tests.yaml');
// Step 1: Start eval with delay and serial execution, then pause it.
// Use --max-concurrency 1 so tests run one at a time (each takes ~500ms).
// With 10 serial tests, SIGINT at ~1.5s reliably lands mid-eval (~3 done).
// Without serial mode, concurrency=4 + fast echo provider can finish all
// 10 tests before SIGINT arrives, making resume a no-op.
const cli = spawnCli([
'eval',
'-c',
configPath,
'--no-cache',
'--delay',
'500',
'--max-concurrency',
'1',
]);
let evalId: string | undefined;
try {
await cli.waitForOutput(/Running \d+ test cases/, 15000);
// Let a few tests complete before pausing (~3 at 500ms each)
await new Promise((resolve) => setTimeout(resolve, 1500));
cli.sendSignal('SIGINT');
const result = await cli.waitForExit(15000);
const combined = result.stdout + result.stderr;
evalId = extractEvalId(combined) || undefined;
expect(evalId).toBeTruthy();
} catch (error) {
cli.kill();
throw error;
}
// Step 2: Resume the eval
// Note: --resume reconstructs config from saved eval, so -o from current
// invocation is NOT applied. We verify via stdout instead.
const { exitCode, stdout } = runCli(['eval', '--resume', evalId!, '--no-cache']);
expect(exitCode).toBe(0);
// Should mention resuming and skipping
expect(stdout).toContain('Resuming');
// Extract the skip count to verify it's a true partial resume
const skipMatch = stdout.match(/skipping (\d+) previously completed cases/);
expect(skipMatch).toBeTruthy();
const skipped = parseInt(skipMatch![1], 10);
// Must have skipped some (completed before pause) but not all (otherwise nothing to resume)
expect(skipped).toBeGreaterThan(0);
expect(skipped).toBeLessThan(10);
// All 10 test results should be present in the final output
expect(stdout).toContain('10 passed');
expect(stdout).toContain('Alice');
expect(stdout).toContain('Judy');
});
it('second SIGINT force-exits the process', async () => {
const configPath = path.join(CONFIGS_DIR, 'resume-many-tests.yaml');
const cli = spawnCli(['eval', '-c', configPath, '--no-cache', '--delay', '500']);
try {
await cli.waitForOutput(/Running \d+ test cases/, 15000);
// First SIGINT: pause
cli.sendSignal('SIGINT');
await cli.waitForOutput('Pausing evaluation', 10000);
// Small delay then second SIGINT: force exit
await new Promise((resolve) => setTimeout(resolve, 200));
cli.sendSignal('SIGINT');
// Process should exit
const result = await cli.waitForExit(10000);
const combined = result.stdout + result.stderr;
// The second SIGINT should either:
// 1. Show "Force exiting" if our handler caught it, OR
// 2. Exit with code 130 if the process died before output was captured
const hasForceExitMessage = combined.includes('Force exiting');
const hasExpectedExitCode = result.exitCode === 130;
// The graceful pause may complete before the second SIGINT arrives (echo provider
// is fast), in which case we see "Evaluation paused" with exit code 0 — also valid.
const gracefulPauseCompleted = combined.includes('Evaluation paused');
expect(hasForceExitMessage || hasExpectedExitCode || gracefulPauseCompleted).toBe(true);
} catch (error) {
cli.kill();
throw error;
}
});
});
describe('Flag conflict detection', () => {
it('--resume with --no-write should error', () => {
const { exitCode, stdout, stderr } = runCli(['eval', '--resume', '--no-write', '--no-cache']);
const combined = stdout + stderr;
expect(exitCode).not.toBe(0);
expect(combined).toContain('Cannot use --resume with --no-write');
});
it('--resume with --retry-errors should error', () => {
const { exitCode, stdout, stderr } = runCli([
'eval',
'--resume',
'--retry-errors',
'--no-cache',
]);
const combined = stdout + stderr;
expect(exitCode).not.toBe(0);
expect(combined).toContain('Cannot use --resume and --retry-errors together');
});
});
});