@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,426 @@
1
+ const fs = require('fs');
2
+ const YAML = require('yaml');
3
+ const { NODEVM_SCRIPT_WRAPPER_OFFSET, QUICKJS_SCRIPT_WRAPPER_OFFSET } = require('./sandbox');
4
+
5
+ const DEFAULT_CONTEXT_LINES = 5;
6
+ const ALLOWED_SOURCE_EXTENSIONS = ['.bru', '.yml', '.yaml'];
7
+
8
+ const isAllowedSourceFile = (filePath) =>
9
+ typeof filePath === 'string' && ALLOWED_SOURCE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
10
+
11
+ const SCRIPT_TYPES = Object.freeze({
12
+ PRE_REQUEST: 'pre-request',
13
+ POST_RESPONSE: 'post-response',
14
+ TEST: 'test'
15
+ });
16
+
17
+ // Bruno script types → OpenCollection YAML script types
18
+ const SCRIPT_TYPE_TO_YML = {
19
+ [SCRIPT_TYPES.PRE_REQUEST]: 'before-request',
20
+ [SCRIPT_TYPES.POST_RESPONSE]: 'after-response',
21
+ [SCRIPT_TYPES.TEST]: 'tests'
22
+ };
23
+
24
+ const readFile = (filePath, cache = null) => {
25
+ if (cache?.has(filePath)) return cache.get(filePath);
26
+ try {
27
+ const content = fs.readFileSync(filePath, 'utf-8').replace(/\r\n/g, '\n');
28
+ if (cache) cache.set(filePath, content);
29
+ return content;
30
+ } catch {
31
+ return null;
32
+ }
33
+ };
34
+
35
+ const BLOCK_PATTERNS = {
36
+ [SCRIPT_TYPES.PRE_REQUEST]: /^script:pre-request\s*\{/,
37
+ [SCRIPT_TYPES.POST_RESPONSE]: /^script:post-response\s*\{/,
38
+ [SCRIPT_TYPES.TEST]: /^tests\s*\{/
39
+ };
40
+
41
+ /** Find the 1-indexed line where a script block's content starts in a .bru file */
42
+ const findScriptBlockStartLine = (filePath, scriptType, cache = null) => {
43
+ if (!filePath.endsWith('.bru')) return null;
44
+
45
+ const cacheKey = `bru:${filePath}:${scriptType}`;
46
+ if (cache?.has(cacheKey)) return cache.get(cacheKey);
47
+
48
+ const content = readFile(filePath, cache);
49
+ if (!content) return null;
50
+
51
+ const pattern = BLOCK_PATTERNS[scriptType];
52
+ if (!pattern) return null;
53
+
54
+ const lines = content.split('\n');
55
+ let result = null;
56
+ for (let i = 0; i < lines.length; i++) {
57
+ if (pattern.test(lines[i])) {
58
+ result = i + 2; // +1 for 1-indexing, +1 for line after opening brace
59
+ break;
60
+ }
61
+ }
62
+
63
+ if (cache) cache.set(cacheKey, result);
64
+ return result;
65
+ };
66
+
67
+ /** Find the 1-indexed line where a script block's content starts in a .yml file */
68
+ const findYmlScriptBlockStartLine = (filePath, scriptType, cache = null) => {
69
+ if (!filePath.endsWith('.yml') && !filePath.endsWith('.yaml')) return null;
70
+
71
+ const cacheKey = `yml:${filePath}:${scriptType}`;
72
+ if (cache?.has(cacheKey)) return cache.get(cacheKey);
73
+
74
+ const content = readFile(filePath, cache);
75
+ if (!content) return null;
76
+
77
+ const ymlType = SCRIPT_TYPE_TO_YML[scriptType];
78
+ if (!ymlType) return null;
79
+
80
+ let result = null;
81
+ try {
82
+ const lineCounter = new YAML.LineCounter();
83
+ const doc = YAML.parseDocument(content, { lineCounter });
84
+
85
+ // Request yml files use runtime.scripts, collection/folder yml files use request.scripts
86
+ const scriptPaths = [['runtime', 'scripts'], ['request', 'scripts']];
87
+ for (const scriptPath of scriptPaths) {
88
+ const scripts = doc.getIn(scriptPath, true);
89
+ if (YAML.isSeq(scripts)) {
90
+ for (const item of scripts.items) {
91
+ if (!YAML.isMap(item)) continue;
92
+ if (item.get('type') === ymlType) {
93
+ const codeNode = item.get('code', true);
94
+ if (codeNode && codeNode.range) {
95
+ result = lineCounter.linePos(codeNode.range[0]).line + 1;
96
+ break;
97
+ }
98
+ }
99
+ }
100
+ if (result) break;
101
+ }
102
+ }
103
+ } catch {
104
+ // invalid YAML
105
+ }
106
+
107
+ if (cache) cache.set(cacheKey, result);
108
+ return result;
109
+ };
110
+
111
+ /** Adjust a runtime-reported line number to the actual line in the .bru/.yml file */
112
+ const adjustLineNumber = (filePath, reportedLine, isQuickJS, scriptType = null, cache = null, scriptMetadata = null) => {
113
+ const isBruFile = filePath.endsWith('.bru');
114
+ const isYmlFile = filePath.endsWith('.yml') || filePath.endsWith('.yaml');
115
+
116
+ if (!isBruFile && !isYmlFile) {
117
+ return reportedLine;
118
+ }
119
+
120
+ const wrapperOffset = isQuickJS ? QUICKJS_SCRIPT_WRAPPER_OFFSET : NODEVM_SCRIPT_WRAPPER_OFFSET;
121
+ const scriptRelativeLine = reportedLine - wrapperOffset;
122
+
123
+ if (scriptRelativeLine < 1) return reportedLine;
124
+
125
+ // Use metadata if available to correctly map line numbers in combined scripts
126
+ if (scriptType && scriptMetadata) {
127
+ const { requestStartLine, requestEndLine } = scriptMetadata;
128
+ if (requestStartLine != null && requestEndLine != null) {
129
+ if (scriptRelativeLine >= requestStartLine && scriptRelativeLine <= requestEndLine) {
130
+ // Error is within the request script segment
131
+ const blockStartLine = isBruFile
132
+ ? findScriptBlockStartLine(filePath, scriptType, cache)
133
+ : findYmlScriptBlockStartLine(filePath, scriptType, cache);
134
+
135
+ if (blockStartLine) {
136
+ return blockStartLine + (scriptRelativeLine - requestStartLine) - 1;
137
+ }
138
+ } else {
139
+ // Error is in a collection/folder-level script
140
+ // Cannot map to the request .bru/.yml file, return null to skip source context.
141
+ return null;
142
+ }
143
+ }
144
+ }
145
+
146
+ // No segment metadata, map script-relative line to file line via block start.
147
+ if (scriptType) {
148
+ const blockStartLine = isBruFile
149
+ ? findScriptBlockStartLine(filePath, scriptType, cache)
150
+ : findYmlScriptBlockStartLine(filePath, scriptType, cache);
151
+
152
+ if (blockStartLine) {
153
+ return blockStartLine + scriptRelativeLine - 1;
154
+ }
155
+ }
156
+
157
+ return scriptRelativeLine;
158
+ };
159
+
160
+ /**
161
+ * Resolve an error in a collection/folder script segment to its source file and line.
162
+ * Uses the segments array in metadata to find which segment the error falls in,
163
+ * then maps to the actual line in that segment's source file.
164
+ */
165
+ const resolveSegmentError = (parsed, metadata, scriptType, cache) => {
166
+ if (!metadata?.segments?.length || !parsed) return null;
167
+
168
+ const wrapperOffset = parsed.isQuickJS ? QUICKJS_SCRIPT_WRAPPER_OFFSET : NODEVM_SCRIPT_WRAPPER_OFFSET;
169
+ const scriptRelativeLine = parsed.line - wrapperOffset;
170
+ if (scriptRelativeLine < 1) return null;
171
+
172
+ for (const segment of metadata.segments) {
173
+ if (scriptRelativeLine >= segment.startLine && scriptRelativeLine <= segment.endLine) {
174
+ const isBru = segment.filePath.endsWith('.bru');
175
+ const isYml = segment.filePath.endsWith('.yml') || segment.filePath.endsWith('.yaml');
176
+ if (!isBru && !isYml) return null;
177
+
178
+ const blockStartLine = isBru
179
+ ? findScriptBlockStartLine(segment.filePath, scriptType, cache)
180
+ : findYmlScriptBlockStartLine(segment.filePath, scriptType, cache);
181
+ if (!blockStartLine) return null;
182
+
183
+ return {
184
+ line: blockStartLine + (scriptRelativeLine - segment.startLine) - 1,
185
+ filePath: segment.filePath,
186
+ displayPath: segment.displayPath
187
+ };
188
+ }
189
+ }
190
+ return null;
191
+ };
192
+
193
+ /** Extract file path, line, column, and runtime type from a single stack trace line */
194
+ const matchStackFrame = (line) => {
195
+ // QuickJS: "at (/path/to/file.bru:11)" or "at <anonymous> (/path/to/file.bru:11)"
196
+ const quickjsMatch = line.match(/at (?:<[^>]+>\s*)?\(((?:[A-Za-z]:)?[^:]+):(\d+)(?::(\d+))?\)/);
197
+ if (quickjsMatch && (quickjsMatch[1].includes('/') || quickjsMatch[1].includes('\\'))) {
198
+ return {
199
+ filePath: quickjsMatch[1],
200
+ line: parseInt(quickjsMatch[2], 10),
201
+ column: quickjsMatch[3] ? parseInt(quickjsMatch[3], 10) : null,
202
+ isQuickJS: true
203
+ };
204
+ }
205
+
206
+ // Node VM: "at /path/to/file.bru:11:5" or "at Object.<anonymous> (/path/to/file.bru:11:5)"
207
+ const nodeMatch = line.match(/at (?:.*?\()?((?:[A-Za-z]:)?[^:]+):(\d+)(?::(\d+))?\)?/);
208
+ if (nodeMatch && (nodeMatch[1].includes('/') || nodeMatch[1].includes('\\'))) {
209
+ return {
210
+ filePath: nodeMatch[1],
211
+ line: parseInt(nodeMatch[2], 10),
212
+ column: nodeMatch[3] ? parseInt(nodeMatch[3], 10) : null,
213
+ isQuickJS: false
214
+ };
215
+ }
216
+
217
+ return null;
218
+ };
219
+
220
+ /** Parse the first stack frame to extract file path, line, and column */
221
+ const parseStackTrace = (stack) => {
222
+ if (!stack) return null;
223
+
224
+ for (const line of stack.split('\n')) {
225
+ const match = matchStackFrame(line);
226
+ if (match) return match;
227
+ }
228
+
229
+ return null;
230
+ };
231
+
232
+ const parseErrorLocation = (error) => {
233
+ if (error.__callSites?.length > 0) {
234
+ const first = error.__callSites[0];
235
+ return {
236
+ filePath: first.filePath,
237
+ line: first.line,
238
+ column: first.column,
239
+ isQuickJS: false
240
+ };
241
+ }
242
+
243
+ /* falls back to string parsing */
244
+ const parsed = parseStackTrace(error.stack);
245
+ if (parsed && error.__isQuickJS) {
246
+ parsed.isQuickJS = true;
247
+ }
248
+ return parsed;
249
+ };
250
+
251
+ /** Read source file and extract context lines around the error location */
252
+ const getSourceContext = (filePath, errorLine, contextLines = DEFAULT_CONTEXT_LINES, cache = null) => {
253
+ const content = readFile(filePath, cache);
254
+ if (!content) return null;
255
+
256
+ const lines = content.split('\n');
257
+ const startLine = Math.max(1, errorLine - contextLines);
258
+ const endLine = Math.min(lines.length, errorLine + contextLines);
259
+
260
+ const contextLinesArray = [];
261
+ for (let i = startLine; i <= endLine; i++) {
262
+ contextLinesArray.push({
263
+ lineNumber: i,
264
+ content: lines[i - 1],
265
+ isError: i === errorLine
266
+ });
267
+ }
268
+
269
+ return { lines: contextLinesArray, startLine, errorLine };
270
+ };
271
+
272
+ /** Build adjusted stack trace string from structured CallSite data */
273
+ const buildStackFromCallSites = (callSites, scriptType = null, cache = null, scriptMetadata = null) => {
274
+ return callSites.map((site) => {
275
+ const adjusted = adjustLineNumber(site.filePath, site.line, false, scriptType, cache, scriptMetadata);
276
+ let fileToUse = site.filePath;
277
+ let lineToUse = adjusted !== null ? adjusted : site.line;
278
+
279
+ // Try segment resolution for collection/folder frames
280
+ if (adjusted === null && scriptMetadata?.segments) {
281
+ const parsed = { line: site.line, isQuickJS: false };
282
+ const resolved = resolveSegmentError(parsed, scriptMetadata, scriptType, cache);
283
+ if (resolved) {
284
+ fileToUse = resolved.filePath;
285
+ lineToUse = resolved.line;
286
+ }
287
+ }
288
+
289
+ const loc = site.column ? `${fileToUse}:${lineToUse}:${site.column}` : `${fileToUse}:${lineToUse}`;
290
+ const name = site.functionName ? `${site.functionName} (${loc})` : loc;
291
+ return ` at ${name}`;
292
+ }).join('\n');
293
+ };
294
+
295
+ /** Adjust all line numbers in a stack trace string */
296
+ const adjustStackTrace = (stack, scriptType = null, cache = null, scriptMetadata = null, forceQuickJS = false) => {
297
+ if (!stack) return stack;
298
+
299
+ return stack.split('\n').map((line) => {
300
+ const match = matchStackFrame(line);
301
+ if (!match) return line;
302
+
303
+ const isQuickJS = forceQuickJS || match.isQuickJS;
304
+ const adjusted = adjustLineNumber(match.filePath, match.line, isQuickJS, scriptType, cache, scriptMetadata);
305
+
306
+ // Try segment resolution for collection/folder frames
307
+ if (adjusted === null && scriptMetadata?.segments) {
308
+ const parsed = { line: match.line, isQuickJS };
309
+ const resolved = resolveSegmentError(parsed, scriptMetadata, scriptType, cache);
310
+ if (resolved) {
311
+ const suffix = match.isQuickJS ? ')' : '';
312
+ return match.column !== null
313
+ ? line.replace(`${match.filePath}:${match.line}:${match.column}${suffix}`, `${resolved.filePath}:${resolved.line}:${match.column}${suffix}`)
314
+ : line.replace(`${match.filePath}:${match.line}${suffix}`, `${resolved.filePath}:${resolved.line}${suffix}`);
315
+ }
316
+ return line;
317
+ }
318
+
319
+ if (adjusted === null || adjusted === match.line) return line;
320
+
321
+ const suffix = match.isQuickJS ? ')' : '';
322
+ return match.column !== null
323
+ ? line.replace(`:${match.line}:${match.column}${suffix}`, `:${adjusted}:${match.column}${suffix}`)
324
+ : line.replace(`:${match.line}${suffix}`, `:${adjusted}${suffix}`);
325
+ }).join('\n');
326
+ };
327
+
328
+ /** Resolve original error name from wrapped errors (QuickJS cause / Node VM ScriptError) */
329
+ const getErrorTypeName = (error) => {
330
+ return error.cause?.name || error.originalError?.name || error.name || error.constructor?.name || 'Error';
331
+ };
332
+
333
+ /** Format an error with source context and adjusted line numbers */
334
+ const formatErrorWithContext = (error, relativeFilePath = null, scriptType = null, contextLines = DEFAULT_CONTEXT_LINES, scriptMetadata = null) => {
335
+ if (!error) return '';
336
+
337
+ const cache = new Map();
338
+ // Use metadata from error object if available, otherwise use passed parameter
339
+ const metadata = error.scriptMetadata || scriptMetadata;
340
+
341
+ const parsed = parseErrorLocation(error);
342
+ if (!parsed) {
343
+ return `${error.message}\n${error.stack || ''}`;
344
+ }
345
+
346
+ const { filePath } = parsed;
347
+ const adjustedLine = adjustLineNumber(filePath, parsed.line, parsed.isQuickJS, scriptType, cache, metadata);
348
+
349
+ // adjustedLine === null means the error is in a collection/folder script
350
+ // resolve to the collection/folder source file using segment metadata
351
+ let segmentResult = null;
352
+ if (adjustedLine === null) {
353
+ segmentResult = resolveSegmentError(parsed, metadata, scriptType, cache);
354
+ if (!segmentResult) {
355
+ // Fallback: no segment resolution possible, show message + stack only
356
+ const errorType = getErrorTypeName(error);
357
+ const parts = [`${errorType}: ${error.message}`];
358
+ if (error.__callSites?.length > 0) {
359
+ parts.push(buildStackFromCallSites(error.__callSites, scriptType, cache, metadata));
360
+ } else if (error.stack) {
361
+ const stackLines = error.stack.split('\n').slice(1);
362
+ for (const stackLine of stackLines) {
363
+ parts.push(` ${stackLine.trim()}`);
364
+ }
365
+ }
366
+ return parts.join('\n');
367
+ }
368
+ }
369
+
370
+ const sourceFile = segmentResult ? segmentResult.filePath : filePath;
371
+ const sourceLine = segmentResult ? segmentResult.line : adjustedLine;
372
+ const context = isAllowedSourceFile(sourceFile) ? getSourceContext(sourceFile, sourceLine, contextLines, cache) : null;
373
+
374
+ if (!context) {
375
+ return `${error.message}\n${error.stack || ''}`;
376
+ }
377
+
378
+ const displayPath = segmentResult ? segmentResult.displayPath : (relativeFilePath || filePath);
379
+ const lines = [];
380
+
381
+ lines.push(`File: ${displayPath}`);
382
+ lines.push('');
383
+
384
+ const maxLineNumber = context.lines[context.lines.length - 1].lineNumber;
385
+ const lineNumberWidth = String(maxLineNumber).length;
386
+
387
+ for (const lineInfo of context.lines) {
388
+ const lineNum = String(lineInfo.lineNumber).padStart(lineNumberWidth, ' ');
389
+ const prefix = lineInfo.isError ? '>' : ' ';
390
+
391
+ lines.push(`${prefix} ${lineNum} | ${lineInfo.content}`);
392
+ }
393
+
394
+ lines.push('');
395
+
396
+ const errorType = getErrorTypeName(error);
397
+ lines.push(`${errorType}: ${error.message}`);
398
+
399
+ if (error.__callSites?.length > 0) {
400
+ lines.push(buildStackFromCallSites(error.__callSites, scriptType, cache, metadata));
401
+ } else {
402
+ const stackToDisplay = adjustStackTrace(error.stack, scriptType, cache, metadata, parsed.isQuickJS);
403
+ const userStackLines = stackToDisplay.split('\n').slice(1);
404
+ for (const stackLine of userStackLines) {
405
+ lines.push(` ${stackLine.trim()}`);
406
+ }
407
+ }
408
+
409
+ return lines.join('\n');
410
+ };
411
+
412
+ module.exports = {
413
+ SCRIPT_TYPES,
414
+ DEFAULT_CONTEXT_LINES,
415
+ parseStackTrace,
416
+ parseErrorLocation,
417
+ buildStackFromCallSites,
418
+ getSourceContext,
419
+ formatErrorWithContext,
420
+ adjustLineNumber,
421
+ resolveSegmentError,
422
+ findScriptBlockStartLine,
423
+ findYmlScriptBlockStartLine,
424
+ adjustStackTrace,
425
+ getErrorTypeName
426
+ };