meld
Version:
Meld: A template language for LLM prompts
377 lines (327 loc) • 13.2 kB
text/typescript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { CLIService, IPromptService } from './CLIService.js';
import { TestContext } from '@tests/utils/index.js';
import { IParserService } from '@services/pipeline/ParserService/IParserService.js';
import { IInterpreterService } from '@services/pipeline/InterpreterService/IInterpreterService.js';
import { IOutputService } from '@services/pipeline/OutputService/IOutputService.js';
import { IFileSystemService } from '@services/fs/FileSystemService/IFileSystemService.js';
import { IPathService } from '@services/fs/PathService/IPathService.js';
import { IStateService } from '@services/state/StateService/IStateService.js';
import * as readline from 'readline';
import { ErrorCollector } from '@tests/utils/ErrorTestUtils.js';
import { ErrorSeverity } from '@core/errors/MeldError.js';
import { cliLogger, type Logger } from '@core/utils/logger.js';
// Import the centralized syntax examples
import { textDirectiveExamples } from '@core/syntax/index.js';
// Mock the API module
vi.mock('@api/index.js', () => ({
main: vi.fn().mockResolvedValue('test output')
}));
// Mock console.log
const consoleLog = vi.spyOn(console, 'log');
// Create a mock prompt service
const mockPromptService: IPromptService = {
getText: vi.fn()
};
const defaultOptions = {
input: 'test.mld',
format: 'xml' as const
};
// Create a proper async iterator implementation for watching
const createAsyncIterable = () => {
let callCount = 0;
return {
[Symbol.asyncIterator]() {
return {
async next() {
if (callCount === 0) {
callCount++;
return {
done: false,
value: { filename: 'test.mld', eventType: 'change' }
};
}
// Simulate an infinite wait to keep the watcher running
// This won't block because we'll interrupt it with an error
await new Promise(resolve => setTimeout(resolve, 1000000));
return { done: true };
}
};
}
};
};
// Mock fs.watch to return a proper async iterable
vi.mock('fs/promises', async () => {
const actual = await vi.importActual('fs/promises');
return {
...actual,
watch: vi.fn().mockImplementation(() => createAsyncIterable())
};
});
// Move mock setup to top level
vi.mock('readline', () => ({
createInterface: vi.fn().mockReturnValue({
question: vi.fn().mockImplementation((_, cb) => cb('y')),
close: vi.fn()
})
}));
describe('CLIService', () => {
let context: TestContext;
let service: CLIService;
let mockParserService: IParserService;
let mockInterpreterService: IInterpreterService;
let mockOutputService: IOutputService;
let mockFileSystemService: IFileSystemService;
let mockPathService: IPathService;
let mockStateService: IStateService;
let mockReadline: any;
let mockLogger: Logger;
beforeEach(async () => {
// Reset the mock state
vi.clearAllMocks();
vi.mocked(mockPromptService.getText).mockReset();
context = new TestContext();
await context.initialize();
// Initialize readline mock
mockReadline = {
question: vi.fn().mockImplementation((_, cb) => cb('y')),
close: vi.fn()
};
vi.mocked(readline.createInterface).mockReturnValue(mockReadline);
// Create mock services
mockParserService = {
parse: vi.fn().mockResolvedValue([]),
parseWithLocations: vi.fn().mockResolvedValue([])
} as unknown as IParserService;
mockInterpreterService = {
initialize: vi.fn(),
interpret: vi.fn().mockResolvedValue(undefined),
interpretNode: vi.fn(),
createChildContext: vi.fn(),
canHandleTransformations: vi.fn().mockReturnValue(true)
} as unknown as IInterpreterService;
mockOutputService = {
convert: vi.fn().mockResolvedValue('test output'),
registerFormat: vi.fn(),
supportsFormat: vi.fn(),
getSupportedFormats: vi.fn(),
canAccessTransformedNodes: vi.fn().mockReturnValue(true)
} as unknown as IOutputService;
// Use the MemfsTestFileSystem for file operations
const fs = context.fs;
mockFileSystemService = {
readFile: fs.readFile.bind(fs),
writeFile: fs.writeFile.bind(fs),
exists: fs.exists.bind(fs),
watch: fs.watch.bind(fs),
getFileSystem: vi.fn().mockReturnValue(fs)
} as unknown as IFileSystemService;
mockPathService = {
initialize: vi.fn(),
resolvePath: vi.fn().mockImplementation(path => path),
enableTestMode: vi.fn(),
disableTestMode: vi.fn(),
isTestMode: vi.fn(),
validatePath: vi.fn(),
normalizePath: vi.fn(),
isAbsolute: vi.fn(),
join: vi.fn(),
dirname: vi.fn(),
basename: vi.fn(),
getHomePath: vi.fn().mockReturnValue('/home'),
getProjectPath: vi.fn().mockReturnValue('/project'),
resolveProjectPath: vi.fn().mockResolvedValue('/project'),
setHomePath: vi.fn(),
setProjectPath: vi.fn()
} as unknown as IPathService;
const mockChildState = {
setPathVar: vi.fn(),
getNodes: vi.fn().mockReturnValue([])
} as unknown as IStateService;
mockStateService = {
createChildState: vi.fn().mockReturnValue(mockChildState)
} as unknown as IStateService;
// Create CLI service with mocks
service = new CLIService(
mockParserService,
mockInterpreterService,
mockOutputService,
mockFileSystemService,
mockPathService,
mockStateService,
mockPromptService
);
// Set up test files
const textExample = textDirectiveExamples.atomic.simpleString;
await context.fs.writeFile('test.mld', textExample.code);
// Initialize mock logger
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
trace: vi.fn(),
level: 'info'
};
});
afterEach(async () => {
await context.cleanup();
vi.resetModules();
vi.clearAllMocks();
mockReadline = null;
});
describe('Format Conversion', () => {
it('should output xml format by default', async () => {
const args = ['node', 'meld', 'test.mld', '--stdout'];
await expect(service.run(args)).resolves.not.toThrow();
expect(mockOutputService.convert).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Object),
'xml',
expect.any(Object)
);
});
it('should handle format aliases correctly', async () => {
const args = ['node', 'meld', 'test.mld', '--format', 'markdown', '--stdout'];
await expect(service.run(args)).resolves.not.toThrow();
expect(mockOutputService.convert).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Object),
'markdown',
expect.any(Object)
);
});
it('should preserve markdown with markdown format', async () => {
const args = ['node', 'meld', 'test.mld', '--format', 'markdown', '--stdout'];
await expect(service.run(args)).resolves.not.toThrow();
expect(mockOutputService.convert).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Object),
'markdown',
expect.any(Object)
);
});
});
describe('Command Line Options', () => {
it('should display version when --version flag is used', async () => {
const consoleLog = vi.spyOn(console, 'log');
const args = ['node', 'meld', '--version'];
await service.run(args);
expect(consoleLog).toHaveBeenCalledWith(expect.stringMatching(/^meld version \d+\.\d+\.\d+$/));
consoleLog.mockRestore();
});
it('should respect --stdout option', async () => {
const consoleLog = vi.spyOn(console, 'log');
const args = ['node', 'meld', 'test.mld', '--stdout'];
await service.run(args);
expect(consoleLog).toHaveBeenCalledWith('test output');
consoleLog.mockRestore();
});
it('should use default output path when not specified', async () => {
const args = ['node', 'meld', 'test.mld'];
await expect(service.run(args)).resolves.not.toThrow();
expect(mockOutputService.convert).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Object),
'xml',
expect.any(Object)
);
});
it('should handle project path option', async () => {
// We no longer support --project-path option for security reasons
// Instead, we test that the project path is resolved correctly
const args = ['node', 'meld', 'test.mld'];
await service.run(args);
expect(mockPathService.resolveProjectPath).toHaveBeenCalled();
const state = mockStateService.createChildState();
expect(state.setPathVar).toHaveBeenCalledWith('PROJECTPATH', '/project');
expect(state.setPathVar).toHaveBeenCalledWith('.', '/project');
});
it('should handle home path option', async () => {
const args = ['node', 'meld', 'test.mld', '--home-path', '/home'];
await service.run(args);
const state = mockStateService.createChildState();
expect(state.setPathVar).toHaveBeenCalledWith('HOMEPATH', '/home');
expect(state.setPathVar).toHaveBeenCalledWith('~', '/home');
});
it('should handle verbose option', async () => {
const args = ['node', 'meld', 'test.mld', '--verbose'];
await service.run(args);
// Verify logging behavior if needed
});
});
describe('File Handling', () => {
it('should handle missing input files', async () => {
mockFileSystemService.exists = vi.fn().mockResolvedValue(false);
const args = ['node', 'meld', 'nonexistent.mld', '--stdout'];
await expect(service.run(args)).rejects.toThrow('File not found');
});
it('should handle write errors', async () => {
mockFileSystemService.writeFile = vi.fn().mockRejectedValue(new Error('Write error'));
const args = ['node', 'meld', 'test.mld', '--output', 'output.md'];
await expect(service.run(args)).rejects.toThrow('Write error');
});
it('should handle read errors', async () => {
mockFileSystemService.readFile = vi.fn().mockRejectedValue(new Error('Read error'));
const args = ['node', 'meld', 'test.mld', '--stdout'];
await expect(service.run(args)).rejects.toThrow('Read error');
});
});
describe('Error Handling', () => {
it('should handle parser errors', async () => {
mockParserService.parse = vi.fn().mockRejectedValue(new Error('Parse error'));
const args = ['node', 'meld', 'test.mld', '--stdout'];
await expect(service.run(args)).rejects.toThrow('Parse error');
});
it('should handle interpreter errors', async () => {
mockInterpreterService.interpret = vi.fn().mockRejectedValue(new Error('Interpret error'));
const args = ['node', 'meld', 'test.mld', '--stdout'];
await expect(service.run(args)).rejects.toThrow('Interpret error');
});
it('should handle output conversion errors', async () => {
mockOutputService.convert = vi.fn().mockRejectedValue(new Error('Convert error'));
const args = ['node', 'meld', 'test.mld', '--stdout'];
await expect(service.run(args)).rejects.toThrow('Convert error');
});
});
describe('File Overwrite Handling', () => {
it('should prompt for overwrite when file exists', async () => {
const args = ['node', 'meld', 'test.mld', '--output', 'test.md'];
await mockFileSystemService.writeFile('test.mld', 'input content');
await mockFileSystemService.writeFile('test.md', 'existing content');
// Mock the prompt service to return 'y'
vi.mocked(mockPromptService.getText).mockResolvedValueOnce('y');
await service.run(args);
expect(mockPromptService.getText).toHaveBeenCalledWith(
'File test.md already exists. Overwrite? [Y/n] ',
'y'
);
});
it('should handle explicit output paths appropriately', async () => {
// Create a mock input file path
const inputPath = '/project/input.mld';
// Mock the exists function to return true for the input path
mockFileSystemService.exists = vi.fn().mockImplementation(async (path) => {
return path === inputPath;
});
// Mock the readFile function to return content for the input path
mockFileSystemService.readFile = vi.fn().mockResolvedValue('test content');
// Mock the writeFile function
mockFileSystemService.writeFile = vi.fn().mockResolvedValue(undefined);
// Set up the args with an explicit output path
const args = ['node', 'meld', inputPath, '--output', 'custom/output.md'];
// Run the CLI with the explicit output path
await service.run(args);
// Verify the output was written to the correct path
expect(mockFileSystemService.writeFile).toHaveBeenCalledWith(
'custom/output.md',
'test output'
);
});
});
it('should handle parsing input content directly', async () => {
// ... existing code ...
const textExample = textDirectiveExamples.atomic.simpleString;
// ... existing code ...
});
});