UNPKG

@craftapit/tester

Version:

A focused, LLM-powered testing framework for natural language test scenarios

468 lines (398 loc) 13.8 kB
import * as path from 'path'; import * as fs from 'fs'; import { CapabilityRegistry } from './CapabilityRegistry'; import { TestExecutor } from './TestExecutor'; import { ScenarioParser } from './ScenarioParser'; import { BaseAdapter } from '../adapters/BaseAdapter'; import { LLMAdapter } from '../adapters/LLMAdapter'; import { AnthropicAdapter } from '../adapters/AnthropicAdapter'; import { OpenAIAdapter } from '../adapters/OpenAIAdapter'; import { OllamaAdapter } from '../adapters/OllamaAdapter'; import { CraftacoderAdapter } from '../adapters/CraftacoderAdapter'; import { TestResult } from '../types/results'; /** * LLM adapter type options */ export type LLMAdapterType = 'craftacoder' | 'anthropic' | 'openai' | 'ollama' | 'custom'; /** * Base configuration for TestRunner */ export interface TestRunnerConfig { /** * LLM adapter type to use * @default 'craftacoder' */ llmAdapter?: LLMAdapterType; /** * LLM adapter configuration * The shape depends on the adapter type */ llmAdapterConfig?: Record<string, any>; /** * Custom LLM adapter instance (if type is 'custom') */ customLLMAdapter?: LLMAdapter; /** * Additional adapters to register */ adapters?: Record<string, BaseAdapter>; /** * Addons to register * The addons should be instances of classes that have a registerCapabilities method */ addons?: Array<{ registerCapabilities: (registry: CapabilityRegistry) => void }>; /** * Whether to enable caching * @default true */ caching?: boolean; /** * Path to the cache file * If not provided, a default path will be used */ cachePath?: string; /** * Timeout for test execution in milliseconds * @default 60000 (1 minute) */ timeout?: number; /** * Whether to print verbose output * @default false */ verbose?: boolean; } /** * A test file descriptor for batch operations */ export interface TestFile { /** * Path to the test file */ path: string; /** * Content of the test file (if available) */ content?: string; /** * Test metadata (if available) */ metadata?: Record<string, any>; } /** * Test runner for executing test scenarios */ export class TestRunner { private registry: CapabilityRegistry; private executor: TestExecutor; private parser: ScenarioParser; private llmAdapter: LLMAdapter; private config: TestRunnerConfig; /** * Create a new test runner * @param config Test runner configuration */ constructor(config: TestRunnerConfig = {}) { this.config = { caching: true, timeout: 60000, verbose: false, llmAdapter: 'craftacoder', ...config }; this.registry = new CapabilityRegistry({ cachingEnabled: this.config.caching, cacheFilePath: this.config.cachePath || path.join(process.cwd(), '.craft-a-tester', 'test-cache.json') }); this.llmAdapter = this.createLLMAdapter(); this.registry.setLLMAdapter(this.llmAdapter); // Create the scenario parser this.parser = new ScenarioParser(); // Register the LLM adapter this.registry.registerAdapter('llm', this.llmAdapter); // Register additional adapters if (this.config.adapters) { Object.entries(this.config.adapters).forEach(([name, adapter]) => { this.registry.registerAdapter(name, adapter); }); } // Register addons if (this.config.addons) { this.config.addons.forEach(addon => { addon.registerCapabilities(this.registry); }); } this.executor = new TestExecutor({}, this.registry); // Make sure we register the adapters with the executor as well this.executor.registerAdapter('llm', this.llmAdapter); if (this.config.adapters) { Object.entries(this.config.adapters).forEach(([name, adapter]) => { this.executor.registerAdapter(name, adapter); }); } } /** * Create an LLM adapter based on the configuration */ private createLLMAdapter(): LLMAdapter { if (this.config.llmAdapter === 'custom' && this.config.customLLMAdapter) { return this.config.customLLMAdapter; } const adapterConfig = this.config.llmAdapterConfig || {}; switch (this.config.llmAdapter) { case 'craftacoder': return new CraftacoderAdapter({ apiKey: adapterConfig.apiKey || process.env.CRAFTACODER_API_KEY, baseUrl: adapterConfig.baseUrl || process.env.CRAFTACODER_API_BASE || 'http://localhost:3000', model: adapterConfig.model || process.env.CRAFTACODER_MODEL || 'claude-3-sonnet-20240229', provider: adapterConfig.provider || process.env.CRAFTACODER_PROVIDER || 'anthropic' }); case 'anthropic': return new AnthropicAdapter({ apiKey: adapterConfig.apiKey || process.env.ANTHROPIC_API_KEY, baseUrl: adapterConfig.baseUrl || process.env.ANTHROPIC_API_BASE || 'https://api.anthropic.com', model: adapterConfig.model || process.env.ANTHROPIC_MODEL || 'claude-3-sonnet-20240229' }); case 'openai': return new OpenAIAdapter({ apiKey: adapterConfig.apiKey || process.env.OPENAI_API_KEY, baseUrl: adapterConfig.baseUrl || process.env.OPENAI_API_BASE, model: adapterConfig.model || process.env.OPENAI_MODEL || 'gpt-4' }); case 'ollama': return new OllamaAdapter({ baseUrl: adapterConfig.baseUrl || process.env.OLLAMA_URL || 'http://localhost:11434', model: adapterConfig.model || process.env.OLLAMA_MODEL || 'phi4:14b-fp16', contextSize: adapterConfig.contextSize || parseInt(process.env.CONTEXT_SIZE || '16384', 10), dynamicContextSizing: adapterConfig.dynamicContextSizing !== undefined ? adapterConfig.dynamicContextSizing : process.env.DYNAMIC_SIZING?.toLowerCase() !== 'false' }); default: throw new Error(`Unsupported LLM adapter type: ${this.config.llmAdapter}`); } } /** * Initialize the test runner * This will initialize all adapters */ async initialize(): Promise<void> { if (this.config.verbose) { console.log('Initializing test runner...'); } try { await this.llmAdapter.initialize(); if (this.config.adapters) { for (const [name, adapter] of Object.entries(this.config.adapters)) { if (this.config.verbose) { console.log(`Initializing adapter: ${name}`); } await adapter.initialize(); } } if (this.config.verbose) { console.log('Test runner initialized'); } } catch (error) { console.error('Failed to initialize test runner:', error); throw error; } } /** * Run a test from a file path * @param filePath Path to the test file * @returns Test results */ async runTestFile(filePath: string): Promise<TestResult> { if (this.config.verbose) { console.log(`Running test file: ${filePath}`); } try { const content = await fs.promises.readFile(filePath, 'utf-8'); return this.runTest(content); } catch (error) { console.error(`Failed to run test file ${filePath}:`, error); throw error; } } /** * Run a test from a string * @param test Test content as a string * @returns Test results */ async runTest(test: string): Promise<TestResult> { if (this.config.verbose) { console.log('Running test...'); } try { // Create a timeout for the test let timeoutId: NodeJS.Timeout | null = null; // Parse the test string into a scenario const scenario = this.parser.parse(test); const results = await Promise.race([ this.executor.executeScenario(scenario), new Promise<TestResult>((_, reject) => { timeoutId = setTimeout(() => { reject(new Error(`Test execution timed out after ${this.config.timeout}ms`)); }, this.config.timeout); }) ]); // Clear the timeout if (timeoutId) { clearTimeout(timeoutId); } if (this.config.verbose) { this.logTestResults(results); } return results; } catch (error) { console.error('Test execution failed:', error); throw error; } } /** * Run all tests in a directory * @param directory Directory containing test files * @param pattern File pattern to match (default: '**\*.md') * @returns Test results by file path */ async runTestDirectory(directory: string, pattern: string = '**/*.md'): Promise<Record<string, TestResult>> { if (this.config.verbose) { console.log(`Running tests in directory: ${directory}`); } try { const testFiles = await this.discoverTestFiles(directory, pattern); if (testFiles.length === 0) { console.warn(`No test files found in ${directory} matching pattern ${pattern}`); return {}; } if (this.config.verbose) { console.log(`Found ${testFiles.length} test files`); } const results: Record<string, TestResult> = {}; for (const file of testFiles) { try { if (this.config.verbose) { console.log(`Running test file: ${file.path}`); } const content = file.content || await fs.promises.readFile(file.path, 'utf-8'); results[file.path] = await this.runTest(content); } catch (error) { console.error(`Failed to run test file ${file.path}:`, error); results[file.path] = { passed: false, steps: [], error: error instanceof Error ? error.message : String(error) }; } } if (this.config.verbose) { this.logTestResultsSummary(results); } return results; } catch (error) { console.error(`Failed to run tests in directory ${directory}:`, error); throw error; } } /** * Discover test files in a directory * @param directory Directory to search * @param pattern File pattern to match */ private async discoverTestFiles(directory: string, pattern: string): Promise<TestFile[]> { // For now, we'll use a simple recursive search // In a full implementation, this would use a proper glob library const files: TestFile[] = []; const scanDirectory = async (dir: string) => { const entries = await fs.promises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { await scanDirectory(fullPath); } else if (entry.isFile() && entry.name.endsWith('.md')) { files.push({ path: fullPath }); } } }; await scanDirectory(directory); return files; } /** * Log test results * @param results Test results */ private logTestResults(results: TestResult): void { console.log('\nTest Results:'); console.log(`Passed: ${results.passed}`); console.log(`Total steps: ${results.steps.length}`); console.log(`Passed steps: ${results.steps.filter(s => s.status === 'passed').length}`); console.log(`Failed steps: ${results.steps.filter(s => s.status === 'failed').length}`); console.log(`Skipped steps: ${results.steps.filter(s => s.status === 'skipped').length}`); if (results.error) { console.log(`Error: ${results.error}`); } console.log('\nDetailed Results:'); results.steps.forEach((step, index) => { console.log(`Step ${index + 1}: ${step.description}`); console.log(` Status: ${step.status}`); if (step.error) { console.log(` Error: ${step.error}`); } }); } /** * Log test results summary * @param results Test results by file path */ private logTestResultsSummary(results: Record<string, TestResult>): void { console.log('\nTest Results Summary:'); const totalTests = Object.keys(results).length; const passedTests = Object.values(results).filter(r => r.passed).length; const failedTests = totalTests - passedTests; console.log(`Total tests: ${totalTests}`); console.log(`Passed tests: ${passedTests}`); console.log(`Failed tests: ${failedTests}`); if (failedTests > 0) { console.log('\nFailed Tests:'); Object.entries(results) .filter(([_, result]) => !result.passed) .forEach(([path, result]) => { console.log(`- ${path}: ${result.error || 'Failed steps'}`); }); } } /** * Clean up the test runner * This will clean up all adapters */ async cleanup(): Promise<void> { if (this.config.verbose) { console.log('Cleaning up test runner...'); } try { await this.llmAdapter.cleanup(); if (this.config.adapters) { for (const [name, adapter] of Object.entries(this.config.adapters)) { if (this.config.verbose) { console.log(`Cleaning up adapter: ${name}`); } await adapter.cleanup(); } } if (this.config.verbose) { console.log('Test runner cleaned up'); } } catch (error) { console.error('Failed to clean up test runner:', error); } } } /** * Create a test runner with the given configuration * @param config Test runner configuration * @returns A test runner instance */ export function createTestRunner(config: TestRunnerConfig): TestRunner { return new TestRunner(config); }