meld
Version:
Meld: A template language for LLM prompts
505 lines (439 loc) • 18.6 kB
text/typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { ValidationService } from './ValidationService.js';
import { DirectiveError, DirectiveErrorCode } from '@services/pipeline/DirectiveService/errors/DirectiveError.js';
import type { DirectiveNode } from 'meld-spec';
import {
createTextDirective,
createDataDirective,
createImportDirective,
createEmbedDirective,
createPathDirective,
createLocation
} from '@tests/utils/testFactories.js';
import { MeldDirectiveError } from '@core/errors/MeldDirectiveError.js';
import { ErrorSeverity, MeldError } from '@core/errors/MeldError.js';
import {
expectDirectiveValidationError,
expectToThrowWithConfig,
expectValidationError,
expectValidationToThrowWithDetails
} from '@tests/utils/errorTestUtils.js';
import { textDirectiveExamples } from '@core/syntax/index.js';
import { getExample, getInvalidExample } from '@tests/utils/syntax-test-helpers.js';
describe('ValidationService', () => {
let service: ValidationService;
beforeEach(() => {
service = new ValidationService();
});
describe('Service initialization', () => {
it('should initialize with default validators', () => {
const kinds = service.getRegisteredDirectiveKinds();
expect(kinds).toContain('text');
expect(kinds).toContain('data');
expect(kinds).toContain('import');
expect(kinds).toContain('embed');
expect(kinds).toContain('path');
});
});
describe('Validator registration', () => {
it('should register a new validator', () => {
const validator = async () => {};
service.registerValidator('custom', validator);
expect(service.hasValidator('custom')).toBe(true);
});
it('should throw on invalid validator registration', () => {
expect(() => service.registerValidator('', async () => {}))
.toThrow('Validator kind must be a non-empty string');
expect(() => service.registerValidator('test', null as any))
.toThrow('Validator must be a function');
});
it('should remove a validator', () => {
service.registerValidator('custom', async () => {});
expect(service.hasValidator('custom')).toBe(true);
service.removeValidator('custom');
expect(service.hasValidator('custom')).toBe(false);
});
});
describe('Text directive validation', () => {
it('should validate a valid text directive', async () => {
const node = createTextDirective('greeting', 'Hello', createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should throw on missing name with Fatal severity', async () => {
const node = createTextDirective('', 'Hello', createLocation(1, 1));
try {
await service.validate(node);
fail('Expected an error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(MeldDirectiveError);
const directiveError = error as MeldDirectiveError;
expect(directiveError.code).toBe(DirectiveErrorCode.VALIDATION_FAILED);
expect(directiveError.directiveKind).toBe('text');
expect(directiveError.severity).toBe(ErrorSeverity.Fatal);
expect(directiveError.message.toLowerCase()).toContain('identifier');
}
});
it('should throw on missing value with Fatal severity', async () => {
const node = createTextDirective('greeting', '', createLocation(1, 1));
try {
await service.validate(node);
fail('Expected an error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(MeldDirectiveError);
const directiveError = error as MeldDirectiveError;
expect(directiveError.code).toBe(DirectiveErrorCode.VALIDATION_FAILED);
expect(directiveError.directiveKind).toBe('text');
expect(directiveError.severity).toBe(ErrorSeverity.Fatal);
expect(directiveError.message.toLowerCase()).toContain('value');
}
});
it('should throw on invalid name format with Fatal severity', async () => {
const node = createTextDirective('123invalid', 'Hello', createLocation(1, 1));
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'text',
messageContains: 'identifier'
}
);
});
it('should validate a text directive with @embed value', async () => {
const example = textDirectiveExamples.atomic.withEmbedValue;
const node = createTextDirective('instructions', '@embed [$./path.md]', createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should validate a text directive with @embed value with section', async () => {
const example = textDirectiveExamples.atomic.withEmbedValueAndSection;
const node = createTextDirective('instructions', '@embed [$./path.md # Section]', createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should validate a text directive with @run value', async () => {
const example = textDirectiveExamples.atomic.withRunValue;
const node = createTextDirective('result', '@run [echo "Hello"]', createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should validate a text directive with @run value with variables', async () => {
const example = textDirectiveExamples.atomic.withRunValueAndVariables;
const node = createTextDirective('result', '@run [oneshot "What\'s broken here? {{tests}}"]', createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should throw on invalid @embed format (missing brackets)', async () => {
const example = textDirectiveExamples.invalid.invalidEmbedFormat;
const node = createTextDirective('instructions', '@embed path.md', createLocation(1, 1));
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'text',
messageContains: 'embed format'
}
);
});
it('should throw on invalid @run format (missing brackets)', async () => {
const example = textDirectiveExamples.invalid.invalidRunFormat;
const node = createTextDirective('result', '@run echo "Hello"', createLocation(1, 1));
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'text',
messageContains: 'run format'
}
);
});
});
describe('Data directive validation', () => {
it('should validate a valid data directive with string value', async () => {
const node = createDataDirective('config', '{"key": "value"}', createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should validate a valid data directive with object value', async () => {
const node = createDataDirective('config', { key: 'value' }, createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should throw on invalid JSON string with Fatal severity', async () => {
const node = createDataDirective('config', '{invalid json}', createLocation(1, 1));
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'data',
messageContains: 'JSON'
}
);
});
it('should throw on missing name with Fatal severity', async () => {
const node = createDataDirective('', { key: 'value' }, createLocation(1, 1));
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'data',
messageContains: 'identifier'
}
);
});
it('should throw on invalid name format with Fatal severity', async () => {
const node = createDataDirective('123invalid', { key: 'value' }, createLocation(1, 1));
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'data',
messageContains: 'identifier'
}
);
});
});
describe('Path directive validation', () => {
it('should validate a valid path directive with $HOMEPATH', async () => {
const node = createPathDirective('docs', '$HOMEPATH/docs', createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should validate a valid path directive with $PROJECTPATH', async () => {
const node = createPathDirective('src', '$PROJECTPATH/src', createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should validate a valid path directive with $~', async () => {
const node = createPathDirective('config', '$~/config', createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should validate a valid path directive with $.', async () => {
const node = createPathDirective('test', '$./test', createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should throw on missing identifier with Fatal severity', async () => {
const node = createPathDirective('', '$HOMEPATH/docs', createLocation(1, 1));
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'path',
messageContains: 'identifier'
}
);
});
it('should throw on invalid identifier format with Fatal severity', async () => {
const node = createPathDirective('123invalid', '$HOMEPATH/docs', createLocation(1, 1));
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'path',
messageContains: 'identifier'
}
);
});
it('should throw on missing value with Fatal severity', async () => {
const node = createPathDirective('docs', '', createLocation(1, 1));
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'path',
messageContains: 'path'
}
);
});
it('should throw on empty path value with Fatal severity', async () => {
const node = createPathDirective('docs', ' ', createLocation(1, 1));
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'path',
messageContains: 'path'
}
);
});
});
describe('Import directive validation', () => {
it('should validate a valid import directive', async () => {
const node = createImportDirective('test.md', createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should validate a valid import directive with from syntax without alias', async () => {
const node = createImportDirective('role', createLocation(1, 1), 'imports.meld');
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should validate a valid import directive with from syntax and alias', async () => {
const node = createImportDirective('role as roles', createLocation(1, 1), 'imports.meld');
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should currently allow empty alias when using as syntax (though this behavior should be fixed)', async () => {
const node = createImportDirective('role as ', createLocation(1, 1), 'imports.meld');
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should validate structured imports using bracket notation without alias', async () => {
const node = {
type: 'Directive',
directive: {
kind: 'import',
identifier: 'import',
value: '[role] from [imports.meld]',
path: 'imports.meld',
imports: [{ name: 'role' }]
},
location: createLocation(1, 1)
} as DirectiveNode;
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should validate structured imports with multiple variables', async () => {
const node = {
type: 'Directive',
directive: {
kind: 'import',
identifier: 'import',
value: '[var1, var2 as alias2, var3] from [imports.meld]',
path: 'imports.meld',
imports: [
{ name: 'var1' },
{ name: 'var2', alias: 'alias2' },
{ name: 'var3' }
]
},
location: createLocation(1, 1)
} as DirectiveNode;
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should throw on missing path with Fatal severity', async () => {
const node = createImportDirective('', createLocation(1, 1));
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'import',
messageContains: 'path'
}
);
});
});
describe('Embed directive validation', () => {
it('should validate a valid embed directive', async () => {
const node = createEmbedDirective('test.md', 'section', createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should validate embed directive without section', async () => {
const node = createEmbedDirective('test.md', undefined, createLocation(1, 1));
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should throw on missing path with Fatal severity', async () => {
const node = createEmbedDirective('', undefined, createLocation(1, 1));
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'embed',
messageContains: 'path'
}
);
});
it('should validate fuzzy matching threshold', async () => {
const node = createEmbedDirective('test.md', 'section', createLocation(1, 1));
node.directive.fuzzy = 0.8;
await expect(service.validate(node)).resolves.not.toThrow();
});
it('should throw on invalid fuzzy threshold (below 0) with Fatal severity', async () => {
const node = createEmbedDirective('test.md', 'section', createLocation(1, 1));
node.directive.fuzzy = -0.1;
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'embed',
messageContains: 'fuzzy'
}
);
});
it('should throw on invalid fuzzy threshold (above 1) with Fatal severity', async () => {
const node = createEmbedDirective('test.md', 'section', createLocation(1, 1));
node.directive.fuzzy = 1.1;
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.VALIDATION_FAILED,
severity: ErrorSeverity.Fatal,
directiveKind: 'embed',
messageContains: 'fuzzy'
}
);
});
});
describe('Unknown directive handling', () => {
it('should throw on unknown directive kind with Fatal severity', async () => {
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'unknown'
},
location: createLocation(1, 1)
};
await expectToThrowWithConfig(
async () => service.validate(node),
{
type: 'MeldDirectiveError',
code: DirectiveErrorCode.HANDLER_NOT_FOUND,
severity: ErrorSeverity.Fatal,
directiveKind: 'unknown',
messageContains: 'kind'
}
);
});
});
describe('Error handling with canBeWarning', () => {
it('should identify recoverable errors correctly', async () => {
const node = createTextDirective('', 'Hello', createLocation(1, 1));
try {
await service.validate(node);
} catch (error) {
expect(error).toBeInstanceOf(MeldError);
const meldError = error as MeldError;
expect(meldError.canBeWarning()).toBe(false);
}
});
it('should identify fatal errors correctly', async () => {
const node: DirectiveNode = {
type: 'Directive',
directive: {
kind: 'unknown'
},
location: createLocation(1, 1)
};
try {
await service.validate(node);
} catch (error) {
expect(error).toBeInstanceOf(MeldError);
const meldError = error as MeldError;
expect(meldError.canBeWarning()).toBe(false);
}
});
});
});