@usebruno/js 0.36.0 → 0.38.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.36.0",
3
+ "version": "0.38.0",
4
4
  "license": "MIT",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -16,7 +16,7 @@
16
16
  "prepack": "npm run test"
17
17
  },
18
18
  "dependencies": {
19
- "@usebruno/common": "0.10.0",
19
+ "@usebruno/common": "0.12.1",
20
20
  "@usebruno/crypto-js": "^3.1.9",
21
21
  "@usebruno/query": "0.1.0",
22
22
  "ajv": "^8.12.0",
@@ -44,8 +44,6 @@
44
44
  "@rollup/plugin-commonjs": "^23.0.2",
45
45
  "@rollup/plugin-node-resolve": "^15.0.1",
46
46
  "rollup": "3.29.5",
47
- "rollup-plugin-terser": "^7.0.2",
48
- "stream": "^0.0.2",
49
- "util": "^0.12.5"
47
+ "rollup-plugin-terser": "^7.0.2"
50
48
  }
51
- }
49
+ }
package/src/bru.js CHANGED
@@ -2,7 +2,7 @@ const { cloneDeep } = require('lodash');
2
2
  const { uuid } = require('./utils');
3
3
  const { interpolate: _interpolate } = require('@usebruno/common');
4
4
  const { sendRequest } = require('@usebruno/requests').scripting;
5
- const { jar: createCookieJar } = require('@usebruno/common').cookies;
5
+ const { jar: createCookieJar } = require('@usebruno/requests').cookies;
6
6
 
7
7
  const variableNameRegex = /^[\w-.]*$/;
8
8
 
@@ -22,7 +22,6 @@ class Bru {
22
22
  this.setVisualizations = setVisualizations;
23
23
  this.collectionName = collectionName;
24
24
  this.sendRequest = sendRequest;
25
-
26
25
  this.cookies = {
27
26
  jar: () => {
28
27
  const cookieJar = createCookieJar();
@@ -66,6 +65,8 @@ class Bru {
66
65
  };
67
66
  }
68
67
  };
68
+ // Holds variables that are marked as persistent by scripts
69
+ this.persistentEnvVariables = {};
69
70
  this.runner = {
70
71
  skipRequest: () => {
71
72
  this.skipRequest = true;
@@ -128,10 +129,21 @@ class Bru {
128
129
  return this.interpolate(this.envVariables[key]);
129
130
  }
130
131
 
131
- setEnvVar(key, value) {
132
+ setEnvVar(key, value, options = {}) {
132
133
  if (!key) {
133
134
  throw new Error('Creating a env variable without specifying a name is not allowed.');
134
135
  }
136
+
137
+ if (variableNameRegex.test(key) === false) {
138
+ throw new Error(
139
+ `Variable name: "${key}" contains invalid characters! Names must only contain alpha-numeric characters, "-", "_", "."`
140
+ );
141
+ }
142
+
143
+ // When persist is true, only string values are allowed
144
+ if (options?.persist && typeof value !== 'string') {
145
+ throw new Error(`Persistent environment variables must be strings. Received ${typeof value} for key "${key}".`);
146
+ }
135
147
 
136
148
  if (this.historyLogger) {
137
149
  this.historyLogger({
@@ -143,6 +155,14 @@ class Bru {
143
155
  }
144
156
 
145
157
  this.envVariables[key] = value;
158
+
159
+ if (options?.persist) {
160
+ this.persistentEnvVariables[key] = value;
161
+ } else {
162
+ if (this.persistentEnvVariables[key]) {
163
+ delete this.persistentEnvVariables[key];
164
+ }
165
+ }
146
166
  }
147
167
 
148
168
  deleteEnvVar(key) {
package/src/index.js CHANGED
@@ -2,10 +2,12 @@ const ScriptRuntime = require('./runtime/script-runtime');
2
2
  const TestRuntime = require('./runtime/test-runtime');
3
3
  const VarsRuntime = require('./runtime/vars-runtime');
4
4
  const AssertRuntime = require('./runtime/assert-runtime');
5
+ const { runScriptInNodeVm } = require('./sandbox/node-vm');
5
6
 
6
7
  module.exports = {
7
8
  ScriptRuntime,
8
9
  TestRuntime,
9
10
  VarsRuntime,
10
- AssertRuntime
11
+ AssertRuntime,
12
+ runScriptInNodeVm
11
13
  };
@@ -14,6 +14,7 @@ const BrunoRequest = require('../bruno-request');
14
14
  const BrunoResponse = require('../bruno-response');
15
15
  const { cleanJson } = require('../utils');
16
16
  const { createBruTestResultMethods } = require('../utils/results');
17
+ const { runScriptInNodeVm } = require('../sandbox/node-vm');
17
18
 
18
19
  // Inbuilt Library Support
19
20
  const ajv = require('ajv');
@@ -119,6 +120,28 @@ class ScriptRuntime {
119
120
  context.bru.runRequest = runRequestByItemPathname;
120
121
  }
121
122
 
123
+ if (this.runtime === 'nodevm') {
124
+ await runScriptInNodeVm({
125
+ script,
126
+ context,
127
+ collectionPath,
128
+ scriptingConfig
129
+ });
130
+
131
+ return {
132
+ request,
133
+ envVariables: cleanJson(envVariables),
134
+ runtimeVariables: cleanJson(runtimeVariables),
135
+ visualizations,
136
+ persistentEnvVariables: bru.persistentEnvVariables,
137
+ globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
138
+ results: cleanJson(__brunoTestResults.getResults()),
139
+ nextRequestName: bru.nextRequest,
140
+ skipRequest: bru.skipRequest,
141
+ stopExecution: bru.stopExecution
142
+ };
143
+ }
144
+
122
145
  if (this.runtime === 'quickjs') {
123
146
  await executeQuickJsVmAsync({
124
147
  script: script,
@@ -131,6 +154,7 @@ class ScriptRuntime {
131
154
  envVariables: cleanJson(envVariables),
132
155
  runtimeVariables: cleanJson(runtimeVariables),
133
156
  visualizations,
157
+ persistentEnvVariables: bru.persistentEnvVariables,
134
158
  globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
135
159
  results: cleanJson(__brunoTestResults.getResults()),
136
160
  nextRequestName: bru.nextRequest,
@@ -187,6 +211,7 @@ class ScriptRuntime {
187
211
  envVariables: cleanJson(envVariables),
188
212
  runtimeVariables: cleanJson(runtimeVariables),
189
213
  visualizations,
214
+ persistentEnvVariables: bru.persistentEnvVariables,
190
215
  globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
191
216
  results: cleanJson(__brunoTestResults.getResults()),
192
217
  nextRequestName: bru.nextRequest,
@@ -275,6 +300,28 @@ class ScriptRuntime {
275
300
  context.bru.runRequest = runRequestByItemPathname;
276
301
  }
277
302
 
303
+ if (this.runtime === 'nodevm') {
304
+ await runScriptInNodeVm({
305
+ script,
306
+ context,
307
+ collectionPath,
308
+ scriptingConfig
309
+ });
310
+
311
+ return {
312
+ response,
313
+ envVariables: cleanJson(envVariables),
314
+ persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
315
+ runtimeVariables: cleanJson(runtimeVariables),
316
+ visualizations,
317
+ globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
318
+ results: cleanJson(__brunoTestResults.getResults()),
319
+ nextRequestName: bru.nextRequest,
320
+ skipRequest: bru.skipRequest,
321
+ stopExecution: bru.stopExecution
322
+ };
323
+ }
324
+
278
325
  if (this.runtime === 'quickjs') {
279
326
  await executeQuickJsVmAsync({
280
327
  script: script,
@@ -285,6 +332,7 @@ class ScriptRuntime {
285
332
  return {
286
333
  response,
287
334
  envVariables: cleanJson(envVariables),
335
+ persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
288
336
  runtimeVariables: cleanJson(runtimeVariables),
289
337
  visualizations,
290
338
  globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
@@ -341,6 +389,7 @@ class ScriptRuntime {
341
389
  return {
342
390
  response,
343
391
  envVariables: cleanJson(envVariables),
392
+ persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
344
393
  runtimeVariables: cleanJson(runtimeVariables),
345
394
  visualizations,
346
395
  globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
@@ -1,4 +1,5 @@
1
1
  const { NodeVM } = require('@usebruno/vm2');
2
+ const { runScriptInNodeVm } = require('../sandbox/node-vm');
2
3
  const chai = require('chai');
3
4
  const path = require('path');
4
5
  const http = require('http');
@@ -136,6 +137,13 @@ class TestRuntime {
136
137
  script: testsFile,
137
138
  context: context
138
139
  });
140
+ } else if (this.runtime === 'nodevm') {
141
+ await runScriptInNodeVm({
142
+ script: testsFile,
143
+ context,
144
+ collectionPath,
145
+ scriptingConfig
146
+ });
139
147
  } else {
140
148
  // default runtime is vm2
141
149
  const vm = new NodeVM({
@@ -197,6 +205,7 @@ class TestRuntime {
197
205
  envVariables: cleanJson(envVariables),
198
206
  runtimeVariables: cleanJson(runtimeVariables),
199
207
  globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
208
+ persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
200
209
  results: cleanJson(__brunoTestResults.getResults()),
201
210
  nextRequestName: bru.nextRequest
202
211
  };
@@ -76,6 +76,7 @@ class VarsRuntime {
76
76
  envVariables,
77
77
  runtimeVariables,
78
78
  globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
79
+ persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
79
80
  error
80
81
  };
81
82
  }
@@ -0,0 +1,223 @@
1
+ const vm = require('node:vm');
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { get } = require('lodash');
5
+ const lodash = require('lodash');
6
+ const { cleanJson } = require('../../utils');
7
+
8
+ class ScriptError extends Error {
9
+ constructor(error, script) {
10
+ super(error.message);
11
+ this.name = 'ScriptError';
12
+ this.originalError = error;
13
+ this.script = script;
14
+ this.stack = error.stack;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Executes a script in a Node.js VM context with enhanced security and module loading
20
+ * @param {Object} options - Configuration options
21
+ * @param {string} options.script - The script code to execute
22
+ * @param {Object} options.context - The execution context with Bruno objects
23
+ * @param {string} options.collectionPath - Path to the collection directory
24
+ * @param {Object} options.scriptingConfig - Scripting configuration options
25
+ * @returns {Promise<Object>} Execution results including variables and test results
26
+ * @throws {ScriptError} When script execution fails
27
+ */
28
+ async function runScriptInNodeVm({
29
+ script,
30
+ context,
31
+ collectionPath,
32
+ scriptingConfig
33
+ }) {
34
+ if (script.trim().length === 0) {
35
+ return;
36
+ }
37
+
38
+ try {
39
+ // Create script context with all necessary variables
40
+ const scriptContext = {
41
+ // Bruno context
42
+ console: context.console,
43
+ req: context.req,
44
+ res: context.res,
45
+ bru: context.bru,
46
+ expect: context.expect,
47
+ assert: context.assert,
48
+ __brunoTestResults: context.__brunoTestResults,
49
+ test: context.test,
50
+ // Configuration for nested module loading
51
+ scriptingConfig: scriptingConfig,
52
+ // Global objects
53
+ Buffer: global.Buffer,
54
+ process: global.process,
55
+ setTimeout: global.setTimeout,
56
+ setInterval: global.setInterval,
57
+ clearTimeout: global.clearTimeout,
58
+ clearInterval: global.clearInterval,
59
+ setImmediate: global.setImmediate,
60
+ clearImmediate: global.clearImmediate
61
+ };
62
+
63
+ // Create shared cache for local modules
64
+ const localModuleCache = new Map();
65
+
66
+ // Create a custom require function and add it to the context
67
+ scriptContext.require = createCustomRequire({
68
+ scriptingConfig,
69
+ collectionPath,
70
+ scriptContext,
71
+ currentModuleDir: collectionPath,
72
+ localModuleCache
73
+ });
74
+
75
+ // Execute the script in an isolated VM context
76
+ await vm.runInNewContext(`
77
+ (async function(){
78
+ ${script}
79
+ })();
80
+ `, scriptContext, {
81
+ filename: path.join(collectionPath, 'script.js'),
82
+ displayErrors: true
83
+ });
84
+ } catch (error) {
85
+ throw new ScriptError(error, script);
86
+ }
87
+
88
+ return;
89
+ }
90
+
91
+
92
+ /**
93
+ * Creates a custom require function with enhanced security and local module support
94
+ * @param {Object} options - Configuration options
95
+ * @param {Object} options.scriptingConfig - Scripting configuration with additional context roots
96
+ * @param {string} options.collectionPath - Base collection path for security checks
97
+ * @param {Object} options.scriptContext - Script execution context
98
+ * @param {string} options.currentModuleDir - Current module directory for relative imports
99
+ * @param {Map} options.localModuleCache - Cache for loaded local modules
100
+ * @returns {Function} Custom require function
101
+ */
102
+ function createCustomRequire({
103
+ scriptingConfig,
104
+ collectionPath,
105
+ scriptContext,
106
+ currentModuleDir = collectionPath,
107
+ localModuleCache = new Map()
108
+ }) {
109
+ const additionalContextRoots = get(scriptingConfig, 'additionalContextRoots', []);
110
+ const additionalContextRootsAbsolute = lodash
111
+ .chain(additionalContextRoots)
112
+ .map((acr) => (acr.startsWith('/') ? acr : path.join(collectionPath, acr)))
113
+ .value();
114
+ additionalContextRootsAbsolute.push(collectionPath);
115
+
116
+ return (moduleName) => {
117
+ // Check if it's a local module (starts with ./ or ../)
118
+ if (moduleName.startsWith('./') || moduleName.startsWith('../')) {
119
+ return loadLocalModule({ moduleName, collectionPath, scriptContext, localModuleCache, currentModuleDir });
120
+ }
121
+
122
+ // First try to require as a native/npm module
123
+ try {
124
+ return require(moduleName);
125
+ } catch {
126
+ // If that fails, try to resolve from additionalContextRoots
127
+ try {
128
+ const modulePath = require.resolve(moduleName, { paths: additionalContextRootsAbsolute });
129
+ return require(modulePath);
130
+ } catch (error) {
131
+ throw new Error(`Could not resolve module "${moduleName}": ${error.message}\n\nThis most likely means you did not install the module under "additionalContextRoots" using a package manager like npm.\n\nThese are your current "additionalContextRoots":\n${additionalContextRootsAbsolute.map(root => ` - ${root}`).join('\n') || ' - No "additionalContextRoots" defined'}`);
132
+ }
133
+ }
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Loads a local module from the filesystem with security checks and caching
139
+ * @param {Object} options - Configuration options
140
+ * @param {string} options.moduleName - Name/path of the module to load
141
+ * @param {string} options.collectionPath - Base collection path for security validation
142
+ * @param {Object} options.scriptContext - Script execution context to inherit
143
+ * @param {Map} options.localModuleCache - Cache for loaded modules
144
+ * @param {string} options.currentModuleDir - Directory of the current module for relative resolution
145
+ * @returns {*} The exported content of the loaded module
146
+ * @throws {Error} When module is outside collection path or cannot be loaded
147
+ */
148
+ function loadLocalModule({
149
+ moduleName,
150
+ collectionPath,
151
+ scriptContext,
152
+ localModuleCache,
153
+ currentModuleDir
154
+ }) {
155
+ // Check if the filename has an extension
156
+ const hasExtension = path.extname(moduleName) !== '';
157
+ const resolvedFilename = hasExtension ? moduleName : `${moduleName}.js`;
158
+
159
+ // Resolve the file path relative to the current module's directory
160
+ const filePath = path.resolve(currentModuleDir, resolvedFilename);
161
+ const normalizedFilePath = path.normalize(filePath);
162
+ const normalizedCollectionPath = path.normalize(collectionPath);
163
+
164
+ // Cross-platform security check: ensure the resolved file is within collectionPath
165
+ const relativePath = path.relative(normalizedCollectionPath, normalizedFilePath);
166
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
167
+ throw new Error(`Access to files outside of the collectionPath is not allowed: ${moduleName}`);
168
+ }
169
+
170
+ // Check cache first (use normalized path as key)
171
+ if (localModuleCache.has(normalizedFilePath)) {
172
+ return localModuleCache.get(normalizedFilePath);
173
+ }
174
+
175
+ if (!fs.existsSync(normalizedFilePath)) {
176
+ throw new Error(`Cannot find module ${moduleName}`);
177
+ }
178
+
179
+ // Read and execute the local module
180
+ const moduleCode = fs.readFileSync(normalizedFilePath, 'utf8');
181
+
182
+ // Create module object
183
+ const moduleObj = { exports: {} };
184
+
185
+ // Get the directory of this module for nested imports
186
+ const moduleDir = path.dirname(normalizedFilePath);
187
+
188
+ // Create a new context that inherits from the script context
189
+ const moduleContext = {
190
+ ...scriptContext,
191
+ module: moduleObj,
192
+ exports: moduleObj.exports,
193
+ __filename: normalizedFilePath,
194
+ __dirname: moduleDir,
195
+ // Create a custom require function for this module that resolves relative to its directory
196
+ require: createCustomRequire({
197
+ scriptingConfig: scriptContext.scriptingConfig || {},
198
+ collectionPath,
199
+ scriptContext,
200
+ currentModuleDir: moduleDir,
201
+ localModuleCache
202
+ })
203
+ };
204
+
205
+ try {
206
+ // Execute the module code in the shared context
207
+ vm.runInNewContext(moduleCode, moduleContext, {
208
+ filename: normalizedFilePath,
209
+ displayErrors: true
210
+ });
211
+
212
+ // Cache the result using normalized path
213
+ localModuleCache.set(normalizedFilePath, moduleObj.exports);
214
+
215
+ return moduleObj.exports;
216
+ } catch (error) {
217
+ throw new Error(`Error loading local module ${moduleName}: ${error.message}`);
218
+ }
219
+ }
220
+
221
+ module.exports = {
222
+ runScriptInNodeVm
223
+ };
@@ -48,8 +48,8 @@ const addBruShimToContext = (vm, bru) => {
48
48
  vm.setProp(bruObject, 'getEnvVar', getEnvVar);
49
49
  getEnvVar.dispose();
50
50
 
51
- let setEnvVar = vm.newFunction('setEnvVar', function (key, value) {
52
- bru.setEnvVar(vm.dump(key), vm.dump(value));
51
+ let setEnvVar = vm.newFunction('setEnvVar', function (key, value, options = {}) {
52
+ bru.setEnvVar(vm.dump(key), vm.dump(value), vm.dump(options));
53
53
  });
54
54
  vm.setProp(bruObject, 'setEnvVar', setEnvVar);
55
55
  setEnvVar.dispose();