@usebruno/js 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usebruno/js",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "main": "src/index.js",
5
5
  "files": [
6
6
  "src",
@@ -9,9 +9,13 @@
9
9
  "peerDependencies": {
10
10
  "vm2": "^3.9.13"
11
11
  },
12
+ "scripts": {
13
+ "test": "jest --testPathIgnorePatterns test.js"
14
+ },
12
15
  "dependencies": {
13
- "atob": "^2.1.2",
16
+ "@usebruno/query": "0.1.0",
14
17
  "ajv": "^8.12.0",
18
+ "atob": "^2.1.2",
15
19
  "btoa": "^1.2.1",
16
20
  "crypto-js": "^4.1.1",
17
21
  "json-query": "^2.2.2",
@@ -1,17 +1,49 @@
1
1
  const _ = require('lodash');
2
- const chai = require('chai');
2
+ const chai = require('chai');
3
+ const { nanoid } = require('nanoid');
3
4
  const Bru = require('../bru');
4
5
  const BrunoRequest = require('../bruno-request');
5
- const { evaluateJsExpression, createResponseParser } = require('../utils');
6
+ const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } = require('../utils');
6
7
 
7
8
  const { expect } = chai;
9
+ chai.use(function (chai, utils) {
10
+ // Custom assertion for checking if a variable is JSON
11
+ chai.Assertion.addProperty('json', function () {
12
+ const obj = this._obj;
13
+ const isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj) && obj.constructor === Object;
14
+
15
+ this.assert(
16
+ isJson,
17
+ `expected ${utils.inspect(obj)} to be JSON`,
18
+ `expected ${utils.inspect(obj)} not to be JSON`
19
+ );
20
+ });
21
+ });
22
+
23
+ // Custom assertion for matching regex
24
+ chai.use(function (chai, utils) {
25
+ chai.Assertion.addMethod('match', function (regex) {
26
+ const obj = this._obj;
27
+ let match = false;
28
+ if(obj === undefined) {
29
+ match = false;
30
+ } else {
31
+ match = regex.test(obj);
32
+ }
33
+
34
+ this.assert(
35
+ match,
36
+ `expected ${utils.inspect(obj)} to match ${regex}`,
37
+ `expected ${utils.inspect(obj)} not to match ${regex}`
38
+ );
39
+ });
40
+ });
8
41
 
9
42
  /**
10
43
  * Assertion operators
11
44
  *
12
45
  * eq : equal to
13
46
  * neq : not equal to
14
- * like : like
15
47
  * gt : greater than
16
48
  * gte : greater than or equal to
17
49
  * lt : less than
@@ -20,7 +52,6 @@ const { expect } = chai;
20
52
  * notIn : not in
21
53
  * contains : contains
22
54
  * notContains : not contains
23
- * count : count
24
55
  * length : length
25
56
  * matches : matches
26
57
  * notMatches : not matches
@@ -47,15 +78,26 @@ const parseAssertionOperator = (str = '') => {
47
78
  }
48
79
 
49
80
  const operators = [
50
- 'eq', 'neq', 'like', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn',
51
- 'contains', 'notContains', 'count', 'length', 'matches', 'notMatches',
81
+ 'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn',
82
+ 'contains', 'notContains', 'length', 'matches', 'notMatches',
52
83
  'startsWith', 'endsWith', 'between', 'isEmpty', 'isNull', 'isUndefined',
53
84
  'isDefined', 'isTruthy', 'isFalsy', 'isJson', 'isNumber', 'isString', 'isBoolean'
54
85
  ];
55
86
 
87
+ const unaryOperators = [
88
+ 'isEmpty', 'isNull', 'isUndefined', 'isDefined', 'isTruthy', 'isFalsy', 'isJson', 'isNumber', 'isString', 'isBoolean'
89
+ ];
90
+
56
91
  const [operator, ...rest] = str.trim().split(' ');
57
92
  const value = rest.join(' ');
58
93
 
94
+ if(unaryOperators.includes(operator)) {
95
+ return {
96
+ operator,
97
+ value: ''
98
+ };
99
+ }
100
+
59
101
  if(operators.includes(operator)) {
60
102
  return {
61
103
  operator,
@@ -69,6 +111,45 @@ const parseAssertionOperator = (str = '') => {
69
111
  };
70
112
  };
71
113
 
114
+ const isUnaryOperator = (operator) => {
115
+ const unaryOperators = [
116
+ 'isEmpty', 'isNull', 'isUndefined', 'isDefined', 'isTruthy', 'isFalsy', 'isJson', 'isNumber', 'isString', 'isBoolean'
117
+ ];
118
+
119
+ return unaryOperators.includes(operator);
120
+ };
121
+
122
+ const evaluateRhsOperand = (rhsOperand, operator, context) => {
123
+ if(isUnaryOperator(operator)) {
124
+ return;
125
+ }
126
+
127
+ // gracefully allow both a,b as well as [a, b]
128
+ if(operator === 'in' || operator === 'notIn') {
129
+ if(rhsOperand.startsWith('[') && rhsOperand.endsWith(']')) {
130
+ rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1);
131
+ }
132
+
133
+ return rhsOperand.split(',').map((v) => evaluateJsTemplateLiteral(v.trim(), context));
134
+ }
135
+
136
+ if(operator === 'between') {
137
+ const [lhs, rhs] = rhsOperand.split(',').map((v) => evaluateJsTemplateLiteral(v.trim(), context));
138
+ return [lhs, rhs];
139
+ }
140
+
141
+ // gracefully allow both ^[a-Z] as well as /^[a-Z]/
142
+ if(operator === 'matches' || operator === 'notMatches') {
143
+ if(rhsOperand.startsWith('/') && rhsOperand.endsWith('/')) {
144
+ rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1);
145
+ }
146
+
147
+ return rhsOperand;
148
+ }
149
+
150
+ return evaluateJsTemplateLiteral(rhsOperand, context);
151
+ };
152
+
72
153
  class AssertRuntime {
73
154
  runAssertions(assertions, request, response, envVariables, collectionVariables, collectionPath) {
74
155
  const enabledAssertions = _.filter(assertions, (a) => a.enabled);
@@ -105,7 +186,7 @@ class AssertRuntime {
105
186
 
106
187
  try {
107
188
  const lhs = evaluateJsExpression(lhsExpr, context);
108
- const rhs = evaluateJsExpression(rhsOperand, context);
189
+ const rhs = evaluateRhsOperand(rhsOperand, operator, context);
109
190
 
110
191
  switch(operator) {
111
192
  case 'eq':
@@ -114,9 +195,6 @@ class AssertRuntime {
114
195
  case 'neq':
115
196
  expect(lhs).to.not.equal(rhs);
116
197
  break;
117
- case 'like':
118
- expect(lhs).to.match(new RegExp(rhs));
119
- break;
120
198
  case 'gt':
121
199
  expect(lhs).to.be.greaterThan(rhs);
122
200
  break;
@@ -141,9 +219,6 @@ class AssertRuntime {
141
219
  case 'notContains':
142
220
  expect(lhs).to.not.include(rhs);
143
221
  break;
144
- case 'count':
145
- expect(lhs).to.have.lengthOf(rhs);
146
- break;
147
222
  case 'length':
148
223
  expect(lhs).to.have.lengthOf(rhs);
149
224
  break;
@@ -160,7 +235,7 @@ class AssertRuntime {
160
235
  expect(lhs).to.endWith(rhs);
161
236
  break;
162
237
  case 'between':
163
- const [min, max] = value.split(' ');
238
+ const [min, max] = value.split(',');
164
239
  expect(lhs).to.be.within(min, max);
165
240
  break;
166
241
  case 'isEmpty':
@@ -199,6 +274,7 @@ class AssertRuntime {
199
274
  }
200
275
 
201
276
  assertionResults.push({
277
+ uid: nanoid(),
202
278
  lhsExpr,
203
279
  rhsExpr,
204
280
  rhsOperand,
@@ -208,6 +284,7 @@ class AssertRuntime {
208
284
  }
209
285
  catch (err) {
210
286
  assertionResults.push({
287
+ uid: nanoid(),
211
288
  lhsExpr,
212
289
  rhsExpr,
213
290
  rhsOperand,
@@ -222,4 +299,4 @@ class AssertRuntime {
222
299
  }
223
300
  }
224
301
 
225
- module.exports = AssertRuntime;
302
+ module.exports = AssertRuntime;
@@ -17,8 +17,8 @@ class ScriptRuntime {
17
17
  constructor() {
18
18
  }
19
19
 
20
- runRequestScript(script, request, environment, collectionVariables, collectionPath) {
21
- const bru = new Bru(environment, collectionVariables);
20
+ runRequestScript(script, request, envVariables, collectionVariables, collectionPath) {
21
+ const bru = new Bru(envVariables, collectionVariables);
22
22
  const req = new BrunoRequest(request);
23
23
 
24
24
  const context = {
@@ -47,13 +47,13 @@ class ScriptRuntime {
47
47
 
48
48
  return {
49
49
  request,
50
- environment,
50
+ envVariables,
51
51
  collectionVariables
52
52
  };
53
53
  }
54
54
 
55
- runResponseScript(script, request, response, environment, collectionVariables, collectionPath) {
56
- const bru = new Bru(environment, collectionVariables);
55
+ runResponseScript(script, request, response, envVariables, collectionVariables, collectionPath) {
56
+ const bru = new Bru(envVariables, collectionVariables);
57
57
  const req = new BrunoRequest(request);
58
58
  const res = new BrunoResponse(response);
59
59
 
@@ -84,7 +84,7 @@ class ScriptRuntime {
84
84
 
85
85
  return {
86
86
  response,
87
- environment,
87
+ envVariables,
88
88
  collectionVariables
89
89
  };
90
90
  }
@@ -20,14 +20,23 @@ class TestRuntime {
20
20
  constructor() {
21
21
  }
22
22
 
23
- runTests(testsFile, request, response, environment, collectionVariables, collectionPath) {
24
- const bru = new Bru(environment, collectionVariables);
23
+ runTests(testsFile, request, response, envVariables, collectionVariables, collectionPath) {
24
+ const bru = new Bru(envVariables, collectionVariables);
25
25
  const req = new BrunoRequest(request);
26
26
  const res = new BrunoResponse(response);
27
27
 
28
28
  const __brunoTestResults = new TestResults();
29
29
  const test = Test(__brunoTestResults, chai);
30
30
 
31
+ if(!testsFile || !testsFile.length) {
32
+ return {
33
+ request,
34
+ envVariables,
35
+ collectionVariables,
36
+ results: __brunoTestResults.getResults()
37
+ };
38
+ }
39
+
31
40
  const context = {
32
41
  test,
33
42
  bru,
@@ -60,7 +69,7 @@ class TestRuntime {
60
69
 
61
70
  return {
62
71
  request,
63
- environment,
72
+ envVariables,
64
73
  collectionVariables,
65
74
  results: __brunoTestResults.getResults()
66
75
  };
@@ -1,7 +1,7 @@
1
1
  const _ = require('lodash');
2
2
  const Bru = require('../bru');
3
3
  const BrunoRequest = require('../bruno-request');
4
- const { evaluateJsExpression, createResponseParser } = require('../utils');
4
+ const { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } = require('../utils');
5
5
 
6
6
  class VarsRuntime {
7
7
  runPreRequestVars(vars, request, envVariables, collectionVariables, collectionPath) {
@@ -25,9 +25,13 @@ class VarsRuntime {
25
25
  }
26
26
 
27
27
  _.each(enabledVars, (v) => {
28
- const value = evaluateJsExpression(v.value, context);
28
+ const value = evaluateJsTemplateLiteral(v.value, context);
29
29
  bru.setVar(v.name, value);
30
30
  });
31
+
32
+ return {
33
+ collectionVariables
34
+ };
31
35
  }
32
36
 
33
37
  runPostResponseVars(vars, request, response, envVariables, collectionVariables, collectionPath) {
@@ -56,6 +60,11 @@ class VarsRuntime {
56
60
  const value = evaluateJsExpression(v.value, context);
57
61
  bru.setVar(v.name, value);
58
62
  });
63
+
64
+ return {
65
+ envVariables,
66
+ collectionVariables
67
+ };
59
68
  }
60
69
  }
61
70
 
package/src/utils.js CHANGED
@@ -1,25 +1,126 @@
1
1
  const jsonQuery = require('json-query');
2
+ const { get } = require("@usebruno/query");
3
+
4
+ const JS_KEYWORDS = `
5
+ break case catch class const continue debugger default delete do
6
+ else export extends false finally for function if import in instanceof
7
+ new null return super switch this throw true try typeof var void while with
8
+ undefined let static yield arguments of
9
+ `.split(/\s+/).filter(word => word.length > 0);
10
+
11
+ /**
12
+ * Creates a function from a Javascript expression
13
+ *
14
+ * When the function is called, the variables used in this expression are picked up from the context
15
+ *
16
+ * ```js
17
+ * res.data.pets.map(pet => pet.name.toUpperCase())
18
+ *
19
+ * function(context) {
20
+ * const { res, pet } = context;
21
+ * return res.data.pets.map(pet => pet.name.toUpperCase())
22
+ * }
23
+ * ```
24
+ */
25
+ const compileJsExpression = (expr) => {
26
+ // get all dotted identifiers (foo, bar.baz, .baz)
27
+ const matches = expr.match(/([\w\.$]+)/g) ?? [];
28
+
29
+ // get valid js identifiers (foo, bar)
30
+ const vars = new Set(
31
+ matches
32
+ .filter(match => /^[a-zA-Z$_]/.test(match)) // starts with valid js identifier (foo.bar)
33
+ .map(match => match.split('.')[0]) // top level identifier (foo)
34
+ .filter(name => !JS_KEYWORDS.includes(name)) // exclude js keywords
35
+ );
36
+
37
+ // globals such as Math
38
+ const globals = [...vars].filter(name => name in globalThis);
39
+
40
+ const code = {
41
+ vars: [...vars].join(", "),
42
+ // pick global from context or globalThis
43
+ globals: globals
44
+ .map(name => ` ${name} = ${name} ?? globalThis.${name};`)
45
+ .join('')
46
+ };
47
+
48
+ const body = `let { ${code.vars} } = context; ${code.globals}; return ${expr}`;
49
+
50
+ return new Function("context", body);
51
+ };
52
+
53
+ const internalExpressionCache = new Map();
2
54
 
3
55
  const evaluateJsExpression = (expression, context) => {
4
- const fn = new Function(...Object.keys(context), `return ${expression}`);
5
- return fn(...Object.values(context));
56
+ let fn = internalExpressionCache.get(expression);
57
+ if (fn == null) {
58
+ internalExpressionCache.set(expression, fn = compileJsExpression(expression));
59
+ }
60
+ return fn(context);
6
61
  };
7
62
 
8
- const createResponseParser = (response = {}) => {
9
- const res = (expr) => {
10
- const output = jsonQuery(expr, { data: response.data });
11
- return output ? output.value : null;
63
+ const evaluateJsTemplateLiteral = (templateLiteral, context) => {
64
+ if(!templateLiteral || !templateLiteral.length || typeof templateLiteral !== 'string') {
65
+ return templateLiteral;
66
+ }
67
+
68
+ templateLiteral = templateLiteral.trim();
69
+
70
+ if(templateLiteral === 'true') {
71
+ return true;
72
+ }
73
+
74
+ if(templateLiteral === 'false') {
75
+ return false;
12
76
  }
13
77
 
78
+ if(templateLiteral === 'null') {
79
+ return null;
80
+ }
81
+
82
+ if(templateLiteral === 'undefined') {
83
+ return undefined;
84
+ }
85
+
86
+ if(templateLiteral.startsWith('"') && templateLiteral.endsWith('"')) {
87
+ return templateLiteral.slice(1, -1);
88
+ }
89
+
90
+ if(templateLiteral.startsWith("'") && templateLiteral.endsWith("'")) {
91
+ return templateLiteral.slice(1, -1);
92
+ }
93
+
94
+ if(!isNaN(templateLiteral)) {
95
+ return Number(templateLiteral);
96
+ }
97
+
98
+ templateLiteral = "`" + templateLiteral + "`";
99
+
100
+ return evaluateJsExpression(templateLiteral, context);
101
+ };
102
+
103
+ const createResponseParser = (response = {}) => {
104
+ const res = (expr, ...fns) => {
105
+ return get(response.data, expr, ...fns);
106
+ };
107
+
14
108
  res.status = response.status;
15
109
  res.statusText = response.statusText;
16
110
  res.headers = response.headers;
17
111
  res.body = response.data;
18
112
 
113
+ res.jq = (expr) => {
114
+ const output = jsonQuery(expr, { data: response.data });
115
+ return output ? output.value : null;
116
+ };
117
+
19
118
  return res;
20
119
  };
21
120
 
22
121
  module.exports = {
23
122
  evaluateJsExpression,
24
- createResponseParser
123
+ evaluateJsTemplateLiteral,
124
+ createResponseParser,
125
+ internalExpressionCache
25
126
  };