UNPKG

@openmrs/esm-expression-evaluator

Version:

Utilities for evaluating user-defined expressions

227 lines (191 loc) 8.44 kB
import { describe, it, expect, vi } from 'vitest'; import { compile, evaluate, evaluateAsBoolean, evaluateAsNumber, evaluateAsType, evaluateAsync } from './evaluator'; describe('OpenMRS Expression Evaluator', () => { it('should evaluate a simple expression', () => { expect(evaluate('1 + 1')).toBe(2); }); it('should support multiplication', () => { expect(evaluate('1 * 2')).toBe(2); }); it('should support the not operator', () => { expect(evaluate('!1')).toBe(false); expect(evaluate('!0')).toBe(true); expect(evaluate('!true')).toBe(false); expect(evaluate('!false')).toBe(true); }); it('should support expressions in parentheses', () => { expect(evaluate('(1, 2, 3)')).toBe(3); }); it('should support order of operations', () => { expect(evaluate('(1 + 2) * 3')).toBe(9); expect(evaluate('1 + 2 * 3')).toBe(7); }); it("Should support javascript's type promotion", () => { expect(evaluate("1 + 'string'")).toBe('1string'); }); it("Should support 'in'", () => { expect(evaluate('1 in [1, 2, 3]')).toBe(true); }); it('should support basic variables', () => { expect(evaluate('1 + a', { a: 3 })).toBe(4); }); it('should support nullish coalescing', () => { expect(evaluate('a ?? b', { a: null, b: 3 })).toBe(3); expect(evaluate('a ?? b', { a: 3, b: null })).toBe(3); }); it('should support functions', () => { expect(evaluate('a(1)', { a: (i: number) => i + 1 })).toBe(2); }); it('should support built-in functions', () => { expect(evaluate('a.includes("v")', { a: 'value' })).toBe(true); expect(evaluate('"value".includes("v")')).toBe(true); expect(evaluate('(3.14159).toPrecision(3)')).toBe('3.14'); }); it('should give a useful error message for properties on missing objects', () => { expect(() => evaluate('a.b')).toThrow('ReferenceError: a is not defined'); expect(() => evaluate('a["b"]')).toThrow('ReferenceError: a is not defined'); expect(() => evaluate('a.b.c', { a: {} })).toThrow("TypeError: cannot read properties of undefined (reading 'c')"); expect(() => evaluate('a.b["c"]', { a: {} })).toThrow( "TypeError: cannot read properties of undefined (reading 'c')", ); expect(() => evaluate('a["b"]["c"]', { a: {} })).toThrow( "TypeError: cannot read properties of undefined (reading 'c')", ); }); it('should not support this', () => { expect(() => evaluate('this')).toThrow( /Expression evaluator does not support expression of type 'ThisExpression'.*/i, ); }); it('should support property references', () => { expect(evaluate('a.b.c', { a: { b: { c: 3 } } })).toBe(3); }); it('should not support prototype references', () => { expect(() => evaluate('a.__proto__', { a: {} })).toThrow(/Cannot access the __proto__ property .*/i); expect(() => evaluate('a["__proto__"]', { a: {} })).toThrow(/Cannot access the __proto__ property .*/i); expect(() => evaluate('a[b]', { a: {}, b: '__proto__' })).toThrow(/Cannot access the __proto__ property .*/i); expect(() => evaluate('a[b()]', { a: {}, b: () => '__proto__' })).toThrow( /Cannot access the __proto__ property .*/i, ); expect(() => evaluate('__proto__')).toThrow(/Cannot access the __proto__ property .*/i); expect(() => evaluate('a.prototype', { a: {} })).toThrow(/Cannot access the prototype property .*/i); expect(() => evaluate('a["prototype"]', { a: {} })).toThrow(/Cannot access the prototype property .*/i); expect(() => evaluate('a[b]', { a: {}, b: 'prototype' })).toThrow(/Cannot access the prototype property .*/i); }); it('should support ternaries', () => { expect(evaluate('a ? 1 : 2', { a: true })).toBe(1); expect(evaluate('a ? 1 : 2', { a: false })).toBe(2); }); it('should support hexadecimal', () => { expect(evaluate('0xff')).toBe(255); }); it('should support string templates', () => { expect(evaluate('`${a.b}`', { a: { b: 'string' } })).toBe('string'); }); it('should support new Date()', () => { expect(evaluate('new Date().getTime()')).toBeLessThanOrEqual(new Date().getTime()); }); it('should support RegExp', () => { expect(evaluate('/.*/.test(a)', { a: 'a' })).toBe(true); }); it('should support RegExp objects', () => { expect(evaluate('new RegExp(".*").test(a)', { a: 'a' })).toBe(true); }); it('should support arrow functions inside expressions', () => { expect(evaluate('[1, 2, 3].find(v => v === 3)')).toBe(3); }); it('should support globals', () => { expect(evaluate('NaN')).toBeNaN(); expect(evaluate('Infinity')).toBe(Infinity); expect(evaluate('Boolean(true)')).toBe(true); expect(evaluate('Math.min(1, 2, 3)')).toBe(1); expect(evaluate('undefined')).toBeUndefined(); expect(evaluate('isNaN(NaN)')).toBe(true); expect(evaluate('Number.isInteger(42)')).toBe(true); }); it('should not support creating arbitrary objects', () => { expect(() => evaluate('new object()')).toThrow(/Cannot instantiate object .*/i); class Fn { constructor() {} } expect(() => evaluate('new Fn()', { Fn })).toThrow(/Cannot instantiate object .*/i); }); it('should not support invalid property references on supported objects', () => { expect(() => evaluate('new Date().__proto__')).toThrow(/Cannot access the __proto__ property .*/i); expect(() => evaluate('new Date().prototype')).toThrow(/Cannot access the prototype property .*/i); }); it('should not return invalid types', () => { expect(() => evaluate('a', { a: {} })).toThrow(/.* did not produce a valid result/i); expect(() => evaluateAsBoolean('a', { a: 'value' })).toThrow(/.* did not produce a valid result/i); expect(() => evaluateAsNumber('a', { a: true })).toThrow(/.* did not produce a valid result/i); expect(() => evaluateAsType('a', { a: false }, (val): val is 'value' => val === 'value')).toThrow( /.* did not produce a valid result/i, ); }); it('should support a compilation phase', () => { const exp = compile('1 + 1'); expect(evaluate(exp)).toBe(2); }); it('should not support variable assignment', () => { expect(() => evaluate('var a = 1; a')).toThrow(); }); it('should support asynchronous evaluation', async () => { await expect(evaluateAsync('1 + 1')).resolves.toBe(2); let a = new Promise((resolve) => { setTimeout(() => resolve(1), 10); }); await expect(evaluateAsync('Promise.resolve(a).then((a) => a + 1)', { a })).resolves.toBe(2); a = new Promise((resolve) => { setTimeout(() => resolve(1), 10); }); await expect( evaluateAsync('resolve(a).then((a) => a + 1)', { a, resolve: Promise.resolve.bind(Promise) }), ).resolves.toBe(2); }); it('should support mock functions', () => { expect(evaluate('api.getValue()', { api: { getValue: vi.fn().mockImplementation(() => 'value') } })).toBe('value'); }); it('should support real-world use-cases', () => { expect( evaluate('!isEmpty(array)', { array: [], isEmpty(arr: unknown) { return Array.isArray(arr) && arr.length === 0; }, }), ).toBe(false); expect( evaluate( "includes(referredToPreventionServices, '88cdde2b-753b-48ac-a51a-ae5e1ab24846') && !includes(referredToPreventionServices, '1691AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')", { referredToPreventionServices: ['88cdde2b-753b-48ac-a51a-ae5e1ab24846'], includes: <T>(collection: Array<T>, value: T) => Array.prototype.includes.call(collection, value), }, ), ).toBe(true); }); it('should throw an error with correct message for non-existent function', () => { expect(() => { evaluate('api.nonExistingFunction()', { api: {} }); }).toThrow('No function named nonExistingFunction is defined in this context'); // nested ref expect(() => { evaluate('objectWrapper.path.deepNested()', { objectWrapper: { path: { deepNested: undefined, }, }, }); }).toThrow('No function named deepNested is defined in this context'); }); it('should throw an error with correct message for non-callable targets', () => { expect(() => { evaluate('objectWrapper.path()', { objectWrapper: { path: {}, }, }); }).toThrow('path is not a function'); }); });