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);
}
});
});
});

View file

@ -73,6 +73,7 @@
"scripts": {
"build": "tsup && oclif manifest",
"dev": "tsup --watch",
"test": "vitest",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist .turbo oclif.manifest.json",
"postpack": "rm -f oclif.manifest.json",
@ -91,11 +92,13 @@
"cli-table3": "^0.6.5"
},
"devDependencies": {
"@tpmjs/test": "workspace:*",
"@tpmjs/tsconfig": "workspace:*",
"@types/node": "^22.15.29",
"oclif": "^4.17.35",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.0.16"
},
"publishConfig": {
"access": "public"

View file

@ -0,0 +1,575 @@
/**
* TpmClient Unit Tests
*
* Tests for the CLI API client including:
* - Request formatting
* - Error handling
* - Authentication
* - Response parsing
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiError, TpmClient } from './api-client.js';
// Mock the config module
vi.mock('./config.js', () => ({
getApiKey: vi.fn(() => undefined),
getApiUrl: vi.fn(() => 'https://default.api.com'),
}));
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('TpmClient', () => {
let client: TpmClient;
beforeEach(() => {
vi.clearAllMocks();
client = new TpmClient({
baseUrl: 'https://api.test.com',
apiKey: 'test-api-key',
timeout: 5000,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should create client with custom options', () => {
const customClient = new TpmClient({
baseUrl: 'https://custom.api.com',
apiKey: 'custom-key',
timeout: 10000,
});
expect(customClient.isAuthenticated()).toBe(true);
});
it('should create client without API key', () => {
// When no apiKey option is passed, and config returns undefined, client is not authenticated
const unauthClient = new TpmClient({
baseUrl: 'https://api.test.com',
apiKey: undefined, // Explicitly set to undefined to override config default
});
expect(unauthClient.isAuthenticated()).toBe(false);
});
});
describe('health', () => {
it('should call health endpoint', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ status: 'ok', timestamp: '2024-01-01T00:00:00Z' }),
});
const result = await client.health();
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/health',
expect.objectContaining({
headers: expect.objectContaining({
'Content-Type': 'application/json',
Authorization: 'Bearer test-api-key',
}),
})
);
expect(result.status).toBe('ok');
});
});
describe('searchTools', () => {
it('should search tools with query', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: '1', name: 'test-tool' }],
pagination: { limit: 20, offset: 0, hasMore: false },
}),
});
const result = await client.searchTools({ query: 'test', limit: 10 });
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/tools?q=test&limit=10',
expect.any(Object)
);
expect(result.data).toHaveLength(1);
});
it('should search tools with category filter', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [],
pagination: { limit: 20, offset: 0, hasMore: false },
}),
});
await client.searchTools({ category: 'sandbox' });
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/tools?category=sandbox',
expect.any(Object)
);
});
it('should handle empty search', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [],
pagination: { limit: 20, offset: 0, hasMore: false },
}),
});
const result = await client.searchTools({});
expect(mockFetch).toHaveBeenCalledWith('https://api.test.com/tools', expect.any(Object));
expect(result.data).toHaveLength(0);
});
});
describe('listAgents', () => {
it('should list agents with pagination', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [
{ id: '1', uid: 'agent-1', name: 'Test Agent' },
{ id: '2', uid: 'agent-2', name: 'Another Agent' },
],
pagination: { limit: 10, offset: 0, hasMore: true },
}),
});
const result = await client.listAgents({ limit: 10, offset: 0 });
// Note: offset=0 is falsy so it won't be included in the URL
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/agents?limit=10',
expect.any(Object)
);
expect(result.data).toHaveLength(2);
expect(result.pagination.hasMore).toBe(true);
});
it('should include offset when non-zero', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [],
pagination: { limit: 10, offset: 10, hasMore: false },
}),
});
await client.listAgents({ limit: 10, offset: 10 });
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/agents?limit=10&offset=10',
expect.any(Object)
);
});
});
describe('createAgent', () => {
it('should create an agent', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
data: {
id: 'new-agent-id',
uid: 'new-agent',
name: 'New Agent',
provider: 'openai',
modelId: 'gpt-4',
},
}),
});
const result = await client.createAgent({
name: 'New Agent',
uid: 'new-agent',
provider: 'openai',
modelId: 'gpt-4',
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/agents',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
name: 'New Agent',
uid: 'new-agent',
provider: 'openai',
modelId: 'gpt-4',
}),
})
);
expect(result.success).toBe(true);
expect(result.data?.uid).toBe('new-agent');
});
});
describe('listCollections', () => {
it('should list collections', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [
{ id: '1', name: 'Collection 1', slug: 'collection-1' },
{ id: '2', name: 'Collection 2', slug: 'collection-2' },
],
pagination: { limit: 20, offset: 0, hasMore: false },
}),
});
const result = await client.listCollections();
expect(result.data).toHaveLength(2);
});
});
describe('listScenarios', () => {
it('should list scenarios with filters', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [{ id: '1', name: 'Scenario 1', qualityScore: 0.9 }],
pagination: { limit: 20, offset: 0, hasMore: false },
}),
});
const result = await client.listScenarios({
collectionId: 'col-1',
sortBy: 'qualityScore',
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/scenarios?collectionId=col-1&sortBy=qualityScore',
expect.any(Object)
);
expect(result.data).toHaveLength(1);
});
});
describe('runScenario', () => {
it('should run a scenario', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
data: {
id: 'run-1',
status: 'pass',
success: true,
evaluator: {
model: 'gpt-4.1-mini',
verdict: 'pass',
reason: 'Task completed successfully',
},
},
}),
});
const result = await client.runScenario('scenario-1');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/scenarios/scenario-1/run',
expect.objectContaining({ method: 'POST' })
);
expect(result.success).toBe(true);
expect(result.data?.status).toBe('pass');
});
});
describe('error handling', () => {
it('should throw ApiError on HTTP error', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
json: async () => ({ message: 'Not found' }),
});
await expect(client.getAgent('non-existent')).rejects.toThrow(ApiError);
});
it('should include status code in ApiError', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ error: 'Unauthorized' }),
});
try {
await client.whoami();
expect.fail('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).statusCode).toBe(401);
}
});
it('should handle network errors', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
await expect(client.health()).rejects.toThrow('Network error');
});
});
describe('authentication', () => {
it('should include Authorization header when API key is set', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, data: {} }),
});
await client.whoami();
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-api-key',
}),
})
);
});
it('should not include Authorization header when no API key', async () => {
// Create client with explicitly undefined apiKey
const unauthClient = new TpmClient({
baseUrl: 'https://api.test.com',
apiKey: undefined,
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ status: 'ok' }),
});
await unauthClient.health();
const callArgs = mockFetch.mock.calls[0];
expect(callArgs).toBeDefined();
const callHeaders = callArgs?.[1]?.headers as Record<string, string> | undefined;
expect(callHeaders?.Authorization).toBeUndefined();
});
});
describe('tool execution', () => {
it('should execute a tool with parameters', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: 'executed successfully' }),
});
const result = await client.executeTool('my-tool', { input: 'test' });
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/tools/my-tool/execute',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ input: 'test' }),
})
);
expect(result).toEqual({ result: 'executed successfully' });
});
});
describe('collection management', () => {
it('should create a collection', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
data: { id: 'col-1', name: 'My Collection', isPublic: true },
}),
});
const result = await client.createCollection({
name: 'My Collection',
isPublic: true,
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/collections',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ name: 'My Collection', isPublic: true }),
})
);
expect(result.success).toBe(true);
});
it('should delete a collection', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
const result = await client.deleteCollection('col-1');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/collections/col-1',
expect.objectContaining({ method: 'DELETE' })
);
expect(result.success).toBe(true);
});
it('should update a collection', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
data: { id: 'col-1', name: 'Updated Name', isPublic: false },
}),
});
const result = await client.updateCollection('col-1', {
name: 'Updated Name',
isPublic: false,
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/collections/col-1',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ name: 'Updated Name', isPublic: false }),
})
);
expect(result.success).toBe(true);
});
});
describe('scenario management', () => {
it('should get scenario runs', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: [
{ id: 'run-1', status: 'pass' },
{ id: 'run-2', status: 'fail' },
],
pagination: { limit: 10, offset: 0, hasMore: false },
}),
});
const result = await client.getScenarioRuns('scenario-1', { limit: 10 });
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/scenarios/scenario-1/runs?limit=10',
expect.any(Object)
);
expect(result.data).toHaveLength(2);
});
it('should create a scenario', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
data: {
id: 'scenario-1',
prompt: 'Test the feature',
name: 'Test Scenario',
},
}),
});
const result = await client.createScenario({
collectionId: 'col-1',
prompt: 'Test the feature',
name: 'Test Scenario',
});
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/scenarios',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
collectionId: 'col-1',
prompt: 'Test the feature',
name: 'Test Scenario',
}),
})
);
expect(result.success).toBe(true);
});
it('should generate scenarios for a collection', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
data: {
scenarios: [
{ scenario: { id: 's1', prompt: 'Generated 1' } },
{ scenario: { id: 's2', prompt: 'Generated 2' } },
],
},
}),
});
const result = await client.generateScenarios('col-1', { count: 5 });
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/collections/col-1/scenarios/generate',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ count: 5 }),
})
);
expect(result.success).toBe(true);
expect(result.data?.scenarios).toHaveLength(2);
});
});
describe('stats', () => {
it('should get stats', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
data: {
tools: { total: 100, official: 10, healthyImport: 95, healthyExecution: 90 },
packages: { total: 50, official: 5 },
categories: [{ name: 'sandbox', count: 20 }],
},
}),
});
const result = await client.getStats();
expect(mockFetch).toHaveBeenCalledWith('https://api.test.com/stats', expect.any(Object));
expect(result.success).toBe(true);
expect(result.data?.tools.total).toBe(100);
});
});
describe('user API keys', () => {
it('should list API keys', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
success: true,
data: [{ id: 'key-1', name: 'Test Key', keyPrefix: 'tpmjs_sk_...' }],
}),
});
const result = await client.listApiKeys();
expect(mockFetch).toHaveBeenCalledWith(
'https://api.test.com/user/tpmjs-api-keys',
expect.any(Object)
);
expect(result.success).toBe(true);
expect(result.data).toHaveLength(1);
});
});
});

View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
},
},
});

119
pnpm-lock.yaml generated
View file

@ -230,6 +230,9 @@ importers:
'@ai-sdk/anthropic':
specifier: ^3.0.9
version: 3.0.9(zod@4.3.5)
'@ai-sdk/devtools':
specifier: ^0.0.8
version: 0.0.8
'@ai-sdk/google':
specifier: ^3.0.6
version: 3.0.6(zod@4.3.5)
@ -479,6 +482,9 @@ importers:
specifier: ^1.1.1
version: 1.1.1
devDependencies:
'@tpmjs/test':
specifier: workspace:*
version: link:../test
'@tpmjs/tsconfig':
specifier: workspace:*
version: link:../config/tsconfig
@ -494,6 +500,9 @@ importers:
typescript:
specifier: ^5.9.3
version: 5.9.3
vitest:
specifier: ^4.0.16
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@22.19.5)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.5)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
packages/config:
dependencies:
@ -4089,6 +4098,10 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
'@ai-sdk/devtools@0.0.8':
resolution: {integrity: sha512-oJXCdZ0svdQiC50FAw39uIp26qhl7zo56C9KHoN67LQZkCFNzgYyhS8ZxuUOWTQJGB4DKx4WpOyFGbzNT2VSyw==}
hasBin: true
'@ai-sdk/gateway@2.0.0-beta.68':
resolution: {integrity: sha512-eYv3hBfu/M+0XmxE1RoH4X7uhYplNbSDvRClzirKzTY+ZM5yQwP86L3DqLR7Y9/vH7yrQaSIwsBT3OMmcBnGaw==}
engines: {node: '>=18'}
@ -4197,6 +4210,10 @@ packages:
resolution: {integrity: sha512-HrEmNt/BH/hkQ7zpi2o6N3k1ZR1QTb7z85WYhYygiTxOQuaml4CMtHCWRbric5WPU+RNsYI7r1EpyVQMKO1pYw==}
engines: {node: '>=18'}
'@ai-sdk/provider@3.0.5':
resolution: {integrity: sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w==}
engines: {node: '>=18'}
'@ai-sdk/react@3.0.23':
resolution: {integrity: sha512-SlGHxj3IrY/t2zf/kKHac8DiwKOPWCSBiaxGejijjST1mlAS5Hjp9CchmQNswNh9NxZQ7mow2C3t2jC/PLfLbw==}
engines: {node: '>=18'}
@ -13278,6 +13295,12 @@ snapshots:
'@ai-sdk/provider-utils': 4.0.4(zod@4.3.5)
zod: 4.3.5
'@ai-sdk/devtools@0.0.8':
dependencies:
'@ai-sdk/provider': 3.0.5
'@hono/node-server': 1.19.8(hono@4.10.6)
hono: 4.10.6
'@ai-sdk/gateway@2.0.0-beta.68(effect@3.18.4)(zod@4.3.5)':
dependencies:
'@ai-sdk/provider': 3.0.0-beta.22
@ -13412,6 +13435,10 @@ snapshots:
dependencies:
json-schema: 0.4.0
'@ai-sdk/provider@3.0.5':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@3.0.23(react@19.2.3)(zod@4.3.5)':
dependencies:
'@ai-sdk/provider-utils': 4.0.4(zod@4.3.5)
@ -17232,6 +17259,15 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.16(msw@2.12.7(@types/node@22.19.5)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.7(@types/node@22.19.5)(typescript@5.9.3)
vite: 7.3.1(@types/node@22.19.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/mocker@4.0.16(msw@2.12.7(@types/node@25.0.3)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.16
@ -21168,6 +21204,32 @@ snapshots:
ms@2.1.3: {}
msw@2.12.7(@types/node@22.19.5)(typescript@5.9.3):
dependencies:
'@inquirer/confirm': 5.1.21(@types/node@22.19.5)
'@mswjs/interceptors': 0.40.0
'@open-draft/deferred-promise': 2.2.0
'@types/statuses': 2.0.6
cookie: 1.1.1
graphql: 16.12.0
headers-polyfill: 4.0.3
is-node-process: 1.2.0
outvariant: 1.4.3
path-to-regexp: 6.3.0
picocolors: 1.1.1
rettime: 0.7.0
statuses: 2.0.2
strict-event-emitter: 0.5.1
tough-cookie: 6.0.0
type-fest: 5.3.1
until-async: 3.0.2
yargs: 17.7.2
optionalDependencies:
typescript: 5.9.3
transitivePeerDependencies:
- '@types/node'
optional: true
msw@2.12.7(@types/node@25.0.3)(typescript@5.9.3):
dependencies:
'@inquirer/confirm': 5.1.21(@types/node@25.0.3)
@ -23550,6 +23612,23 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@7.3.1(@types/node@22.19.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.55.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 22.19.5
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.30.2
terser: 5.46.0
tsx: 4.21.0
yaml: 2.8.2
vite@7.3.1(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
esbuild: 0.27.2
@ -23567,6 +23646,46 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@22.19.5)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.12.7(@types/node@22.19.5)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(msw@2.12.7(@types/node@22.19.5)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
'@vitest/spy': 4.0.16
'@vitest/utils': 4.0.16
es-module-lexer: 1.7.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.3
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 7.3.1(@types/node@22.19.5)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 22.19.5
happy-dom: 20.1.0
jsdom: 27.4.0
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(happy-dom@20.1.0)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.3)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.16