chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,548 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { runDbMigrations } from '../../src/migrate';
|
||||
import Eval from '../../src/models/eval';
|
||||
import EvalResult from '../../src/models/evalResult';
|
||||
import { createApp } from '../../src/server/server';
|
||||
import { STRIPPED_TABLE_CELL_PROMPT } from '../../src/util/eval/evalTableUtils';
|
||||
import invariant from '../../src/util/invariant';
|
||||
import EvalFactory from '../factories/evalFactory';
|
||||
|
||||
vi.mock('../../src/database/signal', async () => {
|
||||
const actual = await vi.importActual('../../src/database/signal');
|
||||
return {
|
||||
...actual,
|
||||
updateSignalFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('eval routes', () => {
|
||||
let api: ReturnType<typeof request.agent>;
|
||||
let server: Server;
|
||||
const testEvalIds = new Set<string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
api = request.agent(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// More robust cleanup with proper error handling
|
||||
const cleanupPromises = Array.from(testEvalIds).map(async (evalId) => {
|
||||
try {
|
||||
const eval_ = await Eval.findById(evalId);
|
||||
if (eval_) {
|
||||
await eval_.delete();
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error instead of silently ignoring it
|
||||
console.error(`Failed to cleanup eval ${evalId}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all cleanups to complete
|
||||
await Promise.allSettled(cleanupPromises);
|
||||
testEvalIds.clear();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
function mockTablePayloadRangeError(shouldThrow: (attempt: number) => boolean) {
|
||||
const originalStringify = JSON.stringify;
|
||||
let tablePayloadAttempts = 0;
|
||||
|
||||
return vi
|
||||
.spyOn(JSON, 'stringify')
|
||||
.mockImplementation((...args: Parameters<typeof JSON.stringify>) => {
|
||||
const value = args[0];
|
||||
if (value && typeof value === 'object' && 'table' in value && 'totalCount' in value) {
|
||||
tablePayloadAttempts += 1;
|
||||
if (shouldThrow(tablePayloadAttempts)) {
|
||||
throw new RangeError('Invalid string length');
|
||||
}
|
||||
}
|
||||
return originalStringify.apply(JSON, args);
|
||||
});
|
||||
}
|
||||
|
||||
async function setResultPromptRaws(eval_: Eval, raws: string[]) {
|
||||
const results = await eval_.getResults();
|
||||
await Promise.all(
|
||||
raws.map(async (raw, index) => {
|
||||
const result = results[index];
|
||||
invariant(result instanceof EvalResult, 'EvalResult is required');
|
||||
result.prompt = { ...result.prompt, raw };
|
||||
await result.save();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createManualRatingPayload(originalResult: any, pass: boolean) {
|
||||
const payload = { ...originalResult.gradingResult };
|
||||
const score = pass ? 1 : 0;
|
||||
payload.componentResults?.push({
|
||||
pass,
|
||||
score,
|
||||
reason: 'Manual result (overrides all other grading results)',
|
||||
assertion: { type: 'human' },
|
||||
});
|
||||
payload.reason = 'Manual result (overrides all other grading results)';
|
||||
payload.pass = pass;
|
||||
payload.score = score;
|
||||
return payload;
|
||||
}
|
||||
|
||||
describe('POST /', () => {
|
||||
it('returns 500 when v4 prompt persistence fails', async () => {
|
||||
const createSpy = vi.spyOn(Eval, 'create');
|
||||
vi.spyOn(Eval.prototype, 'addPrompts').mockRejectedValueOnce(
|
||||
new Error('prompt persistence failed'),
|
||||
);
|
||||
|
||||
const res = await api.post('/api/eval').send({
|
||||
config: {
|
||||
description: 'v4 save test',
|
||||
tests: [],
|
||||
},
|
||||
prompts: [{ raw: 'hello', label: 'hello' }],
|
||||
results: [],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Failed to write eval to database' });
|
||||
|
||||
const createdEval = await createSpy.mock.results[0]?.value;
|
||||
if (createdEval) {
|
||||
testEvalIds.add(createdEval.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('post("/:evalId/results/:id/rating")', () => {
|
||||
it('rejects result ratings when the URL eval does not own the result', async () => {
|
||||
const evalA = await EvalFactory.create();
|
||||
const evalB = await EvalFactory.create();
|
||||
testEvalIds.add(evalA.id);
|
||||
testEvalIds.add(evalB.id);
|
||||
|
||||
const resultsB = await evalB.getResults();
|
||||
const resultB = resultsB[0];
|
||||
invariant(resultB.id, 'Result ID is required');
|
||||
const originalResultB = {
|
||||
gradingResult: structuredClone(resultB.gradingResult),
|
||||
score: resultB.score,
|
||||
success: resultB.success,
|
||||
};
|
||||
const originalEvalAMetrics = structuredClone(evalA.prompts[resultB.promptIdx].metrics);
|
||||
const originalEvalBMetrics = structuredClone(evalB.prompts[resultB.promptIdx].metrics);
|
||||
|
||||
const res = await api
|
||||
.post(`/api/eval/${evalA.id}/results/${resultB.id}/rating`)
|
||||
.send(createManualRatingPayload(resultB, false));
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe('Result not found');
|
||||
|
||||
const updatedResultB = await EvalResult.findById(resultB.id);
|
||||
expect(updatedResultB?.gradingResult).toEqual(originalResultB.gradingResult);
|
||||
expect(updatedResultB?.score).toBe(originalResultB.score);
|
||||
expect(updatedResultB?.success).toBe(originalResultB.success);
|
||||
|
||||
const updatedEvalA = await Eval.findById(evalA.id);
|
||||
const updatedEvalB = await Eval.findById(evalB.id);
|
||||
invariant(updatedEvalA, 'Eval A is required');
|
||||
invariant(updatedEvalB, 'Eval B is required');
|
||||
expect(updatedEvalA.prompts[resultB.promptIdx].metrics).toEqual(originalEvalAMetrics);
|
||||
expect(updatedEvalB.prompts[resultB.promptIdx].metrics).toEqual(originalEvalBMetrics);
|
||||
});
|
||||
|
||||
it('returns a JSON 500 response when rating storage fails', async () => {
|
||||
const findByIdSpy = vi
|
||||
.spyOn(EvalResult, 'findById')
|
||||
.mockRejectedValueOnce(new Error('database unavailable'));
|
||||
|
||||
const res = await api
|
||||
.post('/api/eval/eval-1/results/result-1/rating')
|
||||
.send({ pass: true, score: 1 });
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toEqual({ error: 'Failed to submit rating' });
|
||||
expect(findByIdSpy).toHaveBeenCalledWith('result-1');
|
||||
});
|
||||
|
||||
it('returns the persisted result row so SDK clients see refreshed metrics', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
testEvalIds.add(eval_.id);
|
||||
const results = await eval_.getResults();
|
||||
// Pick the failing result so flipping `pass=true` produces a real
|
||||
// state transition. With `results[0]` (already passing), every numeric
|
||||
// field would be unchanged and the test couldn't distinguish a fresh
|
||||
// post-rating row from a pre-rating snapshot.
|
||||
const result = results[1];
|
||||
invariant(result.id, 'Result ID is required');
|
||||
expect(result.gradingResult?.pass).toBe(false);
|
||||
expect(result.score).toBe(0);
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
const res = await api
|
||||
.post(`/api/eval/${eval_.id}/results/${result.id}/rating`)
|
||||
.send(createManualRatingPayload(result, true));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// The body must reflect the *post-rating* state, not just include the
|
||||
// row's id — external SDK consumers depend on reading refreshed
|
||||
// grading state without a follow-up GET. If the route ever regressed
|
||||
// to returning a pre-save snapshot or a generic `{message}` envelope,
|
||||
// the strict equality on flipped fields below would catch it.
|
||||
expect(res.body.id).toBe(result.id);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.score).toBe(1);
|
||||
expect(res.body.gradingResult?.pass).toBe(true);
|
||||
expect(res.body.gradingResult?.score).toBe(1);
|
||||
expect(res.body.gradingResult?.reason).toContain('Manual result');
|
||||
});
|
||||
|
||||
it('persists the rated result before notifying through the eval save', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
testEvalIds.add(eval_.id);
|
||||
const results = await eval_.getResults();
|
||||
const result = results[1];
|
||||
invariant(result.id, 'Result ID is required');
|
||||
const resultSaveSpy = vi.spyOn(EvalResult.prototype, 'save');
|
||||
const evalSaveSpy = vi.spyOn(Eval.prototype, 'save');
|
||||
|
||||
const res = await api
|
||||
.post(`/api/eval/${eval_.id}/results/${result.id}/rating`)
|
||||
.send(createManualRatingPayload(result, true));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(resultSaveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(evalSaveSpy).toHaveBeenCalledTimes(1);
|
||||
expect(resultSaveSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
evalSaveSpy.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('Passing test and the user marked it as passing (no change)', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
testEvalIds.add(eval_.id);
|
||||
|
||||
const results = await eval_.getResults();
|
||||
const result = results[0];
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.assertPassCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.assertFailCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.testPassCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.testFailCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.score).toBe(1);
|
||||
|
||||
expect(result.gradingResult?.pass).toBe(true);
|
||||
expect(result.gradingResult?.score).toBe(1);
|
||||
const ratingPayload = createManualRatingPayload(result, true);
|
||||
|
||||
const res = await api
|
||||
.post(`/api/eval/${eval_.id}/results/${result.id}/rating`)
|
||||
.send(ratingPayload);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
invariant(result.id, 'Result ID is required');
|
||||
const updatedResult = await EvalResult.findById(result.id);
|
||||
expect(updatedResult?.gradingResult?.pass).toBe(true);
|
||||
expect(updatedResult?.gradingResult?.score).toBe(1);
|
||||
expect(updatedResult?.gradingResult?.componentResults).toHaveLength(2);
|
||||
expect(updatedResult?.gradingResult?.reason).toBe(
|
||||
'Manual result (overrides all other grading results)',
|
||||
);
|
||||
|
||||
const updatedEval = await Eval.findById(eval_.id);
|
||||
invariant(updatedEval, 'Eval is required');
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.score).toBe(1);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.assertPassCount).toBe(2);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.assertFailCount).toBe(1);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.testPassCount).toBe(1);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.testFailCount).toBe(1);
|
||||
});
|
||||
|
||||
it('Passing test and the user changed it to failing', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
testEvalIds.add(eval_.id);
|
||||
|
||||
const results = await eval_.getResults();
|
||||
const result = results[0];
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.assertPassCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.assertFailCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.testPassCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.testFailCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.score).toBe(1);
|
||||
|
||||
expect(result.gradingResult?.pass).toBe(true);
|
||||
expect(result.gradingResult?.score).toBe(1);
|
||||
const ratingPayload = createManualRatingPayload(result, false);
|
||||
const res = await api
|
||||
.post(`/api/eval/${eval_.id}/results/${result.id}/rating`)
|
||||
.send(ratingPayload);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
invariant(result.id, 'Result ID is required');
|
||||
const updatedResult = await EvalResult.findById(result.id);
|
||||
expect(updatedResult?.gradingResult?.pass).toBe(false);
|
||||
expect(updatedResult?.gradingResult?.score).toBe(0);
|
||||
expect(updatedResult?.gradingResult?.componentResults).toHaveLength(2);
|
||||
expect(updatedResult?.gradingResult?.reason).toBe(
|
||||
'Manual result (overrides all other grading results)',
|
||||
);
|
||||
const updatedEval = await Eval.findById(eval_.id);
|
||||
invariant(updatedEval, 'Eval is required');
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.assertPassCount).toBe(1);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.assertFailCount).toBe(2);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.score).toBe(0);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.testPassCount).toBe(0);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.testFailCount).toBe(2);
|
||||
});
|
||||
|
||||
it('Failing test and the user changed it to passing', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
testEvalIds.add(eval_.id);
|
||||
|
||||
const results = await eval_.getResults();
|
||||
const result = results[1];
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.assertPassCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.assertFailCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.testPassCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.testFailCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.score).toBe(1);
|
||||
|
||||
expect(result.gradingResult?.pass).toBe(false);
|
||||
expect(result.gradingResult?.score).toBe(0);
|
||||
|
||||
const ratingPayload = createManualRatingPayload(result, true);
|
||||
|
||||
const res = await api
|
||||
.post(`/api/eval/${eval_.id}/results/${result.id}/rating`)
|
||||
.send(ratingPayload);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
invariant(result.id, 'Result ID is required');
|
||||
|
||||
const updatedResult = await EvalResult.findById(result.id);
|
||||
expect(updatedResult?.gradingResult?.pass).toBe(true);
|
||||
expect(updatedResult?.gradingResult?.score).toBe(1);
|
||||
expect(updatedResult?.gradingResult?.componentResults).toHaveLength(2);
|
||||
expect(updatedResult?.gradingResult?.reason).toBe(
|
||||
'Manual result (overrides all other grading results)',
|
||||
);
|
||||
|
||||
const updatedEval = await Eval.findById(eval_.id);
|
||||
invariant(updatedEval, 'Eval is required');
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.score).toBe(2);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.assertPassCount).toBe(2);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.assertFailCount).toBe(1);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.testPassCount).toBe(2);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.testFailCount).toBe(0);
|
||||
});
|
||||
|
||||
it('Failing test and the user marked it as failing (no change)', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
testEvalIds.add(eval_.id);
|
||||
|
||||
const results = await eval_.getResults();
|
||||
const result = results[1];
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.assertPassCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.assertFailCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.testPassCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.testFailCount).toBe(1);
|
||||
expect(eval_.prompts[result.promptIdx].metrics?.score).toBe(1);
|
||||
|
||||
expect(result.gradingResult?.pass).toBe(false);
|
||||
expect(result.gradingResult?.score).toBe(0);
|
||||
const ratingPayload = createManualRatingPayload(result, false);
|
||||
const res = await api
|
||||
.post(`/api/eval/${eval_.id}/results/${result.id}/rating`)
|
||||
.send(ratingPayload);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
invariant(result.id, 'Result ID is required');
|
||||
const updatedResult = await EvalResult.findById(result.id);
|
||||
expect(updatedResult?.gradingResult?.pass).toBe(false);
|
||||
expect(updatedResult?.gradingResult?.score).toBe(0);
|
||||
expect(updatedResult?.gradingResult?.componentResults).toHaveLength(2);
|
||||
expect(updatedResult?.gradingResult?.reason).toBe(
|
||||
'Manual result (overrides all other grading results)',
|
||||
);
|
||||
const updatedEval = await Eval.findById(eval_.id);
|
||||
invariant(updatedEval, 'Eval is required');
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.assertPassCount).toBe(1);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.assertFailCount).toBe(2);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.score).toBe(1);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.testPassCount).toBe(1);
|
||||
expect(updatedEval.prompts[result.promptIdx].metrics?.testFailCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id/table - large payload handling', () => {
|
||||
it('preserves config tests returned from the table endpoint when saved back', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
testEvalIds.add(eval_.id);
|
||||
|
||||
const res = await api.get(`/api/eval/${eval_.id}/table`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config.tests).toHaveLength(2);
|
||||
|
||||
const patchRes = await api
|
||||
.patch(`/api/eval/${eval_.id}`)
|
||||
.send({ config: { ...res.body.config, description: 'renamed eval' } });
|
||||
|
||||
expect(patchRes.status).toBe(200);
|
||||
|
||||
const updatedEval = await Eval.findById(eval_.id);
|
||||
invariant(updatedEval, 'Eval is required');
|
||||
expect(updatedEval.config.tests).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('preserves Azure Blob SAS tokens when a redacted table config is saved back', async () => {
|
||||
const sasUri =
|
||||
'az://{{ account }}/container/{{ suite }}.yaml?sp=r&sig=azure-secret&sv={{ version }}';
|
||||
const eval_ = await EvalFactory.create();
|
||||
testEvalIds.add(eval_.id);
|
||||
eval_.config.tests = sasUri;
|
||||
await eval_.save();
|
||||
|
||||
const res = await api.get(`/api/eval/${eval_.id}/table`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config.tests).toBe(
|
||||
'az://{{ account }}/container/{{ suite }}.yaml?sp=r&sig=%5BREDACTED%5D&sv={{ version }}',
|
||||
);
|
||||
|
||||
const patchRes = await api
|
||||
.patch(`/api/eval/${eval_.id}`)
|
||||
.send({ config: { ...res.body.config, description: 'renamed eval' } });
|
||||
|
||||
expect(patchRes.status).toBe(200);
|
||||
|
||||
const updatedEval = await Eval.findById(eval_.id);
|
||||
invariant(updatedEval, 'Eval is required');
|
||||
expect(updatedEval.config.tests).toBe(sasUri);
|
||||
expect(updatedEval.config.description).toBe('renamed eval');
|
||||
});
|
||||
|
||||
it('returns table data with only the largest per-cell prompt stripped when possible', async () => {
|
||||
const eval_ = await EvalFactory.create({ numResults: 3 });
|
||||
testEvalIds.add(eval_.id);
|
||||
await setResultPromptRaws(eval_, ['small prompt', 'x'.repeat(100), 'x'.repeat(50)]);
|
||||
|
||||
mockTablePayloadRangeError((attempt) => attempt === 1);
|
||||
|
||||
const res = await api.get(`/api/eval/${eval_.id}/table`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('table');
|
||||
expect(res.body.table.body.length).toBeGreaterThan(0);
|
||||
expect(res.body.config.tests).toHaveLength(2);
|
||||
|
||||
const prompts: Array<string | undefined> = res.body.table.body.flatMap(
|
||||
(row: { outputs: Array<{ prompt?: string }> }) =>
|
||||
row.outputs.map((output) => output?.prompt),
|
||||
);
|
||||
expect(prompts.filter((prompt) => prompt === STRIPPED_TABLE_CELL_PROMPT)).toHaveLength(1);
|
||||
expect(prompts).toContain('small prompt');
|
||||
expect(prompts).toContain('x'.repeat(50));
|
||||
});
|
||||
|
||||
it('strips per-cell prompts largest first until the response serializes', async () => {
|
||||
const eval_ = await EvalFactory.create({ numResults: 3 });
|
||||
testEvalIds.add(eval_.id);
|
||||
await setResultPromptRaws(eval_, ['small prompt', 'x'.repeat(100), 'x'.repeat(50)]);
|
||||
|
||||
mockTablePayloadRangeError((attempt) => attempt <= 2);
|
||||
|
||||
const res = await api.get(`/api/eval/${eval_.id}/table`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('table');
|
||||
expect(res.body.config.tests).toHaveLength(2);
|
||||
|
||||
const prompts: Array<string | undefined> = res.body.table.body.flatMap(
|
||||
(row: { outputs: Array<{ prompt?: string }> }) =>
|
||||
row.outputs.map((output) => output?.prompt),
|
||||
);
|
||||
expect(prompts.filter((prompt) => prompt === STRIPPED_TABLE_CELL_PROMPT)).toHaveLength(2);
|
||||
expect(prompts).toContain('small prompt');
|
||||
});
|
||||
|
||||
it('returns 413 when the table response is still too large after stripping prompts', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
testEvalIds.add(eval_.id);
|
||||
|
||||
mockTablePayloadRangeError(() => true);
|
||||
|
||||
const res = await api.get(`/api/eval/${eval_.id}/table`);
|
||||
|
||||
expect(res.status).toBe(413);
|
||||
expect(res.body).toEqual({
|
||||
error: 'Eval too large to display. Try reducing the page size.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /:id/metadata-keys', () => {
|
||||
it('should return metadata keys for valid eval', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
testEvalIds.add(eval_.id);
|
||||
|
||||
// Add eval results with metadata using direct database insert
|
||||
const { getDb } = await import('../../src/database');
|
||||
const db = await getDb();
|
||||
await db.run(
|
||||
`INSERT INTO eval_results (
|
||||
id, eval_id, prompt_idx, test_idx, test_case, prompt, provider,
|
||||
success, score, metadata
|
||||
) VALUES
|
||||
('result1', '${eval_.id}', 0, 0, '{}', '{}', '{}', 1, 1.0, '{"key1": "value1", "key2": "value2"}'),
|
||||
('result2', '${eval_.id}', 0, 1, '{}', '{}', '{}', 1, 1.0, '{"key2": "value3", "key3": "value4"}')`,
|
||||
);
|
||||
|
||||
const res = await api.get(`/api/eval/${eval_.id}/metadata-keys`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('keys');
|
||||
expect(res.body.keys).toEqual(expect.arrayContaining(['key1', 'key2', 'key3']));
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent eval', async () => {
|
||||
const res = await api.get('/api/eval/non-existent-id/metadata-keys');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toHaveProperty('error', 'Eval not found');
|
||||
});
|
||||
|
||||
it('should return empty keys array for eval with no metadata', async () => {
|
||||
const eval_ = await EvalFactory.create();
|
||||
testEvalIds.add(eval_.id);
|
||||
|
||||
const res = await api.get(`/api/eval/${eval_.id}/metadata-keys`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('keys');
|
||||
expect(res.body.keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import path from 'node:path';
|
||||
import type { PathLike } from 'node:fs';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock fs before importing the module - spread original to keep other functions working
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/esm', () => ({
|
||||
getDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock other server dependencies to prevent initialization errors
|
||||
vi.mock('../../src/migrate', () => ({
|
||||
runDbMigrations: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/database/signal', () => ({
|
||||
setupSignalWatcher: vi.fn().mockReturnValue({ close: vi.fn(), on: vi.fn() }),
|
||||
readSignalFile: vi.fn().mockReturnValue({ type: 'update' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/util/server', () => ({
|
||||
BrowserBehavior: { OPEN: 0, SKIP: 1, ASK: 2 },
|
||||
BrowserBehaviorNames: { 0: 'OPEN', 1: 'SKIP', 2: 'ASK' },
|
||||
openBrowser: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/models/eval', () => ({
|
||||
default: { latest: vi.fn().mockResolvedValue(null) },
|
||||
getEvalSummaries: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/globalConfig/cloud', () => ({
|
||||
cloudConfig: { isEnabled: vi.fn().mockReturnValue(false) },
|
||||
}));
|
||||
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { getDirectory } from '../../src/esm';
|
||||
import logger from '../../src/logger';
|
||||
import { findStaticDir } from '../../src/server/server';
|
||||
|
||||
describe('findStaticDir', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns standard path when index.html exists there (development)', () => {
|
||||
// Simulate development: getDirectory returns 'src/', app is at 'src/app/'
|
||||
vi.mocked(getDirectory).mockReturnValue('/project/src');
|
||||
vi.mocked(fs.existsSync).mockImplementation((filePath: PathLike) => {
|
||||
// Standard path check: /project/src/app/index.html
|
||||
return String(filePath) === path.join('/project/src', 'app', 'index.html');
|
||||
});
|
||||
|
||||
const result = findStaticDir();
|
||||
|
||||
expect(result).toBe(path.join('/project/src', 'app'));
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns parent path when bundled (standard path missing, parent exists)', () => {
|
||||
// Simulate bundled: getDirectory returns 'dist/src/server/', app is at 'dist/src/app/'
|
||||
vi.mocked(getDirectory).mockReturnValue('/project/dist/src/server');
|
||||
vi.mocked(fs.existsSync).mockImplementation((filePath: PathLike) => {
|
||||
const pathStr = String(filePath);
|
||||
// Standard path doesn't exist
|
||||
if (pathStr === path.join('/project/dist/src/server', 'app', 'index.html')) {
|
||||
return false;
|
||||
}
|
||||
// Parent path exists: /project/dist/src/app/index.html
|
||||
if (pathStr === path.resolve('/project/dist/src/server', '..', 'app', 'index.html')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const result = findStaticDir();
|
||||
|
||||
expect(result).toBe(path.resolve('/project/dist/src/server', '..', 'app'));
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Static directory resolved to parent'),
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to standard path with warning when neither path exists', () => {
|
||||
vi.mocked(getDirectory).mockReturnValue('/project/dist/src/server');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const result = findStaticDir();
|
||||
|
||||
// Should return standard path as fallback
|
||||
expect(result).toBe(path.join('/project/dist/src/server', 'app'));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Static directory not found'));
|
||||
});
|
||||
|
||||
it('checks index.html in both paths before falling back', () => {
|
||||
vi.mocked(getDirectory).mockReturnValue('/test/dir');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
findStaticDir();
|
||||
|
||||
// Should have checked both paths
|
||||
expect(fs.existsSync).toHaveBeenCalledWith(path.join('/test/dir', 'app', 'index.html'));
|
||||
expect(fs.existsSync).toHaveBeenCalledWith(
|
||||
path.resolve('/test/dir', '..', 'app', 'index.html'),
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers standard path over parent path when both exist', () => {
|
||||
vi.mocked(getDirectory).mockReturnValue('/project/src');
|
||||
// Both paths have index.html
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
const result = findStaticDir();
|
||||
|
||||
// Should return standard path (first check wins)
|
||||
expect(result).toBe(path.join('/project/src', 'app'));
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { csrfProtection } from '../../../src/server/middleware/csrfProtection';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
vi.mock('../../../src/logger', () => ({
|
||||
default: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/envars', () => ({
|
||||
getEnvString: vi.fn((_key: string, defaultValue?: string) => defaultValue ?? ''),
|
||||
}));
|
||||
|
||||
import { getEnvString } from '../../../src/envars';
|
||||
|
||||
function mockReq(overrides: Partial<Request> = {}): Request {
|
||||
return {
|
||||
method: 'POST',
|
||||
path: '/api/eval',
|
||||
headers: {},
|
||||
...overrides,
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
function mockRes(): Response {
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response;
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('csrfProtection', () => {
|
||||
let next: NextFunction;
|
||||
|
||||
beforeEach(() => {
|
||||
next = vi.fn();
|
||||
vi.mocked(getEnvString).mockImplementation(
|
||||
(_key: string, defaultValue?: string) => defaultValue ?? '',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
// Safe methods always pass
|
||||
describe('safe methods', () => {
|
||||
it.each(['GET', 'HEAD', 'OPTIONS'])('%s requests pass through', (method) => {
|
||||
const req = mockReq({
|
||||
method,
|
||||
headers: { 'sec-fetch-site': 'cross-site', origin: 'http://evil.com' },
|
||||
});
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Path 1: Sec-Fetch-Site present
|
||||
describe('with Sec-Fetch-Site header', () => {
|
||||
it.each(['same-origin', 'same-site', 'none'])('allows POST with sec-fetch-site=%s', (value) => {
|
||||
const req = mockReq({ headers: { 'sec-fetch-site': value, host: 'localhost:15500' } });
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks cross-site POST from evil.com', () => {
|
||||
const req = mockReq({
|
||||
headers: {
|
||||
'sec-fetch-site': 'cross-site',
|
||||
origin: 'http://evil.com',
|
||||
host: 'localhost:15500',
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('allows cross-site from local.promptfoo.app to localhost (localhost equiv)', () => {
|
||||
const req = mockReq({
|
||||
headers: {
|
||||
'sec-fetch-site': 'cross-site',
|
||||
origin: 'http://local.promptfoo.app:5173',
|
||||
host: 'localhost:15500',
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows cross-site from 127.0.0.1 to localhost (localhost equiv)', () => {
|
||||
const req = mockReq({
|
||||
headers: {
|
||||
'sec-fetch-site': 'cross-site',
|
||||
origin: 'http://127.0.0.1:3000',
|
||||
host: 'localhost:15500',
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows cross-site from env var allowlisted origin', () => {
|
||||
vi.mocked(getEnvString).mockImplementation((_key: string, defaultValue?: string) => {
|
||||
if (_key === 'PROMPTFOO_CSRF_ALLOWED_ORIGINS') {
|
||||
return 'http://allowed.com';
|
||||
}
|
||||
return defaultValue ?? '';
|
||||
});
|
||||
const req = mockReq({
|
||||
headers: {
|
||||
'sec-fetch-site': 'cross-site',
|
||||
origin: 'http://allowed.com',
|
||||
host: 'localhost:15500',
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks cross-site with no origin header', () => {
|
||||
const req = mockReq({
|
||||
headers: {
|
||||
'sec-fetch-site': 'cross-site',
|
||||
host: 'localhost:15500',
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
|
||||
// Path 2: No Sec-Fetch-Site, Origin present
|
||||
describe('with Origin header only (no Sec-Fetch-Site)', () => {
|
||||
it('allows POST when origin hostname matches host', () => {
|
||||
const req = mockReq({
|
||||
headers: {
|
||||
origin: 'http://localhost:5173',
|
||||
host: 'localhost:15500',
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks POST when origin hostname differs from host', () => {
|
||||
const req = mockReq({
|
||||
headers: {
|
||||
origin: 'http://evil.com',
|
||||
host: 'localhost:15500',
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('allows localhost equivalence via Origin fallback', () => {
|
||||
const req = mockReq({
|
||||
headers: {
|
||||
origin: 'http://local.promptfoo.app:5173',
|
||||
host: 'localhost:15500',
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Path 3: No browser headers
|
||||
describe('non-browser clients (no Sec-Fetch-Site, no Origin)', () => {
|
||||
it('allows POST with no browser headers', () => {
|
||||
const req = mockReq({
|
||||
headers: { host: 'localhost:15500' },
|
||||
});
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Other mutating methods
|
||||
describe('other mutating methods', () => {
|
||||
it.each(['DELETE', 'PUT', 'PATCH'])('blocks cross-site %s from evil.com', (method) => {
|
||||
const req = mockReq({
|
||||
method,
|
||||
headers: {
|
||||
'sec-fetch-site': 'cross-site',
|
||||
origin: 'http://evil.com',
|
||||
host: 'localhost:15500',
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
|
||||
// x-forwarded-host is NOT trusted (attacker-controllable)
|
||||
describe('x-forwarded-host', () => {
|
||||
it('ignores x-forwarded-host and uses Host header for origin comparison', () => {
|
||||
const req = mockReq({
|
||||
headers: {
|
||||
origin: 'http://evil.com',
|
||||
host: 'localhost:15500',
|
||||
'x-forwarded-host': 'evil.com',
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
csrfProtection(req, res, next);
|
||||
// Should block: origin (evil.com) !== host (localhost), even though
|
||||
// x-forwarded-host matches origin. The header is attacker-controllable.
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { updateResult } from '../../src/util/database';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../src/util/database');
|
||||
|
||||
// Import the handler directly to avoid Express overhead
|
||||
const patchHandler = (req: Request, res: Response): void => {
|
||||
const id = req.params.id as string;
|
||||
const { table, config } = req.body;
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({ error: 'Missing id' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
updateResult(id, config, table);
|
||||
res.json({ message: 'Eval updated successfully' });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Failed to update eval table' });
|
||||
}
|
||||
};
|
||||
|
||||
describe('evalRouter - PATCH /:id', () => {
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
let jsonMock: Mock;
|
||||
let statusMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create lightweight mocks for Request and Response
|
||||
jsonMock = vi.fn();
|
||||
statusMock = vi.fn().mockReturnThis();
|
||||
|
||||
mockRes = {
|
||||
json: jsonMock,
|
||||
status: statusMock,
|
||||
};
|
||||
|
||||
mockReq = {
|
||||
params: {},
|
||||
body: {},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should update eval config including description', async () => {
|
||||
const evalId = 'test-eval-id';
|
||||
const newConfig = {
|
||||
description: 'Updated Test Description',
|
||||
otherField: 'value',
|
||||
};
|
||||
|
||||
mockReq.params = { id: evalId };
|
||||
mockReq.body = { config: newConfig };
|
||||
|
||||
// Mock updateResult to resolve immediately
|
||||
(updateResult as Mock).mockResolvedValue(undefined);
|
||||
|
||||
// Call the handler directly
|
||||
patchHandler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonMock).toHaveBeenCalledWith({ message: 'Eval updated successfully' });
|
||||
expect(updateResult).toHaveBeenCalledWith(evalId, newConfig, undefined);
|
||||
});
|
||||
|
||||
it('should return 400 if id is missing', async () => {
|
||||
mockReq.params = {}; // No id
|
||||
mockReq.body = { config: { description: 'New Description' } };
|
||||
|
||||
patchHandler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(400);
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Missing id' });
|
||||
});
|
||||
|
||||
it('should return 500 if update fails', async () => {
|
||||
const evalId = 'test-eval-id';
|
||||
|
||||
mockReq.params = { id: evalId };
|
||||
mockReq.body = { config: { description: 'New Description' } };
|
||||
|
||||
// Mock updateResult to throw an error
|
||||
(updateResult as Mock).mockImplementation(function () {
|
||||
throw new Error('Database error');
|
||||
});
|
||||
|
||||
patchHandler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(500);
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Failed to update eval table' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,723 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../src/blobs/extractor');
|
||||
vi.mock('../../../src/blobs');
|
||||
vi.mock('../../../src/database');
|
||||
|
||||
// Import after mocking
|
||||
import { getBlobByHash, getBlobUrl } from '../../../src/blobs';
|
||||
import { isBlobStorageEnabled } from '../../../src/blobs/extractor';
|
||||
import { getDb } from '../../../src/database';
|
||||
|
||||
const mockedIsBlobStorageEnabled = vi.mocked(isBlobStorageEnabled);
|
||||
const mockedGetBlobUrl = vi.mocked(getBlobUrl);
|
||||
const mockedGetBlobByHash = vi.mocked(getBlobByHash);
|
||||
const mockedGetDb = vi.mocked(getDb);
|
||||
|
||||
describe('Blobs Routes', () => {
|
||||
let api: ReturnType<typeof request.agent>;
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
api = request.agent(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/blobs/:hash', () => {
|
||||
const validHash = 'a'.repeat(64);
|
||||
|
||||
// Create chainable mock DB that returns assetResult on first .get() and referenceResult on second
|
||||
function createMockDb(assetResult?: any, referenceResult?: any) {
|
||||
let callCount = 0;
|
||||
return {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
get: vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
return callCount === 1 ? assetResult : referenceResult;
|
||||
}),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function setupDbWithAssetAndReference(
|
||||
asset: Record<string, unknown>,
|
||||
reference: Record<string, unknown> = { evalId: 'eval-123' },
|
||||
) {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
const mockDb = createMockDb(asset, reference);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
}
|
||||
|
||||
function createBlobResponse(mimeType: string, sizeBytes: number) {
|
||||
return {
|
||||
data: Buffer.alloc(sizeBytes),
|
||||
metadata: {
|
||||
mimeType,
|
||||
sizeBytes,
|
||||
createdAt: new Date().toISOString(),
|
||||
provider: 'local',
|
||||
key: validHash,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return 404 when blob storage is disabled', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(false);
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Blob storage disabled' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['too short', 'abc123'],
|
||||
['invalid characters', 'g'.repeat(64)],
|
||||
['too long', 'a'.repeat(65)],
|
||||
])('should return 400 for invalid hash (%s)', async (_label, hash) => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const response = await api.get(`/api/blobs/${hash}`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 404 when blob asset not found in DB', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
mockedGetDb.mockReturnValue(createMockDb(undefined));
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Blob not found' });
|
||||
});
|
||||
|
||||
it('should return 403 when no reference exists', async () => {
|
||||
const mockAsset = {
|
||||
hash: validHash,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
provider: 'local',
|
||||
};
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
mockedGetDb.mockReturnValue(createMockDb(mockAsset, undefined));
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body).toEqual({ error: 'Not authorized to access this blob' });
|
||||
});
|
||||
|
||||
it('should redirect 302 when presigned URL is available', async () => {
|
||||
setupDbWithAssetAndReference({
|
||||
hash: validHash,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
provider: 's3',
|
||||
});
|
||||
|
||||
const presignedUrl = 'https://s3.amazonaws.com/bucket/blob?signature=xyz';
|
||||
mockedGetBlobUrl.mockResolvedValue(presignedUrl);
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.header.location).toBe(presignedUrl);
|
||||
expect(mockedGetBlobUrl).toHaveBeenCalledWith(validHash);
|
||||
expect(mockedGetBlobByHash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should serve blob data directly when no presigned URL', async () => {
|
||||
setupDbWithAssetAndReference({
|
||||
hash: validHash,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
provider: 'local',
|
||||
});
|
||||
|
||||
mockedGetBlobUrl.mockResolvedValue(null);
|
||||
mockedGetBlobByHash.mockResolvedValue(createBlobResponse('image/png', 1024));
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.header['content-type']).toBe('image/png');
|
||||
expect(response.header['cache-control']).toBe('public, max-age=31536000, immutable');
|
||||
expect(response.header['accept-ranges']).toBe('none');
|
||||
// Content-Length may be absent if response is gzipped (Express uses transfer-encoding: chunked)
|
||||
expect(
|
||||
response.header['content-length'] === '1024' ||
|
||||
response.header['transfer-encoding'] === 'chunked',
|
||||
).toBe(true);
|
||||
expect(mockedGetBlobByHash).toHaveBeenCalledWith(validHash);
|
||||
});
|
||||
|
||||
it('should use fallback MIME type for invalid MIME types', async () => {
|
||||
setupDbWithAssetAndReference(
|
||||
{ hash: validHash, mimeType: 'audio/wav.html', sizeBytes: 2048, provider: 'local' },
|
||||
{ evalId: 'eval-456' },
|
||||
);
|
||||
|
||||
mockedGetBlobUrl.mockResolvedValue(null);
|
||||
mockedGetBlobByHash.mockResolvedValue(createBlobResponse('audio/wav.html', 2048));
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.header['content-type']).toBe('application/octet-stream');
|
||||
expect(response.header['cache-control']).toBe('public, max-age=31536000, immutable');
|
||||
expect(response.header['accept-ranges']).toBe('none');
|
||||
});
|
||||
|
||||
it('should use blob metadata MIME type when available', async () => {
|
||||
setupDbWithAssetAndReference(
|
||||
{ hash: validHash, mimeType: 'image/png', sizeBytes: 1024, provider: 'local' },
|
||||
{ evalId: 'eval-789' },
|
||||
);
|
||||
|
||||
mockedGetBlobUrl.mockResolvedValue(null);
|
||||
// Blob metadata has different MIME type and size than the asset record
|
||||
mockedGetBlobByHash.mockResolvedValue(createBlobResponse('image/jpeg', 2048));
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.header['content-type']).toBe('image/jpeg');
|
||||
expect(response.header['cache-control']).toBe('public, max-age=31536000, immutable');
|
||||
expect(response.header['accept-ranges']).toBe('none');
|
||||
// Content-Length may be absent if response is gzipped
|
||||
expect(
|
||||
response.header['content-length'] === '2048' ||
|
||||
response.header['transfer-encoding'] === 'chunked',
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 404 when getBlobByHash throws error', async () => {
|
||||
setupDbWithAssetAndReference(
|
||||
{ hash: validHash, mimeType: 'text/plain', sizeBytes: 512, provider: 'local' },
|
||||
{ evalId: 'eval-error' },
|
||||
);
|
||||
|
||||
mockedGetBlobUrl.mockResolvedValue(null);
|
||||
mockedGetBlobByHash.mockRejectedValue(new Error('File system error'));
|
||||
|
||||
const response = await api.get(`/api/blobs/${validHash}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Blob not found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/blobs/library', () => {
|
||||
/**
|
||||
* Create a mock DB for library queries.
|
||||
* The /library route executes 3 chained queries:
|
||||
* 1. COUNT query via .get() → { count: number }
|
||||
* 2. uniqueHashes query via .all() → [{ hash: string }]
|
||||
* 3. items detail query via .all() → full item rows
|
||||
*
|
||||
* We track calls to .get() and .all() to return the right data.
|
||||
*/
|
||||
function createLibraryMockDb(
|
||||
countResult: { count: number },
|
||||
uniqueHashes: Array<{ hash: string }>,
|
||||
items: any[],
|
||||
) {
|
||||
let getAllCallCount = 0;
|
||||
return {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
selectDistinct: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
leftJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
offset: vi.fn().mockReturnThis(),
|
||||
get: vi.fn().mockReturnValue(countResult),
|
||||
all: vi.fn().mockImplementation(() => {
|
||||
getAllCallCount++;
|
||||
// First .all() call: uniqueHashes query
|
||||
// Second .all() call: items detail query
|
||||
return getAllCallCount === 1 ? uniqueHashes : items;
|
||||
}),
|
||||
} as any;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return empty list with blobStorageEnabled=false when blob storage is disabled', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(false);
|
||||
|
||||
const response = await api.get('/api/blobs/library');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
data: { items: [], total: 0, hasMore: false, blobStorageEnabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for invalid query parameters', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const response = await api.get('/api/blobs/library?limit=abc');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: 'Invalid query parameters',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for invalid type filter', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const response = await api.get('/api/blobs/library?type=invalid');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: 'Invalid query parameters',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty list when no items match', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
const mockDb = createLibraryMockDb({ count: 0 }, [], []);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.items).toEqual([]);
|
||||
expect(response.body.data.total).toBe(0);
|
||||
expect(response.body.data.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it('should return items with correct response shape (list mode)', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const hash1 = 'a'.repeat(64);
|
||||
const items = [
|
||||
{
|
||||
hash: hash1,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
createdAt: '2025-01-01 00:00:00',
|
||||
evalId: 'eval-1',
|
||||
testIdx: 0,
|
||||
promptIdx: 0,
|
||||
location: 'response',
|
||||
kind: 'image',
|
||||
evalDescription: 'Test eval',
|
||||
provider: { id: 'openai:gpt-4', label: 'GPT-4' },
|
||||
success: true,
|
||||
score: 0.9,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createLibraryMockDb({ count: 1 }, [{ hash: hash1 }], items);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.total).toBe(1);
|
||||
expect(response.body.data.hasMore).toBe(false);
|
||||
|
||||
const item = response.body.data.items[0];
|
||||
expect(item.hash).toBe(hash1);
|
||||
expect(item.mimeType).toBe('image/png');
|
||||
expect(item.sizeBytes).toBe(1024);
|
||||
expect(item.kind).toBe('image');
|
||||
expect(item.url).toBe(`/api/blobs/${hash1}`);
|
||||
expect(item.context.evalId).toBe('eval-1');
|
||||
expect(item.context.evalDescription).toBe('Test eval');
|
||||
expect(item.context.provider).toBe('GPT-4');
|
||||
expect(item.context.pass).toBe(true);
|
||||
expect(item.context.score).toBe(0.9);
|
||||
// Detail-only fields should NOT be in list responses
|
||||
expect(item.context.prompt).toBeUndefined();
|
||||
expect(item.context.variables).toBeUndefined();
|
||||
expect(item.context.graderResults).toBeUndefined();
|
||||
expect(item.context.latencyMs).toBeUndefined();
|
||||
expect(item.context.cost).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return detail fields when hash filter is provided', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const hash1 = 'a'.repeat(64);
|
||||
const items = [
|
||||
{
|
||||
hash: hash1,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
createdAt: '2025-01-01 00:00:00',
|
||||
evalId: 'eval-1',
|
||||
testIdx: 0,
|
||||
promptIdx: 0,
|
||||
location: 'response',
|
||||
kind: 'image',
|
||||
evalDescription: 'Test eval',
|
||||
provider: { id: 'openai:gpt-4', label: 'GPT-4' },
|
||||
prompt: { raw: 'Generate an image' },
|
||||
success: true,
|
||||
score: 0.9,
|
||||
gradingResult: null,
|
||||
testCase: { vars: { prompt: 'test' } },
|
||||
latencyMs: 500,
|
||||
cost: 0.01,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createLibraryMockDb({ count: 1 }, [{ hash: hash1 }], items);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get(`/api/blobs/library?hash=${hash1}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const item = response.body.data.items[0];
|
||||
expect(item.context.prompt).toBe('Generate an image');
|
||||
expect(item.context.variables).toEqual({ prompt: 'test' });
|
||||
expect(item.context.latencyMs).toBe(500);
|
||||
expect(item.context.cost).toBe(0.01);
|
||||
});
|
||||
|
||||
it('should handle string provider format (legacy)', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const hash1 = 'b'.repeat(64);
|
||||
const items = [
|
||||
{
|
||||
hash: hash1,
|
||||
mimeType: 'audio/wav',
|
||||
sizeBytes: 2048,
|
||||
createdAt: 1700000000,
|
||||
evalId: 'eval-2',
|
||||
testIdx: 0,
|
||||
promptIdx: 0,
|
||||
location: null,
|
||||
kind: null,
|
||||
evalDescription: null,
|
||||
provider: 'openai:tts-1',
|
||||
prompt: null,
|
||||
success: null,
|
||||
score: null,
|
||||
gradingResult: null,
|
||||
testCase: null,
|
||||
latencyMs: null,
|
||||
cost: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createLibraryMockDb({ count: 1 }, [{ hash: hash1 }], items);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const item = response.body.data.items[0];
|
||||
expect(item.context.provider).toBe('openai:tts-1');
|
||||
// Kind should be derived from mimeType when not set in reference
|
||||
expect(item.kind).toBe('audio');
|
||||
});
|
||||
|
||||
it('should indicate hasMore when more items exist beyond the page', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const hash1 = 'c'.repeat(64);
|
||||
const items = [
|
||||
{
|
||||
hash: hash1,
|
||||
mimeType: 'image/jpeg',
|
||||
sizeBytes: 512,
|
||||
createdAt: '2025-06-01 12:00:00',
|
||||
evalId: 'eval-3',
|
||||
testIdx: 0,
|
||||
promptIdx: 0,
|
||||
location: null,
|
||||
kind: 'image',
|
||||
evalDescription: null,
|
||||
provider: null,
|
||||
prompt: null,
|
||||
success: null,
|
||||
score: null,
|
||||
gradingResult: null,
|
||||
testCase: null,
|
||||
latencyMs: null,
|
||||
cost: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Total is 5 but only 1 returned (limit=1, offset=0)
|
||||
const mockDb = createLibraryMockDb({ count: 5 }, [{ hash: hash1 }], items);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library?limit=1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.total).toBe(5);
|
||||
expect(response.body.data.hasMore).toBe(true);
|
||||
expect(response.body.data.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should accept valid filter parameters', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
const mockDb = createLibraryMockDb({ count: 0 }, [], []);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
// Type filter
|
||||
const res1 = await api.get('/api/blobs/library?type=image');
|
||||
expect(res1.status).toBe(200);
|
||||
|
||||
// Sort parameters
|
||||
const res2 = await api.get('/api/blobs/library?sortField=sizeBytes&sortOrder=asc');
|
||||
expect(res2.status).toBe(200);
|
||||
|
||||
// Eval filter
|
||||
const res3 = await api.get('/api/blobs/library?evalId=some-eval-id');
|
||||
expect(res3.status).toBe(200);
|
||||
|
||||
// Hash filter (deep link)
|
||||
const hash = 'd'.repeat(64);
|
||||
const res4 = await api.get(`/api/blobs/library?hash=${hash}`);
|
||||
expect(res4.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle grading results in detail response', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const hash1 = 'e'.repeat(64);
|
||||
const items = [
|
||||
{
|
||||
hash: hash1,
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1024,
|
||||
createdAt: '2025-01-01 00:00:00',
|
||||
evalId: 'eval-grading',
|
||||
testIdx: 0,
|
||||
promptIdx: 0,
|
||||
location: null,
|
||||
kind: 'image',
|
||||
evalDescription: null,
|
||||
provider: null,
|
||||
prompt: null,
|
||||
success: true,
|
||||
score: 0.75,
|
||||
gradingResult: {
|
||||
componentResults: [
|
||||
{ pass: true, score: 1.0, reason: 'Looks good', assertion: { type: 'human' } },
|
||||
{ pass: false, score: 0.5, reason: 'Low quality' },
|
||||
],
|
||||
},
|
||||
testCase: null,
|
||||
latencyMs: null,
|
||||
cost: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createLibraryMockDb({ count: 1 }, [{ hash: hash1 }], items);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
// Use hash filter to trigger detail mode
|
||||
const response = await api.get(`/api/blobs/library?hash=${hash1}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const item = response.body.data.items[0];
|
||||
expect(item.context.graderResults).toHaveLength(2);
|
||||
expect(item.context.graderResults[0]).toEqual({
|
||||
name: 'human',
|
||||
pass: true,
|
||||
score: 1.0,
|
||||
reason: 'Looks good',
|
||||
});
|
||||
expect(item.context.graderResults[1]).toEqual({
|
||||
name: 'Grader 2',
|
||||
pass: false,
|
||||
score: 0.5,
|
||||
reason: 'Low quality',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 500 on database error', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
mockedGetDb.mockImplementation(() => {
|
||||
throw new Error('Database connection failed');
|
||||
});
|
||||
|
||||
const response = await api.get('/api/blobs/library');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/blobs/library/evals', () => {
|
||||
function createEvalsMockDb(evals: any[]) {
|
||||
return {
|
||||
selectDistinct: vi.fn().mockReturnThis(),
|
||||
from: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
all: vi.fn().mockReturnValue(evals),
|
||||
} as any;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return empty array when blob storage is disabled', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(false);
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ success: true, data: [] });
|
||||
});
|
||||
|
||||
it('should return 400 for invalid query parameters', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals?limit=abc');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: 'Invalid query parameters',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return evals with correct shape', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const mockEvals = [
|
||||
{
|
||||
evalId: 'eval-abc-123',
|
||||
description: 'Image generation test',
|
||||
createdAt: '2025-06-15 10:30:00',
|
||||
},
|
||||
{
|
||||
evalId: 'eval-def-456',
|
||||
description: null,
|
||||
createdAt: 1700000000,
|
||||
},
|
||||
];
|
||||
|
||||
const mockDb = createEvalsMockDb(mockEvals);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toHaveLength(2);
|
||||
|
||||
// First eval with description
|
||||
expect(response.body.data[0].evalId).toBe('eval-abc-123');
|
||||
expect(response.body.data[0].description).toBe('Image generation test');
|
||||
expect(response.body.data[0].createdAt).toBeDefined();
|
||||
|
||||
// Second eval falls back to truncated ID
|
||||
expect(response.body.data[1].evalId).toBe('eval-def-456');
|
||||
expect(response.body.data[1].description).toBe('Eval eval-def');
|
||||
});
|
||||
|
||||
it('should respect custom limit parameter', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
const mockDb = createEvalsMockDb([]);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals?limit=5');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockDb.limit).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('should return 500 on database error', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
mockedGetDb.mockImplementation(() => {
|
||||
throw new Error('Database connection failed');
|
||||
});
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should pass search query to where clause', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
const mockDb = createEvalsMockDb([]);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals?search=test');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockDb.where).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add where clause when search is empty', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
const mockDb = createEvalsMockDb([]);
|
||||
mockedGetDb.mockReturnValue(mockDb);
|
||||
|
||||
const response = await api.get('/api/blobs/library/evals');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// where is called with undefined (no conditions)
|
||||
expect(mockDb.where).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should reject search strings exceeding max length', async () => {
|
||||
mockedIsBlobStorageEnabled.mockReturnValue(true);
|
||||
|
||||
const longSearch = 'a'.repeat(201);
|
||||
const response = await api.get(`/api/blobs/library/evals?search=${longSearch}`);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import request from 'supertest';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getDb } from '../../../src/database/index';
|
||||
import { configsTable } from '../../../src/database/tables';
|
||||
import { runDbMigrations } from '../../../src/migrate';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
import {
|
||||
CreateConfigResponseSchema,
|
||||
GetConfigResponseSchema,
|
||||
ListConfigsByTypeResponseSchema,
|
||||
ListConfigsResponseSchema,
|
||||
} from '../../../src/types/api/configs';
|
||||
|
||||
describe('configs routes', () => {
|
||||
let app: ReturnType<typeof createApp>;
|
||||
const testConfigIds = new Set<string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const db = await getDb();
|
||||
for (const id of testConfigIds) {
|
||||
try {
|
||||
await db.delete(configsTable).where(eq(configsTable.id, id));
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
testConfigIds.clear();
|
||||
});
|
||||
|
||||
describe('POST /api/configs', () => {
|
||||
it('should create a config and return id and createdAt', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/configs')
|
||||
.send({
|
||||
name: 'test-config',
|
||||
type: 'eval',
|
||||
config: { prompts: ['hello'] },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('id');
|
||||
expect(res.body).toHaveProperty('createdAt');
|
||||
testConfigIds.add(res.body.id);
|
||||
|
||||
const parsed = CreateConfigResponseSchema.safeParse(res.body);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing name', { type: 'eval', config: {} }],
|
||||
['missing type', { name: 'test-config', config: {} }],
|
||||
['empty name', { name: '', type: 'eval', config: {} }],
|
||||
['missing config', { name: 'test-config', type: 'eval' }],
|
||||
['null config', { name: 'test-config', type: 'eval', config: null }],
|
||||
])('should return 400 for %s', async (_label, body) => {
|
||||
const res = await request(app).post('/api/configs').send(body);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/configs', () => {
|
||||
it('should return all configs with valid response schema', async () => {
|
||||
const createRes = await request(app)
|
||||
.post('/api/configs')
|
||||
.send({
|
||||
name: 'list-test',
|
||||
type: 'eval',
|
||||
config: { foo: 'bar' },
|
||||
});
|
||||
testConfigIds.add(createRes.body.id);
|
||||
|
||||
const res = await request(app).get('/api/configs');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.configs.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const parsed = ListConfigsResponseSchema.safeParse(res.body);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter configs by type query parameter', async () => {
|
||||
const createRes = await request(app).post('/api/configs').send({
|
||||
name: 'filter-test',
|
||||
type: 'redteam',
|
||||
config: {},
|
||||
});
|
||||
testConfigIds.add(createRes.body.id);
|
||||
|
||||
const res = await request(app).get('/api/configs?type=redteam');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.configs.length).toBeGreaterThanOrEqual(1);
|
||||
for (const config of res.body.configs) {
|
||||
expect(config.type).toBe('redteam');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 400 for empty type query parameter', async () => {
|
||||
const res = await request(app).get('/api/configs?type=');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/configs/:type', () => {
|
||||
it('should return configs filtered by type with valid response schema', async () => {
|
||||
const createRes = await request(app)
|
||||
.post('/api/configs')
|
||||
.send({
|
||||
name: 'type-filter-test',
|
||||
type: 'eval',
|
||||
config: { hello: 'world' },
|
||||
});
|
||||
testConfigIds.add(createRes.body.id);
|
||||
|
||||
const res = await request(app).get('/api/configs/eval');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.configs.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const parsed = ListConfigsByTypeResponseSchema.safeParse(res.body);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty array for unknown type', async () => {
|
||||
const res = await request(app).get('/api/configs/nonexistent-type-xyz');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.configs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/configs/:type/:id', () => {
|
||||
it('should return a specific config with valid response schema', async () => {
|
||||
const createRes = await request(app)
|
||||
.post('/api/configs')
|
||||
.send({
|
||||
name: 'get-test',
|
||||
type: 'eval',
|
||||
config: { key: 'value' },
|
||||
});
|
||||
const configId = createRes.body.id;
|
||||
testConfigIds.add(configId);
|
||||
|
||||
const res = await request(app).get(`/api/configs/eval/${configId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe(configId);
|
||||
expect(res.body.name).toBe('get-test');
|
||||
expect(res.body.type).toBe('eval');
|
||||
expect(res.body.config).toEqual({ key: 'value' });
|
||||
|
||||
const parsed = GetConfigResponseSchema.safeParse(res.body);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent config', async () => {
|
||||
const res = await request(app).get('/api/configs/eval/non-existent-id');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toHaveProperty('error', 'Config not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,600 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, Mock, MockedFunction, vi } from 'vitest';
|
||||
import { createCompletedPrompt, createEvaluateTableOutput } from '../../factories/eval';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
// Mock dependencies first
|
||||
vi.mock('../../../src/database', async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
getDb: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../src/models/eval');
|
||||
vi.mock('../../../src/util/eval/evalTableUtils');
|
||||
vi.mock('../../../src/server/utils/downloadHelpers', async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
setDownloadHeaders: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocking
|
||||
import Eval from '../../../src/models/eval';
|
||||
import { setDownloadHeaders } from '../../../src/server/utils/downloadHelpers';
|
||||
import { EVAL_TABLE_MAX_PAGE_SIZE, EvalSchemas } from '../../../src/types/api/eval';
|
||||
import { evalTableToCsv, evalTableToJson } from '../../../src/util/eval/evalTableUtils';
|
||||
|
||||
// Setup mocked functions
|
||||
const mockedEvalFindById = vi.fn() as MockedFunction<typeof Eval.findById>;
|
||||
const mockedEvalTableToCsv = evalTableToCsv as MockedFunction<typeof evalTableToCsv>;
|
||||
const mockedEvalTableToJson = evalTableToJson as MockedFunction<typeof evalTableToJson>;
|
||||
|
||||
// Override the mocked modules
|
||||
(Eval as any).findById = mockedEvalFindById;
|
||||
|
||||
describe('evalRouter - GET /:id/table with export formats', () => {
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
let jsonMock: Mock;
|
||||
let sendMock: Mock;
|
||||
let statusMock: Mock;
|
||||
|
||||
const mockTable = {
|
||||
head: {
|
||||
vars: ['var1', 'var2'],
|
||||
prompts: [
|
||||
createCompletedPrompt('test prompt', {
|
||||
provider: 'openai',
|
||||
label: 'prompt1',
|
||||
display: 'test',
|
||||
}),
|
||||
],
|
||||
},
|
||||
body: [
|
||||
{
|
||||
test: { vars: { var1: 'value1', var2: 'value2' } },
|
||||
testIdx: 0,
|
||||
vars: ['value1', 'value2'],
|
||||
outputs: [
|
||||
createEvaluateTableOutput({
|
||||
text: 'output text',
|
||||
id: 'output-id',
|
||||
latencyMs: 100,
|
||||
provider: 'openai:gpt-3.5-turbo',
|
||||
gradingResult: {
|
||||
pass: true,
|
||||
score: 1,
|
||||
reason: 'Test passed',
|
||||
comment: 'Good response',
|
||||
},
|
||||
metadata: {
|
||||
redteamHistory: ['attempt1', 'attempt2'],
|
||||
messages: [
|
||||
{ role: 'user', content: 'test' },
|
||||
{ role: 'assistant', content: 'response' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
filteredCount: 1,
|
||||
id: 'test-eval-id',
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
redteam: {
|
||||
strategies: ['jailbreak'],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
jsonMock = vi.fn();
|
||||
sendMock = vi.fn();
|
||||
statusMock = vi.fn().mockReturnThis();
|
||||
|
||||
mockRes = {
|
||||
json: jsonMock,
|
||||
send: sendMock,
|
||||
status: statusMock,
|
||||
setHeader: vi.fn(),
|
||||
} as Partial<Response>;
|
||||
|
||||
mockReq = {
|
||||
params: { id: 'test-eval-id' },
|
||||
query: {},
|
||||
} as Partial<Request>;
|
||||
|
||||
// Setup Eval mock
|
||||
const getTablePageMock = vi.fn() as any;
|
||||
getTablePageMock.mockResolvedValue(mockTable);
|
||||
const mockEval = {
|
||||
config: mockConfig,
|
||||
author: 'test-author',
|
||||
version: vi.fn().mockReturnValue('1.0.0'),
|
||||
getTablePage: getTablePageMock,
|
||||
} as unknown as Eval;
|
||||
mockedEvalFindById.mockResolvedValue(mockEval);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return CSV when format=csv is specified', async () => {
|
||||
mockReq.query = { format: 'csv' };
|
||||
const mockCsvData = 'var1,var2,[openai] prompt1\nvalue1,value2,[PASS] output text';
|
||||
mockedEvalTableToCsv.mockReturnValue(mockCsvData);
|
||||
|
||||
// Simulate the route handler (simplified version)
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
const table = await eval_!.getTablePage({
|
||||
offset: 0,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
filterMode: 'all',
|
||||
searchQuery: '',
|
||||
filters: [],
|
||||
});
|
||||
|
||||
const csvData = mockedEvalTableToCsv(table, {
|
||||
isRedteam: !!eval_!.config?.redteam,
|
||||
});
|
||||
setDownloadHeaders(mockRes as Response, 'test-eval-id-results.csv', 'text/csv');
|
||||
(mockRes as Response).send(csvData);
|
||||
|
||||
expect(mockedEvalTableToCsv).toHaveBeenCalledWith(expect.anything(), expect.anything());
|
||||
expect(sendMock).toHaveBeenCalledWith(mockCsvData);
|
||||
});
|
||||
|
||||
it('should return JSON when format=json is specified', async () => {
|
||||
mockReq.query = { format: 'json' };
|
||||
const mockJsonData = { table: mockTable };
|
||||
mockedEvalTableToJson.mockReturnValue(mockJsonData);
|
||||
|
||||
// Simulate the route handler (simplified version)
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
const table = await eval_!.getTablePage({
|
||||
offset: 0,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
filterMode: 'all',
|
||||
searchQuery: '',
|
||||
filters: [],
|
||||
});
|
||||
|
||||
const jsonData = mockedEvalTableToJson(table);
|
||||
setDownloadHeaders(mockRes as Response, 'test-eval-id-results.json', 'application/json');
|
||||
(mockRes as Response).json(jsonData);
|
||||
|
||||
expect(mockedEvalTableToJson).toHaveBeenCalledWith(expect.anything());
|
||||
expect(jsonMock).toHaveBeenCalledWith(mockJsonData);
|
||||
});
|
||||
|
||||
it('should include red team conversation columns in CSV for red team evaluations', async () => {
|
||||
mockReq.query = { format: 'csv' };
|
||||
|
||||
// Mock the CSV generation to verify red team columns are included
|
||||
const expectedCsvWithRedteam =
|
||||
'var1,var2,[openai] prompt1,[openai] prompt1 - Grader Reason,[openai] prompt1 - Comment,Messages,RedteamHistory\n' +
|
||||
'value1,value2,[PASS] output text,Test passed,Good response,"[{\\"role\\":\\"user\\",\\"content\\":\\"test\\"},{\\"role\\":\\"assistant\\",\\"content\\":\\"response\\"}]","[\\"attempt1\\",\\"attempt2\\"]"';
|
||||
|
||||
mockedEvalTableToCsv.mockReturnValue(expectedCsvWithRedteam);
|
||||
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
const table = await eval_!.getTablePage({
|
||||
offset: 0,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
filterMode: 'all',
|
||||
searchQuery: '',
|
||||
filters: [],
|
||||
});
|
||||
|
||||
const csvData = mockedEvalTableToCsv(table, {
|
||||
isRedteam: !!eval_!.config?.redteam,
|
||||
});
|
||||
|
||||
expect(mockedEvalTableToCsv).toHaveBeenCalledWith(expect.anything(), expect.anything());
|
||||
expect(csvData).toContain('Messages');
|
||||
expect(csvData).toContain('RedteamHistory');
|
||||
});
|
||||
|
||||
it('should return standard table response when no format is specified', async () => {
|
||||
mockReq.query = {}; // No format specified
|
||||
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
const table = await eval_!.getTablePage({
|
||||
offset: 0,
|
||||
limit: 50, // Default limit
|
||||
filterMode: 'all',
|
||||
searchQuery: '',
|
||||
filters: [],
|
||||
});
|
||||
|
||||
// Simulate standard response
|
||||
(mockRes as Response).json({
|
||||
table,
|
||||
totalCount: table.totalCount,
|
||||
filteredCount: table.filteredCount,
|
||||
config: eval_!.config,
|
||||
author: eval_!.author,
|
||||
version: eval_!.version(),
|
||||
});
|
||||
|
||||
expect(jsonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
table: mockTable,
|
||||
totalCount: 1,
|
||||
filteredCount: 1,
|
||||
config: mockConfig,
|
||||
author: 'test-author',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
expect(sendMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle CSV export for non-redteam evaluations', async () => {
|
||||
// Setup eval without redteam config
|
||||
const nonRedteamConfig = { someConfig: 'value' };
|
||||
const getTablePageMockNoRedteam = vi.fn() as any;
|
||||
getTablePageMockNoRedteam.mockResolvedValue(mockTable);
|
||||
const mockEvalNoRedteam = {
|
||||
config: nonRedteamConfig,
|
||||
author: 'test-author',
|
||||
version: vi.fn().mockReturnValue('1.0.0'),
|
||||
getTablePage: getTablePageMockNoRedteam,
|
||||
} as unknown as Eval;
|
||||
mockedEvalFindById.mockResolvedValue(mockEvalNoRedteam);
|
||||
|
||||
mockReq.query = { format: 'csv' };
|
||||
const mockCsvData = 'var1,var2,[openai] prompt1\nvalue1,value2,[PASS] output text';
|
||||
mockedEvalTableToCsv.mockReturnValue(mockCsvData);
|
||||
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
const table = await eval_!.getTablePage({
|
||||
offset: 0,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
filterMode: 'all',
|
||||
searchQuery: '',
|
||||
filters: [],
|
||||
});
|
||||
|
||||
const csvData = mockedEvalTableToCsv(table, {
|
||||
isRedteam: !!eval_!.config?.redteam,
|
||||
});
|
||||
|
||||
expect(mockedEvalTableToCsv).toHaveBeenCalledWith(expect.anything(), expect.anything());
|
||||
// Should not contain red team columns
|
||||
expect(csvData).not.toContain('Messages');
|
||||
expect(csvData).not.toContain('RedteamHistory');
|
||||
});
|
||||
|
||||
it('should handle different red team metadata types', async () => {
|
||||
const tableWithMultipleRedteamTypes = {
|
||||
...mockTable,
|
||||
body: [
|
||||
{
|
||||
...mockTable.body[0],
|
||||
outputs: [
|
||||
{
|
||||
pass: true,
|
||||
text: 'output 1',
|
||||
metadata: {
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are helpful' },
|
||||
{ role: 'user', content: 'Hello' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: { vars: { var1: 'val3', var2: 'val4' } },
|
||||
testIdx: 1,
|
||||
vars: ['val3', 'val4'],
|
||||
outputs: [
|
||||
{
|
||||
pass: false,
|
||||
text: 'output 2',
|
||||
metadata: {
|
||||
redteamHistory: ['attempt 1', 'attempt 2', 'attempt 3'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: { vars: { var1: 'val5', var2: 'val6' } },
|
||||
testIdx: 2,
|
||||
vars: ['val5', 'val6'],
|
||||
outputs: [
|
||||
{
|
||||
pass: false,
|
||||
text: 'output 3',
|
||||
metadata: {
|
||||
redteamTreeHistory: 'root->branch1->leaf1\nroot->branch2->leaf2',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const getTablePageMockWithTypes = vi.fn() as any;
|
||||
getTablePageMockWithTypes.mockResolvedValue({
|
||||
...tableWithMultipleRedteamTypes,
|
||||
id: 'test-eval-id',
|
||||
});
|
||||
const mockEvalWithTypes = {
|
||||
config: mockConfig,
|
||||
author: 'test-author',
|
||||
version: vi.fn().mockReturnValue('1.0.0'),
|
||||
getTablePage: getTablePageMockWithTypes,
|
||||
} as unknown as Eval;
|
||||
mockedEvalFindById.mockResolvedValue(mockEvalWithTypes);
|
||||
|
||||
mockReq.query = { format: 'csv' };
|
||||
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
const table = await eval_!.getTablePage({
|
||||
offset: 0,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
filterMode: 'all',
|
||||
searchQuery: '',
|
||||
filters: [],
|
||||
});
|
||||
|
||||
mockedEvalTableToCsv(table, { isRedteam: !!eval_!.config?.redteam });
|
||||
|
||||
expect(mockedEvalTableToCsv).toHaveBeenCalledWith(expect.anything(), {
|
||||
isRedteam: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore pagination parameters for exports if limit exceeds the table page size', async () => {
|
||||
mockReq.query = {
|
||||
format: 'csv',
|
||||
limit: String(EVAL_TABLE_MAX_PAGE_SIZE + 1),
|
||||
offset: '50',
|
||||
};
|
||||
const query = EvalSchemas.Table.Query.parse(mockReq.query);
|
||||
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
|
||||
// When format is specified, should ignore pagination and get all data
|
||||
await eval_!.getTablePage({
|
||||
offset: query.format ? 0 : query.offset,
|
||||
limit: query.format ? Number.MAX_SAFE_INTEGER : query.limit,
|
||||
filterMode: query.filterMode,
|
||||
searchQuery: query.search,
|
||||
filters: query.filter,
|
||||
});
|
||||
|
||||
expect(eval_!.getTablePage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
offset: 0,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle filter parameters in exports', async () => {
|
||||
mockReq.query = {
|
||||
format: 'csv',
|
||||
filterMode: 'failures',
|
||||
search: 'error',
|
||||
filter: ['provider:openai', 'status:fail'],
|
||||
};
|
||||
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
|
||||
await eval_!.getTablePage({
|
||||
offset: 0,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
filterMode: 'failures',
|
||||
searchQuery: 'error',
|
||||
filters: ['provider:openai', 'status:fail'],
|
||||
});
|
||||
|
||||
expect(eval_!.getTablePage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filterMode: 'failures',
|
||||
searchQuery: 'error',
|
||||
filters: ['provider:openai', 'status:fail'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 404 when evaluation not found', async () => {
|
||||
mockedEvalFindById.mockResolvedValue(undefined);
|
||||
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
if (!eval_) {
|
||||
(mockRes as Response).status(404).json({ error: 'Eval not found' });
|
||||
}
|
||||
|
||||
expect(statusMock).toHaveBeenCalledWith(404);
|
||||
expect(jsonMock).toHaveBeenCalledWith({ error: 'Eval not found' });
|
||||
});
|
||||
|
||||
it('should handle empty table data in CSV export', async () => {
|
||||
const emptyTable = {
|
||||
head: {
|
||||
vars: ['var1'],
|
||||
prompts: [createCompletedPrompt('test', { provider: 'openai', label: 'test' })],
|
||||
},
|
||||
body: [],
|
||||
totalCount: 0,
|
||||
filteredCount: 0,
|
||||
};
|
||||
|
||||
const getTablePageMockEmpty = vi.fn() as any;
|
||||
getTablePageMockEmpty.mockResolvedValue({ ...emptyTable, id: 'test-eval-id' });
|
||||
const mockEvalEmpty = {
|
||||
config: mockConfig,
|
||||
author: 'test-author',
|
||||
version: vi.fn().mockReturnValue('1.0.0'),
|
||||
getTablePage: getTablePageMockEmpty,
|
||||
} as unknown as Eval;
|
||||
mockedEvalFindById.mockResolvedValue(mockEvalEmpty);
|
||||
|
||||
mockReq.query = { format: 'csv' };
|
||||
const mockCsvData = 'var1,[openai] test\n';
|
||||
mockedEvalTableToCsv.mockReturnValue(mockCsvData);
|
||||
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
const table = await eval_!.getTablePage({
|
||||
offset: 0,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
filterMode: 'all',
|
||||
searchQuery: '',
|
||||
filters: [],
|
||||
});
|
||||
|
||||
const csvData = mockedEvalTableToCsv(table, {
|
||||
isRedteam: !!eval_!.config?.redteam,
|
||||
});
|
||||
setDownloadHeaders(mockRes as Response, 'test-eval-id-results.csv', 'text/csv');
|
||||
(mockRes as Response).send(csvData);
|
||||
|
||||
expect(sendMock).toHaveBeenCalledWith(mockCsvData);
|
||||
});
|
||||
|
||||
it('should properly escape special characters in CSV', async () => {
|
||||
const tableWithSpecialChars = {
|
||||
...mockTable,
|
||||
body: [
|
||||
{
|
||||
test: { vars: { var1: 'value,with,commas', var2: 'value"with"quotes' } },
|
||||
testIdx: 0,
|
||||
vars: ['value,with,commas', 'value"with"quotes'],
|
||||
outputs: [
|
||||
{
|
||||
pass: true,
|
||||
text: 'Output\nwith\nnewlines',
|
||||
metadata: {
|
||||
messages: [{ role: 'user', content: 'Message with "quotes" and, commas' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const getTablePageMockSpecial = vi.fn() as any;
|
||||
getTablePageMockSpecial.mockResolvedValue({ ...tableWithSpecialChars, id: 'test-eval-id' });
|
||||
const mockEvalSpecial = {
|
||||
config: mockConfig,
|
||||
author: 'test-author',
|
||||
version: vi.fn().mockReturnValue('1.0.0'),
|
||||
getTablePage: getTablePageMockSpecial,
|
||||
} as unknown as Eval;
|
||||
mockedEvalFindById.mockResolvedValue(mockEvalSpecial);
|
||||
|
||||
mockReq.query = { format: 'csv' };
|
||||
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
const table = await eval_!.getTablePage({
|
||||
offset: 0,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
filterMode: 'all',
|
||||
searchQuery: '',
|
||||
filters: [],
|
||||
});
|
||||
|
||||
mockedEvalTableToCsv(table, { isRedteam: !!eval_!.config?.redteam });
|
||||
|
||||
expect(mockedEvalTableToCsv).toHaveBeenCalledWith(expect.anything(), {
|
||||
isRedteam: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle very large datasets efficiently', async () => {
|
||||
// Create a large table with many rows
|
||||
const largeBody = Array.from({ length: 10000 }, (_, i) => ({
|
||||
test: { vars: { var1: `val${i}`, var2: `val${i + 1}` } },
|
||||
testIdx: i,
|
||||
vars: [`val${i}`, `val${i + 1}`],
|
||||
outputs: [
|
||||
{
|
||||
pass: i % 2 === 0,
|
||||
text: `Output ${i}`,
|
||||
metadata: {
|
||||
messages: [{ role: 'user', content: `Message ${i}` }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const largeTable = {
|
||||
head: mockTable.head,
|
||||
body: largeBody,
|
||||
totalCount: 10000,
|
||||
filteredCount: 10000,
|
||||
};
|
||||
|
||||
const getTablePageMockLarge = vi.fn() as any;
|
||||
getTablePageMockLarge.mockResolvedValue({ ...largeTable, id: 'test-eval-id' });
|
||||
const mockEvalLarge = {
|
||||
config: mockConfig,
|
||||
author: 'test-author',
|
||||
version: vi.fn().mockReturnValue('1.0.0'),
|
||||
getTablePage: getTablePageMockLarge,
|
||||
} as unknown as Eval;
|
||||
mockedEvalFindById.mockResolvedValue(mockEvalLarge);
|
||||
|
||||
mockReq.query = { format: 'csv' };
|
||||
mockedEvalTableToCsv.mockReturnValue('large csv data');
|
||||
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
const table = await eval_!.getTablePage({
|
||||
offset: 0,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
filterMode: 'all',
|
||||
searchQuery: '',
|
||||
filters: [],
|
||||
});
|
||||
|
||||
mockedEvalTableToCsv(table, { isRedteam: !!eval_!.config?.redteam });
|
||||
|
||||
expect(mockedEvalTableToCsv).toHaveBeenCalledWith(expect.anything(), {
|
||||
isRedteam: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should set correct content-type headers for different formats', async () => {
|
||||
// Test CSV
|
||||
mockReq.query = { format: 'csv' };
|
||||
mockedEvalTableToCsv.mockReturnValue('csv data');
|
||||
|
||||
const eval_ = await Eval.findById(mockReq.params!.id as string);
|
||||
const table = await eval_!.getTablePage({
|
||||
offset: 0,
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
filterMode: 'all',
|
||||
searchQuery: '',
|
||||
filters: [],
|
||||
});
|
||||
|
||||
const csvData = mockedEvalTableToCsv(table, {
|
||||
isRedteam: !!eval_!.config?.redteam,
|
||||
});
|
||||
|
||||
// Verify CSV generation
|
||||
expect(mockedEvalTableToCsv).toHaveBeenCalled();
|
||||
expect(csvData).toBe('csv data');
|
||||
|
||||
// Test JSON
|
||||
vi.clearAllMocks();
|
||||
mockReq.query = { format: 'json' };
|
||||
mockedEvalTableToJson.mockReturnValue({ data: 'json' });
|
||||
|
||||
const jsonData = mockedEvalTableToJson(table);
|
||||
|
||||
// Verify JSON generation
|
||||
expect(mockedEvalTableToJson).toHaveBeenCalled();
|
||||
expect(jsonData).toEqual({ data: 'json' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Integration tests for GET /api/eval/:id/table with filtered metrics.
|
||||
*
|
||||
* Tests that the API route correctly:
|
||||
* 1. Detects active filters
|
||||
* 2. Calls getFilteredMetrics when appropriate
|
||||
* 3. Includes filteredMetrics in the response
|
||||
* 4. Handles errors gracefully
|
||||
*/
|
||||
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getDb } from '../../../src/database/index';
|
||||
import { runDbMigrations } from '../../../src/migrate';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
import EvalFactory from '../../factories/evalFactory';
|
||||
|
||||
describe('GET /api/eval/:id/table - Filtered Metrics Integration', () => {
|
||||
let api: ReturnType<typeof request.agent>;
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
api = request.agent(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const db = await getDb();
|
||||
await db.run('DELETE FROM eval_results');
|
||||
await db.run('DELETE FROM evals_to_datasets');
|
||||
await db.run('DELETE FROM evals_to_prompts');
|
||||
await db.run('DELETE FROM evals_to_tags');
|
||||
await db.run('DELETE FROM evals');
|
||||
|
||||
// Reset mocks
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('Filtered metrics behavior', () => {
|
||||
it('should include filteredMetrics when filters are active', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'errors' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('filteredMetrics');
|
||||
expect(response.body.filteredMetrics).not.toBeNull();
|
||||
expect(Array.isArray(response.body.filteredMetrics)).toBe(true);
|
||||
expect(response.body.filteredMetrics).toHaveLength(1); // 1 prompt
|
||||
|
||||
// Verify structure
|
||||
expect(response.body.filteredMetrics[0]).toMatchObject({
|
||||
score: expect.any(Number),
|
||||
testPassCount: expect.any(Number),
|
||||
testFailCount: expect.any(Number),
|
||||
testErrorCount: expect.any(Number),
|
||||
assertPassCount: expect.any(Number),
|
||||
assertFailCount: expect.any(Number),
|
||||
totalLatencyMs: expect.any(Number),
|
||||
tokenUsage: expect.any(Object),
|
||||
namedScores: expect.any(Object),
|
||||
namedScoresCount: expect.any(Object),
|
||||
cost: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT include filteredMetrics when NO filters are active', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'all' }); // No filters
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.filteredMetrics).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filter detection', () => {
|
||||
it('should detect filterMode as active filter', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'passes' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.filteredMetrics).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should detect searchQuery as active filter', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
searchableContent: 'searchable',
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ search: 'searchable' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.filteredMetrics).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should detect custom filters as active', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
withNamedScores: true,
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/eval/${eval_.id}/table`).query({
|
||||
filter: JSON.stringify({
|
||||
logicOperator: 'and',
|
||||
type: 'metric',
|
||||
operator: 'equals',
|
||||
value: 'accuracy',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.filteredMetrics).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should detect multiple filters as active', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
withNamedScores: true,
|
||||
searchableContent: 'searchable',
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/eval/${eval_.id}/table`).query({
|
||||
filterMode: 'failures',
|
||||
search: 'searchable',
|
||||
filter: JSON.stringify({
|
||||
logicOperator: 'and',
|
||||
type: 'metric',
|
||||
operator: 'equals',
|
||||
value: 'accuracy',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.filteredMetrics).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metrics correctness', () => {
|
||||
it('should return correct metrics for error filter', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 15,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'errors' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.filteredMetrics).not.toBeNull();
|
||||
|
||||
const metrics = response.body.filteredMetrics[0];
|
||||
expect(metrics.testErrorCount).toBeGreaterThan(0);
|
||||
expect(metrics.testPassCount).toBe(0);
|
||||
expect(metrics.testFailCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct metrics for failure filter', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 15,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
const response = await api
|
||||
.get(`/api/eval/${eval_.id}/table`)
|
||||
.query({ filterMode: 'failures' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.filteredMetrics).not.toBeNull();
|
||||
|
||||
const metrics = response.body.filteredMetrics[0];
|
||||
expect(metrics.testFailCount).toBeGreaterThan(0);
|
||||
expect(metrics.testPassCount).toBe(0);
|
||||
expect(metrics.testErrorCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should return correct metrics for pass filter', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 15,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'passes' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.filteredMetrics).not.toBeNull();
|
||||
|
||||
const metrics = response.body.filteredMetrics[0];
|
||||
expect(metrics.testPassCount).toBeGreaterThan(0);
|
||||
expect(metrics.testFailCount).toBe(0);
|
||||
expect(metrics.testErrorCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should return metrics with named scores when present', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'failure'],
|
||||
withNamedScores: true,
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'passes' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.filteredMetrics).not.toBeNull();
|
||||
|
||||
const metrics = response.body.filteredMetrics[0];
|
||||
expect(metrics.namedScores).toHaveProperty('accuracy');
|
||||
expect(metrics.namedScores).toHaveProperty('relevance');
|
||||
expect(metrics.namedScoresCount).toHaveProperty('accuracy');
|
||||
expect(metrics.namedScoresCount).toHaveProperty('relevance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle nonexistent eval gracefully', async () => {
|
||||
const response = await api
|
||||
.get('/api/eval/nonexistent-id/table')
|
||||
.query({ filterMode: 'errors' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return empty metrics when no results match filter', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
// Delete all results to test empty dataset handling
|
||||
const db = await getDb();
|
||||
await db.run(sql`DELETE FROM eval_results WHERE eval_id = ${eval_.id}`);
|
||||
|
||||
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'errors' });
|
||||
|
||||
// Request should still succeed
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('table');
|
||||
|
||||
// filteredMetrics should be empty array (not null) for zero results
|
||||
expect(response.body.filteredMetrics).not.toBeNull();
|
||||
expect(Array.isArray(response.body.filteredMetrics)).toBe(true);
|
||||
expect(response.body.filteredMetrics[0]).toMatchObject({
|
||||
score: 0,
|
||||
testPassCount: 0,
|
||||
testFailCount: 0,
|
||||
testErrorCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate filteredMetrics array length matches prompts array length', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'errors' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// If filteredMetrics is not null, it must have the same length as prompts
|
||||
if (response.body.filteredMetrics !== null) {
|
||||
expect(response.body.filteredMetrics.length).toBe(response.body.table.head.prompts.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response structure', () => {
|
||||
it('should include all required fields in response', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/eval/${eval_.id}/table`).query({ filterMode: 'errors' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
table: expect.any(Object),
|
||||
totalCount: expect.any(Number),
|
||||
filteredCount: expect.any(Number),
|
||||
filteredMetrics: expect.any(Array),
|
||||
config: expect.any(Object),
|
||||
author: null, // or expect.any(String) if author is set
|
||||
version: expect.any(Number),
|
||||
id: eval_.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing behavior for export formats', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
// CSV export should not include filteredMetrics
|
||||
const csvResponse = await api
|
||||
.get(`/api/eval/${eval_.id}/table`)
|
||||
.query({ format: 'csv', filterMode: 'errors' });
|
||||
|
||||
expect(csvResponse.status).toBe(200);
|
||||
expect(csvResponse.headers['content-type']).toContain('text/csv');
|
||||
expect(typeof csvResponse.text).toBe('string');
|
||||
|
||||
// JSON export should not include filteredMetrics (returns table object, not array)
|
||||
const jsonResponse = await api
|
||||
.get(`/api/eval/${eval_.id}/table`)
|
||||
.query({ format: 'json', filterMode: 'errors' });
|
||||
|
||||
expect(jsonResponse.status).toBe(200);
|
||||
expect(jsonResponse.headers['content-type']).toContain('application/json');
|
||||
// JSON export returns table structure { head, body }, not full EvalTableDTO
|
||||
expect(jsonResponse.body).toHaveProperty('head');
|
||||
expect(jsonResponse.body).toHaveProperty('body');
|
||||
expect(jsonResponse.body).not.toHaveProperty('filteredMetrics');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pagination interaction', () => {
|
||||
it('should calculate metrics for entire filtered dataset, not just current page', async () => {
|
||||
const eval_ = await EvalFactory.create({
|
||||
numResults: 100,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
// Request first page (limited results)
|
||||
const response = await api
|
||||
.get(`/api/eval/${eval_.id}/table`)
|
||||
.query({ filterMode: 'passes', limit: 10, offset: 0 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.table.body).toHaveLength(10); // Paginated results
|
||||
|
||||
// But filteredMetrics should cover ALL passes, not just the 10 on this page
|
||||
const metrics = response.body.filteredMetrics[0];
|
||||
expect(metrics.testPassCount).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Comparison mode', () => {
|
||||
it('should include filteredMetrics for base eval even when comparison evals are present', async () => {
|
||||
const eval1 = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
const eval2 = await EvalFactory.create({
|
||||
numResults: 10,
|
||||
resultTypes: ['success', 'error', 'failure'],
|
||||
});
|
||||
|
||||
const response = await api.get(`/api/eval/${eval1.id}/table`).query({
|
||||
filterMode: 'passes',
|
||||
comparisonEvalIds: eval2.id,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// filteredMetrics should be calculated for the base eval (eval1)
|
||||
// even when comparison evals are included in the table
|
||||
expect(response.body).toHaveProperty('filteredMetrics');
|
||||
expect(response.body.filteredMetrics).not.toBeNull();
|
||||
expect(Array.isArray(response.body.filteredMetrics)).toBe(true);
|
||||
|
||||
// filteredMetrics should match the base eval's prompt count (1), not the combined count (2)
|
||||
expect(response.body.filteredMetrics).toHaveLength(1);
|
||||
|
||||
// Verify the table includes both base and comparison prompts
|
||||
expect(response.body.table.head.prompts.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies BEFORE imports
|
||||
vi.mock('../../../src/node', () => ({
|
||||
evaluateWithSource: vi.fn().mockResolvedValue({
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({ results: [] }),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/util/sharing', () => ({
|
||||
shouldShareResults: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/models/eval', () => ({
|
||||
default: {
|
||||
create: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/globalConfig/accounts');
|
||||
|
||||
import logger from '../../../src/logger';
|
||||
import Eval from '../../../src/models/eval';
|
||||
import { evaluateWithSource } from '../../../src/node';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
import { shouldShareResults } from '../../../src/util/sharing';
|
||||
|
||||
const errorSpy = vi.spyOn(logger, 'error');
|
||||
const mockedEvalCreate = vi.mocked(Eval.create);
|
||||
const mockedEvalFindById = vi.mocked(Eval.findById);
|
||||
const mockedEvaluateWithSource = vi.mocked(evaluateWithSource);
|
||||
const mockedShouldShareResults = vi.mocked(shouldShareResults);
|
||||
|
||||
describe('Eval Routes - Sharing behavior', () => {
|
||||
let api: ReturnType<typeof request.agent>;
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
api = request.agent(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
errorSpy.mockClear();
|
||||
|
||||
mockedEvaluateWithSource.mockResolvedValue({
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({ results: [] }),
|
||||
} as any);
|
||||
|
||||
mockedShouldShareResults.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const postJob = (body: Record<string, unknown>) => api.post('/api/eval/job').send(body);
|
||||
|
||||
const minimalTestSuite = {
|
||||
prompts: ['test prompt'],
|
||||
providers: ['echo'],
|
||||
tests: [{ vars: { input: 'test' } }],
|
||||
};
|
||||
|
||||
it('should use testSuite.sharing when explicitly set to true', async () => {
|
||||
await postJob({ ...minimalTestSuite, sharing: true });
|
||||
|
||||
// Wait for async evaluate call
|
||||
await vi.waitFor(() => {
|
||||
expect(mockedEvaluateWithSource).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const evaluateArg = mockedEvaluateWithSource.mock.calls[0][0] as any;
|
||||
expect(evaluateArg.sharing).toBe(true);
|
||||
});
|
||||
|
||||
it('should use testSuite.sharing when explicitly set to false', async () => {
|
||||
mockedShouldShareResults.mockReturnValue(true);
|
||||
|
||||
await postJob({ ...minimalTestSuite, sharing: false });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockedEvaluateWithSource).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const evaluateArg = mockedEvaluateWithSource.mock.calls[0][0] as any;
|
||||
expect(evaluateArg.sharing).toBe(false);
|
||||
});
|
||||
|
||||
it('should fall back to shouldShareResults when testSuite.sharing is undefined', async () => {
|
||||
mockedShouldShareResults.mockReturnValue(true);
|
||||
|
||||
await postJob(minimalTestSuite);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockedEvaluateWithSource).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const evaluateArg = mockedEvaluateWithSource.mock.calls[0][0] as any;
|
||||
expect(evaluateArg.sharing).toBe(true);
|
||||
expect(mockedShouldShareResults).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('does not publish completion before saved results are ready', async () => {
|
||||
let resolveSummary: ((summary: { results: never[] }) => void) | undefined;
|
||||
mockedEvaluateWithSource.mockResolvedValueOnce({
|
||||
id: 'eval-result-id',
|
||||
toEvaluateSummary: vi.fn(
|
||||
() =>
|
||||
new Promise<{ results: never[] }>((resolve) => {
|
||||
resolveSummary = resolve;
|
||||
}),
|
||||
),
|
||||
} as any);
|
||||
|
||||
const createResponse = await postJob(minimalTestSuite);
|
||||
const jobId = createResponse.body.id;
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(resolveSummary).toBeDefined();
|
||||
});
|
||||
|
||||
const inProgressResponse = await api.get(`/api/eval/job/${jobId}`);
|
||||
expect(inProgressResponse.body.status).toBe('in-progress');
|
||||
expect(inProgressResponse.body.evalId).toBeUndefined();
|
||||
|
||||
resolveSummary!({ results: [] });
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const completedResponse = await api.get(`/api/eval/job/${jobId}`);
|
||||
expect(completedResponse.body).toMatchObject({
|
||||
status: 'complete',
|
||||
evalId: 'eval-result-id',
|
||||
result: { results: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes progress updates while a job is running', async () => {
|
||||
let resolveEvaluation: ((value: any) => void) | undefined;
|
||||
mockedEvaluateWithSource.mockImplementationOnce(
|
||||
(_testSuite, options) =>
|
||||
new Promise((resolve) => {
|
||||
options?.progressCallback?.(2, 5, 0, {} as never, {} as never);
|
||||
resolveEvaluation = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const createResponse = await postJob(minimalTestSuite);
|
||||
const jobId = createResponse.body.id;
|
||||
|
||||
const inProgressResponse = await api.get(`/api/eval/job/${jobId}`);
|
||||
expect(inProgressResponse.body).toMatchObject({
|
||||
status: 'in-progress',
|
||||
progress: 2,
|
||||
total: 5,
|
||||
});
|
||||
|
||||
resolveEvaluation!({
|
||||
id: 'eval-result-id',
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue({ results: [] }),
|
||||
});
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const completedResponse = await api.get(`/api/eval/job/${jobId}`);
|
||||
expect(completedResponse.body.status).toBe('complete');
|
||||
});
|
||||
});
|
||||
|
||||
it('flips status to error when toEvaluateSummary rejects', async () => {
|
||||
mockedEvaluateWithSource.mockResolvedValueOnce({
|
||||
id: 'eval-result-id',
|
||||
toEvaluateSummary: vi.fn().mockRejectedValue(new Error('summary boom')),
|
||||
} as any);
|
||||
|
||||
const createResponse = await postJob(minimalTestSuite);
|
||||
const jobId = createResponse.body.id;
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const response = await api.get(`/api/eval/job/${jobId}`);
|
||||
expect(response.body.status).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not log the raw job body on evaluation failure', async () => {
|
||||
mockedEvaluateWithSource.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
const sensitiveBody = {
|
||||
prompts: ['test prompt'],
|
||||
providers: ['echo'],
|
||||
tests: [{ vars: { input: 'secret value', apiKey: 'sk-test-12345678901234567890' } }],
|
||||
};
|
||||
|
||||
await postJob(sensitiveBody);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to eval tests',
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
prompts: ['test prompt'],
|
||||
providers: ['echo'],
|
||||
tests: [
|
||||
expect.objectContaining({
|
||||
vars: expect.objectContaining({ apiKey: '[REDACTED]' }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('sk-test-12345678901234567890');
|
||||
});
|
||||
|
||||
it('restores redacted Azure SAS tokens before rerunning a stored eval', async () => {
|
||||
const sasUri = 'az://account/container/tests.yaml?sp=r&sig=azure-secret';
|
||||
mockedEvalFindById.mockResolvedValueOnce({ config: { tests: sasUri } } as never);
|
||||
|
||||
await postJob({
|
||||
...minimalTestSuite,
|
||||
tests: 'az://account/container/tests.yaml?sp=r&sig=%5BREDACTED%5D',
|
||||
sourceEvalId: 'source-eval-id',
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockedEvaluateWithSource).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const evaluateArg = mockedEvaluateWithSource.mock.calls[0][0] as any;
|
||||
expect(evaluateArg.tests).toBe(sasUri);
|
||||
});
|
||||
|
||||
it('should not log the raw save body on database failure', async () => {
|
||||
mockedEvalCreate.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
const secret = 'sk-test-12345678901234567890';
|
||||
const response = await api.post('/api/eval').send({
|
||||
config: {
|
||||
providers: [{ id: 'openai:gpt-4o', config: { apiKey: secret } }],
|
||||
},
|
||||
prompts: [{ raw: 'test prompt', label: 'test prompt' }],
|
||||
results: [{ promptIdx: 0, testIdx: 0, success: true, score: 1 }],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Failed to write eval to database',
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providers: [
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({ apiKey: '[REDACTED]' }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(secret);
|
||||
});
|
||||
|
||||
it('should return false from shouldShareResults when cloud is disabled', async () => {
|
||||
mockedShouldShareResults.mockReturnValue(false);
|
||||
|
||||
await postJob(minimalTestSuite);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mockedEvaluateWithSource).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const evaluateArg = mockedEvaluateWithSource.mock.calls[0][0] as any;
|
||||
expect(evaluateArg.sharing).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { getDb } from '../../../src/database/index';
|
||||
import { runDbMigrations } from '../../../src/migrate';
|
||||
import EvalResult from '../../../src/models/evalResult';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
import { createEvaluateResult } from '../../factories/eval';
|
||||
import EvalFactory from '../../factories/evalFactory';
|
||||
|
||||
describe('Eval Routes - Trace linkage persistence', () => {
|
||||
let api: ReturnType<typeof request.agent>;
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
await runDbMigrations();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
api = request.agent(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const db = await getDb();
|
||||
await db.run('DELETE FROM eval_results');
|
||||
await db.run('DELETE FROM evals_to_datasets');
|
||||
await db.run('DELETE FROM evals_to_prompts');
|
||||
await db.run('DELETE FROM evals_to_tags');
|
||||
await db.run('DELETE FROM evals');
|
||||
});
|
||||
|
||||
it('keeps trace linkage when v4 eval saves include traced rows', async () => {
|
||||
const tracedResult = createEvaluateResult({
|
||||
traceId: 'route-create-trace-id',
|
||||
evaluationId: 'route-create-evaluation-id',
|
||||
metadata: { source: 'route-create' },
|
||||
});
|
||||
|
||||
const response = await api.post('/api/eval').send({
|
||||
config: { description: 'Trace linkage route coverage' },
|
||||
prompts: [],
|
||||
results: [tracedResult],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const [persistedResult] = await EvalResult.findManyByEvalId(response.body.id);
|
||||
expect(persistedResult.toEvaluateResult()).toMatchObject({
|
||||
traceId: 'route-create-trace-id',
|
||||
evaluationId: 'route-create-evaluation-id',
|
||||
metadata: { source: 'route-create' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps trace linkage when appended results arrive through POST /api/eval/:id/results', async () => {
|
||||
const eval_ = await EvalFactory.create({ numResults: 0 });
|
||||
const tracedResult = createEvaluateResult({
|
||||
traceId: 'route-append-trace-id',
|
||||
evaluationId: 'route-append-evaluation-id',
|
||||
metadata: { source: 'route-append' },
|
||||
});
|
||||
|
||||
const response = await api.post(`/api/eval/${eval_.id}/results`).send([
|
||||
{
|
||||
id: 'route-append-trace-result',
|
||||
evalId: eval_.id,
|
||||
...tracedResult,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
|
||||
const [persistedResult] = await EvalResult.findManyByEvalId(eval_.id);
|
||||
expect(persistedResult.toEvaluateResult()).toMatchObject({
|
||||
traceId: 'route-append-trace-id',
|
||||
evaluationId: 'route-append-evaluation-id',
|
||||
metadata: { source: 'route-append' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies BEFORE imports
|
||||
vi.mock('../../../src/models/eval');
|
||||
vi.mock('../../../src/globalConfig/accounts');
|
||||
|
||||
import Eval, { EvalQueries } from '../../../src/models/eval';
|
||||
// Import after mocking
|
||||
import { createApp } from '../../../src/server/server';
|
||||
import { EVAL_TABLE_MAX_PAGE_SIZE } from '../../../src/types/api/eval';
|
||||
|
||||
const mockedEval = vi.mocked(Eval);
|
||||
const mockedEvalQueries = vi.mocked(EvalQueries);
|
||||
|
||||
describe('Eval Routes - Zod Validation', () => {
|
||||
let api: ReturnType<typeof request.agent>;
|
||||
let server: Server;
|
||||
let mockFindById: ReturnType<typeof vi.fn>;
|
||||
let mockSave: ReturnType<typeof vi.fn>;
|
||||
let mockGetMetadataKeysFromEval: ReturnType<typeof vi.fn>;
|
||||
let mockGetMetadataValuesFromEval: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
api = request.agent(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Setup mock methods
|
||||
mockFindById = vi.fn();
|
||||
mockSave = vi.fn();
|
||||
mockGetMetadataKeysFromEval = vi.fn();
|
||||
mockGetMetadataValuesFromEval = vi.fn();
|
||||
|
||||
// Mock Eval.findById
|
||||
mockedEval.findById = mockFindById as any;
|
||||
|
||||
// Mock EvalQueries methods
|
||||
mockedEvalQueries.getMetadataKeysFromEval = mockGetMetadataKeysFromEval as any;
|
||||
mockedEvalQueries.getMetadataValuesFromEval = mockGetMetadataValuesFromEval as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('PATCH /api/eval/:id/author', () => {
|
||||
it('should return 400 when body is empty', async () => {
|
||||
const response = await api.patch('/api/eval/test-id/author').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain('author');
|
||||
});
|
||||
|
||||
it('should return 400 when author is not a string', async () => {
|
||||
const response = await api.patch('/api/eval/test-id/author').send({
|
||||
author: 123,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain('author');
|
||||
});
|
||||
|
||||
it('should return 200 when author is valid', async () => {
|
||||
const mockEval = {
|
||||
id: 'test-id',
|
||||
author: 'old@example.com',
|
||||
save: mockSave,
|
||||
};
|
||||
|
||||
mockFindById.mockResolvedValue(mockEval);
|
||||
mockSave.mockResolvedValue(undefined);
|
||||
|
||||
const response = await api.patch('/api/eval/test-id/author').send({
|
||||
author: 'new@example.com',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe('Author updated successfully');
|
||||
expect(mockEval.author).toBe('new@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/eval/:id/metadata-values', () => {
|
||||
it('should return 400 when key query param is missing', async () => {
|
||||
const response = await api.get('/api/eval/test-id/metadata-values');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain('key');
|
||||
});
|
||||
|
||||
it('should return 200 when key query param is provided', async () => {
|
||||
const mockEval = {
|
||||
id: 'test-id',
|
||||
};
|
||||
|
||||
mockFindById.mockResolvedValue(mockEval);
|
||||
mockGetMetadataValuesFromEval.mockReturnValue(['value1', 'value2']);
|
||||
|
||||
const response = await api.get('/api/eval/test-id/metadata-values?key=testKey');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.values).toEqual(['value1', 'value2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/eval/:id/metadata-keys', () => {
|
||||
it('should return 400 when id param is too short', async () => {
|
||||
const response = await api.get('/api/eval/ab/metadata-keys');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain('id');
|
||||
});
|
||||
|
||||
it('should return 200 with valid request', async () => {
|
||||
const mockEval = {
|
||||
id: 'test-id',
|
||||
};
|
||||
|
||||
mockFindById.mockResolvedValue(mockEval);
|
||||
mockGetMetadataKeysFromEval.mockResolvedValue(['key1', 'key2']);
|
||||
|
||||
const response = await api.get('/api/eval/test-id/metadata-keys');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.keys).toEqual(['key1', 'key2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/eval/:id/table', () => {
|
||||
it.each([
|
||||
['format', { format: 'xml' }],
|
||||
['limit', { limit: '1.5' }],
|
||||
['limit', { limit: String(EVAL_TABLE_MAX_PAGE_SIZE + 1) }],
|
||||
['offset', { offset: '1.5' }],
|
||||
])('should return 400 if %s query param is invalid', async (field, query) => {
|
||||
const response = await api.get('/api/eval/test-id/table').query(query);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain(field);
|
||||
expect(mockFindById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/eval/:id/copy', () => {
|
||||
it('should return 404 when id param is whitespace (route does not match)', async () => {
|
||||
const response = await api.post('/api/eval/ /copy').send({});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 201 with valid request', async () => {
|
||||
const mockCopy = vi.fn();
|
||||
const mockGetResultsCount = vi.fn();
|
||||
const mockEval = {
|
||||
id: 'test-id',
|
||||
copy: mockCopy,
|
||||
getResultsCount: mockGetResultsCount,
|
||||
};
|
||||
|
||||
const copiedEval = {
|
||||
id: 'copied-id',
|
||||
};
|
||||
|
||||
mockFindById.mockResolvedValue(mockEval);
|
||||
mockGetResultsCount.mockResolvedValue(10);
|
||||
mockCopy.mockResolvedValue(copiedEval);
|
||||
|
||||
const response = await api.post('/api/eval/test-id/copy').send({
|
||||
description: 'Test copy',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.id).toBe('copied-id');
|
||||
expect(response.body.distinctTestCount).toBe(10);
|
||||
});
|
||||
|
||||
it('should return 400 when description is not a string', async () => {
|
||||
const response = await api.post('/api/eval/test-id/copy').send({
|
||||
description: 123,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain('description');
|
||||
});
|
||||
|
||||
it('should return 201 when description is omitted (optional field)', async () => {
|
||||
const mockCopy = vi.fn();
|
||||
const mockGetResultsCount = vi.fn();
|
||||
const mockEval = {
|
||||
id: 'test-id',
|
||||
copy: mockCopy,
|
||||
getResultsCount: mockGetResultsCount,
|
||||
};
|
||||
|
||||
const copiedEval = {
|
||||
id: 'copied-id',
|
||||
};
|
||||
|
||||
mockFindById.mockResolvedValue(mockEval);
|
||||
mockGetResultsCount.mockResolvedValue(5);
|
||||
mockCopy.mockResolvedValue(copiedEval);
|
||||
|
||||
const response = await api.post('/api/eval/test-id/copy').send({});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.id).toBe('copied-id');
|
||||
expect(mockCopy).toHaveBeenCalledWith(undefined, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/eval/:id', () => {
|
||||
it('should accept empty body (both fields are optional)', async () => {
|
||||
// The schema allows empty body since both table and config are optional
|
||||
const response = await api.patch('/api/eval/test-id').send({});
|
||||
|
||||
// This is a valid request - validation passes, but updateResult may fail
|
||||
// We're only testing validation behavior here
|
||||
expect(response.status).not.toBe(400);
|
||||
});
|
||||
|
||||
it('should return 400 when table has invalid structure', async () => {
|
||||
const response = await api.patch('/api/eval/test-id').send({
|
||||
table: {
|
||||
head: 'invalid', // Should be an object
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 500 when the eval update write fails', async () => {
|
||||
const mockEval = {
|
||||
id: 'test-id',
|
||||
config: {},
|
||||
save: mockSave,
|
||||
setTable: vi.fn(),
|
||||
};
|
||||
|
||||
mockFindById.mockResolvedValue(mockEval);
|
||||
mockSave.mockRejectedValue(new Error('database write failed'));
|
||||
|
||||
const response = await api.patch('/api/eval/test-id').send({
|
||||
config: { description: 'Updated description' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to update eval table' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/eval', () => {
|
||||
it('should return 400 when ids array is empty', async () => {
|
||||
const response = await api.delete('/api/eval').send({
|
||||
ids: [],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain('ids');
|
||||
});
|
||||
|
||||
it('should return 400 when ids is missing', async () => {
|
||||
const response = await api.delete('/api/eval').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain('ids');
|
||||
});
|
||||
|
||||
it('should return 400 when ids contains empty string', async () => {
|
||||
const response = await api.delete('/api/eval').send({
|
||||
ids: ['valid-id', ''],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/eval/replay', () => {
|
||||
it('should return 400 when evaluationId is missing', async () => {
|
||||
const response = await api.post('/api/eval/replay').send({
|
||||
prompt: 'test prompt',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain('evaluationId');
|
||||
});
|
||||
|
||||
it('should return 400 when prompt is missing', async () => {
|
||||
const response = await api.post('/api/eval/replay').send({
|
||||
evaluationId: 'test-id',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain('prompt');
|
||||
});
|
||||
|
||||
it('should return 400 when prompt is empty string', async () => {
|
||||
const response = await api.post('/api/eval/replay').send({
|
||||
evaluationId: 'test-id',
|
||||
prompt: '',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain('prompt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/eval/:id/results', () => {
|
||||
it('should return 400 when results is not an array', async () => {
|
||||
const response = await api.post('/api/eval/test-id/results').send({
|
||||
results: 'not an array',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 400 when result is missing required fields', async () => {
|
||||
const response = await api.post('/api/eval/test-id/results').send([
|
||||
{
|
||||
promptIdx: 0,
|
||||
// Missing testIdx, success, score
|
||||
},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 400 when promptIdx is negative', async () => {
|
||||
const response = await api.post('/api/eval/test-id/results').send([
|
||||
{
|
||||
promptIdx: -1,
|
||||
testIdx: 0,
|
||||
success: true,
|
||||
score: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain('promptIdx');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/eval/job/:id', () => {
|
||||
it('should return 400 when id is not a valid UUID', async () => {
|
||||
const response = await api.get('/api/eval/job/not-a-uuid');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
expect(response.body.error).toContain('id');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
|
||||
import type { MediaStorageProvider } from '../../../src/storage/types';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../src/storage');
|
||||
|
||||
// Import after mocking
|
||||
import { getMediaStorage, mediaExists, retrieveMedia } from '../../../src/storage';
|
||||
|
||||
const mockedGetMediaStorage = vi.mocked(getMediaStorage);
|
||||
const mockedMediaExists = vi.mocked(mediaExists);
|
||||
const mockedRetrieveMedia = vi.mocked(retrieveMedia);
|
||||
|
||||
describe('Media Routes', () => {
|
||||
let api: ReturnType<typeof request.agent>;
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
api = request.agent(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/media/stats', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return success with providerId when storage has no stats', async () => {
|
||||
const mockStorage = {
|
||||
providerId: 'local-fs',
|
||||
} as MediaStorageProvider;
|
||||
|
||||
mockedGetMediaStorage.mockReturnValue(mockStorage);
|
||||
|
||||
const response = await api.get('/api/media/stats');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
providerId: 'local-fs',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return success with providerId and stats when storage has getStats', async () => {
|
||||
const mockStorage = {
|
||||
providerId: 'local-fs',
|
||||
getStats: vi.fn().mockResolvedValue({
|
||||
totalFiles: 42,
|
||||
totalSize: 1024000,
|
||||
}),
|
||||
} as unknown as MediaStorageProvider;
|
||||
|
||||
mockedGetMediaStorage.mockReturnValue(mockStorage);
|
||||
|
||||
const response = await api.get('/api/media/stats');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
providerId: 'local-fs',
|
||||
totalFiles: 42,
|
||||
totalSize: 1024000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 500 when getMediaStorage throws error', async () => {
|
||||
mockedGetMediaStorage.mockImplementation(() => {
|
||||
throw new Error('Storage initialization failed');
|
||||
});
|
||||
|
||||
const response = await api.get('/api/media/stats');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to get storage stats',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/media/info/:type/:filename', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return 400 for invalid type', async () => {
|
||||
const response = await api.get('/api/media/info/document/abcdef123456.pdf');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toContain('type');
|
||||
});
|
||||
|
||||
it('should return 400 when path segments resolve to invalid type and filename', async () => {
|
||||
// Express resolves ../../ before routing, so params become { type: 'etc', filename: 'passwd' }
|
||||
const response = await api.get('/api/media/info/audio/../../etc/passwd');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toContain('filename');
|
||||
});
|
||||
|
||||
it('should return 400 for filename without hex prefix', async () => {
|
||||
const response = await api.get('/api/media/info/audio/malicious.mp3');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toContain('filename');
|
||||
});
|
||||
|
||||
it('should return 400 for filename with wrong hex length', async () => {
|
||||
const response = await api.get('/api/media/info/audio/abc123.mp3');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toContain('filename');
|
||||
});
|
||||
|
||||
it('should return 404 when media file does not exist', async () => {
|
||||
mockedMediaExists.mockResolvedValue(false);
|
||||
|
||||
const response = await api.get('/api/media/info/audio/abcdef123456.mp3');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Media not found',
|
||||
});
|
||||
expect(mockedMediaExists).toHaveBeenCalledWith('audio/abcdef123456.mp3');
|
||||
});
|
||||
|
||||
it('should return info when media file exists', async () => {
|
||||
const mockStorage = {
|
||||
providerId: 'local-fs',
|
||||
getUrl: vi.fn().mockResolvedValue('/media/audio/abcdef123456.mp3'),
|
||||
} as unknown as MediaStorageProvider;
|
||||
|
||||
mockedMediaExists.mockResolvedValue(true);
|
||||
mockedGetMediaStorage.mockReturnValue(mockStorage);
|
||||
|
||||
const response = await api.get('/api/media/info/audio/abcdef123456.mp3');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
key: 'audio/abcdef123456.mp3',
|
||||
exists: true,
|
||||
url: '/media/audio/abcdef123456.mp3',
|
||||
},
|
||||
});
|
||||
expect(mockedMediaExists).toHaveBeenCalledWith('audio/abcdef123456.mp3');
|
||||
expect(mockStorage.getUrl).toHaveBeenCalledWith('audio/abcdef123456.mp3');
|
||||
});
|
||||
|
||||
it('should return info when URL generation is unsupported', async () => {
|
||||
const mockStorage = {
|
||||
providerId: 'custom-storage',
|
||||
getUrl: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as MediaStorageProvider;
|
||||
|
||||
mockedMediaExists.mockResolvedValue(true);
|
||||
mockedGetMediaStorage.mockReturnValue(mockStorage);
|
||||
|
||||
const response = await api.get('/api/media/info/audio/abcdef123456.mp3');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
key: 'audio/abcdef123456.mp3',
|
||||
exists: true,
|
||||
url: null,
|
||||
},
|
||||
});
|
||||
expect(mockedMediaExists).toHaveBeenCalledWith('audio/abcdef123456.mp3');
|
||||
expect(mockStorage.getUrl).toHaveBeenCalledWith('audio/abcdef123456.mp3');
|
||||
});
|
||||
|
||||
it.each(['video', 'image'] as const)('should accept valid %s type', async (type) => {
|
||||
const ext = type === 'video' ? 'mp4' : 'png';
|
||||
const mockStorage = {
|
||||
providerId: 'local-fs',
|
||||
getUrl: vi.fn().mockResolvedValue(`/media/${type}/abcdef123456.${ext}`),
|
||||
} as unknown as MediaStorageProvider;
|
||||
|
||||
mockedMediaExists.mockResolvedValue(true);
|
||||
mockedGetMediaStorage.mockReturnValue(mockStorage);
|
||||
|
||||
const response = await api.get(`/api/media/info/${type}/abcdef123456.${ext}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data.key).toBe(`${type}/abcdef123456.${ext}`);
|
||||
});
|
||||
|
||||
it('should return 500 when mediaExists throws error', async () => {
|
||||
mockedMediaExists.mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
const response = await api.get('/api/media/info/audio/abcdef123456.mp3');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to get media info',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/media/:type/:filename', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return 400 for invalid type', async () => {
|
||||
const response = await api.get('/api/media/text/abcdef123456.txt');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toContain('type');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid filename', async () => {
|
||||
// Filename must be exactly 12 hex characters + extension
|
||||
const response = await api.get('/api/media/audio/invalid-name.mp3');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
expect(response.body.error).toContain('filename');
|
||||
});
|
||||
|
||||
it('should return 404 when media not found', async () => {
|
||||
mockedMediaExists.mockResolvedValue(false);
|
||||
|
||||
const response = await api.get('/api/media/audio/abcdef123456.mp3');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Media not found',
|
||||
});
|
||||
expect(mockedMediaExists).toHaveBeenCalledWith('audio/abcdef123456.mp3');
|
||||
});
|
||||
|
||||
it('should serve audio file with correct headers', async () => {
|
||||
const mockData = Buffer.from('fake wav data');
|
||||
|
||||
mockedMediaExists.mockResolvedValue(true);
|
||||
mockedRetrieveMedia.mockResolvedValue(mockData);
|
||||
|
||||
const response = await api.get('/api/media/audio/abcdef123456.wav');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toBe('audio/wav');
|
||||
expect(response.headers['content-length']).toBe(String(mockData.length));
|
||||
expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable');
|
||||
expect(response.body).toEqual(mockData);
|
||||
expect(mockedRetrieveMedia).toHaveBeenCalledWith('audio/abcdef123456.wav');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['audio', 'mp3', 'audio/mpeg'],
|
||||
['audio', 'ogg', 'audio/ogg'],
|
||||
['audio', 'webm', 'audio/webm'],
|
||||
['image', 'png', 'image/png'],
|
||||
['image', 'jpg', 'image/jpeg'],
|
||||
['image', 'jpeg', 'image/jpeg'],
|
||||
['image', 'gif', 'image/gif'],
|
||||
['image', 'webp', 'image/webp'],
|
||||
['video', 'mp4', 'video/mp4'],
|
||||
['video', 'ogv', 'video/ogg'],
|
||||
])('should serve %s/%s with content-type %s', async (type, ext, expectedContentType) => {
|
||||
const mockData = Buffer.from(`fake ${ext} data`);
|
||||
|
||||
mockedMediaExists.mockResolvedValue(true);
|
||||
mockedRetrieveMedia.mockResolvedValue(mockData);
|
||||
|
||||
const response = await api.get(`/api/media/${type}/abcdef123456.${ext}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toBe(expectedContentType);
|
||||
});
|
||||
|
||||
it('should use application/octet-stream for unknown extension', async () => {
|
||||
const mockData = Buffer.from('unknown data');
|
||||
|
||||
mockedMediaExists.mockResolvedValue(true);
|
||||
mockedRetrieveMedia.mockResolvedValue(mockData);
|
||||
|
||||
const response = await api.get('/api/media/audio/abcdef123456.xyz');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['content-type']).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('should return 500 when retrieveMedia throws error', async () => {
|
||||
mockedMediaExists.mockResolvedValue(true);
|
||||
mockedRetrieveMedia.mockRejectedValue(new Error('Disk read error'));
|
||||
|
||||
const response = await api.get('/api/media/audio/abcdef123456.mp3');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to serve media',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ABCDEF123456', 'uppercase'],
|
||||
['AbCdEf123456', 'mixed case'],
|
||||
])('should accept %s hex characters in filename (%s)', async (hex) => {
|
||||
const mockData = Buffer.from('fake data');
|
||||
|
||||
mockedMediaExists.mockResolvedValue(true);
|
||||
mockedRetrieveMedia.mockResolvedValue(mockData);
|
||||
|
||||
const response = await api.get(`/api/media/audio/${hex}.mp3`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedRetrieveMedia).toHaveBeenCalledWith(`audio/${hex}.mp3`);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,716 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
|
||||
import type { ProviderTestResult } from '../../../src/node/testProvider';
|
||||
import type { ApiProvider, ProviderOptions } from '../../../src/types/providers';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../src/providers/index');
|
||||
vi.mock('../../../src/node/testProvider');
|
||||
vi.mock('../../../src/server/config/serverConfig');
|
||||
vi.mock('../../../src/redteam/commands/discover');
|
||||
vi.mock('../../../src/redteam/remoteGeneration');
|
||||
vi.mock('../../../src/util/fetch/index');
|
||||
vi.mock('../../../src/globalConfig/cloud', () => ({
|
||||
cloudConfig: {
|
||||
isEnabled: vi.fn(),
|
||||
getApiHost: vi.fn(),
|
||||
getApiKey: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocking
|
||||
import { cloudConfig } from '../../../src/globalConfig/cloud';
|
||||
import { testProviderConnectivity, testProviderSession } from '../../../src/node/testProvider';
|
||||
import { loadApiProvider } from '../../../src/providers/index';
|
||||
import { doTargetPurposeDiscovery } from '../../../src/redteam/commands/discover';
|
||||
import { neverGenerateRemote } from '../../../src/redteam/remoteGeneration';
|
||||
import { getAvailableProviders } from '../../../src/server/config/serverConfig';
|
||||
import { fetchWithProxy } from '../../../src/util/fetch/index';
|
||||
|
||||
const mockedLoadApiProvider = vi.mocked(loadApiProvider);
|
||||
const mockedDoTargetPurposeDiscovery = vi.mocked(doTargetPurposeDiscovery);
|
||||
const mockedNeverGenerateRemote = vi.mocked(neverGenerateRemote);
|
||||
const mockedTestProviderConnectivity = vi.mocked(testProviderConnectivity);
|
||||
const mockedGetAvailableProviders = vi.mocked(getAvailableProviders);
|
||||
const mockedTestProviderSession = vi.mocked(testProviderSession);
|
||||
const mockedFetchWithProxy = vi.mocked(fetchWithProxy);
|
||||
|
||||
describe('Providers Routes', () => {
|
||||
let api: ReturnType<typeof request.agent>;
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
api = request.agent(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Default to the non-cloud path so host-dependent assertions are deterministic
|
||||
// regardless of the machine's cloud-login state.
|
||||
vi.mocked(cloudConfig.isEnabled).mockReturnValue(false);
|
||||
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://api.promptfoo.app');
|
||||
vi.mocked(cloudConfig.getApiKey).mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /providers/config-status', () => {
|
||||
it('should return hasCustomConfig: false when no custom config exists', async () => {
|
||||
// getAvailableProviders returns empty array when no config
|
||||
mockedGetAvailableProviders.mockReturnValue([]);
|
||||
|
||||
const response = await api.get('/api/providers/config-status');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
data: { hasCustomConfig: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return hasCustomConfig: true when custom config exists', async () => {
|
||||
const customProviders = [
|
||||
{ id: 'openai:gpt-4o-mini' },
|
||||
{ id: 'anthropic:messages:claude-haiku-4-5-20251001' },
|
||||
];
|
||||
|
||||
mockedGetAvailableProviders.mockReturnValue(customProviders);
|
||||
|
||||
const response = await api.get('/api/providers/config-status');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
data: { hasCustomConfig: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return standardized 500 errors when config status loading fails', async () => {
|
||||
mockedGetAvailableProviders.mockImplementation(() => {
|
||||
throw new Error('config unavailable');
|
||||
});
|
||||
|
||||
const response = await api.get('/api/providers/config-status');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to load provider config status' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /providers/test', () => {
|
||||
let mockProvider: ApiProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup mock provider
|
||||
mockProvider = {
|
||||
id: vi.fn(() => 'test-provider'),
|
||||
callApi: vi.fn(),
|
||||
config: {},
|
||||
} as any;
|
||||
|
||||
// Default mock implementations
|
||||
mockedLoadApiProvider.mockResolvedValue(mockProvider);
|
||||
});
|
||||
|
||||
it('should handle valid request with prompt', async () => {
|
||||
const testPrompt = 'Test prompt';
|
||||
const providerOptions: ProviderOptions = {
|
||||
id: 'http://example.com/api',
|
||||
config: {
|
||||
method: 'POST',
|
||||
},
|
||||
};
|
||||
|
||||
const mockResult: ProviderTestResult = {
|
||||
success: true,
|
||||
message: 'Provider test successful',
|
||||
providerResponse: { output: 'Test response' },
|
||||
transformedRequest: { url: 'http://example.com/api' },
|
||||
};
|
||||
|
||||
mockedTestProviderConnectivity.mockResolvedValue(mockResult);
|
||||
|
||||
const response = await api.post('/api/providers/test').send({
|
||||
prompt: testPrompt,
|
||||
providerOptions,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
testResult: {
|
||||
success: true,
|
||||
message: 'Provider test successful',
|
||||
error: undefined,
|
||||
changes_needed: undefined,
|
||||
changes_needed_reason: undefined,
|
||||
changes_needed_suggestions: undefined,
|
||||
},
|
||||
providerResponse: { output: 'Test response' },
|
||||
transformedRequest: { url: 'http://example.com/api' },
|
||||
});
|
||||
|
||||
expect(mockedLoadApiProvider).toHaveBeenCalledWith('http://example.com/api', {
|
||||
options: {
|
||||
...providerOptions,
|
||||
config: {
|
||||
...providerOptions.config,
|
||||
maxRetries: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedTestProviderConnectivity).toHaveBeenCalledWith({
|
||||
provider: mockProvider,
|
||||
prompt: testPrompt,
|
||||
inputs: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle valid request without prompt (optional)', async () => {
|
||||
const providerOptions: ProviderOptions = {
|
||||
id: 'http://example.com/api',
|
||||
config: {},
|
||||
};
|
||||
|
||||
const mockResult: ProviderTestResult = {
|
||||
success: true,
|
||||
message: 'Provider test successful',
|
||||
};
|
||||
|
||||
mockedTestProviderConnectivity.mockResolvedValue(mockResult);
|
||||
|
||||
const response = await api.post('/api/providers/test').send({
|
||||
providerOptions,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedTestProviderConnectivity).toHaveBeenCalledWith({
|
||||
provider: mockProvider,
|
||||
prompt: undefined,
|
||||
inputs: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for missing providerOptions', async () => {
|
||||
const response = await api.post('/api/providers/test').send({
|
||||
prompt: 'Test prompt',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.stringContaining('providerOptions'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 for missing provider id', async () => {
|
||||
const response = await api.post('/api/providers/test').send({
|
||||
providerOptions: { config: {} },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when provider id is a number', async () => {
|
||||
const response = await api.post('/api/providers/test').send({
|
||||
providerOptions: { id: 123 },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when provider id is an object', async () => {
|
||||
const response = await api.post('/api/providers/test').send({
|
||||
providerOptions: { id: {} },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when provider id is empty string', async () => {
|
||||
const response = await api.post('/api/providers/test').send({
|
||||
providerOptions: { id: '' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should handle provider loading failure', async () => {
|
||||
const providerOptions: ProviderOptions = {
|
||||
id: 'invalid-provider',
|
||||
config: {},
|
||||
};
|
||||
|
||||
mockedLoadApiProvider.mockRejectedValue(new Error('Failed to load provider'));
|
||||
|
||||
const response = await api.post('/api/providers/test').send({
|
||||
providerOptions,
|
||||
});
|
||||
|
||||
// The route should catch the error and return 500
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it('should handle connectivity test failure', async () => {
|
||||
const providerOptions: ProviderOptions = {
|
||||
id: 'http://example.com/api',
|
||||
config: {},
|
||||
};
|
||||
|
||||
const mockResult: ProviderTestResult = {
|
||||
success: false,
|
||||
message: 'Connection failed',
|
||||
error: 'Network timeout',
|
||||
};
|
||||
|
||||
mockedTestProviderConnectivity.mockResolvedValue(mockResult);
|
||||
|
||||
const response = await api.post('/api/providers/test').send({
|
||||
providerOptions,
|
||||
prompt: 'Test',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
testResult: {
|
||||
success: false,
|
||||
message: 'Connection failed',
|
||||
error: 'Network timeout',
|
||||
changes_needed: undefined,
|
||||
changes_needed_reason: undefined,
|
||||
changes_needed_suggestions: undefined,
|
||||
},
|
||||
providerResponse: undefined,
|
||||
transformedRequest: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle successful test with analysis and suggestions', async () => {
|
||||
const providerOptions: ProviderOptions = {
|
||||
id: 'http://example.com/api',
|
||||
config: {},
|
||||
};
|
||||
|
||||
const mockResult: ProviderTestResult = {
|
||||
success: true,
|
||||
message: 'Test completed with suggestions',
|
||||
providerResponse: { output: 'Response' },
|
||||
analysis: {
|
||||
changes_needed: true,
|
||||
changes_needed_reason: 'Response format is not optimal',
|
||||
changes_needed_suggestions: [
|
||||
'Add response transform to extract text field',
|
||||
'Update headers to include authentication',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mockedTestProviderConnectivity.mockResolvedValue(mockResult);
|
||||
|
||||
const response = await api.post('/api/providers/test').send({
|
||||
providerOptions,
|
||||
prompt: 'Test',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
testResult: {
|
||||
success: true,
|
||||
message: 'Test completed with suggestions',
|
||||
error: undefined,
|
||||
changes_needed: true,
|
||||
changes_needed_reason: 'Response format is not optimal',
|
||||
changes_needed_suggestions: [
|
||||
'Add response transform to extract text field',
|
||||
'Update headers to include authentication',
|
||||
],
|
||||
},
|
||||
providerResponse: { output: 'Response' },
|
||||
transformedRequest: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should properly structure response with all fields', async () => {
|
||||
const providerOptions: ProviderOptions = {
|
||||
id: 'http://example.com/api',
|
||||
config: {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
};
|
||||
|
||||
const mockResult: ProviderTestResult = {
|
||||
success: true,
|
||||
message: 'All systems operational',
|
||||
error: undefined,
|
||||
providerResponse: {
|
||||
output: 'AI response text',
|
||||
metadata: { latency: 150 },
|
||||
},
|
||||
transformedRequest: {
|
||||
url: 'http://example.com/api',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { prompt: 'Comprehensive test' },
|
||||
},
|
||||
analysis: {
|
||||
changes_needed: false,
|
||||
},
|
||||
};
|
||||
|
||||
mockedTestProviderConnectivity.mockResolvedValue(mockResult);
|
||||
|
||||
const response = await api.post('/api/providers/test').send({
|
||||
providerOptions,
|
||||
prompt: 'Comprehensive test',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify testResult structure
|
||||
expect(response.body.testResult.success).toBe(true);
|
||||
expect(response.body.testResult.message).toBe('All systems operational');
|
||||
expect(response.body.testResult.error).toBeUndefined();
|
||||
expect(response.body.testResult.changes_needed).toBe(false);
|
||||
expect(response.body.testResult.changes_needed_reason).toBeUndefined();
|
||||
expect(response.body.testResult.changes_needed_suggestions).toBeUndefined();
|
||||
|
||||
// Verify providerResponse
|
||||
expect(response.body.providerResponse).toEqual({
|
||||
output: 'AI response text',
|
||||
metadata: { latency: 150 },
|
||||
});
|
||||
|
||||
// Verify transformedRequest
|
||||
expect(response.body.transformedRequest).toEqual({
|
||||
url: 'http://example.com/api',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: { prompt: 'Comprehensive test' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass maxRetries: 1 to provider config', async () => {
|
||||
const providerOptions: ProviderOptions = {
|
||||
id: 'http://example.com/api',
|
||||
config: {
|
||||
maxRetries: 5, // Should be overridden to 1
|
||||
timeout: 30000,
|
||||
},
|
||||
};
|
||||
|
||||
const mockResult: ProviderTestResult = {
|
||||
success: true,
|
||||
message: 'Success',
|
||||
};
|
||||
|
||||
mockedTestProviderConnectivity.mockResolvedValue(mockResult);
|
||||
|
||||
const response = await api.post('/api/providers/test').send({
|
||||
providerOptions,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedLoadApiProvider).toHaveBeenCalledWith('http://example.com/api', {
|
||||
options: {
|
||||
...providerOptions,
|
||||
config: {
|
||||
maxRetries: 1, // Should be 1, not 5
|
||||
timeout: 30000,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /providers/http-generator validation', () => {
|
||||
it('should return generated HTTP configuration objects from the cloud API', async () => {
|
||||
const generatedConfig = {
|
||||
url: 'https://api.example.com/v1/chat',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
mockedFetchWithProxy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue(generatedConfig),
|
||||
} as any);
|
||||
|
||||
const response = await api
|
||||
.post('/api/providers/http-generator')
|
||||
.send({ requestExample: 'curl https://api.example.com/v1/chat' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(generatedConfig);
|
||||
expect(mockedFetchWithProxy).toHaveBeenCalledWith(
|
||||
'https://api.promptfoo.app/api/v1/http-provider-generator',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
requestExample: 'curl https://api.example.com/v1/chat',
|
||||
responseExample: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call the configured on-prem cloud host with a bearer token when cloud is enabled', async () => {
|
||||
vi.mocked(cloudConfig.isEnabled).mockReturnValue(true);
|
||||
vi.mocked(cloudConfig.getApiHost).mockReturnValue('https://onprem.example.com/');
|
||||
vi.mocked(cloudConfig.getApiKey).mockReturnValue('test-onprem-key');
|
||||
|
||||
const generatedConfig = {
|
||||
url: 'https://api.example.com/v1/chat',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
mockedFetchWithProxy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue(generatedConfig),
|
||||
} as any);
|
||||
|
||||
const response = await api
|
||||
.post('/api/providers/http-generator')
|
||||
.send({ requestExample: 'curl https://api.example.com/v1/chat' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const [calledUrl, calledOpts] = mockedFetchWithProxy.mock.calls[0];
|
||||
// Trailing slash on the configured host is normalized (no //api/v1).
|
||||
expect(calledUrl).toBe('https://onprem.example.com/api/v1/http-provider-generator');
|
||||
expect(calledUrl).not.toContain('api.promptfoo.app');
|
||||
expect((calledOpts?.headers as Record<string, string>)?.Authorization).toBe(
|
||||
'Bearer test-onprem-key',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not send an Authorization header when cloud is not enabled', async () => {
|
||||
// default beforeEach: isEnabled=false
|
||||
mockedFetchWithProxy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({ url: 'https://x', method: 'POST', headers: {} }),
|
||||
} as any);
|
||||
|
||||
const response = await api
|
||||
.post('/api/providers/http-generator')
|
||||
.send({ requestExample: 'curl https://api.example.com/v1/chat' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const [calledUrl, calledOpts] = mockedFetchWithProxy.mock.calls[0];
|
||||
expect(calledUrl).toBe('https://api.promptfoo.app/api/v1/http-provider-generator');
|
||||
expect((calledOpts?.headers as Record<string, string>)?.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not call the hosted HTTP generator when remote generation is disabled', async () => {
|
||||
mockedNeverGenerateRemote.mockReturnValue(true);
|
||||
|
||||
const response = await api
|
||||
.post('/api/providers/http-generator')
|
||||
.send({ requestExample: 'curl https://api.example.com/v1/chat' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({ error: 'Requires remote generation be enabled.' });
|
||||
expect(mockedFetchWithProxy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 500 when the cloud API returns a non-object generator response', async () => {
|
||||
mockedFetchWithProxy.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue(['not', 'a', 'config']),
|
||||
} as any);
|
||||
|
||||
const response = await api
|
||||
.post('/api/providers/http-generator')
|
||||
.send({ requestExample: 'curl https://api.example.com/v1/chat' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to generate HTTP configuration' });
|
||||
});
|
||||
|
||||
it('should return 400 for empty body', async () => {
|
||||
const response = await api.post('/api/providers/http-generator').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 for empty string requestExample', async () => {
|
||||
const response = await api.post('/api/providers/http-generator').send({ requestExample: '' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 for non-string requestExample', async () => {
|
||||
const response = await api
|
||||
.post('/api/providers/http-generator')
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({ requestExample: 123 }));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /providers/test-session validation', () => {
|
||||
it('should return 400 for empty body', async () => {
|
||||
const response = await api.post('/api/providers/test-session').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 for missing provider', async () => {
|
||||
const response = await api
|
||||
.post('/api/providers/test-session')
|
||||
.send({ sessionConfig: {}, mainInputVariable: 'input' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when provider is not an object', async () => {
|
||||
const response = await api
|
||||
.post('/api/providers/test-session')
|
||||
.send({ provider: 'not-an-object' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when provider.id is a number', async () => {
|
||||
const response = await api
|
||||
.post('/api/providers/test-session')
|
||||
.send({ provider: { id: 123 } });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when provider.id is an object', async () => {
|
||||
const response = await api.post('/api/providers/test-session').send({ provider: { id: {} } });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should accept valid minimal body', async () => {
|
||||
const mockProvider = {
|
||||
id: vi.fn(() => 'test-provider'),
|
||||
callApi: vi.fn(),
|
||||
config: {},
|
||||
} as any;
|
||||
|
||||
mockedLoadApiProvider.mockResolvedValue(mockProvider);
|
||||
mockedTestProviderSession.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Session test successful',
|
||||
} as any);
|
||||
|
||||
const response = await api
|
||||
.post('/api/providers/test-session')
|
||||
.send({ provider: { id: 'http://example.com/api' } });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return standardized 500 errors when session provider loading fails', async () => {
|
||||
mockedLoadApiProvider.mockRejectedValue(new Error('provider unavailable'));
|
||||
|
||||
const response = await api
|
||||
.post('/api/providers/test-session')
|
||||
.send({ provider: { id: 'http://example.com/api' } });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to test session' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /providers/discover validation', () => {
|
||||
it('should return discovered target metadata for valid provider options', async () => {
|
||||
const mockProvider = {
|
||||
id: vi.fn(() => 'test-provider'),
|
||||
callApi: vi.fn(),
|
||||
config: {},
|
||||
} as any;
|
||||
const discoveryResult = {
|
||||
purpose: 'Help users',
|
||||
limitations: 'No medical advice',
|
||||
user: 'Support agents',
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
description: 'Fetch account details',
|
||||
arguments: [{ name: 'id', description: 'Account id', type: 'string' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockedNeverGenerateRemote.mockReturnValue(false);
|
||||
mockedLoadApiProvider.mockResolvedValue(mockProvider);
|
||||
mockedDoTargetPurposeDiscovery.mockResolvedValue(discoveryResult);
|
||||
|
||||
const response = await api.post('/api/providers/discover').send({ id: 'echo' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(discoveryResult);
|
||||
});
|
||||
|
||||
it('should return 400 for empty body (missing id)', async () => {
|
||||
const response = await api.post('/api/providers/discover').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when id is a number', async () => {
|
||||
const response = await api.post('/api/providers/discover').send({ id: 123 });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when id is an object', async () => {
|
||||
const response = await api.post('/api/providers/discover').send({ id: {} });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 for non-object body', async () => {
|
||||
const response = await api
|
||||
.post('/api/providers/discover')
|
||||
.send('not-an-object')
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,877 @@
|
||||
import request from 'supertest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../src/redteam/plugins/index');
|
||||
vi.mock('../../../src/redteam/providers/shared');
|
||||
vi.mock('../../../src/redteam/shared');
|
||||
vi.mock('../../../src/redteam/remoteGeneration');
|
||||
vi.mock('../../../src/util/fetch/index');
|
||||
vi.mock('../../../src/server/services/redteamTestCaseGenerationService');
|
||||
|
||||
// Import after mocking
|
||||
import logger from '../../../src/logger';
|
||||
import { Plugins } from '../../../src/redteam/plugins/index';
|
||||
import { redteamProviderManager } from '../../../src/redteam/providers/shared';
|
||||
import { getRemoteGenerationUrl, neverGenerateRemote } from '../../../src/redteam/remoteGeneration';
|
||||
import { doRedteamRun } from '../../../src/redteam/shared';
|
||||
import {
|
||||
extractGeneratedPrompt,
|
||||
getPluginConfigurationError,
|
||||
} from '../../../src/server/services/redteamTestCaseGenerationService';
|
||||
import { fetchWithProxy } from '../../../src/util/fetch/index';
|
||||
|
||||
const mockedPlugins = vi.mocked(Plugins);
|
||||
const mockedRedteamProviderManager = vi.mocked(redteamProviderManager);
|
||||
const mockedGetPluginConfigurationError = vi.mocked(getPluginConfigurationError);
|
||||
const mockedExtractGeneratedPrompt = vi.mocked(extractGeneratedPrompt);
|
||||
const mockedDoRedteamRun = vi.mocked(doRedteamRun);
|
||||
const mockedGetRemoteGenerationUrl = vi.mocked(getRemoteGenerationUrl);
|
||||
const mockedNeverGenerateRemote = vi.mocked(neverGenerateRemote);
|
||||
const mockedFetchWithProxy = vi.mocked(fetchWithProxy);
|
||||
const debugSpy = vi.spyOn(logger, 'debug');
|
||||
|
||||
describe('Redteam Routes', () => {
|
||||
let app: ReturnType<typeof createApp>;
|
||||
|
||||
beforeEach(() => {
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
describe('POST /redteam/generate-test', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Default mock implementations
|
||||
mockedGetPluginConfigurationError.mockReturnValue(null);
|
||||
mockedRedteamProviderManager.getProvider.mockResolvedValue({
|
||||
id: () => 'test-provider',
|
||||
callApi: vi.fn(),
|
||||
} as any);
|
||||
mockedExtractGeneratedPrompt.mockReturnValue('generated test prompt');
|
||||
});
|
||||
|
||||
describe('excluded plugins logic', () => {
|
||||
it('should NOT exclude dataset-exempt plugins without multi-input config', async () => {
|
||||
// 'aegis' is a DATASET_EXEMPT_PLUGIN but should work without multi-input
|
||||
const mockPluginFactory = {
|
||||
key: 'aegis',
|
||||
action: vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]),
|
||||
};
|
||||
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'aegis',
|
||||
config: {},
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Should have called the plugin factory action (not excluded)
|
||||
expect(mockPluginFactory.action).toHaveBeenCalled();
|
||||
expect(response.body.prompt).toBe('generated test prompt');
|
||||
});
|
||||
|
||||
it('should default missing application purpose for generated tests', async () => {
|
||||
const mockPluginFactory = {
|
||||
key: 'aegis',
|
||||
action: vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]),
|
||||
};
|
||||
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'aegis',
|
||||
config: {},
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockPluginFactory.action).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ purpose: 'general AI assistant' }),
|
||||
);
|
||||
expect(response.body.prompt).toBe('generated test prompt');
|
||||
});
|
||||
|
||||
it('should exclude dataset-exempt plugins with multi-input config', async () => {
|
||||
// 'beavertails' is a DATASET_EXEMPT_PLUGIN - should be excluded with inputs
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'beavertails',
|
||||
config: {
|
||||
inputs: { query: 'user query', context: 'additional context' },
|
||||
},
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ testCases: [], count: 0 });
|
||||
});
|
||||
|
||||
it('should exclude multi-input excluded plugins when plugin has multi-input config', async () => {
|
||||
// 'cca' is a MULTI_INPUT_EXCLUDED_PLUGIN - should be excluded only with multi-input
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'cca',
|
||||
config: {
|
||||
inputs: { query: 'user query', context: 'additional context' },
|
||||
},
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ testCases: [], count: 0 });
|
||||
});
|
||||
|
||||
it('should NOT exclude multi-input excluded plugins when plugin has no multi-input config', async () => {
|
||||
// 'cca' is a MULTI_INPUT_EXCLUDED_PLUGIN - should NOT be excluded without multi-input
|
||||
const mockPluginFactory = {
|
||||
key: 'cca',
|
||||
action: vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]),
|
||||
};
|
||||
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'cca',
|
||||
config: {}, // No inputs - should not be excluded
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Should have called the plugin factory action (not returned early)
|
||||
expect(mockPluginFactory.action).toHaveBeenCalled();
|
||||
// Single test case returns 'prompt' instead of 'testCases' array
|
||||
expect(response.body.prompt).toBe('generated test prompt');
|
||||
});
|
||||
|
||||
it('should NOT exclude multi-input excluded plugins when inputs is empty object', async () => {
|
||||
// Empty inputs object should not trigger multi-input exclusion
|
||||
const mockPluginFactory = {
|
||||
key: 'cross-session-leak',
|
||||
action: vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]),
|
||||
};
|
||||
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'cross-session-leak',
|
||||
config: {
|
||||
inputs: {}, // Empty inputs - should not be excluded
|
||||
},
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Should have called the plugin factory action (not returned early)
|
||||
expect(mockPluginFactory.action).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not exclude system-prompt-override with multi-input config', async () => {
|
||||
const mockPluginFactory = {
|
||||
key: 'system-prompt-override',
|
||||
action: vi.fn().mockResolvedValue([{ vars: { __prompt: 'test' } }]),
|
||||
};
|
||||
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'system-prompt-override',
|
||||
config: {
|
||||
inputs: { systemPrompt: 'system', userInput: 'user' },
|
||||
},
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockPluginFactory.action).toHaveBeenCalled();
|
||||
expect(response.body.prompt).toBe('generated test prompt');
|
||||
});
|
||||
|
||||
it('should NOT exclude special-token-injection without multi-input config', async () => {
|
||||
const mockPluginFactory = {
|
||||
key: 'special-token-injection',
|
||||
action: vi.fn().mockResolvedValue([{ vars: { query: 'test' } }]),
|
||||
};
|
||||
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'special-token-injection',
|
||||
config: {}, // No inputs
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockPluginFactory.action).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should process regular plugins normally without inputs', async () => {
|
||||
// 'harmful:hate' is a regular plugin, not in any exclusion list
|
||||
const mockPluginFactory = {
|
||||
key: 'harmful:hate',
|
||||
action: vi.fn().mockResolvedValue([{ vars: { query: 'test case' } }]),
|
||||
};
|
||||
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'harmful:hate',
|
||||
config: {},
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockPluginFactory.action).toHaveBeenCalled();
|
||||
// Single test case returns 'prompt' instead of 'testCases' array
|
||||
expect(response.body.prompt).toBe('generated test prompt');
|
||||
});
|
||||
|
||||
it('should process regular plugins normally with multi-input config', async () => {
|
||||
// Regular plugins with inputs should still be processed
|
||||
const mockPluginFactory = {
|
||||
key: 'harmful:hate',
|
||||
action: vi.fn().mockResolvedValue([{ vars: { query: 'test case' } }]),
|
||||
};
|
||||
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'harmful:hate',
|
||||
config: {
|
||||
inputs: { query: 'user input', context: 'context' },
|
||||
},
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockPluginFactory.action).toHaveBeenCalled();
|
||||
expect(response.body.prompt).toBe('generated test prompt');
|
||||
});
|
||||
|
||||
it('should preserve HarmBench category filters when generating preview tests', async () => {
|
||||
const mockPluginFactory = {
|
||||
key: 'harmbench',
|
||||
action: vi.fn().mockResolvedValue([{ vars: { query: 'test case' } }]),
|
||||
};
|
||||
mockedPlugins.find = vi.fn().mockReturnValue(mockPluginFactory);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'harmbench',
|
||||
config: {
|
||||
categories: ['misinformation'],
|
||||
functionalCategories: ['contextual'],
|
||||
},
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockPluginFactory.action).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
categories: ['misinformation'],
|
||||
functionalCategories: ['contextual'],
|
||||
language: 'en',
|
||||
__nonce: expect.any(Number),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(response.body.prompt).toBe('generated test prompt');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should return 400 for invalid plugin ID', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'invalid-plugin-id',
|
||||
config: {},
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('Invalid plugin ID');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid strategy ID', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'harmful:hate',
|
||||
config: {},
|
||||
},
|
||||
strategy: {
|
||||
id: 'invalid-strategy',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('Invalid strategy ID');
|
||||
});
|
||||
|
||||
it('should return 400 for plugin configuration error', async () => {
|
||||
mockedGetPluginConfigurationError.mockReturnValue(
|
||||
'Plugin requires additional configuration',
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/generate-test')
|
||||
.send({
|
||||
plugin: {
|
||||
id: 'harmful:hate',
|
||||
config: {},
|
||||
},
|
||||
strategy: {
|
||||
id: 'basic',
|
||||
config: {},
|
||||
},
|
||||
config: {
|
||||
applicationDefinition: {
|
||||
purpose: 'test assistant',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('Plugin requires additional configuration');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /redteam/run', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockedDoRedteamRun.mockResolvedValue(undefined as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return job id for valid request', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({
|
||||
config: { purpose: 'test' },
|
||||
force: true,
|
||||
verbose: false,
|
||||
delay: 0,
|
||||
maxConcurrency: 2,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBeDefined();
|
||||
expect(typeof response.body.id).toBe('string');
|
||||
expect(mockedDoRedteamRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should publish a completed redteam eval through the eval job endpoint', async () => {
|
||||
const summary = { results: [] };
|
||||
mockedDoRedteamRun.mockResolvedValueOnce({
|
||||
id: 'redteam-eval-id',
|
||||
toEvaluateSummary: vi.fn().mockResolvedValue(summary),
|
||||
} as any);
|
||||
|
||||
const runResponse = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({ config: { purpose: 'test' } });
|
||||
expect(runResponse.status).toBe(200);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const completedResponse = await request(app).get(`/api/eval/job/${runResponse.body.id}`);
|
||||
expect(completedResponse.body).toMatchObject({
|
||||
status: 'complete',
|
||||
evalId: 'redteam-eval-id',
|
||||
result: summary,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should publish logs and cancellation through the eval job endpoint', async () => {
|
||||
let resolveRun: ((value: undefined) => void) | undefined;
|
||||
mockedDoRedteamRun.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveRun = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const runResponse = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({ config: { purpose: 'test' } });
|
||||
expect(runResponse.status).toBe(200);
|
||||
|
||||
const runArgs = mockedDoRedteamRun.mock.calls[0][0];
|
||||
runArgs.logCallback?.('working');
|
||||
|
||||
const inProgressResponse = await request(app).get(`/api/eval/job/${runResponse.body.id}`);
|
||||
expect(inProgressResponse.body).toMatchObject({
|
||||
status: 'in-progress',
|
||||
logs: ['working'],
|
||||
});
|
||||
|
||||
const cancelResponse = await request(app).post('/api/redteam/cancel');
|
||||
expect(cancelResponse.status).toBe(200);
|
||||
|
||||
const cancelledResponse = await request(app).get(`/api/eval/job/${runResponse.body.id}`);
|
||||
expect(cancelledResponse.body).toMatchObject({
|
||||
status: 'error',
|
||||
logs: ['working', 'Job cancelled by user'],
|
||||
});
|
||||
|
||||
resolveRun!(undefined);
|
||||
await vi.waitFor(async () => {
|
||||
const settledResponse = await request(app).get(`/api/eval/job/${runResponse.body.id}`);
|
||||
expect(settledResponse.body).toMatchObject({
|
||||
status: 'error',
|
||||
logs: ['working', 'Job cancelled by user'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep a replaced job cancelled when its stale run settles', async () => {
|
||||
let resolveFirstRun: ((value: any) => void) | undefined;
|
||||
let resolveSecondRun: ((value: undefined) => void) | undefined;
|
||||
mockedDoRedteamRun
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFirstRun = resolve;
|
||||
}),
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSecondRun = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const firstResponse = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({ config: { purpose: 'first' } });
|
||||
const secondResponse = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({ config: { purpose: 'second' } });
|
||||
expect(firstResponse.status).toBe(200);
|
||||
expect(secondResponse.status).toBe(200);
|
||||
|
||||
const cancelledResponse = await request(app).get(`/api/eval/job/${firstResponse.body.id}`);
|
||||
expect(cancelledResponse.body).toMatchObject({
|
||||
status: 'error',
|
||||
logs: ['Job cancelled - new job started'],
|
||||
});
|
||||
|
||||
const toEvaluateSummary = vi.fn().mockResolvedValue({ results: [] });
|
||||
resolveFirstRun!({
|
||||
id: 'stale-redteam-eval-id',
|
||||
toEvaluateSummary,
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
const settledResponse = await request(app).get(`/api/eval/job/${firstResponse.body.id}`);
|
||||
expect(settledResponse.body).toMatchObject({
|
||||
status: 'error',
|
||||
logs: ['Job cancelled - new job started'],
|
||||
});
|
||||
});
|
||||
expect(toEvaluateSummary).toHaveBeenCalledOnce();
|
||||
|
||||
resolveSecondRun!(undefined);
|
||||
await vi.waitFor(async () => {
|
||||
const statusResponse = await request(app).get('/api/redteam/status');
|
||||
expect(statusResponse.body).toMatchObject({
|
||||
hasRunningJob: false,
|
||||
jobId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve streamed logs when the background run rejects', async () => {
|
||||
mockedDoRedteamRun.mockImplementationOnce(async ({ logCallback }) => {
|
||||
logCallback?.('working');
|
||||
throw new Error('run failed');
|
||||
});
|
||||
|
||||
const runResponse = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({ config: { purpose: 'test' } });
|
||||
expect(runResponse.status).toBe(200);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const failedResponse = await request(app).get(`/api/eval/job/${runResponse.body.id}`);
|
||||
expect(failedResponse.body).toMatchObject({
|
||||
status: 'error',
|
||||
logs: expect.arrayContaining(['working', 'Error: run failed']),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should not force runtime defaults when delay and maxConcurrency are omitted', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({
|
||||
config: { purpose: 'test' },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const runArgs = mockedDoRedteamRun.mock.calls[0][0];
|
||||
expect(runArgs.liveRedteamConfig).toEqual({ purpose: 'test' });
|
||||
expect(runArgs).not.toHaveProperty('delay');
|
||||
expect(runArgs).not.toHaveProperty('maxConcurrency');
|
||||
});
|
||||
|
||||
it('should return 400 when config is missing', async () => {
|
||||
const response = await request(app).post('/api/redteam/run').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when config is not an object', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({ config: 'not-an-object' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when force is not a boolean', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({ config: { purpose: 'test' }, force: 'yes' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should accept string delay and maxConcurrency', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({
|
||||
config: { purpose: 'test' },
|
||||
delay: '100',
|
||||
maxConcurrency: '4',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 400 when delay is a non-numeric string', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({ config: { purpose: 'test' }, delay: 'abc' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when maxConcurrency is a non-numeric string', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({ config: { purpose: 'test' }, maxConcurrency: 'abc' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when maxConcurrency is less than 1', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({ config: { purpose: 'test' }, maxConcurrency: 0 });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should return 400 when delay is negative', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/run')
|
||||
.send({ config: { purpose: 'test' }, delay: -1 });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /redteam/:taskId', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
debugSpy.mockClear();
|
||||
mockedGetRemoteGenerationUrl.mockReturnValue('https://api.example.com/task');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should proxy valid request to cloud', async () => {
|
||||
mockedFetchWithProxy.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ result: 'success' }),
|
||||
} as any);
|
||||
|
||||
const response = await request(app).post('/api/redteam/my-task').send({ data: 'test' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ result: 'success' });
|
||||
expect(mockedFetchWithProxy).toHaveBeenCalledWith(
|
||||
'https://api.example.com/task',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ data: 'test', task: 'my-task' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not proxy task bodies when remote generation is disabled', async () => {
|
||||
mockedNeverGenerateRemote.mockReturnValue(true);
|
||||
|
||||
const response = await request(app).post('/api/redteam/my-task').send({ data: 'test' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: 'Requires remote generation be enabled.',
|
||||
});
|
||||
expect(mockedGetRemoteGenerationUrl).not.toHaveBeenCalled();
|
||||
expect(mockedFetchWithProxy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log task metadata without stringifying the body', async () => {
|
||||
mockedFetchWithProxy.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ result: 'success' }),
|
||||
} as any);
|
||||
|
||||
const response = await request(app).post('/api/redteam/my-task').send({
|
||||
data: 'test',
|
||||
secret: 'value',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
'Received my-task task request',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
url: '/my-task',
|
||||
body: expect.objectContaining({
|
||||
data: 'test',
|
||||
secret: '[REDACTED]',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 500 when cloud function fails', async () => {
|
||||
mockedFetchWithProxy.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
} as any);
|
||||
|
||||
const response = await request(app).post('/api/redteam/my-task').send({ data: 'test' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body.error).toContain('Failed to process my-task task');
|
||||
});
|
||||
|
||||
it('should return 400 when taskId exceeds max length', async () => {
|
||||
const longTaskId = 'a'.repeat(129);
|
||||
const response = await request(app).post(`/api/redteam/${longTaskId}`).send({ data: 'test' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should accept body with various shapes', async () => {
|
||||
mockedFetchWithProxy.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ ok: true }),
|
||||
} as any);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/redteam/some-task')
|
||||
.send({ nested: { deep: true }, list: [1, 2, 3] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /redteam/cancel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return 400 when no job is running', async () => {
|
||||
const response = await request(app).post('/api/redteam/cancel');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBe('No job currently running');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /redteam/status', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return status shape', async () => {
|
||||
const response = await request(app).get('/api/redteam/status');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('hasRunningJob');
|
||||
expect(response.body).toHaveProperty('jobId');
|
||||
expect(response.body.hasRunningJob).toBe(false);
|
||||
expect(response.body.jobId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,562 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
import { promptCacheService } from '../../../src/server/services/promptCacheService';
|
||||
|
||||
vi.mock('../../../src/globalConfig/cloud', () => ({
|
||||
cloudConfig: {
|
||||
isEnabled: vi.fn(),
|
||||
getApiHost: vi.fn(),
|
||||
getAppUrl: vi.fn(),
|
||||
validateAndSetApiToken: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/logger', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/models/eval', () => ({
|
||||
default: {
|
||||
findById: vi.fn(),
|
||||
},
|
||||
getEvalSummaries: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/redteam/remoteGeneration', () => ({
|
||||
getRemoteHealthUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/share', () => ({
|
||||
createShareableUrl: vi.fn(),
|
||||
determineShareDomain: vi.fn(),
|
||||
stripAuthFromUrl: vi.fn((url: string) => url),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/telemetry', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../src/telemetry')>();
|
||||
return {
|
||||
...actual,
|
||||
default: {
|
||||
record: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../src/testCase/synthesis', () => ({
|
||||
synthesizeFromTestSuite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/util/apiHealth', () => ({
|
||||
checkRemoteHealth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/util/database', () => ({
|
||||
getPrompts: vi.fn(),
|
||||
getPromptsForTestCasesHash: vi.fn(),
|
||||
getStandaloneEvals: vi.fn(),
|
||||
getTestCases: vi.fn(),
|
||||
readResult: vi.fn(),
|
||||
}));
|
||||
|
||||
import { cloudConfig } from '../../../src/globalConfig/cloud';
|
||||
import Eval, { getEvalSummaries } from '../../../src/models/eval';
|
||||
import { getRemoteHealthUrl } from '../../../src/redteam/remoteGeneration';
|
||||
import { createShareableUrl, determineShareDomain } from '../../../src/share';
|
||||
import telemetry from '../../../src/telemetry';
|
||||
import { synthesizeFromTestSuite } from '../../../src/testCase/synthesis';
|
||||
import { checkRemoteHealth } from '../../../src/util/apiHealth';
|
||||
import {
|
||||
getPrompts,
|
||||
getPromptsForTestCasesHash,
|
||||
getStandaloneEvals,
|
||||
getTestCases,
|
||||
readResult,
|
||||
} from '../../../src/util/database';
|
||||
|
||||
const mockedCloudConfig = vi.mocked(cloudConfig);
|
||||
const mockedEval = vi.mocked(Eval);
|
||||
const mockedGetEvalSummaries = vi.mocked(getEvalSummaries);
|
||||
const mockedGetRemoteHealthUrl = vi.mocked(getRemoteHealthUrl);
|
||||
const mockedCreateShareableUrl = vi.mocked(createShareableUrl);
|
||||
const mockedDetermineShareDomain = vi.mocked(determineShareDomain);
|
||||
const mockedTelemetry = vi.mocked(telemetry);
|
||||
const mockedSynthesizeFromTestSuite = vi.mocked(synthesizeFromTestSuite);
|
||||
const mockedCheckRemoteHealth = vi.mocked(checkRemoteHealth);
|
||||
const mockedGetPrompts = vi.mocked(getPrompts);
|
||||
const mockedGetPromptsForTestCasesHash = vi.mocked(getPromptsForTestCasesHash);
|
||||
const mockedGetStandaloneEvals = vi.mocked(getStandaloneEvals);
|
||||
const mockedGetTestCases = vi.mocked(getTestCases);
|
||||
const mockedReadResult = vi.mocked(readResult);
|
||||
|
||||
describe('inline server API DTO validation', () => {
|
||||
let api: ReturnType<typeof request.agent>;
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
api = request.agent(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// promptCacheService is a module-level singleton; clear its cache so a prompt list cached
|
||||
// by one test cannot leak into another.
|
||||
promptCacheService.invalidate();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
promptCacheService.invalidate();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('validates and parses remote health responses', async () => {
|
||||
mockedGetRemoteHealthUrl.mockReturnValue('https://api.example.test/health');
|
||||
mockedCheckRemoteHealth.mockResolvedValue({ status: 'OK', message: 'healthy' });
|
||||
|
||||
const response = await api.get('/api/remote-health');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ status: 'OK', message: 'healthy' });
|
||||
});
|
||||
|
||||
it('returns the disabled remote health DTO when remote generation is disabled', async () => {
|
||||
mockedGetRemoteHealthUrl.mockReturnValue(null);
|
||||
|
||||
const response = await api.get('/api/remote-health');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
status: 'DISABLED',
|
||||
message: 'remote generation and grading are disabled',
|
||||
});
|
||||
expect(mockedCheckRemoteHealth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates /api/results query params before loading summaries', async () => {
|
||||
mockedGetEvalSummaries.mockResolvedValue([
|
||||
{
|
||||
evalId: 'eval-1',
|
||||
datasetId: 'dataset-1',
|
||||
createdAt: 1,
|
||||
description: 'Redteam report',
|
||||
numTests: 2,
|
||||
} as never,
|
||||
]);
|
||||
|
||||
const response = await api.get('/api/results?type=redteam&includeProviders=true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.data).toHaveLength(1);
|
||||
expect(mockedGetEvalSummaries).toHaveBeenCalledWith(undefined, 'redteam', true);
|
||||
});
|
||||
|
||||
it('rejects invalid /api/results query params', async () => {
|
||||
const response = await api.get('/api/results?type=sideways');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('type');
|
||||
expect(mockedGetEvalSummaries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns JSON error DTOs for missing result files', async () => {
|
||||
mockedReadResult.mockResolvedValue(undefined);
|
||||
|
||||
const response = await api.get('/api/results/missing-eval');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Result not found' });
|
||||
});
|
||||
|
||||
it('redacts Azure Blob SAS tokens from result and dataset response DTOs', async () => {
|
||||
const sasUri = 'az://account/container/tests.yaml?sp=r&sig=azure-secret';
|
||||
mockedReadResult.mockResolvedValue({
|
||||
id: 'eval-1',
|
||||
createdAt: new Date(),
|
||||
result: { config: { tests: sasUri } },
|
||||
} as never);
|
||||
mockedGetTestCases.mockResolvedValue([{ testCases: sasUri }] as never);
|
||||
|
||||
const resultResponse = await api.get('/api/results/eval-1');
|
||||
const datasetsResponse = await api.get('/api/datasets');
|
||||
|
||||
expect(resultResponse.status).toBe(200);
|
||||
expect(resultResponse.body.data.config.tests).toBe(
|
||||
'az://account/container/tests.yaml?sp=r&sig=%5BREDACTED%5D',
|
||||
);
|
||||
expect(datasetsResponse.status).toBe(200);
|
||||
expect(datasetsResponse.body.data[0].testCases).toBe(
|
||||
'az://account/container/tests.yaml?sp=r&sig=%5BREDACTED%5D',
|
||||
);
|
||||
});
|
||||
|
||||
it('validates prompt hash params and returns prompt DTOs', async () => {
|
||||
const hash = 'a'.repeat(64);
|
||||
mockedGetPromptsForTestCasesHash.mockResolvedValue([{ raw: 'hello', label: 'hello' }] as never);
|
||||
|
||||
const response = await api.get(`/api/prompts/${hash}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ data: [{ raw: 'hello', label: 'hello' }] });
|
||||
expect(mockedGetPromptsForTestCasesHash).toHaveBeenCalledWith(hash);
|
||||
});
|
||||
|
||||
it('rejects invalid prompt hash params', async () => {
|
||||
const response = await api.get('/api/prompts/not-a-sha');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('sha256hash');
|
||||
});
|
||||
|
||||
it('validates history query params and returns history DTOs', async () => {
|
||||
mockedGetStandaloneEvals.mockResolvedValue([{ id: 'eval-1' }] as never);
|
||||
|
||||
const response = await api.get('/api/history?tagName=env&tagValue=prod');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ data: [{ id: 'eval-1' }] });
|
||||
expect(mockedGetStandaloneEvals).toHaveBeenCalledWith({
|
||||
tag: { key: 'env', value: 'prod' },
|
||||
description: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses prompt and dataset list response DTOs', async () => {
|
||||
mockedGetPrompts.mockResolvedValue([{ raw: 'p', label: 'p' }] as never);
|
||||
mockedGetTestCases.mockResolvedValue([{ vars: { q: 'hello' } }] as never);
|
||||
|
||||
const prompts = await api.get('/api/prompts');
|
||||
const datasets = await api.get('/api/datasets');
|
||||
|
||||
expect(prompts.status).toBe(200);
|
||||
expect(prompts.body).toEqual({ data: [{ raw: 'p', label: 'p' }] });
|
||||
expect(datasets.status).toBe(200);
|
||||
expect(datasets.body).toEqual({ data: [{ vars: { q: 'hello' } }] });
|
||||
});
|
||||
|
||||
it('refreshes the prompt list route after invalidating the singleton cache', async () => {
|
||||
mockedGetPrompts
|
||||
.mockResolvedValueOnce([{ raw: 'first', label: 'first' }] as never)
|
||||
.mockResolvedValueOnce([{ raw: 'second', label: 'second' }] as never);
|
||||
|
||||
const first = await api.get('/api/prompts');
|
||||
promptCacheService.invalidate();
|
||||
const second = await api.get('/api/prompts');
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body).toEqual({ data: [{ raw: 'first', label: 'first' }] });
|
||||
expect(second.status).toBe(200);
|
||||
expect(second.body).toEqual({ data: [{ raw: 'second', label: 'second' }] });
|
||||
expect(mockedGetPrompts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('validates share-domain query params', async () => {
|
||||
const response = await api.get('/api/results/share/check-domain');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('id');
|
||||
expect(mockedEval.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns share-domain DTOs for valid evals', async () => {
|
||||
mockedEval.findById.mockResolvedValue({ id: 'eval-1' } as never);
|
||||
mockedDetermineShareDomain.mockReturnValue({ domain: 'https://app.promptfoo.dev' } as never);
|
||||
mockedCloudConfig.isEnabled.mockReturnValue(true);
|
||||
|
||||
const response = await api.get('/api/results/share/check-domain?id=eval-1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
domain: 'https://app.promptfoo.dev',
|
||||
isCloudEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates share request bodies and parses share responses', async () => {
|
||||
mockedEval.findById.mockResolvedValue({ id: 'eval-1' } as never);
|
||||
mockedCreateShareableUrl.mockResolvedValue('https://share.example/eval-1');
|
||||
|
||||
const invalid = await api.post('/api/results/share').send({});
|
||||
const valid = await api.post('/api/results/share').send({ id: 'eval-1' });
|
||||
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(invalid.body.error).toContain('id');
|
||||
expect(valid.status).toBe(200);
|
||||
expect(valid.body).toEqual({ url: 'https://share.example/eval-1' });
|
||||
// The share route intentionally avoids `readResult` because it converts
|
||||
// load failures into `undefined`, which would collapse real DB errors into 404s.
|
||||
expect(mockedReadResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a 404 DTO when sharing a result whose eval row is missing', async () => {
|
||||
mockedEval.findById.mockResolvedValue(null as never);
|
||||
|
||||
const response = await api.post('/api/results/share').send({ id: 'eval-1' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Eval not found' });
|
||||
expect(mockedCreateShareableUrl).not.toHaveBeenCalled();
|
||||
// Symmetric to the happy-path assertion above: the 404 branch must also
|
||||
// never reach `readResult`. Otherwise a future refactor that reintroduces
|
||||
// a `readResult` preflight only on the not-found path would silently
|
||||
// resurrect the DB-error-as-404 regression.
|
||||
expect(mockedReadResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a 500 (not 404) when Eval.findById throws on a real DB error', async () => {
|
||||
// This is the regression the PR fixes: pre-fix, `readResult` swallowed
|
||||
// its own DB errors and returned undefined, which the route then
|
||||
// classified as 404 "Eval not found". Routing the lookup through
|
||||
// `Eval.findById` directly preserves the throw, and the route's
|
||||
// try/catch funnels it through `sendError` for a structured 500.
|
||||
mockedEval.findById.mockRejectedValueOnce(new Error('sqlite: database is locked'));
|
||||
|
||||
const response = await api.post('/api/results/share').send({ id: 'eval-1' });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to load eval for share' });
|
||||
expect(mockedCreateShareableUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not rate-limit share URL creation attempts', async () => {
|
||||
mockedEval.findById.mockResolvedValue(null as never);
|
||||
|
||||
const attempts = 35;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const response = await api.post('/api/results/share').send({ id: `eval-${i}` });
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Eval not found' });
|
||||
}
|
||||
|
||||
expect(mockedEval.findById).toHaveBeenCalledTimes(attempts);
|
||||
});
|
||||
|
||||
it('validates dataset generation request bodies', async () => {
|
||||
mockedSynthesizeFromTestSuite.mockResolvedValue([{ vars: { q: 'generated' } }] as never);
|
||||
|
||||
const emptyPrompts = await api.post('/api/dataset/generate').send({ prompts: [], tests: [] });
|
||||
const invalidPrompt = await api
|
||||
.post('/api/dataset/generate')
|
||||
.send({ prompts: [null], tests: [] });
|
||||
const invalidTest = await api
|
||||
.post('/api/dataset/generate')
|
||||
.send({ prompts: ['Prompt'], tests: [null] });
|
||||
const promptOnly = await api.post('/api/dataset/generate').send({ prompts: ['Prompt'] });
|
||||
const valid = await api
|
||||
.post('/api/dataset/generate')
|
||||
.send({ prompts: ['Prompt'], tests: [{ vars: { q: 'seed' } }] });
|
||||
|
||||
expect(emptyPrompts.status).toBe(400);
|
||||
expect(emptyPrompts.body.error).toContain('prompts');
|
||||
expect(invalidPrompt.status).toBe(400);
|
||||
expect(invalidPrompt.body.error).toContain('prompts');
|
||||
expect(invalidTest.status).toBe(400);
|
||||
expect(invalidTest.body.error).toContain('tests');
|
||||
expect(promptOnly.status).toBe(200);
|
||||
expect(promptOnly.body).toEqual({ results: [{ vars: { q: 'generated' } }] });
|
||||
expect(valid.status).toBe(200);
|
||||
expect(valid.body).toEqual({ results: [{ vars: { q: 'generated' } }] });
|
||||
expect(mockedSynthesizeFromTestSuite).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
{
|
||||
prompts: [{ raw: 'Prompt', label: 'Prompt' }],
|
||||
tests: [],
|
||||
providers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(mockedSynthesizeFromTestSuite).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
{
|
||||
prompts: [{ raw: 'Prompt', label: 'Prompt' }],
|
||||
tests: [{ vars: { q: 'seed' } }],
|
||||
providers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('validates telemetry request bodies and parses success DTOs', async () => {
|
||||
mockedTelemetry.record.mockReturnValue(undefined);
|
||||
|
||||
const invalid = await api.post('/api/telemetry').send({ event: 'not-real' });
|
||||
const valid = await api
|
||||
.post('/api/telemetry')
|
||||
.send({ event: 'webui_api', properties: { route: '/api/results' } });
|
||||
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(invalid.body.error).toContain('event');
|
||||
expect(valid.status).toBe(200);
|
||||
expect(valid.body).toEqual({ success: true });
|
||||
expect(mockedTelemetry.record).toHaveBeenCalledWith('webui_api', { route: '/api/results' });
|
||||
});
|
||||
|
||||
it('accepts the full telemetry property value matrix and rejects nested objects', async () => {
|
||||
mockedTelemetry.record.mockReturnValue(undefined);
|
||||
|
||||
const matrix = await api.post('/api/telemetry').send({
|
||||
event: 'webui_action',
|
||||
properties: {
|
||||
text: 'hi',
|
||||
count: 3,
|
||||
flag: true,
|
||||
tags: ['a', 'b'],
|
||||
},
|
||||
});
|
||||
const nested = await api
|
||||
.post('/api/telemetry')
|
||||
.send({ event: 'webui_action', properties: { nested: { k: 'v' } } });
|
||||
|
||||
expect(matrix.status).toBe(200);
|
||||
expect(matrix.body).toEqual({ success: true });
|
||||
expect(mockedTelemetry.record).toHaveBeenCalledWith('webui_action', {
|
||||
text: 'hi',
|
||||
count: 3,
|
||||
flag: true,
|
||||
tags: ['a', 'b'],
|
||||
});
|
||||
|
||||
expect(nested.status).toBe(400);
|
||||
expect(nested.body.error).toContain('properties');
|
||||
});
|
||||
|
||||
it('returns a 500 DTO when telemetry recording throws', async () => {
|
||||
mockedTelemetry.record.mockImplementation(() => {
|
||||
throw new Error('posthog down');
|
||||
});
|
||||
|
||||
const response = await api
|
||||
.post('/api/telemetry')
|
||||
.send({ event: 'webui_api', properties: { route: '/api/results' } });
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({ error: 'Failed to process telemetry request' });
|
||||
});
|
||||
|
||||
it('returns the static health DTO without schema-parsing the response', async () => {
|
||||
const response = await api.get('/health');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('status', 'OK');
|
||||
expect(typeof response.body.version).toBe('string');
|
||||
expect(response.body.version.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('coerces includeProviders only for "true"/true; everything else resolves to false', async () => {
|
||||
mockedGetEvalSummaries.mockResolvedValue([]);
|
||||
|
||||
const cases: { query: string; expected: boolean }[] = [
|
||||
{ query: '?includeProviders=true', expected: true },
|
||||
{ query: '?includeProviders=false', expected: false },
|
||||
{ query: '?includeProviders=1', expected: false },
|
||||
{ query: '?includeProviders=yes', expected: false },
|
||||
{ query: '', expected: false },
|
||||
];
|
||||
|
||||
for (const { query, expected } of cases) {
|
||||
mockedGetEvalSummaries.mockClear();
|
||||
const response = await api.get(`/api/results${query}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedGetEvalSummaries).toHaveBeenCalledWith(undefined, undefined, expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects the literal string "undefined" on share check-domain', async () => {
|
||||
const response = await api.get('/api/results/share/check-domain?id=undefined');
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('id');
|
||||
expect(mockedEval.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 404 from share check-domain when the eval is missing', async () => {
|
||||
mockedEval.findById.mockResolvedValue(null as never);
|
||||
|
||||
const response = await api.get('/api/results/share/check-domain?id=eval-1');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({ error: 'Eval not found' });
|
||||
expect(mockedDetermineShareDomain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes object-form prompts in dataset generation', async () => {
|
||||
mockedSynthesizeFromTestSuite.mockResolvedValue([{ vars: { q: 'generated' } }] as never);
|
||||
|
||||
const labelMissing = await api.post('/api/dataset/generate').send({
|
||||
prompts: [{ raw: 'P1' }],
|
||||
tests: [],
|
||||
});
|
||||
const labelPresent = await api.post('/api/dataset/generate').send({
|
||||
prompts: [{ raw: 'P2', label: 'custom-label', extra: 'kept' }],
|
||||
tests: [],
|
||||
});
|
||||
|
||||
expect(labelMissing.status).toBe(200);
|
||||
expect(labelPresent.status).toBe(200);
|
||||
expect(mockedSynthesizeFromTestSuite).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
prompts: [{ raw: 'P1', label: 'P1' }],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
expect(mockedSynthesizeFromTestSuite).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
prompts: [{ raw: 'P2', label: 'custom-label', extra: 'kept' }],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('imports server DTO schemas without mutating the user config directory', () => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'promptfoo-server-dto-import-'));
|
||||
const importProbe = `
|
||||
await import('./src/types/api/server.ts');
|
||||
await import('./src/telemetryEvents.ts');
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = spawnSync(process.execPath, ['--import', 'tsx', '-e', importProbe], {
|
||||
cwd: path.resolve(__dirname, '../../..'),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PROMPTFOO_CONFIG_DIR: configDir,
|
||||
PROMPTFOO_DISABLE_TELEMETRY: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status, result.stderr || result.stdout).toBe(0);
|
||||
expect(fs.readdirSync(configDir)).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,878 @@
|
||||
import { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { Duplex } from 'node:stream';
|
||||
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createServerOpenApiDocument,
|
||||
SERVER_OPENAPI_ROUTE_COUNT,
|
||||
} from '../../../src/openapi/server';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
import { promptCacheService } from '../../../src/server/services/promptCacheService';
|
||||
import type { Express } from 'express';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 2000;
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkEmailStatus: vi.fn(),
|
||||
checkModelAuditInstalled: vi.fn(),
|
||||
checkRemoteHealth: vi.fn(),
|
||||
clearUserEmail: vi.fn(),
|
||||
cloudConfig: {
|
||||
delete: vi.fn(),
|
||||
getApiHost: vi.fn(),
|
||||
getAppUrl: vi.fn(),
|
||||
isEnabled: vi.fn(),
|
||||
validateAndSetApiToken: vi.fn(),
|
||||
},
|
||||
createShareableUrl: vi.fn(),
|
||||
deleteEval: vi.fn(),
|
||||
deleteEvals: vi.fn(),
|
||||
determineShareDomain: vi.fn(),
|
||||
doRedteamRun: vi.fn(),
|
||||
evalModel: {
|
||||
create: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
latest: vi.fn(),
|
||||
},
|
||||
evalQueries: {
|
||||
getMetadataKeysFromEval: vi.fn(),
|
||||
getMetadataValuesFromEval: vi.fn(),
|
||||
},
|
||||
evalResultModel: {
|
||||
findById: vi.fn(),
|
||||
},
|
||||
fetchWithProxy: vi.fn(),
|
||||
getAvailableProviders: vi.fn(),
|
||||
getBlobByHash: vi.fn(),
|
||||
getBlobUrl: vi.fn(),
|
||||
getDb: vi.fn(),
|
||||
getEnvBool: vi.fn(),
|
||||
getEnvFloat: vi.fn(),
|
||||
getEnvInt: vi.fn(),
|
||||
getEnvString: vi.fn(),
|
||||
getEvalSummaries: vi.fn(),
|
||||
getLatestVersion: vi.fn(),
|
||||
getMediaStats: vi.fn(),
|
||||
getMediaStorage: vi.fn(),
|
||||
getPrompts: vi.fn(),
|
||||
getPromptsForTestCasesHash: vi.fn(),
|
||||
getStandaloneEvals: vi.fn(),
|
||||
getTestCases: vi.fn(),
|
||||
getTraceStore: vi.fn(),
|
||||
getUpdateCommands: vi.fn(),
|
||||
getUserEmail: vi.fn(),
|
||||
getUserId: vi.fn(),
|
||||
isBlobStorageEnabled: vi.fn(),
|
||||
isRunningUnderNpx: vi.fn(),
|
||||
loadApiProvider: vi.fn(),
|
||||
mediaExists: vi.fn(),
|
||||
modelAudit: {
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
getMany: vi.fn(),
|
||||
},
|
||||
neverGenerateRemote: vi.fn(),
|
||||
promptfooEvaluate: vi.fn(),
|
||||
readResult: vi.fn(),
|
||||
retrieveMedia: vi.fn(),
|
||||
setUserEmail: vi.fn(),
|
||||
stripAuthFromUrl: vi.fn(),
|
||||
synthesizeFromTestSuite: vi.fn(),
|
||||
telemetry: {
|
||||
record: vi.fn(),
|
||||
saveConsent: vi.fn(),
|
||||
},
|
||||
testProviderConnectivity: vi.fn(),
|
||||
testProviderSession: vi.fn(),
|
||||
updateResult: vi.fn(),
|
||||
writeResultsToDatabase: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/blobs', () => ({
|
||||
getBlobByHash: mocks.getBlobByHash,
|
||||
getBlobUrl: mocks.getBlobUrl,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/blobs/extractor', () => ({
|
||||
isBlobStorageEnabled: mocks.isBlobStorageEnabled,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/util/modelAuditInstall', () => ({
|
||||
checkModelAuditInstalled: mocks.checkModelAuditInstalled,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/database', () => ({
|
||||
getDb: mocks.getDb,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/database/index', () => ({
|
||||
getDb: mocks.getDb,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/envars', () => ({
|
||||
getEnvBool: mocks.getEnvBool,
|
||||
getEnvFloat: mocks.getEnvFloat,
|
||||
getEnvInt: mocks.getEnvInt,
|
||||
getEvalTimeoutMs: vi.fn(() => 0),
|
||||
getMaxEvalTimeMs: vi.fn(() => 0),
|
||||
getEnvString: mocks.getEnvString,
|
||||
isCI: vi.fn(() => false),
|
||||
isNonInteractive: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/globalConfig/accounts', () => ({
|
||||
checkEmailStatus: mocks.checkEmailStatus,
|
||||
clearUserEmail: mocks.clearUserEmail,
|
||||
getUserEmail: mocks.getUserEmail,
|
||||
getUserId: mocks.getUserId,
|
||||
setUserEmail: mocks.setUserEmail,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/globalConfig/cloud', () => ({
|
||||
cloudConfig: mocks.cloudConfig,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/node', () => ({
|
||||
evaluate: mocks.promptfooEvaluate,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/models/eval', () => ({
|
||||
EvalQueries: mocks.evalQueries,
|
||||
default: mocks.evalModel,
|
||||
getEvalSummaries: mocks.getEvalSummaries,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/models/evalResult', () => ({
|
||||
default: mocks.evalResultModel,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/models/modelAudit', () => ({
|
||||
default: mocks.modelAudit,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/providers/index', () => ({
|
||||
loadApiProvider: mocks.loadApiProvider,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/redteam/remoteGeneration', () => ({
|
||||
getRemoteGenerationUrl: vi.fn(() => 'https://api.promptfoo.dev/task'),
|
||||
getRemoteHealthUrl: vi.fn(() => null),
|
||||
neverGenerateRemote: mocks.neverGenerateRemote,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/redteam/shared', () => ({
|
||||
doRedteamRun: mocks.doRedteamRun,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/share', () => ({
|
||||
createShareableUrl: mocks.createShareableUrl,
|
||||
determineShareDomain: mocks.determineShareDomain,
|
||||
stripAuthFromUrl: mocks.stripAuthFromUrl,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/storage', () => ({
|
||||
getMediaStorage: mocks.getMediaStorage,
|
||||
mediaExists: mocks.mediaExists,
|
||||
retrieveMedia: mocks.retrieveMedia,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/telemetry', () => ({
|
||||
default: mocks.telemetry,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/testCase/synthesis', () => ({
|
||||
synthesizeFromTestSuite: mocks.synthesizeFromTestSuite,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/tracing/store', () => ({
|
||||
getTraceStore: mocks.getTraceStore,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/updates', () => ({
|
||||
getLatestVersion: mocks.getLatestVersion,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/updates/updateCommands', () => ({
|
||||
getUpdateCommands: mocks.getUpdateCommands,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/util/apiHealth', () => ({
|
||||
checkRemoteHealth: mocks.checkRemoteHealth,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/util/database', () => ({
|
||||
deleteEval: mocks.deleteEval,
|
||||
deleteEvals: mocks.deleteEvals,
|
||||
getPrompts: mocks.getPrompts,
|
||||
getPromptsForTestCasesHash: mocks.getPromptsForTestCasesHash,
|
||||
getStandaloneEvals: mocks.getStandaloneEvals,
|
||||
getTestCases: mocks.getTestCases,
|
||||
readResult: mocks.readResult,
|
||||
updateResult: mocks.updateResult,
|
||||
writeResultsToDatabase: mocks.writeResultsToDatabase,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/util/fetch/index', () => ({
|
||||
fetchWithProxy: mocks.fetchWithProxy,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/util/promptfooCommand', () => ({
|
||||
isRunningUnderNpx: mocks.isRunningUnderNpx,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/node/testProvider', () => ({
|
||||
testProviderConnectivity: mocks.testProviderConnectivity,
|
||||
testProviderSession: mocks.testProviderSession,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/server/config/serverConfig', () => ({
|
||||
getAvailableProviders: mocks.getAvailableProviders,
|
||||
}));
|
||||
|
||||
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'] as const;
|
||||
|
||||
type HttpMethod = (typeof HTTP_METHODS)[number];
|
||||
|
||||
type SmokeCase = {
|
||||
method: HttpMethod;
|
||||
openApiPath: string;
|
||||
path: string;
|
||||
expectedStatus: number;
|
||||
body?: unknown;
|
||||
rawJsonBody?: string;
|
||||
setup?: () => void;
|
||||
};
|
||||
|
||||
class MockSocket extends Duplex {
|
||||
remoteAddress = '127.0.0.1';
|
||||
|
||||
_read() {
|
||||
// This test double never emits readable data; IncomingMessage only needs the stream shape.
|
||||
}
|
||||
|
||||
_write(_chunk: unknown, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
|
||||
// Accept and discard smoke-test transport writes.
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
function createThenableQuery(rows: unknown[] = []) {
|
||||
const query = {
|
||||
all: vi.fn(() => rows),
|
||||
from: vi.fn(() => query),
|
||||
get: vi.fn(() => undefined),
|
||||
innerJoin: vi.fn(() => query),
|
||||
leftJoin: vi.fn(() => query),
|
||||
limit: vi.fn(() => query),
|
||||
offset: vi.fn(() => query),
|
||||
orderBy: vi.fn(() => query),
|
||||
// biome-ignore lint/suspicious/noThenProperty: Drizzle select builders are thenables, and config routes await them directly.
|
||||
then: (...args: Parameters<Promise<unknown[]>['then']>) => Promise.resolve(rows).then(...args),
|
||||
where: vi.fn(() => query),
|
||||
};
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
function createMockDb() {
|
||||
return {
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({
|
||||
returning: vi.fn(() => Promise.resolve([{ id: 'config-1', createdAt: 0 }])),
|
||||
})),
|
||||
})),
|
||||
select: vi.fn(() => createThenableQuery([])),
|
||||
selectDistinct: vi.fn(() => createThenableQuery([])),
|
||||
};
|
||||
}
|
||||
|
||||
function setupDefaultMocks() {
|
||||
const mockDb = createMockDb();
|
||||
mocks.getDb.mockReturnValue(mockDb);
|
||||
|
||||
mocks.checkEmailStatus.mockResolvedValue({ hasEmail: false, status: 'no_email' });
|
||||
mocks.checkModelAuditInstalled.mockResolvedValue({ installed: false, version: null });
|
||||
mocks.checkRemoteHealth.mockResolvedValue({ status: 'OK', message: 'healthy' });
|
||||
mocks.cloudConfig.getApiHost.mockReturnValue('https://api.promptfoo.dev');
|
||||
mocks.cloudConfig.getAppUrl.mockReturnValue('https://app.promptfoo.dev');
|
||||
mocks.cloudConfig.isEnabled.mockReturnValue(false);
|
||||
mocks.deleteEval.mockResolvedValue(undefined);
|
||||
mocks.deleteEvals.mockReturnValue(undefined);
|
||||
mocks.determineShareDomain.mockReturnValue({ domain: 'https://app.promptfoo.dev' });
|
||||
mocks.evalModel.findById.mockResolvedValue(null);
|
||||
mocks.getAvailableProviders.mockReturnValue([]);
|
||||
mocks.getEnvBool.mockReturnValue(false);
|
||||
mocks.getEnvFloat.mockReturnValue(undefined);
|
||||
mocks.getEnvInt.mockReturnValue(undefined);
|
||||
mocks.getEnvString.mockImplementation((_key, fallback) => fallback);
|
||||
mocks.getEvalSummaries.mockResolvedValue([]);
|
||||
mocks.getLatestVersion.mockResolvedValue('999.0.0');
|
||||
mocks.getMediaStats.mockResolvedValue({ totalFiles: 0, totalSize: 0 });
|
||||
mocks.getMediaStorage.mockReturnValue({
|
||||
getStats: mocks.getMediaStats,
|
||||
providerId: 'test-storage',
|
||||
});
|
||||
mocks.getPrompts.mockResolvedValue([]);
|
||||
mocks.getPromptsForTestCasesHash.mockResolvedValue([]);
|
||||
mocks.getStandaloneEvals.mockResolvedValue([]);
|
||||
mocks.getTestCases.mockResolvedValue([]);
|
||||
mocks.getTraceStore.mockReturnValue({
|
||||
getTrace: vi.fn().mockResolvedValue(null),
|
||||
getTracesByEvaluation: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
mocks.getUpdateCommands.mockReturnValue({
|
||||
alternative: null,
|
||||
commandType: 'npm',
|
||||
primary: 'npm install -g promptfoo',
|
||||
});
|
||||
mocks.getUserEmail.mockReturnValue('');
|
||||
mocks.getUserId.mockReturnValue('user-1');
|
||||
mocks.isBlobStorageEnabled.mockReturnValue(false);
|
||||
mocks.isRunningUnderNpx.mockReturnValue(false);
|
||||
mocks.mediaExists.mockResolvedValue(false);
|
||||
mocks.modelAudit.count.mockResolvedValue(0);
|
||||
mocks.modelAudit.findById.mockResolvedValue(null);
|
||||
mocks.modelAudit.getMany.mockResolvedValue([]);
|
||||
mocks.neverGenerateRemote.mockReturnValue(false);
|
||||
mocks.readResult.mockResolvedValue(undefined);
|
||||
mocks.stripAuthFromUrl.mockImplementation((url) => url);
|
||||
mocks.synthesizeFromTestSuite.mockResolvedValue([]);
|
||||
mocks.telemetry.record.mockResolvedValue(undefined);
|
||||
mocks.telemetry.saveConsent.mockResolvedValue(undefined);
|
||||
mocks.updateResult.mockResolvedValue(undefined);
|
||||
mocks.writeResultsToDatabase.mockResolvedValue('eval-1');
|
||||
}
|
||||
|
||||
function documentedRouteKeys() {
|
||||
const document = createServerOpenApiDocument();
|
||||
return Object.entries(document.paths ?? {}).flatMap(([path, pathItem]) =>
|
||||
HTTP_METHODS.flatMap((method) => {
|
||||
const operation = (pathItem as Partial<Record<(typeof HTTP_METHODS)[number], unknown>>)?.[
|
||||
method
|
||||
];
|
||||
return operation ? [`${method.toUpperCase()} ${path}`] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function routeKey(testCase: Pick<SmokeCase, 'method' | 'openApiPath'>) {
|
||||
return `${testCase.method.toUpperCase()} ${testCase.openApiPath}`;
|
||||
}
|
||||
|
||||
const validUuid = '00000000-0000-4000-8000-000000000000';
|
||||
const validMediaFilename = 'abcdef123456.mp3';
|
||||
|
||||
const smokeCases: SmokeCase[] = [
|
||||
{ method: 'get', openApiPath: '/health', path: '/health', expectedStatus: 200 },
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/remote-health',
|
||||
path: '/api/remote-health',
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{ method: 'get', openApiPath: '/api/results', path: '/api/results', expectedStatus: 200 },
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/results/{id}',
|
||||
path: '/api/results/missing-eval',
|
||||
expectedStatus: 404,
|
||||
},
|
||||
{ method: 'get', openApiPath: '/api/prompts', path: '/api/prompts', expectedStatus: 200 },
|
||||
{ method: 'get', openApiPath: '/api/history', path: '/api/history', expectedStatus: 200 },
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/prompts/{sha256hash}',
|
||||
path: '/api/prompts/not-a-sha',
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{ method: 'get', openApiPath: '/api/datasets', path: '/api/datasets', expectedStatus: 200 },
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/results/share/check-domain',
|
||||
path: '/api/results/share/check-domain',
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/results/share',
|
||||
path: '/api/results/share',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/dataset/generate',
|
||||
path: '/api/dataset/generate',
|
||||
body: { prompts: [] },
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/telemetry',
|
||||
path: '/api/telemetry',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{ method: 'get', openApiPath: '/api/configs', path: '/api/configs', expectedStatus: 200 },
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/configs',
|
||||
path: '/api/configs',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/configs/{type}',
|
||||
path: '/api/configs/eval',
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/configs/{type}/{id}',
|
||||
path: '/api/configs/eval/missing-config',
|
||||
expectedStatus: 404,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/eval/job',
|
||||
path: '/api/eval/job',
|
||||
body: { prompts: 'not-an-array' },
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/eval/job/{id}',
|
||||
path: `/api/eval/job/${validUuid}`,
|
||||
expectedStatus: 404,
|
||||
},
|
||||
{
|
||||
method: 'patch',
|
||||
openApiPath: '/api/eval/{id}',
|
||||
path: '/api/eval/eval-1',
|
||||
body: { table: 'not-a-table' },
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
openApiPath: '/api/eval/{id}',
|
||||
path: '/api/eval/eval-1',
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{
|
||||
method: 'patch',
|
||||
openApiPath: '/api/eval/{id}/author',
|
||||
path: '/api/eval/eval-1/author',
|
||||
body: { author: 'not-an-email' },
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/eval/{id}/table',
|
||||
path: '/api/eval/eval-1/table?limit=abc',
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/eval/{id}/metadata-keys',
|
||||
path: '/api/eval/ab/metadata-keys',
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/eval/{id}/metadata-values',
|
||||
path: '/api/eval/eval-1/metadata-values',
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/eval/{id}/results',
|
||||
path: '/api/eval/eval-1/results',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/eval/replay',
|
||||
path: '/api/eval/replay',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/eval/{evalId}/results/{id}/rating',
|
||||
path: '/api/eval/eval-1/results/result-1/rating',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/eval',
|
||||
path: '/api/eval',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
openApiPath: '/api/eval',
|
||||
path: '/api/eval',
|
||||
body: { ids: [] },
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/eval/{id}/copy',
|
||||
path: '/api/eval/eval-1/copy',
|
||||
body: { description: 123 },
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{ method: 'get', openApiPath: '/api/media/stats', path: '/api/media/stats', expectedStatus: 200 },
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/media/info/{type}/{filename}',
|
||||
path: `/api/media/info/audio/${validMediaFilename}`,
|
||||
expectedStatus: 404,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/media/{type}/{filename}',
|
||||
path: `/api/media/audio/${validMediaFilename}`,
|
||||
expectedStatus: 404,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/model-audit/check-installed',
|
||||
path: '/api/model-audit/check-installed',
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/model-audit/scanners',
|
||||
path: '/api/model-audit/scanners',
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/model-audit/check-path',
|
||||
path: '/api/model-audit/check-path',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/model-audit/scan',
|
||||
path: '/api/model-audit/scan',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/model-audit/scans',
|
||||
path: '/api/model-audit/scans',
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/model-audit/scans/latest',
|
||||
path: '/api/model-audit/scans/latest',
|
||||
expectedStatus: 404,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/model-audit/scans/{id}',
|
||||
path: '/api/model-audit/scans/scan-1',
|
||||
expectedStatus: 404,
|
||||
},
|
||||
{
|
||||
method: 'delete',
|
||||
openApiPath: '/api/model-audit/scans/{id}',
|
||||
path: '/api/model-audit/scans/scan-1',
|
||||
expectedStatus: 404,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/providers/config-status',
|
||||
path: '/api/providers/config-status',
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/providers/test',
|
||||
path: '/api/providers/test',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/providers/discover',
|
||||
path: '/api/providers/discover',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/providers/http-generator',
|
||||
path: '/api/providers/http-generator',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/providers/test-request-transform',
|
||||
path: '/api/providers/test-request-transform',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/providers/test-response-transform',
|
||||
path: '/api/providers/test-response-transform',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/providers/test-session',
|
||||
path: '/api/providers/test-session',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/redteam/generate-test',
|
||||
path: '/api/redteam/generate-test',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/redteam/run',
|
||||
path: '/api/redteam/run',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/redteam/cancel',
|
||||
path: '/api/redteam/cancel',
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/redteam/{taskId}',
|
||||
path: '/api/redteam/task-1',
|
||||
body: [],
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/redteam/status',
|
||||
path: '/api/redteam/status',
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/traces/evaluation/{evaluationId}',
|
||||
path: '/api/traces/evaluation/eval-1',
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/traces/{traceId}',
|
||||
path: '/api/traces/trace-1',
|
||||
expectedStatus: 404,
|
||||
},
|
||||
{ method: 'get', openApiPath: '/api/user/email', path: '/api/user/email', expectedStatus: 200 },
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/user/email',
|
||||
path: '/api/user/email',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{ method: 'get', openApiPath: '/api/user/id', path: '/api/user/id', expectedStatus: 200 },
|
||||
{
|
||||
method: 'put',
|
||||
openApiPath: '/api/user/email/clear',
|
||||
path: '/api/user/email/clear',
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/user/email/status',
|
||||
path: '/api/user/email/status',
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/user/login',
|
||||
path: '/api/user/login',
|
||||
body: {},
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
openApiPath: '/api/user/logout',
|
||||
path: '/api/user/logout',
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/user/cloud-config',
|
||||
path: '/api/user/cloud-config',
|
||||
expectedStatus: 200,
|
||||
},
|
||||
{ method: 'get', openApiPath: '/api/version', path: '/api/version', expectedStatus: 200 },
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/blobs/library',
|
||||
path: '/api/blobs/library?limit=abc',
|
||||
setup: () => mocks.isBlobStorageEnabled.mockReturnValue(true),
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/blobs/library/evals',
|
||||
path: '/api/blobs/library/evals?limit=abc',
|
||||
setup: () => mocks.isBlobStorageEnabled.mockReturnValue(true),
|
||||
expectedStatus: 400,
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
openApiPath: '/api/blobs/{hash}',
|
||||
path: '/api/blobs/not-a-hash',
|
||||
setup: () => mocks.isBlobStorageEnabled.mockReturnValue(true),
|
||||
expectedStatus: 400,
|
||||
},
|
||||
];
|
||||
|
||||
async function sendRequest(app: Express, testCase: SmokeCase) {
|
||||
const socket = new MockSocket();
|
||||
const req = new IncomingMessage(socket as never);
|
||||
req.method = testCase.method.toUpperCase();
|
||||
req.url = testCase.path;
|
||||
req.headers = {
|
||||
accept: 'application/json',
|
||||
host: '127.0.0.1',
|
||||
};
|
||||
|
||||
let payload: Buffer | undefined;
|
||||
if (testCase.rawJsonBody !== undefined) {
|
||||
payload = Buffer.from(testCase.rawJsonBody);
|
||||
} else if ('body' in testCase) {
|
||||
payload = Buffer.from(JSON.stringify(testCase.body));
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
req.headers['content-type'] = 'application/json';
|
||||
req.headers['content-length'] = String(payload.length);
|
||||
}
|
||||
|
||||
const res = new ServerResponse(req);
|
||||
res.assignSocket(socket as never);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
const write = res.write.bind(res);
|
||||
const end = res.end.bind(res);
|
||||
|
||||
res.write = ((chunk: unknown, encoding?: BufferEncoding | ((error?: Error) => void)) => {
|
||||
if (chunk !== undefined) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
||||
}
|
||||
return write(chunk as never, encoding as never);
|
||||
}) as typeof res.write;
|
||||
|
||||
res.end = ((chunk?: unknown, encoding?: BufferEncoding | (() => void), callback?: () => void) => {
|
||||
if (chunk !== undefined) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
||||
}
|
||||
return end(chunk as never, encoding as never, callback);
|
||||
}) as typeof res.end;
|
||||
|
||||
const response = new Promise<{
|
||||
body: unknown;
|
||||
headers: ReturnType<ServerResponse['getHeaders']>;
|
||||
status: number;
|
||||
text: string;
|
||||
}>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error(`${routeKey(testCase)} did not finish`));
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
|
||||
res.once('finish', () => {
|
||||
clearTimeout(timeout);
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
let body: unknown = {};
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
resolve({
|
||||
body,
|
||||
headers: res.getHeaders(),
|
||||
status: res.statusCode,
|
||||
text,
|
||||
});
|
||||
});
|
||||
res.once('error', reject);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
|
||||
req.push(payload ?? null);
|
||||
if (payload) {
|
||||
req.push(null);
|
||||
}
|
||||
app(req, res);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
describe('server route end-to-end smoke coverage', { concurrent: false }, () => {
|
||||
let app: Express;
|
||||
|
||||
beforeAll(() => {
|
||||
setupDefaultMocks();
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// promptCacheService is a module-level singleton; clear its cache so a prompt list cached
|
||||
// by one test cannot leak into another.
|
||||
promptCacheService.invalidate();
|
||||
setupDefaultMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
promptCacheService.invalidate();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('has one smoke case for every generated OpenAPI route', () => {
|
||||
const smokeKeys = smokeCases.map(routeKey).sort();
|
||||
const documentedKeys = documentedRouteKeys().sort();
|
||||
|
||||
expect(smokeCases).toHaveLength(SERVER_OPENAPI_ROUTE_COUNT);
|
||||
expect(new Set(smokeKeys).size).toBe(SERVER_OPENAPI_ROUTE_COUNT);
|
||||
expect(smokeKeys).toEqual(documentedKeys);
|
||||
});
|
||||
|
||||
it.each(smokeCases)('$method $openApiPath', async (testCase) => {
|
||||
testCase.setup?.();
|
||||
|
||||
const response = await sendRequest(app, testCase);
|
||||
|
||||
expect(
|
||||
response.status,
|
||||
`${routeKey(testCase)} expected ${testCase.expectedStatus}, got ${response.status}: ${response.text}`,
|
||||
).toBe(testCase.expectedStatus);
|
||||
|
||||
if (response.status !== 204) {
|
||||
expect(response.headers['content-type']).toContain('application/json');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../src/tracing/store');
|
||||
|
||||
// Import after mocking
|
||||
import { getTraceStore } from '../../../src/tracing/store';
|
||||
|
||||
const mockedGetTraceStore = vi.mocked(getTraceStore);
|
||||
|
||||
describe('Traces Routes', () => {
|
||||
let api: ReturnType<typeof request.agent>;
|
||||
let server: Server;
|
||||
let mockGetTracesByEvaluation: ReturnType<typeof vi.fn>;
|
||||
let mockGetTrace: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
api = request.agent(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Setup mock trace store methods
|
||||
mockGetTracesByEvaluation = vi.fn();
|
||||
mockGetTrace = vi.fn();
|
||||
|
||||
mockedGetTraceStore.mockReturnValue({
|
||||
getTracesByEvaluation: mockGetTracesByEvaluation,
|
||||
getTrace: mockGetTrace,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /api/traces/evaluation/:evaluationId', () => {
|
||||
it('should return traces array when traces exist', async () => {
|
||||
const mockTraces = [
|
||||
{
|
||||
id: '1',
|
||||
traceId: 'trace-1',
|
||||
evaluationId: 'eval-123',
|
||||
testCaseId: 'test-1',
|
||||
spans: [],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
traceId: 'trace-2',
|
||||
evaluationId: 'eval-123',
|
||||
testCaseId: 'test-2',
|
||||
spans: [],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetTracesByEvaluation.mockResolvedValue(mockTraces);
|
||||
|
||||
const response = await api.get('/api/traces/evaluation/eval-123');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
traces: mockTraces,
|
||||
});
|
||||
expect(mockGetTracesByEvaluation).toHaveBeenCalledWith('eval-123');
|
||||
});
|
||||
|
||||
it('should return empty array when no traces found', async () => {
|
||||
mockGetTracesByEvaluation.mockResolvedValue([]);
|
||||
|
||||
const response = await api.get('/api/traces/evaluation/eval-456');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
traces: [],
|
||||
});
|
||||
expect(mockGetTracesByEvaluation).toHaveBeenCalledWith('eval-456');
|
||||
});
|
||||
|
||||
it('should return 500 on database error', async () => {
|
||||
mockGetTracesByEvaluation.mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
const response = await api.get('/api/traces/evaluation/eval-789');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to fetch traces',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/traces/:traceId', () => {
|
||||
it('should return trace when found', async () => {
|
||||
const mockTrace = {
|
||||
id: '1',
|
||||
traceId: 'trace-abc',
|
||||
evaluationId: 'eval-123',
|
||||
testCaseId: 'test-1',
|
||||
spans: [
|
||||
{
|
||||
id: 'span-1',
|
||||
spanId: 'span-123',
|
||||
name: 'api-call',
|
||||
startTime: 1234567890,
|
||||
endTime: 1234567900,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGetTrace.mockResolvedValue(mockTrace);
|
||||
|
||||
const response = await api.get('/api/traces/trace-abc');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
trace: mockTrace,
|
||||
});
|
||||
expect(mockGetTrace).toHaveBeenCalledWith('trace-abc');
|
||||
});
|
||||
|
||||
it('should return 404 when trace not found', async () => {
|
||||
mockGetTrace.mockResolvedValue(null);
|
||||
|
||||
const response = await api.get('/api/traces/trace-nonexistent');
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Trace not found',
|
||||
});
|
||||
expect(mockGetTrace).toHaveBeenCalledWith('trace-nonexistent');
|
||||
});
|
||||
|
||||
it('should return 500 on database error', async () => {
|
||||
mockGetTrace.mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
const response = await api.get('/api/traces/trace-error');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toEqual({
|
||||
error: 'Failed to fetch trace',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Server } from 'node:http';
|
||||
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createApp } from '../../../src/server/server';
|
||||
|
||||
// Mock dependencies before imports
|
||||
vi.mock('../../../src/globalConfig/accounts');
|
||||
vi.mock('../../../src/globalConfig/cloud');
|
||||
vi.mock('../../../src/telemetry');
|
||||
|
||||
// Import after mocking
|
||||
import { checkEmailStatus, setUserEmail } from '../../../src/globalConfig/accounts';
|
||||
import telemetry from '../../../src/telemetry';
|
||||
|
||||
const mockedSetUserEmail = vi.mocked(setUserEmail);
|
||||
const mockedCheckEmailStatus = vi.mocked(checkEmailStatus);
|
||||
const mockedTelemetry = vi.mocked(telemetry);
|
||||
|
||||
describe('User Routes', () => {
|
||||
let api: ReturnType<typeof request.agent>;
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = createApp().listen(0, '127.0.0.1', (error?: Error) =>
|
||||
error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
api = request.agent(server);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Setup telemetry mock
|
||||
mockedTelemetry.record = vi.fn().mockResolvedValue(undefined);
|
||||
mockedTelemetry.saveConsent = vi.fn().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('POST /api/user/email', () => {
|
||||
it('should return 400 when body is empty', async () => {
|
||||
const response = await api.post('/api/user/email').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('email');
|
||||
});
|
||||
|
||||
it('should return 400 when email is not a string', async () => {
|
||||
const response = await api.post('/api/user/email').send({ email: 123 });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('email');
|
||||
});
|
||||
|
||||
it('should return 200 when email is valid', async () => {
|
||||
mockedSetUserEmail.mockImplementation(() => {});
|
||||
|
||||
const response = await api.post('/api/user/email').send({ email: 'test@example.com' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.message).toBe('Email updated');
|
||||
expect(mockedSetUserEmail).toHaveBeenCalledWith('test@example.com');
|
||||
expect(mockedTelemetry.record).toHaveBeenCalled();
|
||||
expect(mockedTelemetry.saveConsent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/user/email/status', () => {
|
||||
beforeEach(() => {
|
||||
mockedCheckEmailStatus.mockResolvedValue({
|
||||
hasEmail: true,
|
||||
email: 'test@example.com',
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 200 with default validate=false when no query param', async () => {
|
||||
const response = await api.get('/api/user/email/status');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.hasEmail).toBe(true);
|
||||
expect(response.body.email).toBe('test@example.com');
|
||||
expect(response.body.status).toBe('ok');
|
||||
expect(mockedCheckEmailStatus).toHaveBeenCalledWith({ validate: false });
|
||||
});
|
||||
|
||||
it('should return 200 when validate=true', async () => {
|
||||
const response = await api.get('/api/user/email/status?validate=true');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.hasEmail).toBe(true);
|
||||
expect(response.body.email).toBe('test@example.com');
|
||||
expect(response.body.status).toBe('ok');
|
||||
expect(mockedCheckEmailStatus).toHaveBeenCalledWith({ validate: true });
|
||||
});
|
||||
|
||||
it('should return 200 when validate=yes (permissive - treated as false)', async () => {
|
||||
const response = await api.get('/api/user/email/status?validate=yes');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.hasEmail).toBe(true);
|
||||
expect(mockedCheckEmailStatus).toHaveBeenCalledWith({ validate: false });
|
||||
});
|
||||
|
||||
it('should return 200 when validate=1 (permissive - treated as false)', async () => {
|
||||
const response = await api.get('/api/user/email/status?validate=1');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.hasEmail).toBe(true);
|
||||
expect(mockedCheckEmailStatus).toHaveBeenCalledWith({ validate: false });
|
||||
});
|
||||
|
||||
it('should return 200 when validate is repeated (permissive - treated as false)', async () => {
|
||||
const response = await api.get('/api/user/email/status?validate=true&validate=false');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.hasEmail).toBe(true);
|
||||
expect(mockedCheckEmailStatus).toHaveBeenCalledWith({ validate: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/user/login', () => {
|
||||
it('should return 400 when body is empty', async () => {
|
||||
const response = await api.post('/api/user/login').send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('apiKey');
|
||||
});
|
||||
|
||||
it('should return 400 when apiKey is not a string', async () => {
|
||||
const response = await api.post('/api/user/login').send({ apiKey: 123 });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('apiKey');
|
||||
});
|
||||
|
||||
it('should return 400 when apiKey is empty string', async () => {
|
||||
const response = await api.post('/api/user/login').send({ apiKey: '' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toContain('API key is required');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import request from 'supertest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mockProcessEnv } from '../../util/utils';
|
||||
|
||||
vi.mock('../../../src/updates', () => ({ getLatestVersion: vi.fn() }));
|
||||
vi.mock('../../../src/updates/updateCommands', () => ({ getUpdateCommands: vi.fn() }));
|
||||
vi.mock('../../../src/util/promptfooCommand', () => ({ isRunningUnderNpx: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../../src/server/server';
|
||||
import { getLatestVersion } from '../../../src/updates';
|
||||
import { getUpdateCommands } from '../../../src/updates/updateCommands';
|
||||
import { isRunningUnderNpx } from '../../../src/util/promptfooCommand';
|
||||
|
||||
const mockedGetLatestVersion = vi.mocked(getLatestVersion);
|
||||
const mockedGetUpdateCommands = vi.mocked(getUpdateCommands);
|
||||
const mockedIsRunningUnderNpx = vi.mocked(isRunningUnderNpx);
|
||||
|
||||
describe('Version Route', () => {
|
||||
let app: ReturnType<typeof createApp>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockedIsRunningUnderNpx.mockReturnValue(false);
|
||||
mockedGetUpdateCommands.mockReturnValue({
|
||||
primary: 'npm install -g promptfoo@latest',
|
||||
alternative: 'npx promptfoo@latest',
|
||||
commandType: 'npm',
|
||||
});
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return 200 with valid response schema shape', async () => {
|
||||
mockedGetLatestVersion.mockResolvedValue('99.0.0');
|
||||
|
||||
const response = await request(app).get('/api/version');
|
||||
|
||||
// If schema validation fails (e.g. wrong field names), Response.parse() throws → 500
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
selfHosted: false,
|
||||
isNpx: false,
|
||||
updateCommands: {
|
||||
primary: 'npm install -g promptfoo@latest',
|
||||
alternative: 'npx promptfoo@latest',
|
||||
commandType: 'npm',
|
||||
},
|
||||
commandType: 'npm',
|
||||
});
|
||||
expect(typeof response.body.currentVersion).toBe('string');
|
||||
expect(typeof response.body.latestVersion).toBe('string');
|
||||
expect(typeof response.body.updateAvailable).toBe('boolean');
|
||||
expect(typeof response.body.updateBlockedByRuntime).toBe('boolean');
|
||||
if (process.versions.node.startsWith('20.')) {
|
||||
expect(response.body.runtimeNotice).toMatchObject({ currentMajor: 20, runtime: 'node' });
|
||||
expect(response.body.blockedUpdateNotice).toMatchObject({
|
||||
currentMajor: 20,
|
||||
runtime: 'node',
|
||||
});
|
||||
expect(response.body.runtimePolicy).toEqual({ supportEndDate: '2026-07-30' });
|
||||
} else {
|
||||
expect(response.body.runtimeNotice).toBeNull();
|
||||
expect(response.body.blockedUpdateNotice).toBeNull();
|
||||
expect(response.body.runtimePolicy).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not return 500 when fetch fails (graceful fallback)', async () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2099-01-01T00:00:00.000Z'));
|
||||
mockedGetLatestVersion.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const response = await request(app).get('/api/version');
|
||||
|
||||
// Should still return 200, not 500 — schema must match even on fallback path
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedGetLatestVersion).toHaveBeenCalledTimes(1);
|
||||
expect(typeof response.body.currentVersion).toBe('string');
|
||||
expect(typeof response.body.latestVersion).toBe('string');
|
||||
expect(typeof response.body.updateAvailable).toBe('boolean');
|
||||
expect(typeof response.body.updateBlockedByRuntime).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should skip upstream update checks when they are disabled', async () => {
|
||||
const restoreEnv = mockProcessEnv({ PROMPTFOO_DISABLE_UPDATE: 'true' });
|
||||
|
||||
try {
|
||||
const response = await request(app).get('/api/version');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockedGetLatestVersion).not.toHaveBeenCalled();
|
||||
expect(response.body.latestVersion).toBe(response.body.currentVersion);
|
||||
expect(response.body.updateAvailable).toBe(false);
|
||||
if (process.versions.node.startsWith('20.')) {
|
||||
expect(response.body.runtimePolicy).toEqual({ supportEndDate: '2026-07-30' });
|
||||
}
|
||||
} finally {
|
||||
restoreEnv();
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve blocked update guidance when runtime reminders are disabled', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-07-31T00:00:00.000Z'));
|
||||
vi.spyOn(process, 'version', 'get').mockReturnValue('v20.20.0');
|
||||
const restoreEnv = mockProcessEnv({ PROMPTFOO_DISABLE_RUNTIME_WARNINGS: 'true' });
|
||||
mockedGetLatestVersion.mockResolvedValue('99.0.0');
|
||||
|
||||
try {
|
||||
const response = await request(app).get('/api/version');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
latestVersion: '99.0.0',
|
||||
updateAvailable: false,
|
||||
updateBlockedByRuntime: true,
|
||||
runtimeNotice: null,
|
||||
blockedUpdateNotice: {
|
||||
currentVersion: 'v20.20.0',
|
||||
currentMajor: 20,
|
||||
removalDate: '2026-07-30',
|
||||
minimumVersion: '22.22.0',
|
||||
recommendedVersion: '24 LTS',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
restoreEnv();
|
||||
}
|
||||
});
|
||||
|
||||
it('should include all required fields matching UpdateCommandResult shape', async () => {
|
||||
mockedGetLatestVersion.mockResolvedValue('99.0.0');
|
||||
mockedGetUpdateCommands.mockReturnValue({
|
||||
primary: 'docker pull ghcr.io/promptfoo/promptfoo:latest',
|
||||
alternative: null,
|
||||
commandType: 'docker',
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/version');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// Validates that updateCommands has primary/alternative (not global/npx)
|
||||
expect(response.body.updateCommands).toHaveProperty('primary');
|
||||
expect(response.body.updateCommands).toHaveProperty('alternative');
|
||||
expect(response.body.updateCommands).toHaveProperty('commandType');
|
||||
expect(response.body.updateCommands).not.toHaveProperty('global');
|
||||
expect(response.body.updateCommands).not.toHaveProperty('npx');
|
||||
expect(['docker', 'npx', 'npm']).toContain(response.body.commandType);
|
||||
});
|
||||
|
||||
it('should not classify generic self-hosted mode as Docker', async () => {
|
||||
const restoreEnv = mockProcessEnv({
|
||||
PROMPTFOO_OFFICIAL_DOCKER_IMAGE: undefined,
|
||||
PROMPTFOO_SELF_HOSTED: 'true',
|
||||
});
|
||||
mockedGetLatestVersion.mockResolvedValue('99.0.0');
|
||||
|
||||
try {
|
||||
const response = await request(app).get('/api/version');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.selfHosted).toBe(true);
|
||||
expect(mockedGetUpdateCommands).toHaveBeenCalledWith({
|
||||
isContainer: false,
|
||||
isOfficialDockerImage: false,
|
||||
isNpx: false,
|
||||
});
|
||||
} finally {
|
||||
restoreEnv();
|
||||
}
|
||||
});
|
||||
|
||||
it('should use Docker guidance only when the official-image marker is set', async () => {
|
||||
const restoreEnv = mockProcessEnv({
|
||||
PROMPTFOO_OFFICIAL_DOCKER_IMAGE: 'true',
|
||||
PROMPTFOO_RUNNING_IN_DOCKER: 'true',
|
||||
PROMPTFOO_SELF_HOSTED: 'true',
|
||||
});
|
||||
mockedGetLatestVersion.mockResolvedValue('99.0.0');
|
||||
|
||||
try {
|
||||
const response = await request(app).get('/api/version');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.selfHosted).toBe(true);
|
||||
expect(mockedGetUpdateCommands).toHaveBeenCalledWith({
|
||||
isContainer: true,
|
||||
isOfficialDockerImage: true,
|
||||
isNpx: false,
|
||||
});
|
||||
} finally {
|
||||
restoreEnv();
|
||||
}
|
||||
});
|
||||
|
||||
it('should distinguish custom containers from official images', async () => {
|
||||
const restoreEnv = mockProcessEnv({
|
||||
PROMPTFOO_OFFICIAL_DOCKER_IMAGE: undefined,
|
||||
PROMPTFOO_RUNNING_IN_DOCKER: 'true',
|
||||
PROMPTFOO_SELF_HOSTED: 'true',
|
||||
});
|
||||
mockedGetLatestVersion.mockResolvedValue('99.0.0');
|
||||
mockedGetUpdateCommands.mockReturnValue({
|
||||
primary: '',
|
||||
alternative: null,
|
||||
commandType: 'npm',
|
||||
isCustomContainer: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await request(app).get('/api/version');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
commandType: 'npm',
|
||||
updateCommands: {
|
||||
primary: '',
|
||||
alternative: null,
|
||||
commandType: 'npm',
|
||||
isCustomContainer: true,
|
||||
},
|
||||
});
|
||||
expect(mockedGetUpdateCommands).toHaveBeenCalledWith({
|
||||
isContainer: true,
|
||||
isOfficialDockerImage: false,
|
||||
isNpx: false,
|
||||
});
|
||||
} finally {
|
||||
restoreEnv();
|
||||
}
|
||||
});
|
||||
|
||||
it('should block package updates but preserve Docker updates at the cutoff', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2099-01-01T00:00:00.000Z'));
|
||||
mockedGetLatestVersion.mockRejectedValueOnce(new Error('Seed future retry state'));
|
||||
const futureResponse = await request(app).get('/api/version');
|
||||
expect(futureResponse.status).toBe(200);
|
||||
|
||||
mockedGetLatestVersion.mockReset();
|
||||
mockedGetLatestVersion.mockResolvedValue('99.0.0');
|
||||
vi.setSystemTime(new Date('2026-07-30T00:00:00.000Z'));
|
||||
|
||||
const packageResponse = await request(app).get('/api/version');
|
||||
expect(packageResponse.status).toBe(200);
|
||||
expect(mockedGetLatestVersion).toHaveBeenCalledTimes(1);
|
||||
expect(packageResponse.body.updateBlockedByRuntime).toBe(
|
||||
process.versions.node.startsWith('20.'),
|
||||
);
|
||||
expect(packageResponse.body.updateAvailable).toBe(!process.versions.node.startsWith('20.'));
|
||||
expect(packageResponse.body.blockedUpdateNotice).toEqual(
|
||||
process.versions.node.startsWith('20.')
|
||||
? expect.objectContaining({ currentMajor: 20 })
|
||||
: null,
|
||||
);
|
||||
|
||||
mockedGetUpdateCommands.mockReturnValue({
|
||||
primary: '',
|
||||
alternative: null,
|
||||
commandType: 'npm',
|
||||
isCustomContainer: true,
|
||||
});
|
||||
const customContainerResponse = await request(app).get('/api/version');
|
||||
expect(customContainerResponse.status).toBe(200);
|
||||
expect(customContainerResponse.body.updateBlockedByRuntime).toBe(
|
||||
process.versions.node.startsWith('20.'),
|
||||
);
|
||||
expect(customContainerResponse.body.updateAvailable).toBe(
|
||||
!process.versions.node.startsWith('20.'),
|
||||
);
|
||||
expect(customContainerResponse.body.blockedUpdateNotice).toEqual(
|
||||
process.versions.node.startsWith('20.')
|
||||
? expect.objectContaining({ currentMajor: 20 })
|
||||
: null,
|
||||
);
|
||||
|
||||
mockedGetUpdateCommands.mockReturnValue({
|
||||
primary: 'docker pull ghcr.io/promptfoo/promptfoo:latest',
|
||||
alternative: null,
|
||||
commandType: 'docker',
|
||||
});
|
||||
const dockerResponse = await request(app).get('/api/version');
|
||||
expect(dockerResponse.status).toBe(200);
|
||||
expect(dockerResponse.body.updateBlockedByRuntime).toBe(false);
|
||||
expect(dockerResponse.body.updateAvailable).toBe(true);
|
||||
expect(dockerResponse.body.blockedUpdateNotice).toBeNull();
|
||||
});
|
||||
|
||||
it('should return 500 with fallback response when schema parse fails', async () => {
|
||||
mockedGetLatestVersion.mockResolvedValue('99.0.0');
|
||||
// Return an invalid shape that will cause VersionSchemas.Response.parse() to throw
|
||||
mockedGetUpdateCommands.mockReturnValue({
|
||||
primary: 'npm install -g promptfoo@latest',
|
||||
alternative: 'npx promptfoo@latest',
|
||||
commandType: 'invalid-type' as any,
|
||||
});
|
||||
|
||||
const response = await request(app).get('/api/version');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(response.body).toHaveProperty('error', 'Failed to check version');
|
||||
expect(response.body).toHaveProperty('currentVersion');
|
||||
expect(response.body).toHaveProperty('updateCommands');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import semverGt from 'semver/functions/gt.js';
|
||||
import semverValid from 'semver/functions/valid.js';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getRuntimeNoticeForVersionResponse,
|
||||
getRuntimePolicyForVersionResponse,
|
||||
isUpdateAvailableForRuntime,
|
||||
} from '../../../src/server/routes/versionUtils';
|
||||
|
||||
/**
|
||||
* These tests verify the logic used in src/server/routes/version.ts
|
||||
* for determining when to show the update banner.
|
||||
*
|
||||
* The version route has three key behaviors:
|
||||
* 1. Semantic version comparison (not string comparison)
|
||||
* 2. Development builds never show update banner
|
||||
* 3. Failed fetches are rate-limited to prevent API hammering
|
||||
*/
|
||||
|
||||
// Replicated from version.ts for testing
|
||||
function isDevVersion(version: string): boolean {
|
||||
return version.includes('development') || version === '0.0.0';
|
||||
}
|
||||
|
||||
function isUpdateAvailable(latestVersion: string | null, currentVersion: string): boolean {
|
||||
if (!latestVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDevVersion(currentVersion)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (semverValid(latestVersion) && semverValid(currentVersion)) {
|
||||
return semverGt(latestVersion, currentVersion);
|
||||
}
|
||||
|
||||
return latestVersion !== currentVersion;
|
||||
}
|
||||
|
||||
describe('isDevVersion', () => {
|
||||
it('should return true for development version strings', () => {
|
||||
expect(isDevVersion('0.0.0-development')).toBe(true);
|
||||
expect(isDevVersion('1.0.0-development')).toBe(true);
|
||||
expect(isDevVersion('development')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for 0.0.0', () => {
|
||||
expect(isDevVersion('0.0.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for normal version strings', () => {
|
||||
expect(isDevVersion('1.0.0')).toBe(false);
|
||||
expect(isDevVersion('0.120.9')).toBe(false);
|
||||
expect(isDevVersion('1.2.3-beta.1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUpdateAvailable', () => {
|
||||
describe('basic version comparison', () => {
|
||||
it('should return true when latest version is greater', () => {
|
||||
expect(isUpdateAvailable('1.1.0', '1.0.0')).toBe(true);
|
||||
expect(isUpdateAvailable('2.0.0', '1.9.9')).toBe(true);
|
||||
expect(isUpdateAvailable('1.0.1', '1.0.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when versions are equal', () => {
|
||||
expect(isUpdateAvailable('1.0.0', '1.0.0')).toBe(false);
|
||||
expect(isUpdateAvailable('0.120.9', '0.120.9')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when current version is greater (user on pre-release)', () => {
|
||||
// BUG FIX: User on beta/pre-release should NOT see "downgrade" prompt
|
||||
expect(isUpdateAvailable('1.0.0', '1.1.0-beta.1')).toBe(false);
|
||||
expect(isUpdateAvailable('1.0.0', '2.0.0-alpha')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('development builds', () => {
|
||||
it('should return false for development versions regardless of latest', () => {
|
||||
// BUG FIX: Development builds should never show update banner
|
||||
expect(isUpdateAvailable('1.0.0', '0.0.0-development')).toBe(false);
|
||||
expect(isUpdateAvailable('99.99.99', '0.0.0-development')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for 0.0.0 versions', () => {
|
||||
expect(isUpdateAvailable('1.0.0', '0.0.0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('null/missing latest version', () => {
|
||||
it('should return false when latestVersion is null', () => {
|
||||
expect(isUpdateAvailable(null, '1.0.0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pre-release versions', () => {
|
||||
it('should correctly compare pre-release versions', () => {
|
||||
// Pre-release is less than release
|
||||
expect(isUpdateAvailable('1.0.0', '1.0.0-beta.1')).toBe(true);
|
||||
expect(isUpdateAvailable('1.0.0', '1.0.0-alpha')).toBe(true);
|
||||
|
||||
// Later pre-release
|
||||
expect(isUpdateAvailable('1.0.0-beta.2', '1.0.0-beta.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when user is on newer pre-release', () => {
|
||||
// User on RC should not be prompted to "update" to stable if RC is newer
|
||||
expect(isUpdateAvailable('1.0.0', '1.0.1-rc.1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should fall back to string comparison for invalid semver (different part counts)', () => {
|
||||
// '1.0' is not valid semver (requires 3 parts), so falls back to string comparison
|
||||
// Since '1.0.0' !== '1.0', it returns true (strings are different)
|
||||
expect(isUpdateAvailable('1.0.0', '1.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('should fall back to string comparison for non-semver versions', () => {
|
||||
// If somehow we get non-semver strings, use string inequality
|
||||
expect(isUpdateAvailable('abc', 'def')).toBe(true); // Different strings
|
||||
expect(isUpdateAvailable('abc', 'abc')).toBe(false); // Same strings
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRuntimeNoticeForVersionResponse', () => {
|
||||
it('should omit runtime notices when runtime warnings are disabled', () => {
|
||||
expect(getRuntimeNoticeForVersionResponse('v20.20.2', true)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return applicable runtime notices when warnings are enabled', () => {
|
||||
expect(getRuntimeNoticeForVersionResponse('v20.20.2', false)).toMatchObject({
|
||||
currentMajor: 20,
|
||||
id: 'node20-removal-2026-07-30',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRuntimePolicyForVersionResponse', () => {
|
||||
it('should retain cutoff metadata when runtime warnings are disabled', () => {
|
||||
expect(getRuntimePolicyForVersionResponse('v20.20.2')).toEqual({
|
||||
supportEndDate: '2026-07-30',
|
||||
});
|
||||
});
|
||||
|
||||
it('should omit cutoff metadata for unaffected runtimes', () => {
|
||||
expect(getRuntimePolicyForVersionResponse('v22.22.0')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUpdateAvailableForRuntime', () => {
|
||||
it('should hide runtime-blocked updates from legacy clients', () => {
|
||||
expect(isUpdateAvailableForRuntime(true, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve compatible updates such as Docker image pulls', () => {
|
||||
expect(isUpdateAvailableForRuntime(true, false)).toBe(true);
|
||||
expect(isUpdateAvailableForRuntime(false, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,741 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import http from 'node:http';
|
||||
|
||||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock dependencies before importing the module
|
||||
vi.mock('../../src/migrate', () => ({
|
||||
runDbMigrations: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/database/signal', async () => {
|
||||
const actual = await vi.importActual('../../src/database/signal');
|
||||
return {
|
||||
// Keep the real (pure) signal classifiers so the watcher's emit decisions are exercised.
|
||||
...actual,
|
||||
readSignalFile: vi.fn().mockReturnValue({ type: 'update' }),
|
||||
setupSignalWatcher: vi.fn().mockReturnValue({
|
||||
close: vi.fn(),
|
||||
on: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/models/evalMutation', () => ({
|
||||
invalidateEvaluationCache: vi.fn(),
|
||||
invalidateEvaluationCaches: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/util/server', () => ({
|
||||
BrowserBehavior: { OPEN: 0, SKIP: 1, ASK: 2 },
|
||||
BrowserBehaviorNames: { 0: 'OPEN', 1: 'SKIP', 2: 'ASK' },
|
||||
openBrowser: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/logger', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/models/eval', () => ({
|
||||
default: {
|
||||
findById: vi.fn().mockResolvedValue(null),
|
||||
latest: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
getEvalSummaries: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/server/services/promptCacheService', () => ({
|
||||
promptCacheService: {
|
||||
getAll: vi.fn().mockResolvedValue([]),
|
||||
invalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { readSignalFile, setupSignalWatcher } from '../../src/database/signal';
|
||||
import logger from '../../src/logger';
|
||||
import Eval from '../../src/models/eval';
|
||||
import {
|
||||
invalidateEvaluationCache,
|
||||
invalidateEvaluationCaches,
|
||||
} from '../../src/models/evalMutation';
|
||||
// Import after mocks are set up
|
||||
import { ServerError } from '../../src/server/errors';
|
||||
import { handleServerError, startServer } from '../../src/server/server';
|
||||
import { promptCacheService } from '../../src/server/services/promptCacheService';
|
||||
|
||||
describe('server', () => {
|
||||
describe('handleServerError', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should log specific message for EADDRINUSE error', () => {
|
||||
const error = new Error('Address in use') as NodeJS.ErrnoException;
|
||||
error.code = 'EADDRINUSE';
|
||||
|
||||
const startupError = handleServerError(error, 3000);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Port 3000 is already in use. Do you have another Promptfoo instance running?',
|
||||
);
|
||||
expect(startupError).toBeInstanceOf(ServerError);
|
||||
expect(startupError).toMatchObject({
|
||||
code: 'EADDRINUSE',
|
||||
phase: 'startup',
|
||||
message: 'Port 3000 is already in use. Do you have another Promptfoo instance running?',
|
||||
port: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should log generic message for other errors', () => {
|
||||
const error = new Error('Some other error') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
|
||||
const startupError = handleServerError(error, 3000);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith('Failed to start server: Some other error');
|
||||
expect(startupError).toBeInstanceOf(ServerError);
|
||||
expect(startupError).toMatchObject({
|
||||
code: 'ENOENT',
|
||||
phase: 'startup',
|
||||
message: 'Failed to start server: Some other error',
|
||||
port: 3000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('startServer shutdown behavior', () => {
|
||||
type MockHttpServer = EventEmitter & {
|
||||
listen: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
listening: boolean;
|
||||
};
|
||||
|
||||
let mockHttpServer: MockHttpServer;
|
||||
let originalCreateServer: typeof http.createServer;
|
||||
let signalHandlers: Map<string | symbol, ((...args: unknown[]) => void)[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Track signal handlers using a Map to support multiple handlers per event
|
||||
signalHandlers = new Map();
|
||||
|
||||
vi.spyOn(process, 'once').mockImplementation(
|
||||
(event: string | symbol, handler: (...args: unknown[]) => void) => {
|
||||
if (event === 'SIGINT' || event === 'SIGTERM') {
|
||||
if (!signalHandlers.has(event)) {
|
||||
signalHandlers.set(event, []);
|
||||
}
|
||||
signalHandlers.get(event)!.push(handler);
|
||||
}
|
||||
return process;
|
||||
},
|
||||
);
|
||||
|
||||
// Create mock HTTP server
|
||||
mockHttpServer = Object.assign(new EventEmitter(), {
|
||||
listening: false,
|
||||
listen: vi.fn().mockImplementation(function (
|
||||
this: MockHttpServer,
|
||||
_port: number,
|
||||
callback: () => void,
|
||||
) {
|
||||
// Simulate async listen - server is now listening
|
||||
this.listening = true;
|
||||
setImmediate(() => callback());
|
||||
return this;
|
||||
}),
|
||||
close: vi.fn().mockImplementation(function (
|
||||
this: MockHttpServer,
|
||||
callback?: (err?: Error) => void,
|
||||
) {
|
||||
this.listening = false;
|
||||
setImmediate(() => callback?.());
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock http.createServer
|
||||
originalCreateServer = http.createServer;
|
||||
(http.createServer as unknown) = vi.fn().mockReturnValue(mockHttpServer);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
http.createServer = originalCreateServer;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const triggerSignal = (signal: 'SIGINT' | 'SIGTERM') => {
|
||||
const handlers = signalHandlers.get(signal);
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
handler();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
it('should register SIGINT and SIGTERM handlers', async () => {
|
||||
// Start server and immediately trigger shutdown
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
// Wait for server to start
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(signalHandlers.has('SIGINT')).toBe(true);
|
||||
expect(signalHandlers.has('SIGTERM')).toBe(true);
|
||||
|
||||
// Trigger shutdown to complete the promise
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should reject startup errors without exiting the process', async () => {
|
||||
const startupError = new Error('Address in use') as NodeJS.ErrnoException;
|
||||
startupError.code = 'EADDRINUSE';
|
||||
const mockWatcher = { close: vi.fn(), on: vi.fn() };
|
||||
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
|
||||
|
||||
mockHttpServer.listen = vi.fn().mockImplementation(function (this: MockHttpServer) {
|
||||
setImmediate(() => this.emit('error', startupError));
|
||||
return this;
|
||||
});
|
||||
|
||||
await expect(startServer(3000)).rejects.toMatchObject({
|
||||
name: 'ServerError',
|
||||
code: 'EADDRINUSE',
|
||||
phase: 'startup',
|
||||
message: 'Port 3000 is already in use. Do you have another Promptfoo instance running?',
|
||||
port: 3000,
|
||||
});
|
||||
|
||||
expect(mockWatcher.close).toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Port 3000 is already in use. Do you have another Promptfoo instance running?',
|
||||
);
|
||||
});
|
||||
|
||||
it('should close the live server before rejecting runtime server errors', async () => {
|
||||
const runtimeError = new Error('Unexpected runtime failure') as NodeJS.ErrnoException;
|
||||
const mockWatcher = { close: vi.fn(), on: vi.fn() };
|
||||
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
|
||||
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Server running at http://localhost:0 and monitoring for new evals.',
|
||||
),
|
||||
);
|
||||
mockHttpServer.emit('error', runtimeError);
|
||||
|
||||
await expect(serverPromise).rejects.toMatchObject({
|
||||
name: 'ServerError',
|
||||
phase: 'runtime',
|
||||
message: 'Server error: Unexpected runtime failure',
|
||||
port: 0,
|
||||
});
|
||||
expect(mockWatcher.close).toHaveBeenCalled();
|
||||
expect(mockHttpServer.close).toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith('Server error: Unexpected runtime failure');
|
||||
});
|
||||
|
||||
it('should close file watcher on shutdown', async () => {
|
||||
const mockWatcher = { close: vi.fn(), on: vi.fn() };
|
||||
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
|
||||
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
// Wait for server to start
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Trigger SIGINT
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
|
||||
expect(mockWatcher.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should invalidate process-local evaluation caches when the signal file changes', async () => {
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
// The default unscoped update can touch any eval's cached count, so it clears all of them
|
||||
// (no-arg call) rather than scoping the clear to a single id.
|
||||
expect(invalidateEvaluationCache).toHaveBeenCalledWith();
|
||||
expect(invalidateEvaluationCaches).not.toHaveBeenCalled();
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should invalidate the prompts cache on every signal', async () => {
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() => expect(promptCacheService.invalidate).toHaveBeenCalledOnce());
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should invalidate the prompts cache even when the signal emits no eval update', async () => {
|
||||
// Regression: a signal whose eval has no surviving record (here a scoped update for a
|
||||
// missing eval) emits nothing, but cached `/api/prompts` output must still be dropped so a
|
||||
// later request re-reads the DB. Invalidation must not be gated on a non-empty result/emit.
|
||||
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'update', evalId: 'missing-eval' });
|
||||
vi.mocked(Eval.findById).mockResolvedValueOnce(undefined as never);
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() => expect(promptCacheService.invalidate).toHaveBeenCalledOnce());
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(emitSpy).not.toHaveBeenCalledWith('update', expect.anything());
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should invalidate the prompts cache for a delete signal', async () => {
|
||||
// A delete takes a different branch than an update; the prompt cache must still be dropped.
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'delete', deletedEvalIds: ['gone'] });
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() => expect(promptCacheService.invalidate).toHaveBeenCalledOnce());
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should invalidate the prompts cache for a delete-all signal', async () => {
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'delete' });
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() => expect(promptCacheService.invalidate).toHaveBeenCalledOnce());
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should scope process-local evaluation cache invalidation to the signaled eval', async () => {
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({
|
||||
type: 'update',
|
||||
evalId: 'eval-12345-abcdef',
|
||||
});
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
expect(invalidateEvaluationCaches).toHaveBeenCalledWith(['eval-12345-abcdef']);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should emit scoped updates for legacy evals without normalized result rows', async () => {
|
||||
const getResultsCount = vi.fn().mockResolvedValue(0);
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'update', evalId: 'legacy-eval' });
|
||||
vi.mocked(Eval.findById).mockResolvedValueOnce({
|
||||
id: 'legacy-eval',
|
||||
config: {},
|
||||
getResultsCount,
|
||||
} as never);
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(logger.debug).toHaveBeenCalledWith('Emitting update for eval: legacy-eval'),
|
||||
);
|
||||
expect(getResultsCount).not.toHaveBeenCalled();
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should emit an empty update when no evals remain after a mutation', async () => {
|
||||
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith('update', null));
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should ignore scoped updates for evals that were not persisted', async () => {
|
||||
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'update', evalId: 'missing-eval' });
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(emitSpy).not.toHaveBeenCalledWith('update', null);
|
||||
expect(emitSpy).not.toHaveBeenCalledWith('update', { evalId: 'missing-eval' });
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should broadcast the latest eval for an unscoped mutation', async () => {
|
||||
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
|
||||
vi.mocked(Eval.latest).mockResolvedValueOnce({ id: 'surviving-eval' } as never);
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'surviving-eval' }),
|
||||
);
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('emits the latest eval for an unscoped refresh coalesced with a scoped update', async () => {
|
||||
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
|
||||
// A bare unscoped update coalesced with a scoped one (refreshLatest marker): the pinned eval
|
||||
// refreshes AND the root view still follows the latest eval.
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({
|
||||
type: 'update',
|
||||
evalId: 'eval-scoped',
|
||||
updatedEvalIds: ['eval-scoped'],
|
||||
refreshLatest: true,
|
||||
});
|
||||
vi.mocked(Eval.findById).mockResolvedValueOnce({ id: 'eval-scoped', config: {} } as never);
|
||||
vi.mocked(Eval.latest).mockResolvedValueOnce({ id: 'eval-latest', config: {} } as never);
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'eval-scoped' }),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'eval-latest' }),
|
||||
);
|
||||
// An unscoped refresh clears all count caches (the latest eval id is not known at signal time).
|
||||
expect(invalidateEvaluationCache).toHaveBeenCalledWith();
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should broadcast deleted eval IDs without selecting a surviving eval', async () => {
|
||||
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['deleted-eval'],
|
||||
});
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('update', { deletedEvalIds: ['deleted-eval'] }),
|
||||
);
|
||||
// The deleted eval's count cache must be cleared so a re-import can't reuse a stale count.
|
||||
expect(invalidateEvaluationCaches).toHaveBeenCalledWith(['deleted-eval']);
|
||||
expect(invalidateEvaluationCache).not.toHaveBeenCalled();
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should broadcast an empty id list when a delete signal omits ids (delete-all)', async () => {
|
||||
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
|
||||
// delete-all writes `{type:'delete'}` with no deletedEvalIds; the watcher should still
|
||||
// broadcast a delete with an empty list so clients clear/reload.
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'delete' });
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('update', { deletedEvalIds: [] }),
|
||||
);
|
||||
// A delete-all can invalidate any eval's count, so it clears every cache (no-arg call).
|
||||
expect(invalidateEvaluationCache).toHaveBeenCalledWith();
|
||||
expect(invalidateEvaluationCaches).not.toHaveBeenCalled();
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('should emit both a delete and an update for a coalesced delete+update signal', async () => {
|
||||
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
|
||||
// A delete that folded in a pending scoped update (two mutations coalesced in the window)
|
||||
// must broadcast the removed eval AND refresh clients to the surviving update.
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['deleted-eval'],
|
||||
evalId: 'surviving-eval',
|
||||
});
|
||||
vi.mocked(Eval.findById).mockResolvedValueOnce({
|
||||
id: 'surviving-eval',
|
||||
config: {},
|
||||
} as never);
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('update', { deletedEvalIds: ['deleted-eval'] }),
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'surviving-eval' }),
|
||||
);
|
||||
// Both the surviving update AND the deleted eval are invalidated — the delete component of a
|
||||
// coalesced signal must not leave the removed eval's count cache stale.
|
||||
expect(invalidateEvaluationCaches).toHaveBeenCalledWith(['surviving-eval', 'deleted-eval']);
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('emits an update per eval for a coalesced multi-update signal', async () => {
|
||||
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
|
||||
// Two back-to-back scoped updates coalesced in the window must each refresh their clients.
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({
|
||||
type: 'update',
|
||||
evalId: 'eval-Z',
|
||||
updatedEvalIds: ['eval-Y', 'eval-Z'],
|
||||
});
|
||||
// The watcher resolves the ids in order; mockResolvedValueOnce avoids leaking a persistent
|
||||
// implementation into other (randomly-ordered) tests.
|
||||
vi.mocked(Eval.findById)
|
||||
.mockResolvedValueOnce({ id: 'eval-Y', config: {} } as never)
|
||||
.mockResolvedValueOnce({ id: 'eval-Z', config: {} } as never);
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'eval-Y' }));
|
||||
await vi.waitFor(() => expect(emitSpy).toHaveBeenCalledWith('update', { evalId: 'eval-Z' }));
|
||||
// Every coalesced eval's process-local caches are invalidated, not just the latest.
|
||||
expect(invalidateEvaluationCaches).toHaveBeenCalledWith(['eval-Y', 'eval-Z']);
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('does not resurrect a deleted eval when a coalesced delete carries its own id', async () => {
|
||||
const emitSpy = vi.spyOn(SocketIOServer.prototype, 'emit');
|
||||
// Pathological coalescing: the carried update id is also in deletedEvalIds. The delete
|
||||
// must broadcast, but the update component must NOT emit (findById -> null) — and it must
|
||||
// not fall back to broadcasting the latest eval, since the signal was scoped.
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({
|
||||
type: 'delete',
|
||||
deletedEvalIds: ['doomed-eval'],
|
||||
evalId: 'doomed-eval',
|
||||
});
|
||||
vi.mocked(Eval.findById).mockResolvedValueOnce(undefined as never);
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(emitSpy).toHaveBeenCalledWith('update', { deletedEvalIds: ['doomed-eval'] }),
|
||||
);
|
||||
// Give the async update branch a chance to (not) emit.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(emitSpy).not.toHaveBeenCalledWith('update', { evalId: 'doomed-eval' });
|
||||
expect(emitSpy).not.toHaveBeenCalledWith('update', null);
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
});
|
||||
|
||||
it('keeps the server alive when a signal-triggered eval lookup rejects', async () => {
|
||||
// The watcher callback is fire-and-forget; a rejected DB lookup must be caught and logged
|
||||
// rather than becoming an unhandledRejection that tears down the long-running view server.
|
||||
vi.mocked(readSignalFile).mockReturnValueOnce({ type: 'update', evalId: 'boom-eval' });
|
||||
vi.mocked(Eval.findById).mockRejectedValueOnce(new Error('libsql exploded'));
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const onSignalChange = vi.mocked(setupSignalWatcher).mock.calls[0][0];
|
||||
onSignalChange();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to handle eval signal update'),
|
||||
),
|
||||
);
|
||||
|
||||
// The server is still running and shuts down cleanly.
|
||||
triggerSignal('SIGINT');
|
||||
await expect(serverPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not deadlock the shutdown promise if watcher.close throws', async () => {
|
||||
const mockWatcher = {
|
||||
close: vi.fn().mockImplementation(() => {
|
||||
throw new Error('watcher already closed');
|
||||
}),
|
||||
on: vi.fn(),
|
||||
};
|
||||
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
|
||||
|
||||
const serverPromise = startServer(0);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
triggerSignal('SIGINT');
|
||||
await expect(serverPromise).resolves.toBeUndefined();
|
||||
|
||||
expect(mockWatcher.close).toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error closing file watcher'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not deadlock startup-error rejection if watcher.close throws', async () => {
|
||||
const startupError = new Error('Address in use') as NodeJS.ErrnoException;
|
||||
startupError.code = 'EADDRINUSE';
|
||||
const mockWatcher = {
|
||||
close: vi.fn().mockImplementation(() => {
|
||||
throw new Error('watcher unhappy');
|
||||
}),
|
||||
on: vi.fn(),
|
||||
};
|
||||
vi.mocked(setupSignalWatcher).mockReturnValueOnce(mockWatcher as never);
|
||||
|
||||
mockHttpServer.listen = vi.fn().mockImplementation(function (this: MockHttpServer) {
|
||||
setImmediate(() => this.emit('error', startupError));
|
||||
return this;
|
||||
});
|
||||
|
||||
await expect(startServer(3000)).rejects.toMatchObject({
|
||||
name: 'ServerError',
|
||||
phase: 'startup',
|
||||
});
|
||||
expect(mockWatcher.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log server closure', async () => {
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
// Wait for server to start
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Trigger SIGINT
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Shutting down server...');
|
||||
expect(logger.info).toHaveBeenCalledWith('Server closed');
|
||||
});
|
||||
|
||||
it('should handle httpServer.close error gracefully', async () => {
|
||||
// Mock close to call callback with error
|
||||
mockHttpServer.close = vi.fn().mockImplementation(function (
|
||||
callback?: (err?: Error) => void,
|
||||
) {
|
||||
setImmediate(() => callback?.(new Error('Close failed')));
|
||||
});
|
||||
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
// Wait for server to start
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Trigger SIGINT
|
||||
triggerSignal('SIGINT');
|
||||
await serverPromise;
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith('Error closing server: Close failed');
|
||||
expect(logger.info).toHaveBeenCalledWith('Server closed');
|
||||
});
|
||||
|
||||
// Note: Testing the case where socket.io already closed the HTTP server would require
|
||||
// mocking socket.io at the module level. The fix (checking httpServer.listening) handles
|
||||
// this case in production where io.close() closes the underlying server.
|
||||
//
|
||||
// Note: Testing the 5-second force shutdown timeout requires mocking socket.io
|
||||
// at the module level, which is complex. The timeout exists as a safety measure
|
||||
// and the core shutdown logic is tested by the other tests.
|
||||
|
||||
it('should handle SIGTERM the same as SIGINT', async () => {
|
||||
const serverPromise = startServer(0);
|
||||
|
||||
// Wait for server to start
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Trigger SIGTERM instead of SIGINT
|
||||
triggerSignal('SIGTERM');
|
||||
await serverPromise;
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Shutting down server...');
|
||||
expect(logger.info).toHaveBeenCalledWith('Server closed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EvalJobService } from '../../../src/server/services/evalJobService';
|
||||
|
||||
describe('EvalJobService', () => {
|
||||
it('creates an in-progress job', () => {
|
||||
const service = new EvalJobService();
|
||||
|
||||
expect(service.create('job-1')).toEqual({
|
||||
evalId: null,
|
||||
status: 'in-progress',
|
||||
progress: 0,
|
||||
total: 0,
|
||||
result: null,
|
||||
logs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks progress, completion, and logs', () => {
|
||||
const service = new EvalJobService();
|
||||
service.create('job-1');
|
||||
|
||||
expect(service.setProgress('job-1', 2, 5)).toBe(true);
|
||||
expect(service.appendLog('job-1', 'working')).toBe(true);
|
||||
expect(service.complete('job-1', { results: [] } as never, 'eval-1')).toBe(true);
|
||||
|
||||
expect(service.get('job-1')).toMatchObject({
|
||||
evalId: 'eval-1',
|
||||
status: 'complete',
|
||||
progress: 2,
|
||||
total: 5,
|
||||
result: { results: [] },
|
||||
logs: ['working'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns defensive snapshots', () => {
|
||||
const service = new EvalJobService();
|
||||
service.create('job-1');
|
||||
const result = { results: [{ output: { nested: 'original' } }] } as never;
|
||||
service.complete('job-1', result, 'eval-1');
|
||||
|
||||
const snapshot = service.get('job-1');
|
||||
expect(snapshot).toBeDefined();
|
||||
snapshot?.logs.push('mutated outside service');
|
||||
(snapshot?.result as any).results[0].output.nested = 'mutated outside service';
|
||||
(result as any).results[0].output.nested = 'mutated after completion';
|
||||
|
||||
expect(service.get('job-1')?.logs).toEqual([]);
|
||||
expect((service.get('job-1')?.result as any).results[0].output.nested).toBe('original');
|
||||
});
|
||||
|
||||
it('stores JSON-safe snapshots for function-backed prompts', () => {
|
||||
const service = new EvalJobService();
|
||||
service.create('job-1');
|
||||
|
||||
expect(() =>
|
||||
service.complete(
|
||||
'job-1',
|
||||
{
|
||||
prompts: [{ function: () => 'generated prompt', label: 'dynamic prompt' }],
|
||||
results: [],
|
||||
} as never,
|
||||
'eval-1',
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(service.get('job-1')?.result).toEqual({
|
||||
prompts: [{ label: 'dynamic prompt' }],
|
||||
results: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('stores JSON-safe snapshots for circular references and bigint values', () => {
|
||||
const service = new EvalJobService();
|
||||
service.create('job-1');
|
||||
const output: { count: bigint; self?: unknown } = { count: 42n };
|
||||
output.self = output;
|
||||
|
||||
expect(() =>
|
||||
service.complete(
|
||||
'job-1',
|
||||
{
|
||||
results: [{ response: { output } }],
|
||||
} as never,
|
||||
'eval-1',
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(service.get('job-1')?.result).toEqual({
|
||||
results: [{ response: { output: { count: '42' } } }],
|
||||
});
|
||||
});
|
||||
|
||||
it('supports replacing and appending failure logs', () => {
|
||||
const service = new EvalJobService();
|
||||
service.create('job-1');
|
||||
service.appendLog('job-1', 'before failure');
|
||||
|
||||
expect(service.fail('job-1', ['first failure'])).toBe(true);
|
||||
expect(service.get('job-1')?.logs).toEqual(['first failure']);
|
||||
|
||||
expect(service.fail('job-1', ['second failure'], { append: true })).toBe(true);
|
||||
expect(service.get('job-1')).toMatchObject({
|
||||
evalId: null,
|
||||
status: 'error',
|
||||
result: null,
|
||||
logs: ['first failure', 'second failure'],
|
||||
});
|
||||
});
|
||||
|
||||
it('can preserve completed results when marking a job as failed', () => {
|
||||
const service = new EvalJobService();
|
||||
service.create('job-1');
|
||||
service.complete('job-1', { results: [] } as never, 'eval-1');
|
||||
|
||||
expect(
|
||||
service.fail('job-1', ['cancelled after completion'], {
|
||||
append: true,
|
||||
resetResult: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(service.get('job-1')).toMatchObject({
|
||||
evalId: 'eval-1',
|
||||
status: 'error',
|
||||
result: { results: [] },
|
||||
logs: ['cancelled after completion'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns false when updating a missing job', () => {
|
||||
const service = new EvalJobService();
|
||||
|
||||
expect(service.setProgress('missing', 1, 1)).toBe(false);
|
||||
expect(service.complete('missing', null, null)).toBe(false);
|
||||
expect(service.fail('missing', ['missing'])).toBe(false);
|
||||
expect(service.appendLog('missing', 'missing')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PromptCacheService } from '../../../src/server/services/promptCacheService';
|
||||
import { getPrompts } from '../../../src/util/database';
|
||||
import { createDeferred } from '../../util/utils';
|
||||
|
||||
vi.mock('../../../src/util/database', () => ({
|
||||
getPrompts: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('PromptCacheService', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('caches prompts until invalidated', async () => {
|
||||
vi.mocked(getPrompts)
|
||||
.mockResolvedValueOnce([{ id: 'first' }] as never)
|
||||
.mockResolvedValueOnce([{ id: 'second' }] as never);
|
||||
const service = new PromptCacheService();
|
||||
|
||||
expect(await service.getAll()).toEqual([{ id: 'first' }]);
|
||||
expect(await service.getAll()).toEqual([{ id: 'first' }]);
|
||||
expect(getPrompts).toHaveBeenCalledOnce();
|
||||
|
||||
service.invalidate();
|
||||
|
||||
expect(await service.getAll()).toEqual([{ id: 'second' }]);
|
||||
expect(getPrompts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not cache a load that resolves after invalidation', async () => {
|
||||
const pending = createDeferred<Awaited<ReturnType<typeof getPrompts>>>();
|
||||
vi.mocked(getPrompts)
|
||||
.mockReturnValueOnce(pending.promise)
|
||||
.mockResolvedValueOnce([{ id: 'fresh' }] as never);
|
||||
const service = new PromptCacheService();
|
||||
|
||||
const staleRequest = service.getAll();
|
||||
service.invalidate();
|
||||
pending.resolve([{ id: 'stale' }] as never);
|
||||
|
||||
await expect(staleRequest).resolves.toEqual([{ id: 'stale' }]);
|
||||
await expect(service.getAll()).resolves.toEqual([{ id: 'fresh' }]);
|
||||
expect(getPrompts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retries after a rejected load', async () => {
|
||||
vi.mocked(getPrompts)
|
||||
.mockRejectedValueOnce(new Error('database unavailable'))
|
||||
.mockResolvedValueOnce([{ id: 'fresh' }] as never);
|
||||
const service = new PromptCacheService();
|
||||
|
||||
await expect(service.getAll()).rejects.toThrow('database unavailable');
|
||||
await expect(service.getAll()).resolves.toEqual([{ id: 'fresh' }]);
|
||||
expect(getPrompts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createMockResponse } from '../../util/utils';
|
||||
|
||||
import type { MultiTurnPromptParams } from '../../../src/server/services/redteamTestCaseGenerationService';
|
||||
|
||||
async function getExpectedRemoteGenerationUrl() {
|
||||
const { getRemoteGenerationUrl } = await vi.importActual<
|
||||
typeof import('../../../src/redteam/remoteGeneration')
|
||||
>('../../../src/redteam/remoteGeneration');
|
||||
return getRemoteGenerationUrl();
|
||||
}
|
||||
const TEST_REQUEST_TIMEOUT_MS = 300000;
|
||||
const MOCKED_MODULES = [
|
||||
'../../../src/util/fetch/index',
|
||||
'../../../src/redteam/remoteGeneration',
|
||||
'../../../src/providers/shared',
|
||||
'../../../src/constants',
|
||||
];
|
||||
|
||||
function mockRemoteGeneration(responseBody: unknown, rejectWith?: Error) {
|
||||
const fetchWithRetries = rejectWith
|
||||
? vi.fn().mockRejectedValueOnce(rejectWith)
|
||||
: vi.fn().mockResolvedValueOnce(
|
||||
createMockResponse({
|
||||
body: responseBody,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.doMock('../../../src/util/fetch/index', () => ({
|
||||
fetchWithRetries,
|
||||
}));
|
||||
vi.doMock('../../../src/redteam/remoteGeneration', async () => ({
|
||||
getRemoteGenerationUrl: vi.fn().mockReturnValue(await getExpectedRemoteGenerationUrl()),
|
||||
getRemoteGenerationHeaders: vi.fn((extra) => ({
|
||||
'Content-Type': 'application/json',
|
||||
...extra,
|
||||
})),
|
||||
neverGenerateRemote: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
vi.doMock('../../../src/providers/shared', () => ({
|
||||
getRequestTimeoutMs: () => TEST_REQUEST_TIMEOUT_MS,
|
||||
}));
|
||||
vi.doMock('../../../src/constants', () => ({
|
||||
VERSION: '0.0.0-test',
|
||||
}));
|
||||
|
||||
return fetchWithRetries;
|
||||
}
|
||||
|
||||
async function generatePromptForStrategy(strategyId: MultiTurnPromptParams['strategyId']) {
|
||||
const { generateMultiTurnPrompt } = await import(
|
||||
'../../../src/server/services/redteamTestCaseGenerationService'
|
||||
);
|
||||
|
||||
await generateMultiTurnPrompt({
|
||||
pluginId: 'harmful:hate',
|
||||
strategyId,
|
||||
strategyConfigRecord: {},
|
||||
history: [],
|
||||
turn: 0,
|
||||
maxTurns: 5,
|
||||
baseMetadata: { pluginConfig: {} },
|
||||
generatedPrompt: 'initial prompt',
|
||||
purpose: 'test purpose',
|
||||
});
|
||||
}
|
||||
|
||||
async function expectTaskRequest(fetchWithRetries: ReturnType<typeof vi.fn>, expectedTask: string) {
|
||||
expect(fetchWithRetries).toHaveBeenCalledTimes(1);
|
||||
const [url, request, timeout] = fetchWithRetries.mock.calls[0]!;
|
||||
const body = JSON.parse(String(request.body));
|
||||
|
||||
expect(url).toBe(await getExpectedRemoteGenerationUrl());
|
||||
expect(request).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(body.task).toBe(expectedTask);
|
||||
expect(timeout).toBe(TEST_REQUEST_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
describe('redteamTestCaseGenerationService', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
for (const modulePath of MOCKED_MODULES) {
|
||||
vi.doUnmock(modulePath);
|
||||
}
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('multi-turn strategy handlers use fetchWithRetries', () => {
|
||||
it('should call fetchWithRetries with correct parameters for GOAT strategy', async () => {
|
||||
const fetchWithRetries = mockRemoteGeneration({
|
||||
message: { content: 'test prompt' },
|
||||
tokenUsage: { total: 100 },
|
||||
});
|
||||
|
||||
await generatePromptForStrategy('goat');
|
||||
|
||||
await expectTaskRequest(fetchWithRetries, 'goat');
|
||||
});
|
||||
|
||||
it('should propagate remote generation failures', async () => {
|
||||
const remoteError = new Error('remote generation failed');
|
||||
const fetchWithRetries = mockRemoteGeneration(undefined, remoteError);
|
||||
|
||||
await expect(generatePromptForStrategy('goat')).rejects.toThrow('remote generation failed');
|
||||
expect(fetchWithRetries).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call fetchWithRetries with correct parameters for Crescendo strategy', async () => {
|
||||
const fetchWithRetries = mockRemoteGeneration({
|
||||
result: {
|
||||
generatedQuestion: 'test question',
|
||||
lastResponseSummary: 'summary',
|
||||
rationaleBehindJailbreak: 'rationale',
|
||||
},
|
||||
});
|
||||
|
||||
await generatePromptForStrategy('crescendo');
|
||||
|
||||
await expectTaskRequest(fetchWithRetries, 'crescendo');
|
||||
});
|
||||
|
||||
it('should call fetchWithRetries with correct parameters for Hydra strategy', async () => {
|
||||
const fetchWithRetries = mockRemoteGeneration({
|
||||
result: { prompt: 'test prompt' },
|
||||
});
|
||||
|
||||
await generatePromptForStrategy('jailbreak:hydra');
|
||||
|
||||
await expectTaskRequest(fetchWithRetries, 'hydra-decision');
|
||||
});
|
||||
|
||||
it('should call fetchWithRetries with correct parameters for Mischievous User strategy', async () => {
|
||||
const fetchWithRetries = mockRemoteGeneration({
|
||||
result: 'test prompt',
|
||||
});
|
||||
|
||||
await generatePromptForStrategy('mischievous-user');
|
||||
|
||||
await expectTaskRequest(fetchWithRetries, 'mischievous-user-redteam');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||
import { setDownloadHeaders } from '../../../src/server/utils/downloadHelpers';
|
||||
import type { Response } from 'express';
|
||||
|
||||
describe('downloadHelpers', () => {
|
||||
describe('setDownloadHeaders', () => {
|
||||
let mockRes: Partial<Response>;
|
||||
let setHeaderMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
setHeaderMock = vi.fn().mockReturnThis();
|
||||
mockRes = {
|
||||
setHeader: setHeaderMock,
|
||||
} as Partial<Response>;
|
||||
});
|
||||
|
||||
it('should set correct headers for CSV download', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'test-file.csv', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', 'text/csv');
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="test-file.csv"',
|
||||
);
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Cache-Control',
|
||||
'no-cache, no-store, must-revalidate',
|
||||
);
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('Pragma', 'no-cache');
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('Expires', '0');
|
||||
});
|
||||
|
||||
it('should set correct headers for JSON download', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'data.json', 'application/json');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', 'application/json');
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="data.json"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle special characters in filename', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'file with spaces & special.csv', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="file with spaces & special.csv"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle very long filenames', () => {
|
||||
const longFileName = 'a'.repeat(255) + '.csv';
|
||||
setDownloadHeaders(mockRes as Response, longFileName, 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${longFileName}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Unicode characters in filename', () => {
|
||||
setDownloadHeaders(mockRes as Response, '测试文件.csv', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="测试文件.csv"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle different content types', () => {
|
||||
const testCases = [
|
||||
{ fileName: 'file.pdf', contentType: 'application/pdf' },
|
||||
{ fileName: 'file.xml', contentType: 'application/xml' },
|
||||
{ fileName: 'file.txt', contentType: 'text/plain' },
|
||||
{ fileName: 'file.yaml', contentType: 'application/x-yaml' },
|
||||
];
|
||||
|
||||
testCases.forEach(({ fileName, contentType }) => {
|
||||
setHeaderMock.mockClear();
|
||||
setDownloadHeaders(mockRes as Response, fileName, contentType);
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('Content-Type', contentType);
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${fileName}"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set all cache control headers', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'test.csv', 'text/csv');
|
||||
|
||||
// Verify all cache-preventing headers are set
|
||||
const calls = setHeaderMock.mock.calls;
|
||||
const headerMap = new Map(calls.map((call) => [call[0] as string, call[1]]));
|
||||
|
||||
expect(headerMap.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate');
|
||||
expect(headerMap.get('Pragma')).toBe('no-cache');
|
||||
expect(headerMap.get('Expires')).toBe('0');
|
||||
});
|
||||
|
||||
it('should be called exactly 5 times for all headers', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'test.csv', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('should handle empty filename', () => {
|
||||
setDownloadHeaders(mockRes as Response, '', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename=""');
|
||||
});
|
||||
|
||||
it('should handle filename with quotes', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'file"with"quotes.csv', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="file"with"quotes.csv"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle filename with path separators', () => {
|
||||
setDownloadHeaders(mockRes as Response, 'path/to/file.csv', 'text/csv');
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'attachment; filename="path/to/file.csv"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import logger from '../../../src/logger';
|
||||
import { replyValidationError, sendError } from '../../../src/server/utils/errors';
|
||||
import type { Response } from 'express';
|
||||
|
||||
vi.mock('../../../src/logger', () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function createResponseMock(): {
|
||||
res: Response;
|
||||
status: Mock;
|
||||
json: Mock;
|
||||
} {
|
||||
const json = vi.fn().mockReturnThis();
|
||||
const status = vi.fn(() => ({ json }));
|
||||
return {
|
||||
res: { status, json } as unknown as Response,
|
||||
status: status as unknown as Mock,
|
||||
json,
|
||||
};
|
||||
}
|
||||
|
||||
describe('server/utils/errors', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('sendError', () => {
|
||||
it('returns the public message and never leaks the internal error', () => {
|
||||
const { res, status, json } = createResponseMock();
|
||||
|
||||
sendError(res, 500, 'Failed to do thing', new Error('secret db password leaked'));
|
||||
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
const body = json.mock.calls[0]?.[0];
|
||||
expect(body).toEqual({ error: 'Failed to do thing' });
|
||||
expect(JSON.stringify(body)).not.toContain('secret db password');
|
||||
});
|
||||
|
||||
it('skips logging when no internal error is provided', () => {
|
||||
const { res } = createResponseMock();
|
||||
|
||||
sendError(res, 404, 'Not found');
|
||||
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs Error name, message, and stack as a structured object', () => {
|
||||
const { res } = createResponseMock();
|
||||
const err = new Error('boom');
|
||||
err.stack = 'Error: boom\n at someFn';
|
||||
|
||||
sendError(res, 500, 'Failed', err);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
const [message, context] = (logger.error as unknown as Mock).mock.calls[0];
|
||||
expect(message).toBe('Failed');
|
||||
expect(context).toEqual({
|
||||
error: {
|
||||
name: 'Error',
|
||||
message: 'boom',
|
||||
stack: 'Error: boom\n at someFn',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves Error.cause when present, serialized to retain its stack', () => {
|
||||
const { res } = createResponseMock();
|
||||
const cause = new Error('underlying');
|
||||
cause.stack = 'Error: underlying\n at someInnerFn';
|
||||
const err = new Error('wrapper', { cause });
|
||||
|
||||
sendError(res, 500, 'Failed', err);
|
||||
|
||||
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
|
||||
const ctx = context as { error: { cause: { name: string; message: string; stack: string } } };
|
||||
expect(ctx.error.cause.name).toBe('Error');
|
||||
expect(ctx.error.cause.message).toBe('underlying');
|
||||
// Stack must survive — serializing the raw Error reference would have
|
||||
// dropped it via JSON.stringify, which is the regression this helper exists to prevent.
|
||||
expect(ctx.error.cause.stack).toContain('someInnerFn');
|
||||
});
|
||||
|
||||
it('passes non-Error values through verbatim', () => {
|
||||
const { res } = createResponseMock();
|
||||
|
||||
sendError(res, 500, 'Failed', 'plain string error');
|
||||
|
||||
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
|
||||
expect(context).toEqual({ error: 'plain string error' });
|
||||
});
|
||||
|
||||
it('logs `null` as a real (no-detail) failure rather than skipping', () => {
|
||||
// Pin current behavior: the guard is `internalError !== undefined`, so
|
||||
// `null` reaches the logger. This is intentional — a future refactor to
|
||||
// `if (internalError)` would silently swallow falsy-but-real signals
|
||||
// (e.g. `0`, `false`, `''`).
|
||||
const { res } = createResponseMock();
|
||||
|
||||
sendError(res, 500, 'Failed', null);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1);
|
||||
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
|
||||
expect(context).toEqual({ error: null });
|
||||
});
|
||||
|
||||
it('preserves enumerable diagnostic fields on Error subclasses', () => {
|
||||
// Node SystemError carries `code`/`errno`/`syscall`/`path` as enumerable
|
||||
// own props; AWS SDK errors carry `$metadata`/`$fault`; fetch errors
|
||||
// carry `code`/`errno`. Logging the explicit four fields without
|
||||
// spreading would silently drop these — exactly the production-triage
|
||||
// regression flagged by the Codex P2 reviewer on this PR.
|
||||
const { res } = createResponseMock();
|
||||
const sysErr = Object.assign(new Error('ENOENT: no such file or directory, open ...'), {
|
||||
code: 'ENOENT',
|
||||
errno: -2,
|
||||
syscall: 'open',
|
||||
path: '/missing/file',
|
||||
});
|
||||
|
||||
sendError(res, 500, 'Failed', sysErr);
|
||||
|
||||
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
|
||||
const ctx = context as {
|
||||
error: {
|
||||
name: string;
|
||||
message: string;
|
||||
stack: string;
|
||||
code: string;
|
||||
errno: number;
|
||||
syscall: string;
|
||||
path: string;
|
||||
};
|
||||
};
|
||||
expect(ctx.error.name).toBe('Error');
|
||||
expect(ctx.error.code).toBe('ENOENT');
|
||||
expect(ctx.error.errno).toBe(-2);
|
||||
expect(ctx.error.syscall).toBe('open');
|
||||
expect(ctx.error.path).toBe('/missing/file');
|
||||
// Standard fields still win even though the subclass also has its own.
|
||||
expect(typeof ctx.error.stack).toBe('string');
|
||||
});
|
||||
|
||||
it('preserves Error subclass name (TypeError, custom subclasses)', () => {
|
||||
const { res } = createResponseMock();
|
||||
class CustomError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CustomError';
|
||||
}
|
||||
}
|
||||
|
||||
sendError(res, 500, 'Failed', new TypeError('not a string'));
|
||||
sendError(res, 500, 'Failed', new CustomError('bespoke'));
|
||||
|
||||
const calls = (logger.error as unknown as Mock).mock.calls;
|
||||
expect((calls[0][1] as { error: { name: string } }).error.name).toBe('TypeError');
|
||||
expect((calls[1][1] as { error: { name: string } }).error.name).toBe('CustomError');
|
||||
});
|
||||
|
||||
it('recursively unwraps Error.cause chains so nested stacks survive serialization', () => {
|
||||
const { res } = createResponseMock();
|
||||
const root = new Error('root cause');
|
||||
root.stack = 'Error: root cause\n at deep';
|
||||
const middle = new Error('middle', { cause: root });
|
||||
middle.stack = 'Error: middle\n at middle';
|
||||
const top = new Error('top', { cause: middle });
|
||||
top.stack = 'Error: top\n at top';
|
||||
|
||||
sendError(res, 500, 'Failed', top);
|
||||
|
||||
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
|
||||
const ctx = context as {
|
||||
error: {
|
||||
stack: string;
|
||||
cause: { stack: string; cause: { stack: string } };
|
||||
};
|
||||
};
|
||||
expect(ctx.error.stack).toContain('top');
|
||||
expect(ctx.error.cause.stack).toContain('middle');
|
||||
expect(ctx.error.cause.cause.stack).toContain('deep');
|
||||
});
|
||||
|
||||
it('does not crash on a circular Error.cause', () => {
|
||||
const { res, status } = createResponseMock();
|
||||
const cyclic = new Error('cyclic') as Error & { cause?: unknown };
|
||||
cyclic.cause = cyclic;
|
||||
|
||||
expect(() => sendError(res, 500, 'Failed', cyclic)).not.toThrow();
|
||||
// The response must still ship — cycle detection in serializeError is
|
||||
// the entire reason this canonical 500 funnel doesn't unhandled-throw.
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
});
|
||||
|
||||
it('caps long Error.cause chains before serializing unbounded logger context', () => {
|
||||
const { res } = createResponseMock();
|
||||
const chain = Array.from({ length: 6 }, (_, index) => {
|
||||
const err = new Error(`level-${index}`) as Error & { cause?: unknown };
|
||||
err.stack = `Error: level-${index}\n at level${index}`;
|
||||
return err;
|
||||
});
|
||||
for (let i = 0; i < chain.length - 1; i++) {
|
||||
chain[i].cause = chain[i + 1];
|
||||
}
|
||||
|
||||
sendError(res, 500, 'Failed', chain[0]);
|
||||
|
||||
const [, context] = (logger.error as unknown as Mock).mock.calls[0];
|
||||
const cappedCause = (
|
||||
context as {
|
||||
error: { cause: { cause: { cause: { cause: unknown } } } };
|
||||
}
|
||||
).error.cause.cause.cause.cause;
|
||||
expect(cappedCause).toEqual({ name: 'Error', message: 'level-4' });
|
||||
expect(JSON.stringify(context)).not.toContain('level-5');
|
||||
});
|
||||
|
||||
it('falls back to a hand-built envelope if ErrorResponseSchema.parse throws', async () => {
|
||||
// Force the schema parse to throw, simulating a future tightening that
|
||||
// rejects what `sendError` produces. Without the safeRespond fallback,
|
||||
// the throw would escape the handler's try/catch and land in Express's
|
||||
// default error path (empty 500 with no JSON body).
|
||||
const common = await import('../../../src/types/api/common');
|
||||
const parseSpy = vi.spyOn(common.ErrorResponseSchema, 'parse').mockImplementationOnce(() => {
|
||||
throw new Error('forced schema rejection');
|
||||
});
|
||||
|
||||
try {
|
||||
const { res, status, json } = createResponseMock();
|
||||
|
||||
expect(() => sendError(res, 500, 'Public message')).not.toThrow();
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
expect(json.mock.calls[0]?.[0]).toEqual({ error: 'Public message' });
|
||||
expect(parseSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
parseSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('replyValidationError', () => {
|
||||
let zodError: z.ZodError;
|
||||
|
||||
beforeEach(() => {
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number().int().nonnegative(),
|
||||
});
|
||||
const result = schema.safeParse({ name: 42, age: -1 });
|
||||
if (result.success) {
|
||||
throw new Error('expected parse to fail');
|
||||
}
|
||||
zodError = result.error;
|
||||
});
|
||||
|
||||
it('returns a 400 with prettified message in `error`', () => {
|
||||
const { res, status, json } = createResponseMock();
|
||||
|
||||
replyValidationError(res, zodError);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
const body = json.mock.calls[0]?.[0] as { error: string };
|
||||
expect(typeof body.error).toBe('string');
|
||||
expect(body.error.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('exposes structured Zod issues in `details` for programmatic clients', () => {
|
||||
const { res, json } = createResponseMock();
|
||||
|
||||
replyValidationError(res, zodError);
|
||||
|
||||
const body = json.mock.calls[0]?.[0] as {
|
||||
details: { issues: z.core.$ZodIssue[] };
|
||||
};
|
||||
expect(Array.isArray(body.details.issues)).toBe(true);
|
||||
expect(body.details.issues.length).toBe(zodError.issues.length);
|
||||
const paths = body.details.issues.map((issue) => issue.path.join('.'));
|
||||
expect(paths).toContain('name');
|
||||
expect(paths).toContain('age');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as legacyEvalTableUtils from '../../../src/server/utils/evalTableUtils';
|
||||
import * as evalTableUtils from '../../../src/util/eval/evalTableUtils';
|
||||
|
||||
describe('legacy evalTableUtils import', () => {
|
||||
it('re-exports the node-layer implementation for enterprise compatibility', () => {
|
||||
expect(legacyEvalTableUtils.evalTableToCsv).toBe(evalTableUtils.evalTableToCsv);
|
||||
expect(legacyEvalTableUtils.streamEvalCsv).toBe(evalTableUtils.streamEvalCsv);
|
||||
expect(legacyEvalTableUtils.mergeComparisonTables).toBe(evalTableUtils.mergeComparisonTables);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,507 @@
|
||||
{
|
||||
"data": {
|
||||
"version": 3,
|
||||
"createdAt": "2024-10-02T16:30:21.627Z",
|
||||
"author": "steve@promptfoo.dev",
|
||||
"results": {
|
||||
"version": 2,
|
||||
"timestamp": "2024-10-02T16:29:47.070Z",
|
||||
"results": [
|
||||
{
|
||||
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
|
||||
"prompt": {
|
||||
"raw": "Translate this to conversational French: I'm hungry",
|
||||
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
|
||||
},
|
||||
"vars": { "language": "French", "body": "I'm hungry" },
|
||||
"response": {
|
||||
"output": "J'ai faim.",
|
||||
"tokenUsage": { "cached": 19, "total": 19 },
|
||||
"cached": true,
|
||||
"cost": 0.0000046499999999999995
|
||||
},
|
||||
"success": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"latencyMs": 2,
|
||||
"cost": 0.0000046499999999999995,
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
|
||||
"prompt": {
|
||||
"raw": "Rephrase this in French: I'm hungry",
|
||||
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
|
||||
},
|
||||
"vars": { "language": "French", "body": "I'm hungry" },
|
||||
"response": {
|
||||
"output": "J'ai faim.",
|
||||
"tokenUsage": { "cached": 19, "total": 19 },
|
||||
"cached": true,
|
||||
"cost": 0.0000046499999999999995
|
||||
},
|
||||
"success": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"latencyMs": 4,
|
||||
"cost": 0.0000046499999999999995,
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
|
||||
"prompt": {
|
||||
"raw": "Rephrase this in French: Hello world",
|
||||
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
|
||||
},
|
||||
"vars": { "language": "French", "body": "Hello world" },
|
||||
"response": {
|
||||
"output": "Bonjour le monde.",
|
||||
"tokenUsage": { "cached": 19, "total": 19 },
|
||||
"cached": true,
|
||||
"cost": 0.0000046499999999999995
|
||||
},
|
||||
"success": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"latencyMs": 6,
|
||||
"cost": 0.0000046499999999999995,
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
|
||||
"prompt": {
|
||||
"raw": "Translate this to conversational French: Hello world",
|
||||
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
|
||||
},
|
||||
"vars": { "language": "French", "body": "Hello world" },
|
||||
"response": {
|
||||
"output": "Salut tout le monde !",
|
||||
"tokenUsage": { "cached": 20, "total": 20 },
|
||||
"cached": true,
|
||||
"cost": 0.0000052500000000000006
|
||||
},
|
||||
"success": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"latencyMs": 6,
|
||||
"cost": 0.0000052500000000000006,
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
|
||||
"prompt": {
|
||||
"raw": "Rephrase this in Pirate: Hello world",
|
||||
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
|
||||
},
|
||||
"vars": { "language": "Pirate", "body": "Hello world" },
|
||||
"response": {
|
||||
"output": "Ahoy, me hearties!",
|
||||
"tokenUsage": { "cached": 22, "total": 22 },
|
||||
"cached": true,
|
||||
"cost": 0.000006449999999999999
|
||||
},
|
||||
"success": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"latencyMs": 3,
|
||||
"cost": 0.000006449999999999999,
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
|
||||
"prompt": {
|
||||
"raw": "Translate this to conversational Pirate: Hello world",
|
||||
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
|
||||
},
|
||||
"vars": { "language": "Pirate", "body": "Hello world" },
|
||||
"response": {
|
||||
"output": "Ahoy, me hearty! Greetings to ye, world!",
|
||||
"tokenUsage": { "cached": 27, "total": 27 },
|
||||
"cached": true,
|
||||
"cost": 0.00000945
|
||||
},
|
||||
"success": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"latencyMs": 3,
|
||||
"cost": 0.00000945,
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
|
||||
"prompt": {
|
||||
"raw": "Rephrase this in Pirate: I'm hungry",
|
||||
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
|
||||
},
|
||||
"vars": { "language": "Pirate", "body": "I'm hungry" },
|
||||
"response": {
|
||||
"output": "Arrr, me belly be grumblin'!",
|
||||
"tokenUsage": { "cached": 27, "total": 27 },
|
||||
"cached": true,
|
||||
"cost": 0.00000945
|
||||
},
|
||||
"success": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"latencyMs": 2,
|
||||
"cost": 0.00000945,
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"provider": { "id": "openai:gpt-4o-mini", "label": "" },
|
||||
"prompt": {
|
||||
"raw": "Translate this to conversational Pirate: I'm hungry",
|
||||
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
|
||||
},
|
||||
"vars": { "language": "Pirate", "body": "I'm hungry" },
|
||||
"response": {
|
||||
"output": "Arrr, me belly be rumblin'! I be feelin' mighty hungry, matey!",
|
||||
"tokenUsage": { "cached": 37, "total": 37 },
|
||||
"cached": true,
|
||||
"cost": 0.00001545
|
||||
},
|
||||
"success": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"latencyMs": 1,
|
||||
"cost": 0.00001545,
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"successes": 8,
|
||||
"failures": 0,
|
||||
"tokenUsage": { "total": 190, "prompt": 0, "completion": 0, "cached": 190 }
|
||||
},
|
||||
"table": {
|
||||
"head": {
|
||||
"prompts": [
|
||||
{
|
||||
"raw": "Rephrase this in {{language}}: {{body}}",
|
||||
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}",
|
||||
"id": "ad447337160016cbd0a5c7f2626d0c1fe2ade0d4d653e20bed9250632bc56e03",
|
||||
"provider": "openai:gpt-4o-mini",
|
||||
"metrics": {
|
||||
"score": 4,
|
||||
"testPassCount": 4,
|
||||
"testFailCount": 0,
|
||||
"assertPassCount": 0,
|
||||
"assertFailCount": 0,
|
||||
"totalLatencyMs": 15,
|
||||
"tokenUsage": { "total": 87, "prompt": 0, "completion": 0, "cached": 87 },
|
||||
"namedScores": {},
|
||||
"namedScoresCount": {},
|
||||
"cost": 0.000025199999999999996
|
||||
}
|
||||
},
|
||||
{
|
||||
"raw": "Translate this to conversational {{language}}: {{body}}",
|
||||
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}",
|
||||
"id": "0ceccef5d40adcbf45bda7be485d2ee420e4d5943975932e7a38c5468d97c6ee",
|
||||
"provider": "openai:gpt-4o-mini",
|
||||
"metrics": {
|
||||
"score": 4,
|
||||
"testPassCount": 4,
|
||||
"testFailCount": 0,
|
||||
"assertPassCount": 0,
|
||||
"assertFailCount": 0,
|
||||
"totalLatencyMs": 12,
|
||||
"tokenUsage": { "total": 103, "prompt": 0, "completion": 0, "cached": 103 },
|
||||
"namedScores": {},
|
||||
"namedScoresCount": {},
|
||||
"cost": 0.0000348
|
||||
}
|
||||
}
|
||||
],
|
||||
"vars": ["body", "language"]
|
||||
},
|
||||
"body": [
|
||||
{
|
||||
"description": "Row #1",
|
||||
"outputs": [
|
||||
{
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"text": "Bonjour le monde.",
|
||||
"prompt": "Rephrase this in French: Hello world",
|
||||
"provider": "openai:gpt-4o-mini",
|
||||
"latencyMs": 6,
|
||||
"tokenUsage": { "cached": 19, "total": 19 },
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"cost": 0.0000046499999999999995
|
||||
},
|
||||
{
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"text": "Salut tout le monde !",
|
||||
"prompt": "Translate this to conversational French: Hello world",
|
||||
"provider": "openai:gpt-4o-mini",
|
||||
"latencyMs": 6,
|
||||
"tokenUsage": { "cached": 20, "total": 20 },
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"cost": 0.0000052500000000000006
|
||||
}
|
||||
],
|
||||
"test": {
|
||||
"vars": { "language": "French", "body": "Hello world" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #1",
|
||||
"metadata": {}
|
||||
},
|
||||
"vars": ["Hello world", "French"]
|
||||
},
|
||||
{
|
||||
"description": "Row #2",
|
||||
"outputs": [
|
||||
{
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"text": "J'ai faim.",
|
||||
"prompt": "Rephrase this in French: I'm hungry",
|
||||
"provider": "openai:gpt-4o-mini",
|
||||
"latencyMs": 4,
|
||||
"tokenUsage": { "cached": 19, "total": 19 },
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"cost": 0.0000046499999999999995
|
||||
},
|
||||
{
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"text": "J'ai faim.",
|
||||
"prompt": "Translate this to conversational French: I'm hungry",
|
||||
"provider": "openai:gpt-4o-mini",
|
||||
"latencyMs": 2,
|
||||
"tokenUsage": { "cached": 19, "total": 19 },
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"cost": 0.0000046499999999999995
|
||||
}
|
||||
],
|
||||
"test": {
|
||||
"vars": { "language": "French", "body": "I'm hungry" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #2",
|
||||
"metadata": {}
|
||||
},
|
||||
"vars": ["I'm hungry", "French"]
|
||||
},
|
||||
{
|
||||
"description": "Row #3",
|
||||
"outputs": [
|
||||
{
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"text": "Ahoy, me hearties!",
|
||||
"prompt": "Rephrase this in Pirate: Hello world",
|
||||
"provider": "openai:gpt-4o-mini",
|
||||
"latencyMs": 3,
|
||||
"tokenUsage": { "cached": 22, "total": 22 },
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"cost": 0.000006449999999999999
|
||||
},
|
||||
{
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"text": "Ahoy, me hearty! Greetings to ye, world!",
|
||||
"prompt": "Translate this to conversational Pirate: Hello world",
|
||||
"provider": "openai:gpt-4o-mini",
|
||||
"latencyMs": 3,
|
||||
"tokenUsage": { "cached": 27, "total": 27 },
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"cost": 0.00000945
|
||||
}
|
||||
],
|
||||
"test": {
|
||||
"vars": { "language": "Pirate", "body": "Hello world" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #3",
|
||||
"metadata": {}
|
||||
},
|
||||
"vars": ["Hello world", "Pirate"]
|
||||
},
|
||||
{
|
||||
"description": "Row #4",
|
||||
"outputs": [
|
||||
{
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"text": "Arrr, me belly be grumblin'!",
|
||||
"prompt": "Rephrase this in Pirate: I'm hungry",
|
||||
"provider": "openai:gpt-4o-mini",
|
||||
"latencyMs": 2,
|
||||
"tokenUsage": { "cached": 27, "total": 27 },
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"cost": 0.00000945
|
||||
},
|
||||
{
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"namedScores": {},
|
||||
"text": "Arrr, me belly be rumblin'! I be feelin' mighty hungry, matey!",
|
||||
"prompt": "Translate this to conversational Pirate: I'm hungry",
|
||||
"provider": "openai:gpt-4o-mini",
|
||||
"latencyMs": 1,
|
||||
"tokenUsage": { "cached": 37, "total": 37 },
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"cost": 0.00001545
|
||||
}
|
||||
],
|
||||
"test": {
|
||||
"vars": { "language": "Pirate", "body": "I'm hungry" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #4",
|
||||
"metadata": {}
|
||||
},
|
||||
"vars": ["I'm hungry", "Pirate"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"tags": {},
|
||||
"description": "A translator built with LLM",
|
||||
"prompts": ["file:///Users/steve/src/promptfoo/examples/simple-csv/prompts.txt"],
|
||||
"providers": ["openai:gpt-4o-mini"],
|
||||
"tests": [
|
||||
{
|
||||
"vars": { "language": "French", "body": "Hello world" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #1"
|
||||
},
|
||||
{
|
||||
"vars": { "language": "French", "body": "I'm hungry" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #2"
|
||||
},
|
||||
{
|
||||
"vars": { "language": "Pirate", "body": "Hello world" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #3"
|
||||
},
|
||||
{
|
||||
"vars": { "language": "Pirate", "body": "I'm hungry" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #4"
|
||||
}
|
||||
],
|
||||
"scenarios": [],
|
||||
"env": {},
|
||||
"sharing": true,
|
||||
"defaultTest": { "vars": {}, "assert": [], "options": {}, "metadata": {} },
|
||||
"outputPath": [],
|
||||
"extensions": [],
|
||||
"metadata": {},
|
||||
"redteam": { "plugins": [], "strategies": [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
{
|
||||
"createdAt": 1727905040496,
|
||||
"id": "eval-2024-10-02T21:37:20-kMQ",
|
||||
"config": {
|
||||
"tags": {},
|
||||
"description": "A translator built with LLM",
|
||||
"prompts": ["file:///Users/steve/src/promptfoo/examples/simple-csv/prompts.txt"],
|
||||
"providers": ["openai:gpt-4o-mini"],
|
||||
"tests": [
|
||||
{
|
||||
"vars": { "language": "French", "body": "Hello world" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #1"
|
||||
},
|
||||
{
|
||||
"vars": { "language": "French", "body": "I'm hungry" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #2"
|
||||
},
|
||||
{
|
||||
"vars": { "language": "Pirate", "body": "Hello world" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #3"
|
||||
},
|
||||
{
|
||||
"vars": { "language": "Pirate", "body": "I'm hungry" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #4"
|
||||
}
|
||||
],
|
||||
"scenarios": [],
|
||||
"env": {},
|
||||
"sharing": true,
|
||||
"defaultTest": { "vars": {}, "assert": [], "options": {}, "metadata": {} },
|
||||
"outputPath": [],
|
||||
"extensions": [],
|
||||
"metadata": {},
|
||||
"redteam": { "plugins": [], "strategies": [] }
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"id": "14c141a7-be6f-460f-b3cf-711e0a64fe88",
|
||||
"evalId": "eval-2024-10-02T21:37:20-kMQ",
|
||||
"promptIdx": 0,
|
||||
"testIdx": 0,
|
||||
"testCase": {
|
||||
"vars": { "language": "French", "body": "Hello world" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #1",
|
||||
"metadata": {}
|
||||
},
|
||||
"prompt": {
|
||||
"raw": "Rephrase this in French: Hello world",
|
||||
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
|
||||
},
|
||||
"promptId": "ad447337160016cbd0a5c7f2626d0c1fe2ade0d4d653e20bed9250632bc56e03",
|
||||
"error": null,
|
||||
"score": 1,
|
||||
"success": true,
|
||||
"providerResponse": {
|
||||
"output": "Bonjour le monde.",
|
||||
"tokenUsage": { "cached": 19, "total": 19 },
|
||||
"cached": true,
|
||||
"cost": 0.0000046499999999999995
|
||||
},
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"namedScores": {},
|
||||
"provider": {
|
||||
"id": "openai:gpt-4o-mini",
|
||||
"label": "",
|
||||
"config": { "basePath": "examples/simple-csv" }
|
||||
},
|
||||
"latencyMs": 3,
|
||||
"cost": 0.0000046499999999999995
|
||||
},
|
||||
{
|
||||
"id": "013a4746-4051-4dc5-87d1-1c8414bc124f",
|
||||
"evalId": "eval-2024-10-02T21:37:20-kMQ",
|
||||
"promptIdx": 1,
|
||||
"testIdx": 0,
|
||||
"testCase": {
|
||||
"vars": { "language": "French", "body": "Hello world" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #1",
|
||||
"metadata": {}
|
||||
},
|
||||
"prompt": {
|
||||
"raw": "Translate this to conversational French: Hello world",
|
||||
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
|
||||
},
|
||||
"promptId": "0ceccef5d40adcbf45bda7be485d2ee420e4d5943975932e7a38c5468d97c6ee",
|
||||
"error": null,
|
||||
"score": 1,
|
||||
"success": true,
|
||||
"providerResponse": {
|
||||
"output": "Salut tout le monde !",
|
||||
"tokenUsage": { "cached": 20, "total": 20 },
|
||||
"cached": true,
|
||||
"cost": 0.0000052500000000000006
|
||||
},
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"namedScores": {},
|
||||
"provider": {
|
||||
"id": "openai:gpt-4o-mini",
|
||||
"label": "",
|
||||
"config": { "basePath": "examples/simple-csv" }
|
||||
},
|
||||
"latencyMs": 7,
|
||||
"cost": 0.0000052500000000000006
|
||||
},
|
||||
{
|
||||
"id": "7791f530-38e0-431a-b8ef-fc2a64b14cb5",
|
||||
"evalId": "eval-2024-10-02T21:37:20-kMQ",
|
||||
"promptIdx": 1,
|
||||
"testIdx": 1,
|
||||
"testCase": {
|
||||
"vars": { "language": "French", "body": "I'm hungry" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #2",
|
||||
"metadata": {}
|
||||
},
|
||||
"prompt": {
|
||||
"raw": "Translate this to conversational French: I'm hungry",
|
||||
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
|
||||
},
|
||||
"promptId": "0ceccef5d40adcbf45bda7be485d2ee420e4d5943975932e7a38c5468d97c6ee",
|
||||
"error": null,
|
||||
"score": 1,
|
||||
"success": true,
|
||||
"providerResponse": {
|
||||
"output": "J'ai faim.",
|
||||
"tokenUsage": { "cached": 19, "total": 19 },
|
||||
"cached": true,
|
||||
"cost": 0.0000046499999999999995
|
||||
},
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"namedScores": {},
|
||||
"provider": {
|
||||
"id": "openai:gpt-4o-mini",
|
||||
"label": "",
|
||||
"config": { "basePath": "examples/simple-csv" }
|
||||
},
|
||||
"latencyMs": 8,
|
||||
"cost": 0.0000046499999999999995
|
||||
},
|
||||
{
|
||||
"id": "1720b007-3e86-4920-a738-95fb0dcf2a3f",
|
||||
"evalId": "eval-2024-10-02T21:37:20-kMQ",
|
||||
"promptIdx": 0,
|
||||
"testIdx": 1,
|
||||
"testCase": {
|
||||
"vars": { "language": "French", "body": "I'm hungry" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #2",
|
||||
"metadata": {}
|
||||
},
|
||||
"prompt": {
|
||||
"raw": "Rephrase this in French: I'm hungry",
|
||||
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
|
||||
},
|
||||
"promptId": "ad447337160016cbd0a5c7f2626d0c1fe2ade0d4d653e20bed9250632bc56e03",
|
||||
"error": null,
|
||||
"score": 1,
|
||||
"success": true,
|
||||
"providerResponse": {
|
||||
"output": "J'ai faim.",
|
||||
"tokenUsage": { "cached": 19, "total": 19 },
|
||||
"cached": true,
|
||||
"cost": 0.0000046499999999999995
|
||||
},
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"namedScores": {},
|
||||
"provider": {
|
||||
"id": "openai:gpt-4o-mini",
|
||||
"label": "",
|
||||
"config": { "basePath": "examples/simple-csv" }
|
||||
},
|
||||
"latencyMs": 10,
|
||||
"cost": 0.0000046499999999999995
|
||||
},
|
||||
{
|
||||
"id": "4540d2da-9a5a-402e-9243-41be20f63b1f",
|
||||
"evalId": "eval-2024-10-02T21:37:20-kMQ",
|
||||
"promptIdx": 0,
|
||||
"testIdx": 2,
|
||||
"testCase": {
|
||||
"vars": { "language": "Pirate", "body": "Hello world" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #3",
|
||||
"metadata": {}
|
||||
},
|
||||
"prompt": {
|
||||
"raw": "Rephrase this in Pirate: Hello world",
|
||||
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
|
||||
},
|
||||
"promptId": "ad447337160016cbd0a5c7f2626d0c1fe2ade0d4d653e20bed9250632bc56e03",
|
||||
"error": null,
|
||||
"score": 1,
|
||||
"success": true,
|
||||
"providerResponse": {
|
||||
"output": "Ahoy, me hearties!",
|
||||
"tokenUsage": { "cached": 22, "total": 22 },
|
||||
"cached": true,
|
||||
"cost": 0.000006449999999999999
|
||||
},
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"namedScores": {},
|
||||
"provider": {
|
||||
"id": "openai:gpt-4o-mini",
|
||||
"label": "",
|
||||
"config": { "basePath": "examples/simple-csv" }
|
||||
},
|
||||
"latencyMs": 5,
|
||||
"cost": 0.000006449999999999999
|
||||
},
|
||||
{
|
||||
"id": "467d9521-22f8-45ae-a3c2-0777dd7b1bb0",
|
||||
"evalId": "eval-2024-10-02T21:37:20-kMQ",
|
||||
"promptIdx": 1,
|
||||
"testIdx": 2,
|
||||
"testCase": {
|
||||
"vars": { "language": "Pirate", "body": "Hello world" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #3",
|
||||
"metadata": {}
|
||||
},
|
||||
"prompt": {
|
||||
"raw": "Translate this to conversational Pirate: Hello world",
|
||||
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
|
||||
},
|
||||
"promptId": "0ceccef5d40adcbf45bda7be485d2ee420e4d5943975932e7a38c5468d97c6ee",
|
||||
"error": null,
|
||||
"score": 1,
|
||||
"success": true,
|
||||
"providerResponse": {
|
||||
"output": "Ahoy, me hearty! Greetings to ye, world!",
|
||||
"tokenUsage": { "cached": 27, "total": 27 },
|
||||
"cached": true,
|
||||
"cost": 0.00000945
|
||||
},
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"namedScores": {},
|
||||
"provider": {
|
||||
"id": "openai:gpt-4o-mini",
|
||||
"label": "",
|
||||
"config": { "basePath": "examples/simple-csv" }
|
||||
},
|
||||
"latencyMs": 5,
|
||||
"cost": 0.00000945
|
||||
},
|
||||
{
|
||||
"id": "70b1ebd1-f748-4969-9d38-b6460ff6107f",
|
||||
"evalId": "eval-2024-10-02T21:37:20-kMQ",
|
||||
"promptIdx": 0,
|
||||
"testIdx": 3,
|
||||
"testCase": {
|
||||
"vars": { "language": "Pirate", "body": "I'm hungry" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #4",
|
||||
"metadata": {}
|
||||
},
|
||||
"prompt": {
|
||||
"raw": "Rephrase this in Pirate: I'm hungry",
|
||||
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}"
|
||||
},
|
||||
"promptId": "ad447337160016cbd0a5c7f2626d0c1fe2ade0d4d653e20bed9250632bc56e03",
|
||||
"error": null,
|
||||
"score": 1,
|
||||
"success": true,
|
||||
"providerResponse": {
|
||||
"output": "Arrr, me belly be grumblin'!",
|
||||
"tokenUsage": { "cached": 27, "total": 27 },
|
||||
"cached": true,
|
||||
"cost": 0.00000945
|
||||
},
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"namedScores": {},
|
||||
"provider": {
|
||||
"id": "openai:gpt-4o-mini",
|
||||
"label": "",
|
||||
"config": { "basePath": "examples/simple-csv" }
|
||||
},
|
||||
"latencyMs": 3,
|
||||
"cost": 0.00000945
|
||||
},
|
||||
{
|
||||
"id": "5b65ede9-f787-4637-b18d-9e0ff69dd778",
|
||||
"evalId": "eval-2024-10-02T21:37:20-kMQ",
|
||||
"promptIdx": 1,
|
||||
"testIdx": 3,
|
||||
"testCase": {
|
||||
"vars": { "language": "Pirate", "body": "I'm hungry" },
|
||||
"assert": [],
|
||||
"options": {},
|
||||
"description": "Row #4",
|
||||
"metadata": {}
|
||||
},
|
||||
"prompt": {
|
||||
"raw": "Translate this to conversational Pirate: I'm hungry",
|
||||
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}"
|
||||
},
|
||||
"promptId": "0ceccef5d40adcbf45bda7be485d2ee420e4d5943975932e7a38c5468d97c6ee",
|
||||
"error": null,
|
||||
"score": 1,
|
||||
"success": true,
|
||||
"providerResponse": {
|
||||
"output": "Arrr, me belly be rumblin'! I be feelin' mighty hungry, matey!",
|
||||
"tokenUsage": { "cached": 37, "total": 37 },
|
||||
"cached": true,
|
||||
"cost": 0.00001545
|
||||
},
|
||||
"gradingResult": {
|
||||
"pass": true,
|
||||
"score": 1,
|
||||
"reason": "No assertions",
|
||||
"tokensUsed": { "total": 0, "prompt": 0, "completion": 0, "cached": 0 },
|
||||
"assertion": null
|
||||
},
|
||||
"namedScores": {},
|
||||
"provider": {
|
||||
"id": "openai:gpt-4o-mini",
|
||||
"label": "",
|
||||
"config": { "basePath": "examples/simple-csv" }
|
||||
},
|
||||
"latencyMs": 4,
|
||||
"cost": 0.00001545
|
||||
}
|
||||
],
|
||||
"prompts": [
|
||||
{
|
||||
"raw": "Rephrase this in {{language}}: {{body}}",
|
||||
"label": "examples/simple-csv/prompts.txt: Rephrase this in {{language}}: {{body}}",
|
||||
"id": "ad447337160016cbd0a5c7f2626d0c1fe2ade0d4d653e20bed9250632bc56e03",
|
||||
"provider": "openai:gpt-4o-mini",
|
||||
"metrics": {
|
||||
"score": 4,
|
||||
"testPassCount": 4,
|
||||
"testFailCount": 0,
|
||||
"assertPassCount": 0,
|
||||
"assertFailCount": 0,
|
||||
"totalLatencyMs": 21,
|
||||
"tokenUsage": { "total": 87, "prompt": 0, "completion": 0, "cached": 87 },
|
||||
"namedScores": {},
|
||||
"namedScoresCount": {},
|
||||
"cost": 0.000025199999999999996
|
||||
}
|
||||
},
|
||||
{
|
||||
"raw": "Translate this to conversational {{language}}: {{body}}",
|
||||
"label": "examples/simple-csv/prompts.txt: Translate this to conversational {{language}}: {{body}}",
|
||||
"id": "0ceccef5d40adcbf45bda7be485d2ee420e4d5943975932e7a38c5468d97c6ee",
|
||||
"provider": "openai:gpt-4o-mini",
|
||||
"metrics": {
|
||||
"score": 4,
|
||||
"testPassCount": 4,
|
||||
"testFailCount": 0,
|
||||
"assertPassCount": 0,
|
||||
"assertFailCount": 0,
|
||||
"totalLatencyMs": 24,
|
||||
"tokenUsage": { "total": 103, "prompt": 0, "completion": 0, "cached": 103 },
|
||||
"namedScores": {},
|
||||
"namedScoresCount": {},
|
||||
"cost": 0.0000348
|
||||
}
|
||||
}
|
||||
],
|
||||
"datasetId": "27018c2ea4e47d8af2fc1b2d69c131ed777f1d89e9069bf38622701b7e1e786a"
|
||||
}
|
||||
Reference in New Issue
Block a user