test: add comprehensive tests for CLI and scenarios

Add unit tests for:
- CLI TpmClient (api-client.test.ts) - 26 tests covering authentication,
  tool execution, agent/collection/scenario management, error handling
- Scenario evaluation (evaluate.test.ts) - 16 tests for regex assertions
  and verdict determination
- Cosine similarity (similarity.test.ts) - 16 tests for embedding comparison

Add integration tests for scenarios:
- CRUD operations (scenarios-crud.integration.test.ts)
- Run execution and quota management (scenarios-run.integration.test.ts)

Configure vitest for CLI package with @tpmjs/test shared config.
This commit is contained in:
Ajax Davis 2026-01-25 03:28:28 +10:00
parent 9c731d582e
commit a44f38eda9
8 changed files with 1491 additions and 1 deletions

View file

@ -0,0 +1,222 @@
/**
* Scenario Evaluation Unit Tests
*
* Tests for the evaluation logic including:
* - Regex assertion matching
* - Final verdict determination
* - Assertion result handling
*/
import { describe, expect, it } from 'vitest';
import { determineFinalVerdict, runAssertions } from './evaluate';
describe('runAssertions', () => {
describe('regex assertions', () => {
it('should pass when regex matches output', () => {
const output = 'Hello, World!';
const assertions = { regex: ['hello', 'world'] };
const result = runAssertions(output, assertions);
expect(result.passed).toContain('regex:hello');
expect(result.passed).toContain('regex:world');
expect(result.failed).toHaveLength(0);
});
it('should fail when regex does not match output', () => {
const output = 'Goodbye, World!';
const assertions = { regex: ['hello'] };
const result = runAssertions(output, assertions);
expect(result.failed).toContain('regex:hello');
expect(result.passed).toHaveLength(0);
});
it('should handle multiple assertions with mixed results', () => {
const output = 'The result is 42 and status is success';
const assertions = { regex: ['result.*42', 'success', 'error', 'failure'] };
const result = runAssertions(output, assertions);
expect(result.passed).toContain('regex:result.*42');
expect(result.passed).toContain('regex:success');
expect(result.failed).toContain('regex:error');
expect(result.failed).toContain('regex:failure');
});
it('should handle case-insensitive matching', () => {
const output = 'SUCCESS';
const assertions = { regex: ['success'] };
const result = runAssertions(output, assertions);
expect(result.passed).toContain('regex:success');
});
it('should handle invalid regex patterns gracefully', () => {
const output = 'test';
const assertions = { regex: ['[invalid', '(unclosed'] };
const result = runAssertions(output, assertions);
expect(result.failed).toContain('regex:[invalid (invalid pattern)');
expect(result.failed).toContain('regex:(unclosed (invalid pattern)');
});
it('should handle empty regex array', () => {
const output = 'test';
const assertions = { regex: [] };
const result = runAssertions(output, assertions);
expect(result.passed).toHaveLength(0);
expect(result.failed).toHaveLength(0);
});
it('should handle complex regex patterns', () => {
const output = 'User john@example.com has ID 12345';
const assertions = {
regex: [
'[a-z]+@[a-z]+\\.[a-z]+', // Email pattern
'ID \\d+', // ID pattern
'^User', // Starts with User
],
};
const result = runAssertions(output, assertions);
expect(result.passed).toHaveLength(3);
expect(result.failed).toHaveLength(0);
});
});
describe('schema assertions', () => {
it('should note when schema is provided', () => {
const output = '{"name": "test"}';
const assertions = { schema: { type: 'object' } };
const result = runAssertions(output, assertions);
expect(result.passed).toContain('schema:provided (validation pending)');
});
});
describe('combined assertions', () => {
it('should handle both regex and schema assertions', () => {
const output = '{"status": "success"}';
const assertions = {
regex: ['success'],
schema: { type: 'object' },
};
const result = runAssertions(output, assertions);
expect(result.passed).toContain('regex:success');
expect(result.passed).toContain('schema:provided (validation pending)');
});
});
});
describe('determineFinalVerdict', () => {
describe('LLM evaluation only', () => {
it('should return pass when LLM says pass', () => {
const evaluation = {
verdict: 'pass' as const,
reason: 'Task completed successfully',
confidence: 0.95,
};
const result = determineFinalVerdict(evaluation);
expect(result).toBe('pass');
});
it('should return fail when LLM says fail', () => {
const evaluation = {
verdict: 'fail' as const,
reason: 'Task was not completed',
confidence: 0.9,
};
const result = determineFinalVerdict(evaluation);
expect(result).toBe('fail');
});
});
describe('with assertions', () => {
it('should return pass when LLM passes and all assertions pass', () => {
const evaluation = {
verdict: 'pass' as const,
reason: 'Task completed',
confidence: 0.9,
};
const assertions = {
passed: ['regex:success', 'regex:result'],
failed: [],
};
const result = determineFinalVerdict(evaluation, assertions);
expect(result).toBe('pass');
});
it('should return fail when LLM passes but assertions fail', () => {
const evaluation = {
verdict: 'pass' as const,
reason: 'Task completed',
confidence: 0.9,
};
const assertions = {
passed: ['regex:success'],
failed: ['regex:expected_output'],
};
const result = determineFinalVerdict(evaluation, assertions);
expect(result).toBe('fail');
});
it('should return fail when LLM fails regardless of assertions', () => {
const evaluation = {
verdict: 'fail' as const,
reason: 'Task failed',
confidence: 0.95,
};
const assertions = {
passed: ['regex:success'],
failed: [],
};
const result = determineFinalVerdict(evaluation, assertions);
expect(result).toBe('fail');
});
it('should handle null assertions', () => {
const evaluation = {
verdict: 'pass' as const,
reason: 'Task completed',
confidence: 0.9,
};
const result = determineFinalVerdict(evaluation, null);
expect(result).toBe('pass');
});
it('should handle undefined assertions', () => {
const evaluation = {
verdict: 'pass' as const,
reason: 'Task completed',
confidence: 0.9,
};
const result = determineFinalVerdict(evaluation, undefined);
expect(result).toBe('pass');
});
});
});

View file

@ -0,0 +1,134 @@
/**
* Scenario Similarity Unit Tests
*
* Tests for the similarity functions including:
* - Cosine similarity calculation
* - Edge cases and error handling
*/
import { describe, expect, it } from 'vitest';
import { cosineSimilarity } from './similarity';
describe('cosineSimilarity', () => {
it('should return 1 for identical vectors', () => {
const vector = [1, 2, 3, 4, 5];
const result = cosineSimilarity(vector, vector);
expect(result).toBeCloseTo(1, 10);
});
it('should return 0 for orthogonal vectors', () => {
const a = [1, 0];
const b = [0, 1];
const result = cosineSimilarity(a, b);
expect(result).toBeCloseTo(0, 10);
});
it('should return -1 for opposite vectors', () => {
const a = [1, 2, 3];
const b = [-1, -2, -3];
const result = cosineSimilarity(a, b);
expect(result).toBeCloseTo(-1, 10);
});
it('should handle normalized vectors correctly', () => {
// Two unit vectors at 60 degrees have cosine similarity of 0.5
const a = [1, 0];
const b = [0.5, Math.sqrt(3) / 2]; // 60 degree rotation
const result = cosineSimilarity(a, b);
expect(result).toBeCloseTo(0.5, 5);
});
it('should handle non-normalized vectors', () => {
// Scaling a vector should not change the cosine similarity
const a = [1, 2, 3];
const b = [2, 4, 6]; // Same direction, scaled by 2
const result = cosineSimilarity(a, b);
expect(result).toBeCloseTo(1, 10);
});
it('should handle vectors with different magnitudes', () => {
const a = [3, 4]; // magnitude 5
const b = [6, 8]; // magnitude 10, same direction
const result = cosineSimilarity(a, b);
expect(result).toBeCloseTo(1, 10);
});
it('should throw error for vectors of different lengths', () => {
const a = [1, 2, 3];
const b = [1, 2];
expect(() => cosineSimilarity(a, b)).toThrow('Vectors must have same length');
});
it('should return 0 for zero vectors', () => {
const a = [0, 0, 0];
const b = [1, 2, 3];
const result = cosineSimilarity(a, b);
expect(result).toBe(0);
});
it('should return 0 for two zero vectors', () => {
const a = [0, 0, 0];
const b = [0, 0, 0];
const result = cosineSimilarity(a, b);
expect(result).toBe(0);
});
it('should handle high-dimensional vectors', () => {
// Simulate embedding vectors (typically 1536 dimensions for text-embedding-3-small)
const dim = 1536;
const a = Array.from({ length: dim }, (_, i) => Math.sin(i));
const b = Array.from({ length: dim }, (_, i) => Math.sin(i));
const result = cosineSimilarity(a, b);
expect(result).toBeCloseTo(1, 5);
});
it('should handle high-dimensional dissimilar vectors', () => {
const dim = 1536;
const a = Array.from({ length: dim }, (_, i) => Math.sin(i));
const b = Array.from({ length: dim }, (_, i) => Math.cos(i));
const result = cosineSimilarity(a, b);
// Should be between -1 and 1, but not 1
expect(result).toBeLessThan(1);
expect(result).toBeGreaterThanOrEqual(-1);
});
it('should produce consistent results for partial similarity', () => {
const a = [1, 0, 1, 0];
const b = [1, 1, 0, 0];
const result = cosineSimilarity(a, b);
// Expected: (1*1 + 0*1 + 1*0 + 0*0) / (sqrt(2) * sqrt(2)) = 1/2 = 0.5
expect(result).toBeCloseTo(0.5, 10);
});
it('should handle negative values correctly', () => {
const a = [-1, 2, -3];
const b = [1, -2, 3];
const result = cosineSimilarity(a, b);
// Opposite directions
expect(result).toBeCloseTo(-1, 10);
});
it('should handle mixed positive and negative values', () => {
const a = [1, -1, 1];
const b = [1, 1, -1];
const result = cosineSimilarity(a, b);
// (1 - 1 - 1) / (sqrt(3) * sqrt(3)) = -1/3
expect(result).toBeCloseTo(-1 / 3, 10);
});
it('should handle very small values without underflow', () => {
const a = [1e-10, 2e-10, 3e-10];
const b = [1e-10, 2e-10, 3e-10];
const result = cosineSimilarity(a, b);
expect(result).toBeCloseTo(1, 5);
});
it('should handle empty arrays', () => {
const a: number[] = [];
const b: number[] = [];
const result = cosineSimilarity(a, b);
// 0/0 case, returns 0 per implementation
expect(result).toBe(0);
});
});

View file

@ -0,0 +1,233 @@
/**
* Scenarios CRUD Integration Tests
*
* Tests the scenario API endpoints including:
* - Creating scenarios
* - Listing scenarios
* - Getting scenario details
* - Updating scenarios
* - Deleting scenarios
*
* NOTE: These are integration tests that require a running server.
* Run `pnpm dev --filter=@tpmjs/web` first, then run tests.
* Tests will be skipped if server is not available.
*
* To run integration tests manually:
* INTEGRATION_TESTS=true pnpm --filter=@tpmjs/web test
*/
import { prisma } from '@tpmjs/db';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
// Skip integration tests unless explicitly enabled
const INTEGRATION_TESTS_ENABLED = process.env.INTEGRATION_TESTS === 'true';
const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000';
// Check if server is available
async function isServerAvailable(): Promise<boolean> {
if (!INTEGRATION_TESTS_ENABLED) return false;
try {
const response = await fetch(`${BASE_URL}/api/health`, { signal: AbortSignal.timeout(2000) });
return response.ok;
} catch {
return false;
}
}
describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Scenarios CRUD Integration', () => {
let serverAvailable = false;
let testCollection: { id: string; slug: string; userId: string } | null = null;
const testScenarioId: string | null = null;
beforeAll(async () => {
serverAvailable = await isServerAvailable();
if (!serverAvailable) {
console.warn('⚠️ Server not available - skipping scenario integration tests');
return;
}
// Find a test collection to use
testCollection = await prisma.collection.findFirst({
where: { isPublic: true },
select: { id: true, slug: true, userId: true },
});
if (!testCollection) {
console.warn('⚠️ No public collection found - skipping scenario tests');
}
});
afterAll(async () => {
if (!serverAvailable) return;
try {
// Clean up test scenario if created
if (testScenarioId) {
await prisma.scenario.delete({ where: { id: testScenarioId } }).catch(() => {});
}
await prisma.$disconnect();
} catch {
// Ignore cleanup errors
}
});
describe('GET /api/scenarios', () => {
it('should list public scenarios', async () => {
if (!serverAvailable) return;
const response = await fetch(`${BASE_URL}/api/scenarios?limit=10`);
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.success).toBe(true);
expect(Array.isArray(result.data)).toBe(true);
expect(result.pagination).toBeDefined();
});
it('should support pagination', async () => {
if (!serverAvailable) return;
const response = await fetch(`${BASE_URL}/api/scenarios?limit=5&offset=0`);
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.pagination.limit).toBe(5);
expect(result.pagination.offset).toBe(0);
});
it('should filter by collection', async () => {
if (!serverAvailable || !testCollection) return;
const response = await fetch(`${BASE_URL}/api/scenarios?collectionId=${testCollection.id}`);
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.success).toBe(true);
// All returned scenarios should belong to the collection
for (const scenario of result.data) {
expect(scenario.collectionId).toBe(testCollection.id);
}
});
});
describe('GET /api/scenarios/featured', () => {
it('should return featured scenarios', async () => {
if (!serverAvailable) return;
const response = await fetch(`${BASE_URL}/api/scenarios/featured`);
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.success).toBe(true);
expect(Array.isArray(result.data)).toBe(true);
});
});
describe('POST /api/collections/[id]/scenarios/generate', () => {
it('should require authentication', async () => {
if (!serverAvailable || !testCollection) return;
const response = await fetch(
`${BASE_URL}/api/collections/${testCollection.id}/scenarios/generate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ count: 1 }),
}
);
// Should return 401 without auth
expect(response.status).toBe(401);
});
});
describe('GET /api/scenarios/[id]', () => {
it('should return scenario details', async () => {
if (!serverAvailable) return;
// First find a scenario
const listResponse = await fetch(`${BASE_URL}/api/scenarios?limit=1`);
const listResult = await listResponse.json();
if (listResult.data.length === 0) {
console.log('No scenarios found to test');
return;
}
const scenarioId = listResult.data[0].id;
const response = await fetch(`${BASE_URL}/api/scenarios/${scenarioId}`);
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.success).toBe(true);
expect(result.data.id).toBe(scenarioId);
expect(result.data.name).toBeDefined();
expect(result.data.prompt).toBeDefined();
});
it('should return 404 for non-existent scenario', async () => {
if (!serverAvailable) return;
const response = await fetch(`${BASE_URL}/api/scenarios/non-existent-scenario-id-12345`);
expect(response.status).toBe(404);
});
});
describe('GET /api/scenarios/[id]/runs', () => {
it('should list scenario runs', async () => {
if (!serverAvailable) return;
// Find a scenario with runs
const listResponse = await fetch(`${BASE_URL}/api/scenarios?limit=10`);
const listResult = await listResponse.json();
if (listResult.data.length === 0) {
console.log('No scenarios found');
return;
}
// Try each scenario until we find one (may or may not have runs)
const scenarioId = listResult.data[0].id;
const response = await fetch(`${BASE_URL}/api/scenarios/${scenarioId}/runs`);
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.success).toBe(true);
expect(Array.isArray(result.data)).toBe(true);
});
});
describe('POST /api/scenarios/check-similarity', () => {
it('should check scenario similarity', async () => {
if (!serverAvailable || !testCollection) return;
const response = await fetch(`${BASE_URL}/api/scenarios/check-similarity`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
collectionId: testCollection.id,
prompt: 'Test prompt for similarity checking',
}),
});
expect(response.ok).toBe(true);
const result = await response.json();
expect(result.success).toBe(true);
expect(typeof result.data.hasSimilar).toBe('boolean');
});
it('should require collectionId and prompt', async () => {
if (!serverAvailable) return;
const response = await fetch(`${BASE_URL}/api/scenarios/check-similarity`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(response.status).toBe(400);
});
});
});

View file

@ -0,0 +1,193 @@
/**
* Scenario Execution Integration Tests
*
* Tests the scenario run API endpoint including:
* - Running scenarios
* - Quota management
* - Result evaluation
*
* NOTE: These tests require authentication and may consume API quotas.
* Run with caution in development environments only.
*/
import { prisma } from '@tpmjs/db';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
// Skip integration tests unless explicitly enabled
const INTEGRATION_TESTS_ENABLED = process.env.INTEGRATION_TESTS === 'true';
const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000';
// Check if server is available
async function isServerAvailable(): Promise<boolean> {
if (!INTEGRATION_TESTS_ENABLED) return false;
try {
const response = await fetch(`${BASE_URL}/api/health`, { signal: AbortSignal.timeout(2000) });
return response.ok;
} catch {
return false;
}
}
describe.skipIf(!INTEGRATION_TESTS_ENABLED)('Scenario Execution Integration', () => {
let serverAvailable = false;
let testScenario: { id: string; name: string; collectionId: string } | null = null;
beforeAll(async () => {
serverAvailable = await isServerAvailable();
if (!serverAvailable) {
console.warn('⚠️ Server not available - skipping scenario execution tests');
return;
}
// Find a test scenario
testScenario = await prisma.scenario.findFirst({
where: {
collection: { isPublic: true },
},
select: { id: true, name: true, collectionId: true },
});
if (!testScenario) {
console.warn('⚠️ No public scenario found - some tests will be skipped');
}
});
afterAll(async () => {
if (!serverAvailable) return;
await prisma.$disconnect();
});
describe('POST /api/scenarios/[id]/run', () => {
it('should require authentication', async () => {
if (!serverAvailable || !testScenario) return;
const response = await fetch(`${BASE_URL}/api/scenarios/${testScenario.id}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
// Should return 401 without auth
expect(response.status).toBe(401);
});
it('should return 404 for non-existent scenario', async () => {
if (!serverAvailable) return;
const response = await fetch(`${BASE_URL}/api/scenarios/non-existent-id-12345/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
// Should return 401 (auth required first) or 404
expect([401, 404]).toContain(response.status);
});
});
describe('Scenario metrics', () => {
it('should have quality score between 0 and 1', async () => {
if (!serverAvailable) return;
const scenarios = await prisma.scenario.findMany({
take: 10,
select: { id: true, qualityScore: true },
});
for (const scenario of scenarios) {
expect(scenario.qualityScore).toBeGreaterThanOrEqual(0);
expect(scenario.qualityScore).toBeLessThanOrEqual(1);
}
});
it('should track consecutive passes and fails', async () => {
if (!serverAvailable) return;
const scenarios = await prisma.scenario.findMany({
take: 10,
select: {
id: true,
consecutivePasses: true,
consecutiveFails: true,
totalRuns: true,
},
});
for (const scenario of scenarios) {
expect(scenario.consecutivePasses).toBeGreaterThanOrEqual(0);
expect(scenario.consecutiveFails).toBeGreaterThanOrEqual(0);
expect(scenario.totalRuns).toBeGreaterThanOrEqual(0);
}
});
});
describe('Scenario runs history', () => {
it('should have valid run records', async () => {
if (!serverAvailable) return;
const runs = await prisma.scenarioRun.findMany({
take: 10,
select: {
id: true,
status: true,
scenarioId: true,
userId: true,
createdAt: true,
},
});
for (const run of runs) {
expect(['pending', 'running', 'pass', 'fail', 'error']).toContain(run.status);
expect(run.scenarioId).toBeDefined();
expect(run.userId).toBeDefined();
expect(run.createdAt).toBeInstanceOf(Date);
}
});
it('should track token usage for completed runs', async () => {
if (!serverAvailable) return;
const completedRuns = await prisma.scenarioRun.findMany({
where: { status: { in: ['pass', 'fail'] } },
take: 10,
select: {
id: true,
inputTokens: true,
outputTokens: true,
totalTokens: true,
executionTimeMs: true,
},
});
for (const run of completedRuns) {
// Completed runs should have token counts
if (run.inputTokens !== null) {
expect(run.inputTokens).toBeGreaterThanOrEqual(0);
}
if (run.outputTokens !== null) {
expect(run.outputTokens).toBeGreaterThanOrEqual(0);
}
}
});
});
describe('Quota management', () => {
it('should track user quotas', async () => {
if (!serverAvailable) return;
const quotas = await prisma.scenarioQuota.findMany({
take: 5,
select: {
userId: true,
dailyLimit: true,
dailyUsed: true,
lastResetAt: true,
},
});
for (const quota of quotas) {
expect(quota.dailyLimit).toBeGreaterThan(0);
expect(quota.dailyUsed).toBeGreaterThanOrEqual(0);
expect(quota.dailyUsed).toBeLessThanOrEqual(quota.dailyLimit);
}
});
});
});