@usebruno/js 0.50.0 → 0.52.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.
@@ -0,0 +1,40 @@
1
+ const { marshallToVm } = require('../../utils');
2
+ const addGrpcMetadataListShimToContext = require('./grpc-metadata-list');
3
+ const addGrpcMessageListShimToContext = require('./grpc-message-list');
4
+
5
+ // Keep this in step with BrunoGrpcResponse.
6
+ const addBrunoGrpcResponseShimToContext = (vm, response, grpcObject) => {
7
+ const responseObject = vm.newObject();
8
+
9
+ // Marshalled once, as on the request: the call is over, so no scalar can change mid-hook.
10
+ const scalars = ['statusCode', 'statusText', 'duration'];
11
+
12
+ for (const property of scalars) {
13
+ const value = marshallToVm(response?.[property], vm);
14
+ vm.setProp(responseObject, property, value);
15
+ value.dispose();
16
+ }
17
+
18
+ // response.metadata / .trailers / .messages — the same lists `bru.grpc.request` gets, read-only here
19
+ const listEvalCode = ['metadata', 'trailers'].map((property) =>
20
+ addGrpcMetadataListShimToContext(vm, response[property], responseObject, property, 'globalThis.bru.grpc.response')
21
+ );
22
+
23
+ listEvalCode.push(
24
+ addGrpcMessageListShimToContext(vm, response.messages, responseObject, 'globalThis.bru.grpc.response')
25
+ );
26
+
27
+ // response.message — present only in `afterMessageReceive`
28
+ if (response.message) {
29
+ const message = marshallToVm(response.message, vm);
30
+ vm.setProp(responseObject, 'message', message);
31
+ message.dispose();
32
+ }
33
+
34
+ vm.setProp(grpcObject, 'response', responseObject);
35
+ responseObject.dispose();
36
+
37
+ return { evalCode: listEvalCode };
38
+ };
39
+
40
+ module.exports = addBrunoGrpcResponseShimToContext;
@@ -0,0 +1,29 @@
1
+ const { createPropertyListBridge } = require('../../utils/property-list-bridge');
2
+
3
+ /**
4
+ * Bridges a GrpcMessageList — `bru.grpc.request.messages`, `bru.grpc.response.messages`
5
+ * — onto a VM object. Keep in sync with GrpcMessageList
6
+ *
7
+ * @param {Object} vm - QuickJS VM instance
8
+ * @param {Object} list - The native GrpcMessageList
9
+ * @param {Object} targetObject - VM object handle the list is attached to
10
+ * @param {string} objectPath - Path to `targetObject` in the VM, e.g. `globalThis.bru.grpc.request`
11
+ * @returns {string} Code the caller must eval once `objectPath` resolves
12
+ */
13
+ const addGrpcMessageListShimToContext = (vm, list, targetObject, objectPath) => {
14
+ const listObject = vm.newObject();
15
+
16
+ const { evalCode } = createPropertyListBridge(vm, list, listObject, {
17
+ globalPath: `${objectPath}.messages`,
18
+ syncReadMethods: ['count'],
19
+ syncReadObjectMethods: ['get', 'all', 'toJSON'],
20
+ withIterators: true
21
+ });
22
+
23
+ vm.setProp(targetObject, 'messages', listObject);
24
+ listObject.dispose();
25
+
26
+ return evalCode;
27
+ };
28
+
29
+ module.exports = addGrpcMessageListShimToContext;
@@ -0,0 +1,31 @@
1
+ const { createPropertyListBridge } = require('../../utils/property-list-bridge');
2
+
3
+ /**
4
+ * Bridges a GrpcMetadataList — `bru.grpc.request.metadata`, `bru.grpc.response.metadata`,
5
+ * `bru.grpc.response.trailers` — onto a VM object. Keep in sync with GrpcMetadataList
6
+ *
7
+ * @param {Object} vm - QuickJS VM instance
8
+ * @param {Object} list - The native GrpcMetadataList
9
+ * @param {Object} targetObject - VM object handle the list is attached to
10
+ * @param {string} property - Property name on `targetObject`
11
+ * @param {string} objectPath - Path to `targetObject` in the VM, e.g. `globalThis.bru.grpc.request`
12
+ * @returns {string} Code the caller must eval once `objectPath` resolves
13
+ */
14
+ const addGrpcMetadataListShimToContext = (vm, list, targetObject, property, objectPath) => {
15
+ const listObject = vm.newObject();
16
+
17
+ const { evalCode } = createPropertyListBridge(vm, list, listObject, {
18
+ globalPath: `${objectPath}.${property}`,
19
+ syncReadMethods: ['get', 'has', 'count', 'indexOf', 'toObject', 'toString'],
20
+ syncReadObjectMethods: ['one', 'all', 'toJSON'],
21
+ syncWriteMethods: ['upsert', 'add', 'remove', 'clear'],
22
+ withIterators: true
23
+ });
24
+
25
+ vm.setProp(targetObject, property, listObject);
26
+ listObject.dispose();
27
+
28
+ return evalCode;
29
+ };
30
+
31
+ module.exports = addGrpcMetadataListShimToContext;
@@ -8,7 +8,7 @@ const createManagedQuickJsContext = (module) => {
8
8
  const vm = module.newContext();
9
9
  const disposeTracked = trackQuickJsContext(vm);
10
10
  const evalCodeRetained = vm.evalCode.bind(vm);
11
- const waitForPendingDeferreds = trackPendingDeferreds(vm);
11
+ const { waitForPendingDeferreds, disposePendingDeferreds } = trackPendingDeferreds(vm);
12
12
 
13
13
  vm.evalCode = (code, filename = 'eval.js') => {
14
14
  const result = evalCodeRetained(code, filename);
@@ -25,7 +25,7 @@ const createManagedQuickJsContext = (module) => {
25
25
  return {
26
26
  vm,
27
27
  waitForPendingDeferreds,
28
- dispose: () => disposeQuickJsContext(vm, disposeTracked)
28
+ dispose: () => disposeQuickJsContext(vm, disposeTracked, disposePendingDeferreds)
29
29
  };
30
30
  };
31
31
 
@@ -38,27 +38,41 @@ const createManagedQuickJsContext = (module) => {
38
38
  * `QuickJSUseAfterFree`. Each `.settled` resolves once the deferred is
39
39
  * resolved/rejected, so awaiting them keeps the context alive long enough.
40
40
  *
41
- * The hook is installed now (at context creation) so it captures promises as
42
- * the script runs. Returns a function that drains the captured deferreds at
43
- * teardown; new deferreds can be created while we wait (a chained timer), so it
44
- * drains in place until none remain.
41
+ * The hook installs at context creation. `waitForPendingDeferreds` drains in
42
+ * place (waiting can chain new deferreds) with no timeout; `disposePendingDeferreds`
43
+ * frees unsettled resolve/reject handles, which left alive abort JS_FreeRuntime.
45
44
  */
46
-
47
45
  const trackPendingDeferreds = (vm) => {
48
- const pendingDeferreds = [];
46
+ const pendingSettles = [];
47
+ const deferreds = [];
49
48
  const originalNewPromise = vm.newPromise.bind(vm);
50
49
  vm.newPromise = (...args) => {
51
50
  const deferred = originalNewPromise(...args);
52
- pendingDeferreds.push(deferred.settled.catch(() => { }));
51
+ deferreds.push(deferred);
52
+ pendingSettles.push(deferred.settled.catch(() => { }));
53
53
  return deferred;
54
54
  };
55
55
 
56
- return async () => {
57
- while (pendingDeferreds.length) {
58
- const batch = pendingDeferreds.splice(0);
56
+ const waitForPendingDeferreds = async () => {
57
+ while (pendingSettles.length) {
58
+ const batch = pendingSettles.splice(0);
59
59
  await Promise.all(batch);
60
60
  }
61
61
  };
62
+
63
+ const disposePendingDeferreds = () => {
64
+ while (deferreds.length) {
65
+ const deferred = deferreds.pop();
66
+ if (deferred.alive) {
67
+ deferred.dispose();
68
+ }
69
+ }
70
+ };
71
+
72
+ return {
73
+ waitForPendingDeferreds,
74
+ disposePendingDeferreds
75
+ };
62
76
  };
63
77
 
64
78
  /**
@@ -98,22 +112,18 @@ const trackQuickJsContext = (vm) => {
98
112
  };
99
113
 
100
114
  /**
101
- * Clears shim globals, drains pending QuickJS jobs, and disposes the context.
102
- * Pass disposeTracked from trackQuickJsContext() to free shim handles first.
115
+ * Drains pending QuickJS jobs, frees leftover deferreds and tracked handles,
116
+ * and disposes the context. Jobs are drained BEFORE the handle flush: a job
117
+ * can call allocating shims or start new async work, and anything it creates
118
+ * must still be freed or JS_FreeRuntime aborts on the leftover GC objects.
103
119
  */
104
- const disposeQuickJsContext = (vm, disposeTracked) => {
120
+ const disposeQuickJsContext = (vm, disposeTracked, disposePendingDeferreds) => {
105
121
  if (!vm?.alive) {
106
122
  return;
107
123
  }
108
124
 
109
- if (typeof disposeTracked === 'function') {
110
- disposeTracked();
111
- }
112
-
113
- // Drain the runtime's pending job queue (resolved/rejected promise callbacks)
114
- // before disposing. Executing a job can schedule more jobs (chained `.then()`s),
115
- // so we keep going until `hasPendingJob()` reports the queue is empty or a job
116
- // throws.
125
+ // Executing a job can schedule more jobs (chained `.then()`s), so keep going
126
+ // until `hasPendingJob()` reports the queue is empty or a job throws.
117
127
  while (vm.runtime?.hasPendingJob?.()) {
118
128
  const result = vm.runtime.executePendingJobs();
119
129
  // On error, dispose the error handle and stop draining.
@@ -122,6 +132,15 @@ const disposeQuickJsContext = (vm, disposeTracked) => {
122
132
  break;
123
133
  }
124
134
  }
135
+
136
+ if (typeof disposePendingDeferreds === 'function') {
137
+ disposePendingDeferreds();
138
+ }
139
+
140
+ if (typeof disposeTracked === 'function') {
141
+ disposeTracked();
142
+ }
143
+
125
144
  vm.dispose();
126
145
  };
127
146
 
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const YAML = require('yaml');
4
+ const { SCRIPT_TYPES } = require('@usebruno/common');
4
5
  const { NODEVM_SCRIPT_WRAPPER_OFFSET, QUICKJS_SCRIPT_WRAPPER_OFFSET } = require('./sandbox');
5
6
 
6
7
  const posixifyPath = (p) => (p ? p.replace(/\\/g, '/') : p);
@@ -11,17 +12,15 @@ const ALLOWED_SOURCE_EXTENSIONS = ['.bru', '.yml'];
11
12
  const isAllowedSourceFile = (filePath) =>
12
13
  typeof filePath === 'string' && ALLOWED_SOURCE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
13
14
 
14
- const SCRIPT_TYPES = Object.freeze({
15
- PRE_REQUEST: 'pre-request',
16
- POST_RESPONSE: 'post-response',
17
- TEST: 'test'
18
- });
19
-
20
15
  // Bruno script types → OpenCollection YAML script types
21
16
  const SCRIPT_TYPE_TO_YML = {
22
17
  [SCRIPT_TYPES.PRE_REQUEST]: 'before-request',
23
18
  [SCRIPT_TYPES.POST_RESPONSE]: 'after-response',
24
- [SCRIPT_TYPES.TEST]: 'tests'
19
+ [SCRIPT_TYPES.TEST]: 'tests',
20
+ [SCRIPT_TYPES.BEFORE_CALL_START]: 'grpc:before-call-start',
21
+ [SCRIPT_TYPES.BEFORE_MESSAGE_SEND]: 'grpc:before-message-send',
22
+ [SCRIPT_TYPES.AFTER_MESSAGE_RECEIVE]: 'grpc:after-message-receive',
23
+ [SCRIPT_TYPES.AFTER_CALL_END]: 'grpc:after-call-end'
25
24
  };
26
25
 
27
26
  const readFile = (filePath, cache = null) => {
@@ -38,7 +37,11 @@ const readFile = (filePath, cache = null) => {
38
37
  const BLOCK_PATTERNS = {
39
38
  [SCRIPT_TYPES.PRE_REQUEST]: /^script:pre-request\s*\{/,
40
39
  [SCRIPT_TYPES.POST_RESPONSE]: /^script:post-response\s*\{/,
41
- [SCRIPT_TYPES.TEST]: /^tests\s*\{/
40
+ [SCRIPT_TYPES.TEST]: /^tests\s*\{/,
41
+ [SCRIPT_TYPES.BEFORE_CALL_START]: /^script:grpc:before-call-start\s*\{/,
42
+ [SCRIPT_TYPES.BEFORE_MESSAGE_SEND]: /^script:grpc:before-message-send\s*\{/,
43
+ [SCRIPT_TYPES.AFTER_MESSAGE_RECEIVE]: /^script:grpc:after-message-receive\s*\{/,
44
+ [SCRIPT_TYPES.AFTER_CALL_END]: /^script:grpc:after-call-end\s*\{/
42
45
  };
43
46
 
44
47
  /** Find the 1-indexed line where a script block's content starts in a .bru file */
@@ -747,5 +750,6 @@ module.exports = {
747
750
  findYmlScriptBlockStartLine,
748
751
  findYmlScriptBlockEndLine,
749
752
  adjustStackTrace,
750
- getErrorTypeName
753
+ getErrorTypeName,
754
+ posixifyPath
751
755
  };
@@ -11,7 +11,8 @@ const {
11
11
  parseErrorLocation,
12
12
  getSourceContextFromContent,
13
13
  adjustStackTrace,
14
- buildStackFromCallSites
14
+ buildStackFromCallSites,
15
+ posixifyPath
15
16
  } = require('./error-formatter');
16
17
  const fs = require('fs');
17
18
  const path = require('path');
@@ -120,6 +121,64 @@ const COLLECTION_YML = [
120
121
  ' });'
121
122
  ].join('\n');
122
123
 
124
+ // gRPC lifecycle hooks:
125
+ // 11: script:grpc:before-call-start { → blockStartLine = 12
126
+ // 16: script:grpc:after-call-end { → blockStartLine = 17
127
+ const GRPC_BRU = `meta {
128
+ name: grpc-test
129
+ type: grpc
130
+ seq: 1
131
+ }
132
+
133
+ grpc {
134
+ url: grpc://localhost:50051
135
+ }
136
+
137
+ script:grpc:before-call-start {
138
+ const token = bru.getEnvVar('token');
139
+ request.setHeader('authorization', token);
140
+ }
141
+
142
+ script:grpc:after-call-end {
143
+ const data = response.data;
144
+ bru.setVar('userId', data.id);
145
+ console.log(data);
146
+ }
147
+
148
+ script:grpc:before-message-send {
149
+ bru.setVar('outbound', bru.grpc.request.message.data.greeting);
150
+ }
151
+
152
+ script:grpc:after-message-receive {
153
+ const message = bru.grpc.response.message;
154
+ bru.setVar('inbound', message.data.reply);
155
+ }`;
156
+
157
+ // gRPC yml fixture: blockStartLine = 8 (before-call-start), 12 (after-call-end),
158
+ // 16 (before-message-send), 19 (after-message-receive)
159
+ const GRPC_YML = [
160
+ 'info:',
161
+ ' name: grpc-yaml-test',
162
+ ' version: "1"',
163
+ 'runtime:',
164
+ ' scripts:',
165
+ ' - type: grpc:before-call-start',
166
+ ' code: |-',
167
+ ' const token = bru.getEnvVar(\'token\');',
168
+ ' request.setHeader(\'authorization\', token);',
169
+ ' - type: grpc:after-call-end',
170
+ ' code: |-',
171
+ ' const data = response.data;',
172
+ ' bru.setVar(\'userId\', data.id);',
173
+ ' - type: grpc:before-message-send',
174
+ ' code: |-',
175
+ ' bru.setVar(\'outbound\', bru.grpc.request.message.data.greeting);',
176
+ ' - type: grpc:after-message-receive',
177
+ ' code: |-',
178
+ ' const message = bru.grpc.response.message;',
179
+ ' bru.setVar(\'inbound\', message.data.reply);'
180
+ ].join('\n');
181
+
123
182
  // Wrapper offsets: QuickJS = 9 (script line 1 = VM line 10), NodeVM = 2 (script line 1 = VM line 3)
124
183
 
125
184
  describe('Error Formatter', () => {
@@ -128,6 +187,8 @@ describe('Error Formatter', () => {
128
187
  let ymlFilePath;
129
188
  let bruWithCommentsPath;
130
189
  let collectionYmlPath;
190
+ let grpcBruPath;
191
+ let grpcYmlPath;
131
192
 
132
193
  beforeEach(() => {
133
194
  testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-test-'));
@@ -135,10 +196,14 @@ describe('Error Formatter', () => {
135
196
  ymlFilePath = path.join(testDir, 'test.yml');
136
197
  bruWithCommentsPath = path.join(testDir, 'comments.bru');
137
198
  collectionYmlPath = path.join(testDir, 'opencollection.yml');
199
+ grpcBruPath = path.join(testDir, 'grpc.bru');
200
+ grpcYmlPath = path.join(testDir, 'grpc.yml');
138
201
  fs.writeFileSync(bruFilePath, MULTI_BLOCK_BRU);
139
202
  fs.writeFileSync(ymlFilePath, MULTI_BLOCK_YML);
140
203
  fs.writeFileSync(bruWithCommentsPath, BRU_WITH_COMMENTS);
141
204
  fs.writeFileSync(collectionYmlPath, COLLECTION_YML);
205
+ fs.writeFileSync(grpcBruPath, GRPC_BRU);
206
+ fs.writeFileSync(grpcYmlPath, GRPC_YML);
142
207
  });
143
208
 
144
209
  afterEach(() => {
@@ -152,6 +217,13 @@ describe('Error Formatter', () => {
152
217
  expect(findScriptBlockStartLine(bruFilePath, 'test')).toBe(25);
153
218
  });
154
219
 
220
+ it('should find gRPC lifecycle hook blocks in .bru files', () => {
221
+ expect(findScriptBlockStartLine(grpcBruPath, 'before-call-start')).toBe(12);
222
+ expect(findScriptBlockStartLine(grpcBruPath, 'after-call-end')).toBe(17);
223
+ expect(findScriptBlockStartLine(grpcBruPath, 'before-message-send')).toBe(23);
224
+ expect(findScriptBlockStartLine(grpcBruPath, 'after-message-receive')).toBe(27);
225
+ });
226
+
155
227
  it('should return null for missing block or non-.bru files', () => {
156
228
  const noBlockPath = path.join(testDir, 'no-block.bru');
157
229
  fs.writeFileSync(noBlockPath, 'meta {\n name: test\n}');
@@ -167,6 +239,13 @@ describe('Error Formatter', () => {
167
239
  expect(findScriptBlockEndLine(bruFilePath, 'test')).toBe(30);
168
240
  });
169
241
 
242
+ it('should find last content line for gRPC lifecycle hook blocks', () => {
243
+ expect(findScriptBlockEndLine(grpcBruPath, 'before-call-start')).toBe(13);
244
+ expect(findScriptBlockEndLine(grpcBruPath, 'after-call-end')).toBe(19);
245
+ expect(findScriptBlockEndLine(grpcBruPath, 'before-message-send')).toBe(23);
246
+ expect(findScriptBlockEndLine(grpcBruPath, 'after-message-receive')).toBe(28);
247
+ });
248
+
170
249
  it('should return null for empty block', () => {
171
250
  const emptyBlockPath = path.join(testDir, 'empty.bru');
172
251
  fs.writeFileSync(emptyBlockPath, 'script:pre-request {\n}');
@@ -197,6 +276,13 @@ describe('Error Formatter', () => {
197
276
  expect(findYmlScriptBlockStartLine(collectionYmlPath, 'test')).toBe(11);
198
277
  });
199
278
 
279
+ it('should find gRPC lifecycle hook blocks in .yml files', () => {
280
+ expect(findYmlScriptBlockStartLine(grpcYmlPath, 'before-call-start')).toBe(8);
281
+ expect(findYmlScriptBlockStartLine(grpcYmlPath, 'after-call-end')).toBe(12);
282
+ expect(findYmlScriptBlockStartLine(grpcYmlPath, 'before-message-send')).toBe(16);
283
+ expect(findYmlScriptBlockStartLine(grpcYmlPath, 'after-message-receive')).toBe(19);
284
+ });
285
+
200
286
  it('should return null for missing block or non-.yml files', () => {
201
287
  const noRuntimePath = path.join(testDir, 'no-runtime.yml');
202
288
  fs.writeFileSync(noRuntimePath, 'info:\n name: simple\n version: "1"\n');
@@ -216,6 +302,13 @@ describe('Error Formatter', () => {
216
302
  expect(findYmlScriptBlockEndLine(collectionYmlPath, 'test')).toBe(13);
217
303
  });
218
304
 
305
+ it('should find last content line for gRPC lifecycle hook blocks', () => {
306
+ expect(findYmlScriptBlockEndLine(grpcYmlPath, 'before-call-start')).toBe(9);
307
+ expect(findYmlScriptBlockEndLine(grpcYmlPath, 'after-call-end')).toBe(13);
308
+ expect(findYmlScriptBlockEndLine(grpcYmlPath, 'before-message-send')).toBe(16);
309
+ expect(findYmlScriptBlockEndLine(grpcYmlPath, 'after-message-receive')).toBe(20);
310
+ });
311
+
219
312
  it('should return null for missing block', () => {
220
313
  const noRuntimePath = path.join(testDir, 'no-runtime.yml');
221
314
  fs.writeFileSync(noRuntimePath, 'info:\n name: simple\n version: "1"\n');
@@ -804,7 +897,9 @@ get {
804
897
  const result = formatErrorWithContextV2(error, 'pre-request');
805
898
 
806
899
  expect(result).not.toBeNull();
807
- expect(result.filePath).toBe(bruFilePath);
900
+ // display paths are posixified, so the Windows path.join backslashes
901
+ // must be normalized before comparing
902
+ expect(result.filePath).toBe(posixifyPath(bruFilePath));
808
903
  });
809
904
  });
810
905
 
@@ -1,7 +1,6 @@
1
1
  const TestResults = require('../test-results');
2
2
  const Test = require('../test');
3
3
 
4
- // Calculate summary statistics for test results
5
4
  const getResultsSummary = (results) => {
6
5
  const summary = {
7
6
  total: results.length,
@@ -20,12 +19,40 @@ const getResultsSummary = (results) => {
20
19
  return summary;
21
20
  };
22
21
 
22
+ /**
23
+ * Called once per script phase - pre-request, post-response, and the Tests tab each call
24
+ * this separately, from their own place in runtime/script-runtime.js and
25
+ * runtime/test-runtime.js. Every call creates its own private `pendingTestPromises` array
26
+ * below, so each phase tracks and waits for only its own test() calls
27
+ */
23
28
  const createBruTestResultMethods = (bru, assertionResults, chai) => {
24
29
  const __brunoTestResults = new TestResults();
25
- const test = Test(__brunoTestResults, chai);
30
+ const baseTest = Test(__brunoTestResults, chai);
31
+
32
+ const pendingTestPromises = [];
33
+
34
+ const test = (description, callback) => {
35
+ const promise = baseTest(description, callback);
36
+ pendingTestPromises.push(promise.catch(() => {}));
37
+ return promise;
38
+ };
39
+
40
+ /**
41
+ * Waits for every test() call registered so far to settle - including a test() called
42
+ * from inside another test()'s callback, after this wait has already started. Mirrors
43
+ * QuickJS's own waitForPendingDeferreds(). If a test() callback has its own delay (a
44
+ * setTimeout, a slow request), this simply waits until that delay is over
45
+ */
46
+ const waitForPendingTests = async () => {
47
+ while (pendingTestPromises.length) {
48
+ const batch = pendingTestPromises.splice(0);
49
+ await Promise.all(batch);
50
+ }
51
+ };
52
+
26
53
  setupBruTestMethods(bru, __brunoTestResults, assertionResults);
27
54
 
28
- return { __brunoTestResults, test };
55
+ return { __brunoTestResults, test, waitForPendingTests };
29
56
  };
30
57
 
31
58
  const setupBruTestMethods = (bru, __brunoTestResults, assertionResults) => {