Files
promptfoo--promptfoo/test/evaluator.sigint.test.ts
T
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

381 lines
13 KiB
TypeScript

import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { updateSignalFile } from '../src/database/signal';
import { runDbMigrations } from '../src/migrate';
import Eval from '../src/models/eval';
import { JsonlFileWriter } from '../src/util/exportToFile/writeToFile';
import { createMockProvider } from './factories/provider';
import type { ApiProvider, TestSuite } from '../src/types';
// Only mock what we need to verify
vi.mock('../src/database/signal', () => ({
updateSignalFile: vi.fn(),
readSignalEvalId: vi.fn(),
}));
import { evaluate } from '../src/evaluator';
import { ResultFailureReason } from '../src/types';
import { createEmptyTokenUsage } from '../src/util/tokenUsageUtils';
describe('evaluate SIGINT/abort handling', () => {
beforeEach(async () => {
await runDbMigrations();
vi.clearAllMocks();
vi.mocked(updateSignalFile).mockReset();
});
afterEach(() => {
vi.resetAllMocks();
});
it('should return early when user aborts (not max-duration timeout)', async () => {
const abortController = new AbortController();
let providerCallCount = 0;
// Provider that aborts after first successful call
const testProvider = createMockProvider({
callApi: vi.fn<ApiProvider['callApi']>().mockImplementation(async () => {
providerCallCount++;
if (providerCallCount === 1) {
// First call succeeds, then we trigger abort to simulate user SIGINT
// Use setImmediate to abort after this call resolves
setTimeout(() => abortController.abort(), 0);
return {
output: 'First response',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
};
}
// Second call will see the abort
throw new Error('Operation cancelled');
}),
});
const mockAddResult = vi.fn().mockResolvedValue(undefined);
const mockSetVars = vi.fn();
const mockAddPrompts = vi.fn().mockResolvedValue(undefined);
const mockEvalRecord = {
id: 'test-eval-abort-123',
results: [],
prompts: [],
persisted: false,
config: {},
addResult: mockAddResult,
addPrompts: mockAddPrompts,
fetchResultsByTestIdx: vi.fn().mockResolvedValue([]),
getResults: vi.fn().mockResolvedValue([]),
toEvaluateSummary: vi.fn().mockResolvedValue({
results: [],
prompts: [],
stats: {
successes: 1,
failures: 0,
errors: 0,
tokenUsage: createEmptyTokenUsage(),
},
}),
save: vi.fn().mockResolvedValue(undefined),
setVars: mockSetVars,
setDurationMs: vi.fn(),
};
const testSuite: TestSuite = {
providers: [testProvider],
prompts: [{ raw: 'Test prompt', label: 'Test prompt' }],
tests: [{}, {}], // Two tests - second should see abort
};
const result = await evaluate(testSuite, mockEvalRecord as unknown as Eval, {
abortSignal: abortController.signal,
});
// Should return the eval record
expect(result).toBeDefined();
expect(result.id).toBe('test-eval-abort-123');
// Non-persisted evals are not visible to database watchers.
expect(updateSignalFile).not.toHaveBeenCalled();
// Should persist vars and prompts before early return
expect(mockSetVars).toHaveBeenCalled();
expect(mockAddPrompts).toHaveBeenCalled();
});
it('should continue normal flow and write timeout rows when per-call timeout triggers', async () => {
// This test verifies that per-call timeouts (timeoutMs option) write
// timeout error rows and continue the normal evaluation flow.
//
// Key difference from user SIGINT: evaluation continues normally rather than returning
// early. The evaluator no longer writes the signal file directly — persisted evals emit a
// refresh via Eval.addPrompts()/save(); a non-persisted eval (this test) writes no signal,
// which is asserted below.
let longTimer: NodeJS.Timeout | null = null;
const slowProvider = createMockProvider({
id: 'slow-provider',
cleanup: true,
callApi: vi
.fn<ApiProvider['callApi']>()
.mockImplementation((_prompt, _context, callApiOptions) => {
// Long-running call that will be interrupted by timeout
return new Promise((resolve, reject) => {
const abortSignal = callApiOptions?.abortSignal;
const onAbort = () => {
if (longTimer) {
clearTimeout(longTimer);
longTimer = null;
}
const abortError = new Error('Operation aborted');
abortError.name = 'AbortError';
reject(abortError);
};
if (abortSignal?.aborted) {
onAbort();
return;
}
abortSignal?.addEventListener('abort', onAbort, { once: true });
longTimer = setTimeout(() => {
abortSignal?.removeEventListener('abort', onAbort);
resolve({
output: 'Slow response',
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
});
}, 5000); // 5 seconds - will be interrupted by 100ms timeout
});
}),
});
const mockAddResult = vi.fn().mockResolvedValue(undefined);
const mockEvalRecord = {
id: 'test-eval-timeout-123',
results: [],
prompts: [],
persisted: false,
config: {},
addResult: mockAddResult,
addPrompts: vi.fn().mockResolvedValue(undefined),
fetchResultsByTestIdx: vi.fn().mockResolvedValue([]),
getResults: vi.fn().mockResolvedValue([]),
toEvaluateSummary: vi.fn().mockResolvedValue({
results: [],
prompts: [],
stats: {
successes: 0,
failures: 0,
errors: 1,
tokenUsage: createEmptyTokenUsage(),
},
}),
save: vi.fn().mockResolvedValue(undefined),
setVars: vi.fn(),
setDurationMs: vi.fn(),
};
const testSuite: TestSuite = {
providers: [slowProvider],
prompts: [{ raw: 'Test prompt', label: 'Test prompt' }],
tests: [{}],
};
try {
// Use timeoutMs (per-call timeout) which triggers timeout for individual test cases
// Unlike maxEvalTimeMs (max-duration), this doesn't abort the entire evaluation
await evaluate(testSuite, mockEvalRecord as unknown as Eval, {
timeoutMs: 100,
});
// When timeout triggers, should write timeout error row
expect(mockAddResult).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringContaining('timed out'),
success: false,
failureReason: ResultFailureReason.ERROR,
}),
);
// Timeout should abort the in-flight call without invoking provider-wide cleanup
expect(vi.mocked(slowProvider.callApi).mock.calls[0]?.[2]?.abortSignal?.aborted).toBe(true);
expect(slowProvider.cleanup).not.toHaveBeenCalled();
} finally {
if (longTimer) {
clearTimeout(longTimer);
}
}
});
it('should honor external abortSignal and return partial results', async () => {
const abortController = new AbortController();
const resultsAdded: unknown[] = [];
const testProvider = createMockProvider({
callApi: vi
.fn<ApiProvider['callApi']>()
.mockImplementation(async (_prompt, _context, opts) => {
// Check if already aborted
if (opts?.abortSignal?.aborted) {
throw new Error('Operation cancelled');
}
// Abort after returning first result
if (resultsAdded.length === 1) {
abortController.abort();
throw new Error('Operation cancelled');
}
return {
output: `Response ${resultsAdded.length}`,
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
};
}),
});
const mockAddResult = vi.fn().mockImplementation(async (result: unknown) => {
resultsAdded.push(result);
});
const mockEvalRecord = {
id: 'test-eval-partial-123',
results: [],
prompts: [],
persisted: false,
config: {},
addResult: mockAddResult,
addPrompts: vi.fn().mockResolvedValue(undefined),
fetchResultsByTestIdx: vi.fn().mockResolvedValue([]),
getResults: vi.fn().mockResolvedValue([]),
toEvaluateSummary: vi.fn().mockResolvedValue({
results: [],
prompts: [],
stats: {
successes: resultsAdded.length,
failures: 0,
errors: 0,
tokenUsage: createEmptyTokenUsage(),
},
}),
save: vi.fn().mockResolvedValue(undefined),
setVars: vi.fn(),
setDurationMs: vi.fn(),
};
const testSuite: TestSuite = {
providers: [testProvider],
prompts: [{ raw: 'Test prompt', label: 'Test prompt' }],
tests: [{}, {}, {}], // Three tests
};
await evaluate(testSuite, mockEvalRecord as unknown as Eval, {
abortSignal: abortController.signal,
});
// Should have added at least one result before abort
expect(resultsAdded.length).toBeGreaterThanOrEqual(1);
// Non-persisted evals are not visible to database watchers.
expect(updateSignalFile).not.toHaveBeenCalled();
});
it('should redact streamed JSONL rows when user aborts before final export', async () => {
const outputPath = path.join(os.tmpdir(), `promptfoo-abort-${randomUUID()}.jsonl`);
const abortController = new AbortController();
let providerCallCount = 0;
const provider = createMockProvider({
callApi: vi.fn<ApiProvider['callApi']>().mockImplementation(async () => {
providerCallCount++;
if (providerCallCount === 1) {
return {
output: {
http: {
status: 200,
statusText: 'OK',
headers: {
authorization: 'model output should stay intact',
},
},
},
metadata: {
headers: {
'x-request-id': 'legacy_abort_should_not_persist',
'x-safe-debug': 'keep-legacy',
},
http: {
status: 200,
statusText: 'OK',
headers: {
'set-cookie': 'session=secret',
'x-request-id': 'req_should_not_persist',
},
},
},
tokenUsage: createEmptyTokenUsage(),
};
}
throw new Error('Operation cancelled');
}),
});
// Abort right after the first row streams to disk, so finalization never runs and the
// on-disk artifact is the streamed (write-time-sanitized) row.
const originalWrite = JsonlFileWriter.prototype.write;
const writeSpy = vi
.spyOn(JsonlFileWriter.prototype, 'write')
.mockImplementation(async function (this: JsonlFileWriter, data) {
await originalWrite.call(this, data);
abortController.abort();
});
const evalRecord = new Eval({ outputPath });
const testSuite: TestSuite = {
providers: [provider],
prompts: [{ raw: 'Test prompt', label: 'Test prompt' }],
tests: [{ vars: { apiKey: 'sk-abort-vars-should-not-persist', safe: 'keep-me' } }, {}],
};
try {
await evaluate(testSuite, evalRecord, {
abortSignal: abortController.signal,
maxConcurrency: 1,
});
const rows = fs
.readFileSync(outputPath, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line));
expect(rows).toHaveLength(1);
expect(rows[0].response.metadata.headers).toEqual({
'x-request-id': '[REDACTED]',
'x-safe-debug': 'keep-legacy',
});
expect(rows[0].response.metadata.http.headers).toEqual({
'set-cookie': '[REDACTED]',
'x-request-id': '[REDACTED]',
});
expect(rows[0].response.output.http.headers.authorization).toBe(
'model output should stay intact',
);
expect(rows[0].vars).toEqual({
apiKey: '[REDACTED]',
safe: 'keep-me',
});
expect(rows[0].testCase.vars).toEqual({
apiKey: '[REDACTED]',
safe: 'keep-me',
});
expect(JSON.stringify(rows[0])).not.toContain('session=secret');
expect(JSON.stringify(rows[0])).not.toContain('req_should_not_persist');
expect(JSON.stringify(rows[0])).not.toContain('legacy_abort_should_not_persist');
expect(JSON.stringify(rows[0])).not.toContain('sk-abort-vars-should-not-persist');
} finally {
writeSpy.mockRestore();
fs.rmSync(outputPath, { force: true });
}
});
});