UNPKG

meld

Version:

Meld: A template language for LLM prompts

632 lines (544 loc) 20.9 kB
import { MemfsTestFileSystem } from './MemfsTestFileSystem.js'; import { MemfsTestFileSystemAdapter } from './MemfsTestFileSystemAdapter.js'; import { ProjectBuilder } from './ProjectBuilder.js'; import { TestSnapshot } from './TestSnapshot.js'; import { FixtureManager } from './FixtureManager.js'; import * as testFactories from './testFactories.js'; import { ParserService } from '@services/pipeline/ParserService/ParserService.js'; import { InterpreterService } from '@services/pipeline/InterpreterService/InterpreterService.js'; import { DirectiveService } from '@services/pipeline/DirectiveService/DirectiveService.js'; import { ValidationService } from '@services/resolution/ValidationService/ValidationService.js'; import { StateService } from '@services/state/StateService/StateService.js'; import { PathService } from '@services/fs/PathService/PathService.js'; import { CircularityService } from '@services/resolution/CircularityService/CircularityService.js'; import { ResolutionService } from '@services/resolution/ResolutionService/ResolutionService.js'; import { FileSystemService } from '@services/fs/FileSystemService/FileSystemService.js'; import { OutputService } from '@services/pipeline/OutputService/OutputService.js'; import { OutputFormat } from '@services/pipeline/OutputService/IOutputService.js'; import { StateTrackingService } from './debug/StateTrackingService/StateTrackingService.js'; import { StateVisualizationService } from './debug/StateVisualizationService/StateVisualizationService.js'; import { StateDebuggerService } from './debug/StateDebuggerService/StateDebuggerService.js'; import { StateHistoryService } from './debug/StateHistoryService/StateHistoryService.js'; import { StateEventService } from '@services/state/StateEventService/StateEventService.js'; import { TestOutputFilterService } from './debug/TestOutputFilterService/TestOutputFilterService.js'; import type { IParserService } from '@services/pipeline/ParserService/IParserService.js'; import type { IInterpreterService } from '@services/pipeline/InterpreterService/IInterpreterService.js'; import type { IDirectiveService } from '@services/pipeline/DirectiveService/IDirectiveService.js'; import type { IValidationService } from '@services/resolution/ValidationService/IValidationService.js'; import type { IStateService } from '@services/state/StateService/IStateService.js'; import type { IPathService } from '@services/fs/PathService/IPathService.js'; import type { ICircularityService } from '@services/resolution/CircularityService/ICircularityService.js'; import type { IResolutionService } from '@services/resolution/ResolutionService/IResolutionService.js'; import type { IFileSystemService } from '@services/fs/FileSystemService/IFileSystemService.js'; import type { IOutputService } from '@services/pipeline/OutputService/IOutputService.js'; import type { IStateTrackingService } from './debug/StateTrackingService/IStateTrackingService.js'; import type { IStateVisualizationService } from './debug/StateVisualizationService/IStateVisualizationService.js'; import type { IStateDebuggerService } from './debug/StateDebuggerService/IStateDebuggerService.js'; import type { IStateHistoryService } from './debug/StateHistoryService/IStateHistoryService.js'; import * as fs from 'fs-extra'; import * as path from 'path'; import { filesystemLogger as logger } from '@core/utils/logger.js'; import { PathOperationsService } from '@services/fs/FileSystemService/PathOperationsService.js'; import type { IStateEventService } from '@services/state/StateEventService/IStateEventService.js'; import type { DebugSessionConfig, DebugSessionResult } from './debug/StateDebuggerService/IStateDebuggerService.js'; import { TestDebuggerService } from './debug/TestDebuggerService.js'; import { mockProcessExit } from './cli/mockProcessExit.js'; import { mockConsole } from './cli/mockConsole.js'; interface SnapshotDiff { added: string[]; removed: string[]; modified: string[]; modifiedContents: Map<string, string>; } interface TestFixtures { load(fixtureName: string): Promise<void>; } interface TestSnapshotInterface { takeSnapshot(): Promise<Map<string, string>>; compare(before: Map<string, string>, after: Map<string, string>): SnapshotDiff; } interface TestServices { parser: IParserService; interpreter: IInterpreterService; directive: IDirectiveService; validation: IValidationService; state: IStateService; path: IPathService; circularity: ICircularityService; resolution: IResolutionService; filesystem: IFileSystemService; output: IOutputService; debug: IStateDebuggerService; eventService: IStateEventService; } /** * Main test context that provides access to all test utilities */ export class TestContext { public readonly fs: MemfsTestFileSystem; public builder: ProjectBuilder; public readonly fixtures: TestFixtures; public readonly snapshot: TestSnapshot; public factory: typeof testFactories; public readonly services: TestServices; private fixturesDir: string; private cleanupFunctions: Array<() => void> = []; constructor(fixturesDir: string = 'tests/fixtures') { this.fs = new MemfsTestFileSystem(); this.fs.initialize(); this.builder = new ProjectBuilder(this.fs); this.fixturesDir = fixturesDir; // Setup console mocking to suppress output during tests const { restore } = mockConsole(); this.cleanupFunctions.push(restore); // Initialize fixtures this.fixtures = { load: async (fixtureName: string): Promise<void> => { const fixturePath = path.join(process.cwd(), this.fixturesDir, `${fixtureName}.json`); const fixtureContent = await fs.readFile(fixturePath, 'utf-8'); const fixture = JSON.parse(fixtureContent); await this.fs.loadFixture(fixture); } }; // Initialize snapshot this.snapshot = new TestSnapshot(this.fs); this.factory = testFactories; // Initialize services const pathOps = new PathOperationsService(); const filesystem = new FileSystemService(pathOps, this.fs); const validation = new ValidationService(); const path = new PathService(); // Initialize PathService first path.initialize(filesystem); path.enableTestMode(); path.setProjectPath('/project'); // Make FileSystemService use PathService for path resolution filesystem.setPathService(path); const parser = new ParserService(); const circularity = new CircularityService(); const interpreter = new InterpreterService(); // Initialize event service const eventService = new StateEventService(); // Initialize state service const state = new StateService(eventService); state.setCurrentFilePath('test.meld'); // Set initial file path state.enableTransformation(true); // Initialize special path variables state.setPathVar('PROJECTPATH', '/project'); state.setPathVar('HOMEPATH', '/home/user'); // Initialize resolution service const resolution = new ResolutionService(state, filesystem, parser, path); // Initialize debugger service const debuggerService = new TestDebuggerService(state); debuggerService.initialize(state); // Initialize directive service const directive = new DirectiveService(); directive.initialize( validation, state, path, filesystem, parser, interpreter, circularity, resolution ); // Initialize interpreter service interpreter.initialize(directive, state); // Register default handlers after all services are initialized directive.registerDefaultHandlers(); // Initialize output service last, after all other services are ready const output = new OutputService(); output.initialize(state, resolution); // Expose services this.services = { parser, interpreter, directive, validation, state, path, circularity, resolution, filesystem, output, debug: debuggerService, eventService }; } /** * Initialize the test context */ async initialize(): Promise<void> { this.fs.initialize(); // Ensure project directory exists await this.fs.mkdir('/project'); // Ensure fixture directories exist await this.fs.mkdir('/project/src'); await this.fs.mkdir('/project/nested'); await this.fs.mkdir('/project/shared'); } /** * Clean up resources */ async cleanup(): Promise<void> { this.fs.cleanup(); this.cleanupFunctions.forEach(fn => fn()); this.cleanupFunctions = []; } /** * Write a file in the test context * This method will automatically create parent directories if needed */ async writeFile(relativePath: string, content: string): Promise<void> { logger.debug('Writing file in test context', { relativePath }); // Use the PathService to properly resolve the path let resolvedPath; try { // Ensure path format compliance with new path rules if (relativePath.includes('/')) { // If path contains slashes and doesn't have a special prefix, add project path variable if (!relativePath.startsWith('$./') && !relativePath.startsWith('$~/') && !relativePath.startsWith('$PROJECTPATH/') && !relativePath.startsWith('$HOMEPATH/')) { // Prefix with project path variable resolvedPath = this.services.path.resolvePath(`$PROJECTPATH/${relativePath}`); } else { // Path already has a special prefix resolvedPath = this.services.path.resolvePath(relativePath); } } else { // Simple filename with no slashes resolvedPath = this.services.path.resolvePath(relativePath); } logger.debug('Resolved path for writing', { relativePath, resolvedPath }); } catch (error) { logger.error('Path resolution error', { relativePath, error }); // If PathService validation fails, use a standardized absolute path format // First ensure the path is normalized with forward slashes const normalizedPath = relativePath.replace(/\\/g, '/'); // Use a direct absolute path for tests resolvedPath = normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`; logger.debug('Using direct path', { relativePath, resolvedPath }); } // Create parent directories if needed const dirPath = this.services.path.dirname(resolvedPath); await this.fs.mkdir(dirPath, { recursive: true }); // Write the file logger.debug('Writing file', { resolvedPath }); await this.fs.writeFile(resolvedPath, content); } /** * Parse meld content using meld-ast */ parseMeld(content: string) { return this.services.parser.parse(content); } parseMeldWithLocations(content: string, filePath?: string) { return this.services.parser.parseWithLocations(content, filePath); } /** * Convert content to XML using llmxml */ public async toXML(content: any): Promise<string> { const { createLLMXML } = await import('llmxml'); const llmxml = createLLMXML(); return llmxml.toXML(content); } /** * Create a basic test project structure */ async createBasicProject(): Promise<void> { await this.builder.createBasicProject(); } /** * Take a snapshot of the current filesystem state */ async takeSnapshot(dir?: string): Promise<Map<string, string>> { return this.snapshot.takeSnapshot(dir); } /** * Compare two filesystem snapshots */ compareSnapshots(before: Map<string, string>, after: Map<string, string>): SnapshotDiff { return this.snapshot.compare(before, after); } /** * Start a debug session for test tracing */ async startDebugSession(config?: Partial<DebugSessionConfig>): Promise<string> { const defaultConfig: DebugSessionConfig = { captureConfig: { capturePoints: ['pre-transform', 'post-transform', 'error'] as const, includeFields: ['nodes', 'transformedNodes', 'variables'] as const, format: 'full' }, visualization: { format: 'mermaid', includeMetadata: true, includeTimestamps: true }, traceOperations: true, collectMetrics: true }; const mergedConfig = { ...defaultConfig, ...config }; return await this.services.debug.startSession(mergedConfig); } /** * End a debug session and get results */ async endDebugSession(sessionId: string): Promise<DebugSessionResult> { return this.services.debug.endSession(sessionId); } /** * Get a visualization of the current state */ async visualizeState(format: 'mermaid' | 'dot' = 'mermaid'): Promise<string> { return this.services.debug.visualizeState(format); } /** * Enable transformation mode * @param options Options for selective transformation, or true/false for all */ enableTransformation(options: any = true): void { this.services.state.enableTransformation(options); } /** * Disable transformation mode */ disableTransformation(): void { this.services.state.enableTransformation(false); } /** * Enable debug mode */ enableDebug(): void { // Initialize debug service if not already done if (!this.services.debug) { const debuggerService = new StateDebuggerService( this.services.debug.visualization, this.services.debug.history, this.services.debug.tracking ); (this.services as any).debug = debuggerService; } } /** * Disable debug mode */ disableDebug(): void { if (this.services.debug) { (this.services as any).debug = undefined; } } /** * Set output format */ setFormat(format: OutputFormat): void { this.services.output.setFormat(format); } /** * Reset all services to initial state */ reset(): void { // Reset state service this.services.state.reset(); // Reset debug service if enabled if (this.services.debug) { this.services.debug.reset(); } // Reset tracking service this.services.debug.tracking.reset(); // Reset history service this.services.debug.history.reset(); // Reset visualization service this.services.debug.visualization.reset(); } /** * Mock process.exit to prevent tests from exiting the process * @returns Object with exit code and exit was called flag */ mockProcessExit() { const result = mockProcessExit(); this.registerCleanup(result.restore); return result; } /** * Mock console methods (log, error, warn) to capture output * @returns Object with captured output and restore function */ mockConsole() { const result = mockConsole(); this.registerCleanup(result.restore); return result; } /** * Set up environment variables for testing * @param envVars - Environment variables to set * @returns This TestContext instance for chaining */ withEnvironment(envVars: Record<string, string>) { const originalEnv = { ...process.env }; // Set environment variables Object.entries(envVars).forEach(([key, value]) => { process.env[key] = value; }); // Register cleanup this.registerCleanup(() => { process.env = originalEnv; }); return this; } /** * Set up a complete CLI test environment * @param options - Options for setting up the CLI test environment * @returns Object containing mock functions and file system */ async setupCliTest(options: { files?: Record<string, string>; env?: Record<string, string>; mockExit?: boolean; mockConsoleOutput?: boolean; projectRoot?: string; } = {}) { const result: Record<string, any> = {}; // Create project directory structure first const projectRoot = options.projectRoot || '/project'; await this.fs.mkdir(projectRoot, { recursive: true }); // Set up file system if needed if (options.files && Object.keys(options.files).length > 0) { // Add files to the memory file system for (const [filePath, content] of Object.entries(options.files)) { try { // Ensure the path is absolute const absolutePath = filePath.startsWith('/') ? filePath : `/${filePath}`; // Handle special paths like $./file.txt const resolvedPath = this.resolveSpecialPath(absolutePath, projectRoot); // Create parent directories if needed const dirPath = resolvedPath.substring(0, resolvedPath.lastIndexOf('/')); if (dirPath) { await this.fs.mkdir(dirPath, { recursive: true }); } // Write the file await this.fs.writeFile(resolvedPath, content); } catch (error) { // Silently fail to prevent console output during tests } } result.fs = this.fs; } // Set up environment variables if needed if (options.env && Object.keys(options.env).length > 0) { this.withEnvironment(options.env); } // Mock process.exit if needed if (options.mockExit !== false) { result.exitMock = this.mockProcessExit(); } // Mock console if needed if (options.mockConsoleOutput !== false) { result.consoleMocks = this.mockConsole(); } return result; } /** * Resolve special path syntax ($./file.txt, $~/file.txt) * @param path The path to resolve * @param projectRoot The project root directory * @returns Resolved absolute path */ private resolveSpecialPath(path: string, projectRoot: string): string { if (path.includes('$./') || path.includes('$PROJECTPATH/')) { return path.replace(/\$\.\//g, `${projectRoot}/`).replace(/\$PROJECTPATH\//g, `${projectRoot}/`); } else if (path.includes('$~/') || path.includes('$HOMEPATH/')) { return path.replace(/\$~\//g, '/home/user/').replace(/\$HOMEPATH\//g, '/home/user/'); } return path; } /** * Use memory file system for testing * This is a no-op since TestContext already uses a memory file system by default * Added for compatibility with setupCliTest */ useMemoryFileSystem(): void { // No-op: TestContext already uses MemfsTestFileSystem by default // This method exists for API compatibility with setupCliTest } /** * Register a cleanup function * @param fn - Cleanup function to register */ registerCleanup(fn: () => void) { this.cleanupFunctions.push(fn); } /** * Run the Meld CLI with the given options * @param options - Options for running Meld * @returns Result of the CLI execution */ async runMeld(options: { input: string; output?: string; format?: 'markdown' | 'xml'; transformation?: boolean; strict?: boolean; stdout?: boolean; }): Promise<{ stdout: string; stderr: string; exitCode: number; }> { // Import CLI module const cli = await import('../../cli/index.js'); // Prepare arguments const args = [options.input]; // Add format option if specified if (options.format) { args.push('--format', options.format); } // Add output option if specified if (options.output) { args.push('--output', options.output); } // Add transformation option if specified if (options.transformation === false) { args.push('--no-transformation'); } // Add strict option if specified if (options.strict) { args.push('--strict'); } // Add stdout option if specified if (options.stdout) { args.push('--stdout'); } // Mock console output const consoleMocks = this.mockConsole(); // Mock process.exit const exitMock = this.mockProcessExit(); // Set up process.argv process.argv = ['node', 'meld', ...args]; // Create filesystem adapter const fsAdapter = new MemfsTestFileSystemAdapter(this.fs); try { // Run the CLI await cli.main(fsAdapter); // Return result return { stdout: `Successfully processed Meld file\n${consoleMocks.mocks.log.mock.calls.map(args => args.join(' ')).join('\n')}`, stderr: consoleMocks.mocks.error.mock.calls.map(args => args.join(' ')).join('\n'), exitCode: exitMock.mockExit.mock.calls.length > 0 ? exitMock.mockExit.mock.calls[0][0] : 0 }; } catch (error) { // Return error result return { stdout: consoleMocks.mocks.log.mock.calls.map(args => args.join(' ')).join('\n'), stderr: error instanceof Error ? error.message : String(error), exitCode: 1 }; } finally { // Restore mocks consoleMocks.restore(); exitMock.restore(); } } }