meld
Version:
Meld: A template language for LLM prompts
455 lines (376 loc) • 14.3 kB
text/typescript
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ResolutionService } from './ResolutionService.js';
import { IStateService } from '@services/state/StateService/IStateService.js';
import { IFileSystemService } from '@services/fs/FileSystemService/IFileSystemService.js';
import { IParserService } from '@services/pipeline/ParserService/IParserService.js';
import { ResolutionContext } from './IResolutionService.js';
import { ResolutionError } from './errors/ResolutionError.js';
import type { MeldNode, DirectiveNode, TextNode } from 'meld-spec';
// Import centralized syntax examples and helpers
import {
textDirectiveExamples,
dataDirectiveExamples,
defineDirectiveExamples,
pathDirectiveExamples
} from '@core/syntax/index.js';
// Import run examples directly
import runDirectiveExamplesModule from '@core/syntax/run.js';
import { createExample, createInvalidExample, createNodeFromExample } from '@core/syntax/helpers';
// Use the correctly imported run directive examples
const runDirectiveExamples = runDirectiveExamplesModule;
// Mock the logger
vi.mock('@core/utils/logger', () => ({
resolutionLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
}));
describe('ResolutionService', () => {
let service: ResolutionService;
let stateService: IStateService;
let fileSystemService: IFileSystemService;
let parserService: IParserService;
let context: ResolutionContext;
beforeEach(() => {
stateService = {
getTextVar: vi.fn(),
getDataVar: vi.fn(),
getPathVar: vi.fn(),
getCommand: vi.fn(),
} as unknown as IStateService;
fileSystemService = {
exists: vi.fn(),
readFile: vi.fn(),
} as unknown as IFileSystemService;
parserService = {
parse: vi.fn(),
} as unknown as IParserService;
service = new ResolutionService(
stateService,
fileSystemService,
parserService
);
context = {
currentFilePath: 'test.meld',
allowedVariableTypes: {
text: true,
data: true,
path: true,
command: true
},
state: stateService
};
});
describe('resolveInContext', () => {
it('should handle text nodes', async () => {
const textNode: TextNode = {
type: 'Text',
content: 'simple text'
};
vi.mocked(parserService.parse).mockResolvedValue([textNode]);
const result = await service.resolveInContext('simple text', context);
expect(result).toBe('simple text');
});
it('should resolve text variables', async () => {
// Use centralized syntax example for text directive
const example = textDirectiveExamples.atomic.simpleString;
// Create a node matching what the parser would return for "{{greeting}}"
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'greeting',
value: 'Hello'
}
};
vi.mocked(parserService.parse).mockResolvedValue([node]);
vi.mocked(stateService.getTextVar).mockReturnValue('Hello World');
const result = await service.resolveInContext('{{greeting}}', context);
expect(result).toBe('Hello World');
});
it('should resolve data variables', async () => {
// Use centralized syntax example for data directive
const example = dataDirectiveExamples.atomic.simpleObject;
// Create a node matching what the parser would return for "{{config}}"
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'data',
identifier: 'user',
value: '{ "name": "Alice", "id": 123 }'
}
};
vi.mocked(parserService.parse).mockResolvedValue([node]);
vi.mocked(stateService.getDataVar).mockReturnValue({ name: 'Alice', id: 123 });
const result = await service.resolveInContext('{{user}}', context);
expect(result).toBe('{"name":"Alice","id":123}');
});
it('should resolve system path variables', async () => {
// System path variables like $HOMEPATH are handled differently
// than user-defined path variables
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'path',
identifier: 'HOMEPATH'
}
};
vi.mocked(parserService.parse).mockResolvedValue([node]);
vi.mocked(stateService.getPathVar).mockReturnValue('/home/user');
const result = await service.resolveInContext('$HOMEPATH', context);
expect(result).toBe('/home/user');
});
it('should resolve user-defined path variables', async () => {
// Use centralized syntax example for path directive
const example = pathDirectiveExamples.atomic.homePath;
// Create a node matching what the parser would return for "$home"
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'path',
identifier: 'home',
value: '$HOMEPATH/meld'
}
};
// Mock parser and path resolver
vi.mocked(parserService.parse).mockResolvedValue([node]);
vi.mocked(stateService.getPathVar).mockImplementation((name: string) => {
if (name === 'home') return '/home/user/meld';
if (name === 'HOMEPATH') return '/home/user';
return undefined;
});
// Use the exposed VariableReferenceResolver for more accurate path resolution testing
const variableResolver = service.getVariableResolver();
const originalResolve = variableResolver.resolve;
variableResolver.resolve = vi.fn().mockImplementation((text: string, ctx: ResolutionContext) => {
if (text === '$home') {
return Promise.resolve('/home/user/meld');
}
return originalResolve.call(variableResolver, text, ctx);
});
const result = await service.resolveInContext('$home', context);
// After test, restore original method
variableResolver.resolve = originalResolve;
expect(result).toBe('/home/user/meld');
});
it('should resolve command references', async () => {
// Use centralized syntax example for run directive
const example = runDirectiveExamples.atomic.simple;
// Create a node matching what the parser would return for "$echo(hello)"
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'run',
identifier: 'echo',
value: '$echo(test)',
args: ['test']
}
};
vi.mocked(parserService.parse).mockResolvedValue([node]);
vi.mocked(stateService.getCommand).mockReturnValue({
command: '@run [echo ${text}]'
});
const result = await service.resolveInContext('$echo(test)', context);
expect(result).toBe('echo test');
});
it('should handle parsing failures by treating value as text', async () => {
vi.mocked(parserService.parse).mockRejectedValue(new Error('Parse error'));
const result = await service.resolveInContext('unparseable content', context);
expect(result).toBe('unparseable content');
});
it('should concatenate multiple nodes', async () => {
const nodes: MeldNode[] = [
{
type: 'Text',
content: 'Hello '
},
{
type: 'Directive',
directive: {
kind: 'text',
identifier: 'name',
value: 'World'
}
}
];
vi.mocked(parserService.parse).mockResolvedValue(nodes);
vi.mocked(stateService.getTextVar).mockReturnValue('World');
const result = await service.resolveInContext('Hello {{name}}', context);
expect(result).toBe('Hello World');
});
});
describe('resolveContent', () => {
it('should read file content', async () => {
vi.mocked(fileSystemService.exists).mockResolvedValue(true);
vi.mocked(fileSystemService.readFile).mockResolvedValue('file content');
const result = await service.resolveContent('/path/to/file');
expect(result).toBe('file content');
expect(fileSystemService.readFile).toHaveBeenCalledWith('/path/to/file');
});
it('should throw when file does not exist', async () => {
vi.mocked(fileSystemService.exists).mockResolvedValue(false);
await expect(service.resolveContent('/missing/file'))
.rejects
.toThrow('File not found: /missing/file');
});
});
describe('extractSection', () => {
it('should extract section by heading', async () => {
const content = `# Title
Some content
## Section 1
Content 1
## Section 2
Content 2`;
const result = await service.extractSection(content, 'Section 1');
expect(result).toBe('## Section 1\n\nContent 1');
});
it('should include content until next heading of same or higher level', async () => {
const content = `# Title
Some content
## Section 1
Content 1
### Subsection
Subcontent
## Section 2
Content 2`;
const result = await service.extractSection(content, 'Section 1');
expect(result).toBe('## Section 1\n\nContent 1\n\n### Subsection\n\nSubcontent');
});
it('should throw when section is not found', async () => {
const content = '# Title\nContent';
await expect(service.extractSection(content, 'Missing Section'))
.rejects
.toThrow('Section not found: Missing Section');
});
});
describe('validateResolution', () => {
it('should validate text variables are allowed', async () => {
context.allowedVariableTypes.text = false;
// Use centralized syntax example for text directive
const example = textDirectiveExamples.atomic.simpleString;
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'greeting',
value: 'Hello'
}
};
vi.mocked(parserService.parse).mockResolvedValue([node]);
await expect(service.validateResolution('{{greeting}}', context))
.rejects
.toThrow('Text variables are not allowed in this context');
});
it('should validate data variables are allowed', async () => {
context.allowedVariableTypes.data = false;
// Use centralized syntax example for data directive
const example = dataDirectiveExamples.atomic.simpleObject;
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'data',
identifier: 'user',
value: '{ "name": "Alice", "id": 123 }'
}
};
vi.mocked(parserService.parse).mockResolvedValue([node]);
await expect(service.validateResolution('{{user}}', context))
.rejects
.toThrow('Data variables are not allowed in this context');
});
it('should validate path variables are allowed', async () => {
context.allowedVariableTypes.path = false;
// Use centralized syntax example for path directive
const example = pathDirectiveExamples.atomic.homePath;
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'path',
identifier: 'home'
}
};
vi.mocked(parserService.parse).mockResolvedValue([node]);
await expect(service.validateResolution('$home', context))
.rejects
.toThrow('Path variables are not allowed in this context');
});
it('should validate command references are allowed', async () => {
context.allowedVariableTypes.command = false;
// Use centralized syntax example for run directive with defined command
const example = runDirectiveExamples.combinations.definedCommand;
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'run',
identifier: 'greet',
value: '$greet()',
args: []
}
};
vi.mocked(parserService.parse).mockResolvedValue([node]);
await expect(service.validateResolution('$greet()', context))
.rejects
.toThrow('Command references are not allowed in this context');
});
});
describe('detectCircularReferences', () => {
it('should detect direct circular references', async () => {
// For circular references, we need custom nodes
// but we'll use naming consistent with the examples
const nodeA: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'var1',
value: '{{var2}}'
}
};
const nodeB: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'var2',
value: '{{var1}}'
}
};
vi.mocked(parserService.parse)
.mockImplementation((text) => {
if (text === '{{var1}}') return [nodeA];
if (text === '{{var2}}') return [nodeB];
return [];
});
vi.mocked(stateService.getTextVar)
.mockImplementation((name) => {
if (name === 'var1') return '{{var2}}';
if (name === 'var2') return '{{var1}}';
return undefined;
});
await expect(service.detectCircularReferences('{{var1}}'))
.rejects
.toThrow('Circular reference detected: var1 -> var2 -> var1');
});
it('should handle non-circular references', async () => {
// Use the basicInterpolation example which refers to other variables
const example = textDirectiveExamples.combinations.basicInterpolation;
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'text',
identifier: 'message',
value: '`{{greeting}}, {{subject}}!`'
}
};
vi.mocked(parserService.parse).mockResolvedValue([node]);
vi.mocked(stateService.getTextVar)
.mockReturnValueOnce('`{{greeting}}, {{subject}}!`')
.mockReturnValueOnce('Hello')
.mockReturnValueOnce('World');
await expect(service.detectCircularReferences('{{message}}'))
.resolves
.not.toThrow();
});
});
});