@usebruno/js 0.45.0 → 0.46.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,388 @@
1
+ const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
2
+ const {
3
+ formatErrorWithContext,
4
+ findScriptBlockStartLine,
5
+ findYmlScriptBlockStartLine,
6
+ adjustLineNumber,
7
+ parseStackTrace,
8
+ parseErrorLocation
9
+ } = require('./error-formatter');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+
14
+ // Line numbers annotated for reference:
15
+ // 13: script:pre-request { → blockStartLine = 14
16
+ // 14: const token = ... → script line 1
17
+ // 18: script:post-response { → blockStartLine = 19
18
+ // 19: const data = res.body; → script line 1
19
+ // 20: bru.setVar(...) → script line 2
20
+ // 24: tests { → blockStartLine = 25
21
+ // 25: test("status is 200"...) → script line 1
22
+ const MULTI_BLOCK_BRU = `meta {
23
+ name: multi-block-test
24
+ type: http
25
+ seq: 1
26
+ }
27
+
28
+ get {
29
+ url: https://example.com
30
+ body: none
31
+ auth: none
32
+ }
33
+
34
+ script:pre-request {
35
+ const token = bru.getEnvVar('token');
36
+ req.setHeader('Authorization', token);
37
+ }
38
+
39
+ script:post-response {
40
+ const data = res.body;
41
+ bru.setVar('userId', data.id);
42
+ console.log(data);
43
+ }
44
+
45
+ tests {
46
+ test("status is 200", function() {
47
+ expect(res.status).to.equal(200);
48
+ });
49
+ test("has body", function() {
50
+ expect(res.body).to.not.be.null;
51
+ });
52
+ }`;
53
+
54
+ // Fixture with JS comments to verify line mapping when comments are present.
55
+ // 11: script:post-response { → blockStartLine = 12
56
+ // 12: // This is a comment → script line 1
57
+ // 13: const data = res.body; → script line 2
58
+ // 14: // Another comment → script line 3
59
+ // 15: bru.setVar('userId', ...); → script line 4
60
+ const BRU_WITH_COMMENTS = `meta {
61
+ name: comment-test
62
+ type: http
63
+ seq: 1
64
+ }
65
+
66
+ get {
67
+ url: https://example.com
68
+ }
69
+
70
+ script:post-response {
71
+ // This is a comment
72
+ const data = res.body;
73
+ // Another comment
74
+ bru.setVar('userId', data.id);
75
+ }`;
76
+
77
+ // YML fixture: blockStartLine = 8 (pre-request), 12 (post-response), 16 (tests)
78
+ const MULTI_BLOCK_YML = [
79
+ 'info:',
80
+ ' name: yaml-test',
81
+ ' version: "1"',
82
+ 'runtime:',
83
+ ' scripts:',
84
+ ' - type: before-request',
85
+ ' code: |-',
86
+ ' const token = bru.getEnvVar(\'token\');',
87
+ ' req.setHeader(\'Authorization\', token);',
88
+ ' - type: after-response',
89
+ ' code: |-',
90
+ ' const data = res.body;',
91
+ ' bru.setVar(\'userId\', data.id);',
92
+ ' - type: tests',
93
+ ' code: |-',
94
+ ' test("status is 200", function() {',
95
+ ' expect(res.status).to.equal(200);',
96
+ ' });'
97
+ ].join('\n');
98
+
99
+ // Collection/folder yml : scripts at request.scripts
100
+ // blockStartLine: before-request = 5, tests = 9
101
+ const COLLECTION_YML = [
102
+ 'info:',
103
+ ' name: test-collection',
104
+ 'request:',
105
+ ' scripts:',
106
+ ' - type: before-request',
107
+ ' code: |-',
108
+ ' const abc = fc()',
109
+ ' const x = bru.getVar(\'x\');',
110
+ ' - type: tests',
111
+ ' code: |-',
112
+ ' test("example", function() {',
113
+ ' expect(true).to.be.true;',
114
+ ' });'
115
+ ].join('\n');
116
+
117
+ // Wrapper offsets: QuickJS = 9 (script line 1 = VM line 10), NodeVM = 2 (script line 1 = VM line 3)
118
+
119
+ describe('Error Formatter', () => {
120
+ let testDir;
121
+ let bruFilePath;
122
+ let ymlFilePath;
123
+ let bruWithCommentsPath;
124
+ let collectionYmlPath;
125
+
126
+ beforeEach(() => {
127
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-test-'));
128
+ bruFilePath = path.join(testDir, 'test.bru');
129
+ ymlFilePath = path.join(testDir, 'test.yml');
130
+ bruWithCommentsPath = path.join(testDir, 'comments.bru');
131
+ collectionYmlPath = path.join(testDir, 'opencollection.yml');
132
+ fs.writeFileSync(bruFilePath, MULTI_BLOCK_BRU);
133
+ fs.writeFileSync(ymlFilePath, MULTI_BLOCK_YML);
134
+ fs.writeFileSync(bruWithCommentsPath, BRU_WITH_COMMENTS);
135
+ fs.writeFileSync(collectionYmlPath, COLLECTION_YML);
136
+ });
137
+
138
+ afterEach(() => {
139
+ fs.rmSync(testDir, { recursive: true, force: true });
140
+ });
141
+
142
+ describe('findScriptBlockStartLine', () => {
143
+ it('should find each block type in .bru files', () => {
144
+ expect(findScriptBlockStartLine(bruFilePath, 'pre-request')).toBe(14);
145
+ expect(findScriptBlockStartLine(bruFilePath, 'post-response')).toBe(19);
146
+ expect(findScriptBlockStartLine(bruFilePath, 'test')).toBe(25);
147
+ });
148
+
149
+ it('should return null for missing block or non-.bru files', () => {
150
+ const noBlockPath = path.join(testDir, 'no-block.bru');
151
+ fs.writeFileSync(noBlockPath, 'meta {\n name: test\n}');
152
+ expect(findScriptBlockStartLine(noBlockPath, 'post-response')).toBeNull();
153
+ expect(findScriptBlockStartLine('/some/file.js', 'post-response')).toBeNull();
154
+ });
155
+ });
156
+
157
+ describe('findYmlScriptBlockStartLine', () => {
158
+ it('should find each block type in .yml files', () => {
159
+ expect(findYmlScriptBlockStartLine(ymlFilePath, 'pre-request')).toBe(8);
160
+ expect(findYmlScriptBlockStartLine(ymlFilePath, 'post-response')).toBe(12);
161
+ expect(findYmlScriptBlockStartLine(ymlFilePath, 'test')).toBe(16);
162
+ });
163
+
164
+ it('should find script blocks in collection/folder yml files (request.scripts path)', () => {
165
+ expect(findYmlScriptBlockStartLine(collectionYmlPath, 'pre-request')).toBe(7);
166
+ expect(findYmlScriptBlockStartLine(collectionYmlPath, 'test')).toBe(11);
167
+ });
168
+
169
+ it('should return null for missing block or non-.yml files', () => {
170
+ const noRuntimePath = path.join(testDir, 'no-runtime.yml');
171
+ fs.writeFileSync(noRuntimePath, 'info:\n name: simple\n version: "1"\n');
172
+ expect(findYmlScriptBlockStartLine(noRuntimePath, 'pre-request')).toBeNull();
173
+ });
174
+ });
175
+
176
+ describe('adjustLineNumber', () => {
177
+ it('should adjust QuickJS lines for .bru files', () => {
178
+ // VM line - offset(9) = scriptLine → blockStart + scriptLine - 1
179
+ expect(adjustLineNumber(bruFilePath, 10, true, 'pre-request')).toBe(14);
180
+ expect(adjustLineNumber(bruFilePath, 11, true, 'post-response')).toBe(20);
181
+ expect(adjustLineNumber(bruFilePath, 10, true, 'test')).toBe(25);
182
+ });
183
+
184
+ it('should adjust NodeVM lines for .bru files', () => {
185
+ // VM line 4 - offset(2) = scriptLine 2 → blockStart(19) + 2 - 1 = 20
186
+ expect(adjustLineNumber(bruFilePath, 4, false, 'post-response')).toBe(20);
187
+ });
188
+
189
+ it('should adjust lines for .yml files', () => {
190
+ expect(adjustLineNumber(ymlFilePath, 10, true, 'pre-request')).toBe(8);
191
+ expect(adjustLineNumber(ymlFilePath, 11, true, 'post-response')).toBe(13);
192
+ expect(adjustLineNumber(ymlFilePath, 4, false, 'post-response')).toBe(13);
193
+ });
194
+
195
+ it('should adjust lines correctly when script has comments', () => {
196
+ // VM line 12 - offset(9) = scriptLine 3 → blockStart(12) + 3 - 1 = 14
197
+ expect(adjustLineNumber(bruWithCommentsPath, 12, true, 'post-response')).toBe(14);
198
+ });
199
+
200
+ it('should return reportedLine for non-.bru/.yml files or invalid offset', () => {
201
+ expect(adjustLineNumber('/some/file.js', 10, true, 'post-response')).toBe(10);
202
+ // VM line 5 - offset(9) = -4, which is < 1
203
+ expect(adjustLineNumber(bruFilePath, 5, true, 'post-response')).toBe(5);
204
+ });
205
+
206
+ it('should use metadata for combined scripts', () => {
207
+ // scriptLine 5 within request range [5, 7] → blockStart(19) + (5-5) - 1 = 18
208
+ const metadata = { requestStartLine: 5, requestEndLine: 7 };
209
+ expect(adjustLineNumber(bruFilePath, 14, true, 'post-response', null, metadata)).toBe(18);
210
+ });
211
+
212
+ it('should return null for collection/folder segment errors', () => {
213
+ // scriptLine 3 is before requestStartLine(10) → cannot map to request file
214
+ const metadata = { requestStartLine: 10, requestEndLine: 15 };
215
+ expect(adjustLineNumber(bruFilePath, 12, true, 'post-response', null, metadata)).toBeNull();
216
+ });
217
+
218
+ it('should return null when request segment is empty', () => {
219
+ // requestStartLine: 0 indicates the request segment was empty
220
+ const metadata = { requestStartLine: 0, requestEndLine: 0 };
221
+ expect(adjustLineNumber(bruFilePath, 12, true, 'post-response', null, metadata)).toBeNull();
222
+ });
223
+ });
224
+
225
+ describe('parseStackTrace', () => {
226
+ it('should detect QuickJS stack frame formats', () => {
227
+ expect(parseStackTrace('Error: test\n at (/path/file.bru:11)'))
228
+ .toMatchObject({ filePath: '/path/file.bru', line: 11, isQuickJS: true });
229
+ expect(parseStackTrace('Error: test\n at <anonymous> (/path/file.bru:11)'))
230
+ .toMatchObject({ filePath: '/path/file.bru', line: 11, isQuickJS: true });
231
+ expect(parseStackTrace('Error: test\n at <eval> (/path/file.bru:11:5)'))
232
+ .toMatchObject({ filePath: '/path/file.bru', line: 11, column: 5, isQuickJS: true });
233
+ });
234
+
235
+ it('should detect NodeVM stack frame formats', () => {
236
+ expect(parseStackTrace('Error: test\n at /path/file.js:10:5'))
237
+ .toMatchObject({ filePath: '/path/file.js', line: 10, column: 5, isQuickJS: false });
238
+ expect(parseStackTrace('Error: test\n at Object.<anonymous> (/path/file.js:10:5)'))
239
+ .toMatchObject({ filePath: '/path/file.js', line: 10, column: 5, isQuickJS: false });
240
+ });
241
+
242
+ it('should return null for unparseable or null input', () => {
243
+ expect(parseStackTrace('just a plain string')).toBeNull();
244
+ expect(parseStackTrace(null)).toBeNull();
245
+ });
246
+ });
247
+
248
+ describe('formatErrorWithContext', () => {
249
+ it('should format error with arrow pointing at the correct line', () => {
250
+ const error = new Error('data is not defined');
251
+ error.name = 'ReferenceError';
252
+ error.stack = `ReferenceError: data is not defined\n at (${bruFilePath}:10)`;
253
+
254
+ const formatted = formatErrorWithContext(error, 'test.bru', 'post-response');
255
+ expect(formatted).toContain('ReferenceError: data is not defined');
256
+
257
+ const arrowLine = formatted.split('\n').find((l) => l.startsWith('>'));
258
+ expect(arrowLine).toContain('const data = res.body;');
259
+ });
260
+
261
+ it('should show original error type for wrapped QuickJS errors', () => {
262
+ const error = new Error('x is not defined');
263
+ error.name = 'QuickJSUnwrapError';
264
+ error.cause = { name: 'ReferenceError', message: 'x is not defined' };
265
+ error.stack = `QuickJSUnwrapError: x is not defined\n at (${bruFilePath}:10)`;
266
+
267
+ const formatted = formatErrorWithContext(error, 'test.bru', 'post-response');
268
+ expect(formatted).toContain('ReferenceError:');
269
+ expect(formatted).not.toContain('QuickJSUnwrapError');
270
+ });
271
+
272
+ it('should use __callSites and adjust line numbers in stack', () => {
273
+ const error = new Error('data is not defined');
274
+ error.name = 'ReferenceError';
275
+ error.stack = `ReferenceError: data is not defined\n at ${bruFilePath}:4:5`;
276
+ error.__callSites = [{ filePath: bruFilePath, line: 4, column: 5, functionName: null }];
277
+
278
+ const formatted = formatErrorWithContext(error, 'test.bru', 'post-response');
279
+ // VM line 4 → file line 20
280
+ expect(formatted).toContain(`${bruFilePath}:20:5`);
281
+
282
+ const arrowLine = formatted.split('\n').find((l) => l.startsWith('>'));
283
+ expect(arrowLine).toContain('bru.setVar');
284
+ });
285
+
286
+ it('should show message-only output for collection/folder script errors', () => {
287
+ const error = new Error('x is not defined');
288
+ error.name = 'ReferenceError';
289
+ // scriptLine 3 (VM 12 - offset 9) is before requestStartLine(10)
290
+ error.stack = `ReferenceError: x is not defined\n at (${bruFilePath}:12)`;
291
+
292
+ const metadata = { requestStartLine: 10, requestEndLine: 15 };
293
+ const formatted = formatErrorWithContext(error, 'test.bru', 'post-response', 5, metadata);
294
+
295
+ expect(formatted).toContain('ReferenceError: x is not defined');
296
+ // Should NOT show source context from the request file
297
+ expect(formatted).not.toContain('File:');
298
+ expect(formatted).not.toContain('meta {');
299
+ expect(formatted).not.toContain('>');
300
+ });
301
+
302
+ it('should show source context from collection.bru when segments are provided', () => {
303
+ const collectionBruPath = path.join(testDir, 'collection.bru');
304
+ fs.writeFileSync(collectionBruPath, 'meta {\n name: My Collection\n}\n\nscript:pre-request {\n const x = undefined;\n x.foo();\n}');
305
+
306
+ const error = new Error('Cannot read properties of undefined');
307
+ error.name = 'TypeError';
308
+ // NodeVM offset=2, scriptRelativeLine = 5-2 = 3 → line 3 of wrapped segment = x.foo()
309
+ error.stack = `TypeError: Cannot read properties of undefined\n at ${bruFilePath}:5:5`;
310
+
311
+ // Collection segment is lines 1-4 in combined script (3-line wrap of 2-line script)
312
+ const metadata = {
313
+ requestStartLine: 0,
314
+ requestEndLine: 0,
315
+ segments: [
316
+ { startLine: 1, endLine: 4, filePath: collectionBruPath, displayPath: 'collection.bru' }
317
+ ]
318
+ };
319
+
320
+ const formatted = formatErrorWithContext(error, 'test.bru', 'pre-request', 5, metadata);
321
+ expect(formatted).toContain('File: collection.bru');
322
+ expect(formatted).toContain('x.foo()');
323
+ expect(formatted).toContain('TypeError: Cannot read properties of undefined');
324
+ const arrowLine = formatted.split('\n').find((l) => l.startsWith('>'));
325
+ expect(arrowLine).toContain('x.foo()');
326
+ });
327
+
328
+ it('should resolve error to correct folder when multiple segments exist', () => {
329
+ const folder1Dir = path.join(testDir, 'folder1');
330
+ const folder2Dir = path.join(testDir, 'folder2');
331
+ fs.mkdirSync(folder1Dir);
332
+ fs.mkdirSync(folder2Dir);
333
+
334
+ const folder1Bru = path.join(folder1Dir, 'folder.bru');
335
+ const folder2Bru = path.join(folder2Dir, 'folder.bru');
336
+ fs.writeFileSync(folder1Bru, 'meta {\n name: Folder1\n}\n\nscript:pre-request {\n let a = 1;\n}');
337
+ fs.writeFileSync(folder2Bru, 'meta {\n name: Folder2\n}\n\nscript:pre-request {\n let b = undefined;\n b.pop();\n}');
338
+
339
+ const error = new Error('Cannot read properties of undefined');
340
+ error.name = 'TypeError';
341
+ // NodeVM offset=2, scriptRelativeLine = 9-2 = 7, falls in folder2 segment [5,7]
342
+ error.stack = `TypeError: Cannot read properties of undefined\n at ${bruFilePath}:9:5`;
343
+
344
+ const metadata = {
345
+ requestStartLine: 0,
346
+ requestEndLine: 0,
347
+ segments: [
348
+ { startLine: 1, endLine: 3, filePath: folder1Bru, displayPath: 'folder1/folder.bru' },
349
+ { startLine: 5, endLine: 7, filePath: folder2Bru, displayPath: 'folder2/folder.bru' }
350
+ ]
351
+ };
352
+
353
+ const formatted = formatErrorWithContext(error, 'test.bru', 'pre-request', 5, metadata);
354
+ expect(formatted).toContain('File: folder2/folder.bru');
355
+ expect(formatted).toContain('b.pop()');
356
+ });
357
+
358
+ it('should resolve collection yml segment errors to opencollection.yml', () => {
359
+ const error = new Error('\'fc\' is not defined');
360
+ error.name = 'ReferenceError';
361
+ error.__isQuickJS = true;
362
+ // QuickJS offset=9, scriptRelativeLine = 11-9 = 2 → falls in collection segment [1,4]
363
+ error.stack = `ReferenceError: 'fc' is not defined\n at <anonymous> (${ymlFilePath}:11)`;
364
+
365
+ const metadata = {
366
+ requestStartLine: 6,
367
+ requestEndLine: 8,
368
+ segments: [
369
+ { startLine: 1, endLine: 4, filePath: collectionYmlPath, displayPath: 'opencollection.yml' }
370
+ ]
371
+ };
372
+
373
+ const formatted = formatErrorWithContext(error, 'test.yml', 'pre-request', 5, metadata);
374
+ expect(formatted).toContain('File: opencollection.yml');
375
+ expect(formatted).toContain('\'fc\' is not defined');
376
+ const arrowLine = formatted.split('\n').find((l) => l.startsWith('>'));
377
+ expect(arrowLine).toContain('fc()');
378
+ });
379
+
380
+ it('should handle edge cases gracefully', () => {
381
+ expect(formatErrorWithContext(null)).toBe('');
382
+
383
+ const error = new Error('Test error');
384
+ error.stack = 'Invalid stack trace';
385
+ expect(formatErrorWithContext(error)).toContain('Test error');
386
+ });
387
+ });
388
+ });
@@ -0,0 +1,64 @@
1
+ // Sandbox script wrapping utilities for Node VM and QuickJS.
2
+ // Line offsets are computed from the prefix strings so error-formatter.js can map
3
+ // VM-reported line numbers back to the original .bru/.yml source lines.
4
+
5
+ const SANDBOX = Object.freeze({
6
+ NODEVM: 'nodevm',
7
+ QUICKJS: 'quickjs'
8
+ });
9
+
10
+ // -- Node VM --
11
+
12
+ const NODEVM_SCRIPT_PREFIX = `
13
+ (async function(){
14
+ `;
15
+
16
+ const NODEVM_SCRIPT_SUFFIX = `
17
+ })();
18
+ `;
19
+
20
+ // -- QuickJS --
21
+
22
+ const QUICKJS_SCRIPT_PREFIX = `
23
+ (async () => {
24
+ const setTimeout = async(fn, timer) => {
25
+ v = await bru.sleep(timer);
26
+ fn.apply();
27
+ }
28
+
29
+ await bru.sleep(0);
30
+ try {
31
+ `;
32
+
33
+ const QUICKJS_SCRIPT_SUFFIX = `
34
+ }
35
+ catch(error) {
36
+ throw error;
37
+ }
38
+ return 'done';
39
+ })()
40
+ `;
41
+
42
+ // Computed offsets — number of newlines before user script in each wrapper
43
+ const NODEVM_SCRIPT_WRAPPER_OFFSET = NODEVM_SCRIPT_PREFIX.split('\n').length - 1;
44
+ const QUICKJS_SCRIPT_WRAPPER_OFFSET = QUICKJS_SCRIPT_PREFIX.split('\n').length - 1;
45
+
46
+ /**
47
+ * Wraps a script in the appropriate sandbox closure.
48
+ * @param {string} script - The script code to wrap
49
+ * @param {'nodevm'|'quickjs'} sandbox - The sandbox runtime to wrap for
50
+ * @returns {string} The wrapped script
51
+ */
52
+ const wrapScriptInClosure = (script, sandbox) => {
53
+ if (sandbox === SANDBOX.QUICKJS) {
54
+ return QUICKJS_SCRIPT_PREFIX + script + QUICKJS_SCRIPT_SUFFIX;
55
+ }
56
+ return NODEVM_SCRIPT_PREFIX + script + NODEVM_SCRIPT_SUFFIX;
57
+ };
58
+
59
+ module.exports = {
60
+ SANDBOX,
61
+ wrapScriptInClosure,
62
+ NODEVM_SCRIPT_WRAPPER_OFFSET,
63
+ QUICKJS_SCRIPT_WRAPPER_OFFSET
64
+ };
package/src/utils.js CHANGED
@@ -162,13 +162,34 @@ const cleanJson = (data) => {
162
162
  ].filter(Boolean);
163
163
  const binaryNames = typedArrays.map((d) => d.name);
164
164
 
165
+ const seen = new WeakSet();
166
+
165
167
  const replacer = (key, value) => {
166
- const isBinary = typedArrays.find((d) => value instanceof d);
167
- if (isBinary) {
168
- return {
169
- __cleanJSONType: isBinary.name,
170
- __cleanJSONValue: Buffer.from(value.buffer).toJSON()
171
- };
168
+ if (typeof value === 'object' && value !== null) {
169
+ if (seen.has(value)) {
170
+ return '[Circular Reference]';
171
+ }
172
+ seen.add(value);
173
+
174
+ // instanceof + [[Class]] cover same-realm; duck-type fallback for cross-realm/cross-context Error-like objects
175
+ if (value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' || (typeof value.message === 'string' && typeof value.stack === 'string')) {
176
+ const error = {};
177
+ // name/message are often on prototype; ensure they're in the output
178
+ error.name = value.name;
179
+ error.message = value.message;
180
+ Object.getOwnPropertyNames(value).forEach((prop) => {
181
+ error[prop] = value[prop];
182
+ });
183
+ return error;
184
+ }
185
+
186
+ const isBinary = typedArrays.find((d) => value instanceof d);
187
+ if (isBinary) {
188
+ return {
189
+ __cleanJSONType: isBinary.name,
190
+ __cleanJSONValue: Buffer.from(value.buffer).toJSON()
191
+ };
192
+ }
172
193
  }
173
194
  return value;
174
195
  };