diff --git a/packages/bruno-js/package.json b/packages/bruno-js/package.json index 95c16e8f2..2a907eee6 100644 --- a/packages/bruno-js/package.json +++ b/packages/bruno-js/package.json @@ -9,6 +9,9 @@ "peerDependencies": { "vm2": "^3.9.13" }, + "scripts": { + "test": "jest --testPathIgnorePatterns test.js" + }, "dependencies": { "atob": "^2.1.2", "ajv": "^8.12.0", diff --git a/packages/bruno-js/src/utils.js b/packages/bruno-js/src/utils.js index 0f4563252..6bc73da83 100644 --- a/packages/bruno-js/src/utils.js +++ b/packages/bruno-js/src/utils.js @@ -1,15 +1,69 @@ const jsonQuery = require('json-query'); +const JS_KEYWORDS = ` + break case catch class const continue debugger default delete do + else export extends false finally for function if import in instanceof + new null return super switch this throw true try typeof var void while with + undefined let static yield arguments of +`.split(/\s+/).filter(word => word.length > 0); + +/** + * Creates a function from a Javascript expression + * + * When the function is called, the variables used in this expression are picked up from the context + * + * ```js + * res.data.pets.map(pet => pet.name.toUpperCase()) + * + * function(context) { + * const { res, pet } = context; + * return res.data.pets.map(pet => pet.name.toUpperCase()) + * } + * ``` + */ +const compileJsExpression = (expr) => { + // get all dotted identifiers (foo, bar.baz, .baz) + const matches = expr.match(/([\w\.$]+)/g) ?? []; + + // get valid js identifiers (foo, bar) + const vars = new Set( + matches + .filter(match => /^[a-zA-Z$_]/.test(match)) // starts with valid js identifier (foo.bar) + .map(match => match.split('.')[0]) // top level identifier (foo) + .filter(name => !JS_KEYWORDS.includes(name)) // exclude js keywords + ); + + // globals such as Math + const globals = [...vars].filter(name => name in globalThis); + + const code = { + vars: [...vars].join(", "), + // pick global from context or globalThis + globals: globals + .map(name => ` ${name} = ${name} ?? globalThis.${name};`) + .join('') + }; + + const body = `let { ${code.vars} } = context; ${code.globals}; return ${expr}`; + + return new Function("context", body); +}; + +const internalExpressionCache = new Map(); + const evaluateJsExpression = (expression, context) => { - const fn = new Function(...Object.keys(context), `return ${expression}`); - return fn(...Object.values(context)); + let fn = internalExpressionCache.get(expression); + if (fn == null) { + internalExpressionCache.set(expression, fn = compileJsExpression(expression)); + } + return fn(context); }; const createResponseParser = (response = {}) => { - const res = (expr) => { + const res = (expr) => { const output = jsonQuery(expr, { data: response.data }); return output ? output.value : null; - } + }; res.status = response.status; res.statusText = response.statusText; @@ -21,5 +75,6 @@ const createResponseParser = (response = {}) => { module.exports = { evaluateJsExpression, - createResponseParser + createResponseParser, + internalExpressionCache }; \ No newline at end of file diff --git a/packages/bruno-js/tests/utils.spec.js b/packages/bruno-js/tests/utils.spec.js new file mode 100644 index 000000000..6d5212407 --- /dev/null +++ b/packages/bruno-js/tests/utils.spec.js @@ -0,0 +1,115 @@ +const { evaluateJsExpression, internalExpressionCache: cache } = require("../src/utils"); + +describe("utils", () => { + describe("expression evaluation", () => { + const context = { + res: { + data: { pets: ["bruno", "max"] } + } + }; + + beforeEach(() => cache.clear()); + afterEach(() => cache.clear()); + + it("should evaluate expression", () => { + let result; + + result = evaluateJsExpression("res.data.pets", context); + expect(result).toEqual(["bruno", "max"]); + + result = evaluateJsExpression("res.data.pets[0].toUpperCase()", context); + expect(result).toEqual("BRUNO"); + }); + + it("should cache expression", () => { + expect(cache.size).toBe(0); + evaluateJsExpression("res.data.pets", context); + expect(cache.size).toBe(1); + }); + + it("should use cached expression", () => { + const expr = "res.data.pets"; + + evaluateJsExpression(expr, context); + + const fn = cache.get(expr); + expect(fn).toBeDefined(); + + evaluateJsExpression(expr, context); + + // cache should not be overwritten + expect(cache.get(expr)).toBe(fn); + }); + + it("should identify top level variables", () => { + const expr = "res.data.pets[0].toUpperCase()"; + evaluateJsExpression(expr, context); + expect(cache.get(expr).toString()).toContain("let { res } = context;"); + }); + + it("should not duplicate variables", () => { + const expr = "res.data.pets[0] + res.data.pets[1]"; + evaluateJsExpression(expr, context); + expect(cache.get(expr).toString()).toContain("let { res } = context;"); + }); + + it("should exclude js keywords like true false from vars", () => { + const expr = "res.data.pets.length > 0 ? true : false"; + evaluateJsExpression(expr, context); + expect(cache.get(expr).toString()).toContain("let { res } = context;"); + }); + + it("should exclude numbers from vars", () => { + const expr = "res.data.pets.length + 10"; + evaluateJsExpression(expr, context); + expect(cache.get(expr).toString()).toContain("let { res } = context;"); + }); + + it("should pick variables from complex expressions", () => { + const expr = "res.data.pets.map(pet => pet.length)"; + const result = evaluateJsExpression(expr, context); + expect(result).toEqual([5, 3]); + expect(cache.get(expr).toString()).toContain("let { res, pet } = context;"); + }); + + it("should be ok picking extra vars from strings", () => { + const expr = "'hello' + ' ' + res.data.pets[0]"; + const result = evaluateJsExpression(expr, context); + expect(result).toBe("hello bruno"); + // extra var hello is harmless + expect(cache.get(expr).toString()).toContain("let { hello, res } = context;"); + }); + + it("should evaluate expressions referencing globals", () => { + const startTime = new Date("2022-02-01").getTime(); + const currentTime = new Date("2022-02-02").getTime(); + + jest.useFakeTimers({ now: currentTime }); + + const expr = "Math.max(Date.now(), startTime)"; + const result = evaluateJsExpression(expr, { startTime }); + + expect(result).toBe(currentTime); + + expect(cache.get(expr).toString()).toContain("Math = Math ?? globalThis.Math;"); + expect(cache.get(expr).toString()).toContain("Date = Date ?? globalThis.Date;"); + }); + + it("should use global overridden in context", () => { + const startTime = new Date("2022-02-01").getTime(); + const currentTime = new Date("2022-02-02").getTime(); + + jest.useFakeTimers({ now: currentTime }); + + const context = { + Date: { now: () => new Date("2022-01-31").getTime() }, + startTime + }; + + const expr = "Math.max(Date.now(), startTime)"; + const result = evaluateJsExpression(expr, context); + + expect(result).toBe(startTime); + }); + }); +});