@usebruno/js 0.46.0 → 0.47.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 +8 -7
- package/src/bru.js +173 -75
- package/src/bruno-request.js +2 -0
- package/src/cookie-list.js +272 -0
- package/src/index.js +27 -2
- package/src/property-list.js +184 -0
- package/src/readonly-property-list.js +227 -0
- package/src/runtime/assert-runtime.js +8 -10
- package/src/runtime/script-runtime.js +55 -6
- package/src/runtime/test-runtime.js +19 -1
- package/src/runtime/vars-runtime.js +20 -3
- package/src/sandbox/bundle-libraries.js +1 -1
- package/src/sandbox/quickjs/index.js +2 -34
- package/src/sandbox/quickjs/shims/bru.js +31 -1
- package/src/sandbox/quickjs/shims/bruno-response.js +29 -2
- package/src/sandbox/quickjs/shims/bruno-response.spec.js +91 -0
- package/src/sandbox/quickjs/shims/lib/uuid.spec.js +166 -0
- package/src/sandbox/quickjs/shims/require.js +56 -0
- package/src/sandbox/quickjs/shims/require.spec.js +154 -0
- package/src/sandbox/quickjs/shims/test.js +2 -2
- package/src/sandbox/quickjs/utils/property-list-bridge.js +165 -0
- package/src/sandbox/quickjs/utils/test-helpers.js +31 -0
- package/src/utils/error-formatter.js +345 -20
- package/src/utils/error-formatter.spec.js +683 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const { cleanJson, cleanCircularJson } = require('../../../utils');
|
|
2
|
+
const { marshallToVm } = require('../utils');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates an async bridge that resolves with `undefined` (write-only).
|
|
6
|
+
* Do NOT reuse this for read methods that need to return values —
|
|
7
|
+
* those require resolving with the callback's result argument instead.
|
|
8
|
+
*/
|
|
9
|
+
const createAsyncBridge = (vm, targetObj, propName, nativeMethod) => {
|
|
10
|
+
const fn = vm.newFunction(propName, (...vmArgs) => {
|
|
11
|
+
const promise = vm.newPromise();
|
|
12
|
+
const args = vmArgs.map((a) => vm.dump(a));
|
|
13
|
+
nativeMethod(...args, (err) => {
|
|
14
|
+
if (err) {
|
|
15
|
+
promise.reject(marshallToVm(cleanJson(err), vm));
|
|
16
|
+
} else {
|
|
17
|
+
promise.resolve(vm.undefined);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
promise.settled.then(vm.runtime.executePendingJobs);
|
|
21
|
+
return promise.handle;
|
|
22
|
+
});
|
|
23
|
+
fn.consume((handle) => vm.setProp(targetObj, propName, handle));
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Factory that auto-wires PropertyList methods onto a QuickJS VM object.
|
|
28
|
+
*
|
|
29
|
+
* Generates:
|
|
30
|
+
* - Sync read methods: `vm.newFunction` → `marshallToVm(nativeList.method(...args), vm)`
|
|
31
|
+
* - Sync read object methods: same but wrapped with `cleanCircularJson()`
|
|
32
|
+
* - Async write methods: `_prefix` bridge pattern (native callback → QuickJS promise)
|
|
33
|
+
* - Returns `{ evalCode }` string containing `callWithCallback` helper + async wrappers + iterators
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* In shims/bru.js, wiring up bru.cookies takes a single call:
|
|
37
|
+
*
|
|
38
|
+
* const { evalCode: cookiesEvalCode } = createPropertyListBridge(vm, bru.cookies, bruCookiesObject, {
|
|
39
|
+
* globalPath: 'globalThis.bru.cookies',
|
|
40
|
+
* syncReadMethods: ['get', 'has', 'count', 'indexOf', 'toObject', 'toString'],
|
|
41
|
+
* syncReadObjectMethods: ['one', 'all', 'idx', 'toJSON'],
|
|
42
|
+
* asyncWriteMethods: ['add', 'upsert', 'remove', 'clear', 'delete'],
|
|
43
|
+
* withIterators: true
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* Without this factory, each method would require manual boilerplate like the
|
|
47
|
+
* hand-written jar() bridge in bru.js (~100 lines), where every method needs:
|
|
48
|
+
*
|
|
49
|
+
* const _fn = vm.newFunction('_method', (...vmArgs) => {
|
|
50
|
+
* const promise = vm.newPromise();
|
|
51
|
+
* nativeObj.method(vm.dump(vmArgs[0]), (err, result) => {
|
|
52
|
+
* if (err) {
|
|
53
|
+
* promise.reject(marshallToVm(cleanJson(err), vm));
|
|
54
|
+
* } else {
|
|
55
|
+
* promise.resolve(marshallToVm(cleanCircularJson(result), vm));
|
|
56
|
+
* }
|
|
57
|
+
* });
|
|
58
|
+
* promise.settled.then(vm.runtime.executePendingJobs);
|
|
59
|
+
* return promise.handle;
|
|
60
|
+
* });
|
|
61
|
+
* _fn.consume((handle) => vm.setProp(obj, '_method', handle));
|
|
62
|
+
*
|
|
63
|
+
* …repeated for every method, plus separate evalCode for async wrappers.
|
|
64
|
+
*
|
|
65
|
+
* To wire up a new PropertyList-backed object, add one createPropertyListBridge
|
|
66
|
+
* call instead of duplicating all that boilerplate.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} vm - QuickJS VM instance
|
|
69
|
+
* @param {Object} nativeList - Native PropertyList instance
|
|
70
|
+
* @param {Object} targetObj - QuickJS object handle to attach methods to
|
|
71
|
+
* @param {Object} options
|
|
72
|
+
* @param {string} options.globalPath - Global path in QuickJS (e.g. 'globalThis.bru.cookies')
|
|
73
|
+
* @param {string[]} [options.syncReadMethods] - Methods that return primitive values
|
|
74
|
+
* @param {string[]} [options.syncReadObjectMethods] - Methods that return objects (need cleanCircularJson)
|
|
75
|
+
* @param {string[]} [options.asyncWriteMethods] - Async write methods (use _prefix bridge)
|
|
76
|
+
* @param {boolean} [options.withIterators] - Whether to add each/find/filter/map/reduce
|
|
77
|
+
* @returns {{ evalCode: string }} - JavaScript code to eval in the VM for async wrappers and iterators
|
|
78
|
+
*/
|
|
79
|
+
const createPropertyListBridge = (vm, nativeList, targetObj, options) => {
|
|
80
|
+
const {
|
|
81
|
+
globalPath,
|
|
82
|
+
syncReadMethods = [],
|
|
83
|
+
syncReadObjectMethods = [],
|
|
84
|
+
asyncWriteMethods = [],
|
|
85
|
+
withIterators = false
|
|
86
|
+
} = options;
|
|
87
|
+
|
|
88
|
+
// Sync read methods — return primitive values
|
|
89
|
+
for (const methodName of syncReadMethods) {
|
|
90
|
+
const fn = vm.newFunction(methodName, (...vmArgs) => {
|
|
91
|
+
const args = vmArgs.map((a) => vm.dump(a));
|
|
92
|
+
return marshallToVm(nativeList[methodName](...args), vm);
|
|
93
|
+
});
|
|
94
|
+
fn.consume((handle) => vm.setProp(targetObj, methodName, handle));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Sync read object methods — need cleanCircularJson
|
|
98
|
+
for (const methodName of syncReadObjectMethods) {
|
|
99
|
+
const fn = vm.newFunction(methodName, (...vmArgs) => {
|
|
100
|
+
const args = vmArgs.map((a) => vm.dump(a));
|
|
101
|
+
return marshallToVm(cleanCircularJson(nativeList[methodName](...args)), vm);
|
|
102
|
+
});
|
|
103
|
+
fn.consume((handle) => vm.setProp(targetObj, methodName, handle));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Async write methods — two-phase setup:
|
|
107
|
+
// Phase 1 (native): Register `_prefixed` bridge functions (e.g. `_add`, `_remove`) via
|
|
108
|
+
// createAsyncBridge. These are QuickJS promise-based wrappers that call the native method's
|
|
109
|
+
// callback API and resolve with `undefined` (write-only).
|
|
110
|
+
// Phase 2 (evalCode): Generates JS code eval'd in the VM that:
|
|
111
|
+
// 1. Defines a `callWithCallback` helper supporting both `await method(args)` and
|
|
112
|
+
// `method(args, callback)` calling styles.
|
|
113
|
+
// 2. Captures `_prefixed` direct references, then overwrites the public method name with
|
|
114
|
+
// a wrapper that auto-detects whether the last argument is a callback.
|
|
115
|
+
for (const methodName of asyncWriteMethods) {
|
|
116
|
+
createAsyncBridge(vm, targetObj, `_${methodName}`, (...a) => nativeList[methodName](...a));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let evalCode = '';
|
|
120
|
+
|
|
121
|
+
if (asyncWriteMethods.length > 0) {
|
|
122
|
+
evalCode += `const callWithCallback = async (promiseFn, callback) => {
|
|
123
|
+
if (!callback) return await promiseFn();
|
|
124
|
+
try {
|
|
125
|
+
const result = await promiseFn();
|
|
126
|
+
try { await callback(null, result); } catch(cbErr) { return Promise.reject(cbErr); }
|
|
127
|
+
} catch(err) {
|
|
128
|
+
try { await callback(err, null); } catch(cbErr) { return Promise.reject(cbErr); }
|
|
129
|
+
}
|
|
130
|
+
};\n`;
|
|
131
|
+
|
|
132
|
+
// Capture _prefixed direct references before overwriting
|
|
133
|
+
for (const methodName of asyncWriteMethods) {
|
|
134
|
+
evalCode += `const _${methodName}Direct = ${globalPath}._${methodName};\n`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Generate wrapper functions: method(...args, cb?) => callWithCallback(() => _direct(...args), cb)
|
|
138
|
+
for (const methodName of asyncWriteMethods) {
|
|
139
|
+
evalCode += `${globalPath}.${methodName} = (...args) => {
|
|
140
|
+
const cb = typeof args[args.length - 1] === 'function' ? args.pop() : undefined;
|
|
141
|
+
return callWithCallback(() => _${methodName}Direct(...args), cb);
|
|
142
|
+
};\n`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Iterators — these can't be bridged as syncReadObjectMethods because they take a callback
|
|
147
|
+
// function as an argument, and functions can't cross the native↔VM boundary (vm.dump() can't
|
|
148
|
+
// serialize them). Instead, we pull the data into the VM via `all()`, then run the array
|
|
149
|
+
// operation inside the VM where the callback lives. Requires `all` in `syncReadObjectMethods`.
|
|
150
|
+
if (withIterators) {
|
|
151
|
+
evalCode += `const _allNative = ${globalPath}.all;
|
|
152
|
+
${globalPath}.each = (fn) => { _allNative().forEach(fn); };
|
|
153
|
+
${globalPath}.filter = (fn) => _allNative().filter(fn);
|
|
154
|
+
${globalPath}.find = (fn) => _allNative().find(fn);
|
|
155
|
+
${globalPath}.map = (fn) => _allNative().map(fn);
|
|
156
|
+
${globalPath}.reduce = (fn, ...rest) => rest.length ? _allNative().reduce(fn, rest[0]) : _allNative().reduce(fn);\n`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { evalCode };
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
createPropertyListBridge,
|
|
164
|
+
createAsyncBridge
|
|
165
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evaluates code in a QuickJS VM and returns the dumped result.
|
|
3
|
+
* Handles unwrapping and disposing of handles automatically.
|
|
4
|
+
*
|
|
5
|
+
* @param {Object} vm - QuickJS VM context
|
|
6
|
+
* @param {string} code - JavaScript code to evaluate
|
|
7
|
+
* @returns {*} The evaluated and dumped result
|
|
8
|
+
*/
|
|
9
|
+
function evalAndDump(vm, code) {
|
|
10
|
+
const result = vm.evalCode(code);
|
|
11
|
+
const handle = vm.unwrapResult(result);
|
|
12
|
+
const value = vm.dump(handle);
|
|
13
|
+
handle.dispose();
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a helper function bound to a specific VM instance.
|
|
19
|
+
* Useful in beforeEach to create a test-scoped helper.
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} vm - QuickJS VM context
|
|
22
|
+
* @returns {Function} evalAndDump function bound to the VM
|
|
23
|
+
*/
|
|
24
|
+
function createEvalHelper(vm) {
|
|
25
|
+
return (code) => evalAndDump(vm, code);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
evalAndDump,
|
|
30
|
+
createEvalHelper
|
|
31
|
+
};
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
2
3
|
const YAML = require('yaml');
|
|
3
4
|
const { NODEVM_SCRIPT_WRAPPER_OFFSET, QUICKJS_SCRIPT_WRAPPER_OFFSET } = require('./sandbox');
|
|
4
5
|
|
|
6
|
+
const posixifyPath = (p) => (p ? p.replace(/\\/g, '/') : p);
|
|
7
|
+
|
|
5
8
|
const DEFAULT_CONTEXT_LINES = 5;
|
|
6
|
-
const ALLOWED_SOURCE_EXTENSIONS = ['.bru', '.yml'
|
|
9
|
+
const ALLOWED_SOURCE_EXTENSIONS = ['.bru', '.yml'];
|
|
7
10
|
|
|
8
11
|
const isAllowedSourceFile = (filePath) =>
|
|
9
12
|
typeof filePath === 'string' && ALLOWED_SOURCE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
|
|
@@ -64,9 +67,45 @@ const findScriptBlockStartLine = (filePath, scriptType, cache = null) => {
|
|
|
64
67
|
return result;
|
|
65
68
|
};
|
|
66
69
|
|
|
70
|
+
/** Find the 1-indexed last content line of a script block in a .bru file (excludes closing }) */
|
|
71
|
+
const findScriptBlockEndLine = (filePath, scriptType, cache = null) => {
|
|
72
|
+
if (!filePath.endsWith('.bru')) return null;
|
|
73
|
+
|
|
74
|
+
const cacheKey = `bru-end:${filePath}:${scriptType}`;
|
|
75
|
+
if (cache?.has(cacheKey)) return cache.get(cacheKey);
|
|
76
|
+
|
|
77
|
+
const content = readFile(filePath, cache);
|
|
78
|
+
if (!content) return null;
|
|
79
|
+
|
|
80
|
+
const pattern = BLOCK_PATTERNS[scriptType];
|
|
81
|
+
if (!pattern) return null;
|
|
82
|
+
|
|
83
|
+
const lines = content.split('\n');
|
|
84
|
+
let inBlock = false;
|
|
85
|
+
let hasContent = false;
|
|
86
|
+
let result = null;
|
|
87
|
+
for (let i = 0; i < lines.length; i++) {
|
|
88
|
+
if (!inBlock && pattern.test(lines[i])) {
|
|
89
|
+
inBlock = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (inBlock) {
|
|
93
|
+
if (/^\}/.test(lines[i])) {
|
|
94
|
+
// Closing brace at 0-indexed position i; last content line is at 0-indexed (i-1) = 1-indexed i
|
|
95
|
+
result = hasContent ? i : null;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
hasContent = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (cache) cache.set(cacheKey, result);
|
|
103
|
+
return result;
|
|
104
|
+
};
|
|
105
|
+
|
|
67
106
|
/** Find the 1-indexed line where a script block's content starts in a .yml file */
|
|
68
107
|
const findYmlScriptBlockStartLine = (filePath, scriptType, cache = null) => {
|
|
69
|
-
if (!filePath.endsWith('.yml')
|
|
108
|
+
if (!filePath.endsWith('.yml')) return null;
|
|
70
109
|
|
|
71
110
|
const cacheKey = `yml:${filePath}:${scriptType}`;
|
|
72
111
|
if (cache?.has(cacheKey)) return cache.get(cacheKey);
|
|
@@ -97,7 +136,52 @@ const findYmlScriptBlockStartLine = (filePath, scriptType, cache = null) => {
|
|
|
97
136
|
}
|
|
98
137
|
}
|
|
99
138
|
}
|
|
100
|
-
if (result) break;
|
|
139
|
+
if (result != null) break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// invalid YAML
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (cache) cache.set(cacheKey, result);
|
|
147
|
+
return result;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** Find the 1-indexed last content line of a script block in a .yml file */
|
|
151
|
+
const findYmlScriptBlockEndLine = (filePath, scriptType, cache = null) => {
|
|
152
|
+
if (!filePath.endsWith('.yml')) return null;
|
|
153
|
+
|
|
154
|
+
const cacheKey = `yml-end:${filePath}:${scriptType}`;
|
|
155
|
+
if (cache?.has(cacheKey)) return cache.get(cacheKey);
|
|
156
|
+
|
|
157
|
+
const content = readFile(filePath, cache);
|
|
158
|
+
if (!content) return null;
|
|
159
|
+
|
|
160
|
+
const ymlType = SCRIPT_TYPE_TO_YML[scriptType];
|
|
161
|
+
if (!ymlType) return null;
|
|
162
|
+
|
|
163
|
+
let result = null;
|
|
164
|
+
try {
|
|
165
|
+
const lineCounter = new YAML.LineCounter();
|
|
166
|
+
const doc = YAML.parseDocument(content, { lineCounter });
|
|
167
|
+
|
|
168
|
+
const scriptPaths = [['runtime', 'scripts'], ['request', 'scripts']];
|
|
169
|
+
for (const scriptPath of scriptPaths) {
|
|
170
|
+
const scripts = doc.getIn(scriptPath, true);
|
|
171
|
+
if (YAML.isSeq(scripts)) {
|
|
172
|
+
for (const item of scripts.items) {
|
|
173
|
+
if (!YAML.isMap(item)) continue;
|
|
174
|
+
if (item.get('type') === ymlType) {
|
|
175
|
+
const codeNode = item.get('code', true);
|
|
176
|
+
if (codeNode && codeNode.range) {
|
|
177
|
+
// range[1] is the end offset; go back 1 to get the last content character
|
|
178
|
+
const endOffset = Math.max(codeNode.range[1] - 1, codeNode.range[0]);
|
|
179
|
+
result = lineCounter.linePos(endOffset).line;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (result != null) break;
|
|
101
185
|
}
|
|
102
186
|
}
|
|
103
187
|
} catch {
|
|
@@ -111,7 +195,7 @@ const findYmlScriptBlockStartLine = (filePath, scriptType, cache = null) => {
|
|
|
111
195
|
/** Adjust a runtime-reported line number to the actual line in the .bru/.yml file */
|
|
112
196
|
const adjustLineNumber = (filePath, reportedLine, isQuickJS, scriptType = null, cache = null, scriptMetadata = null) => {
|
|
113
197
|
const isBruFile = filePath.endsWith('.bru');
|
|
114
|
-
const isYmlFile = filePath.endsWith('.yml')
|
|
198
|
+
const isYmlFile = filePath.endsWith('.yml');
|
|
115
199
|
|
|
116
200
|
if (!isBruFile && !isYmlFile) {
|
|
117
201
|
return reportedLine;
|
|
@@ -157,6 +241,13 @@ const adjustLineNumber = (filePath, reportedLine, isQuickJS, scriptType = null,
|
|
|
157
241
|
return scriptRelativeLine;
|
|
158
242
|
};
|
|
159
243
|
|
|
244
|
+
/** Look up the script block start line for a .bru or .yml file */
|
|
245
|
+
const findBlockStart = (filePath, scriptType, cache) => {
|
|
246
|
+
if (filePath.endsWith('.bru')) return findScriptBlockStartLine(filePath, scriptType, cache);
|
|
247
|
+
if (filePath.endsWith('.yml')) return findYmlScriptBlockStartLine(filePath, scriptType, cache);
|
|
248
|
+
return null;
|
|
249
|
+
};
|
|
250
|
+
|
|
160
251
|
/**
|
|
161
252
|
* Resolve an error in a collection/folder script segment to its source file and line.
|
|
162
253
|
* Uses the segments array in metadata to find which segment the error falls in,
|
|
@@ -171,19 +262,34 @@ const resolveSegmentError = (parsed, metadata, scriptType, cache) => {
|
|
|
171
262
|
|
|
172
263
|
for (const segment of metadata.segments) {
|
|
173
264
|
if (scriptRelativeLine >= segment.startLine && scriptRelativeLine <= segment.endLine) {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
265
|
+
if (!isAllowedSourceFile(segment.filePath)) return null;
|
|
266
|
+
|
|
267
|
+
const blockStartLine = findBlockStart(segment.filePath, scriptType, cache);
|
|
268
|
+
if (!blockStartLine) {
|
|
269
|
+
// No script block on disk — only possible when user added a new script as a draft.
|
|
270
|
+
// If we have in-memory content, return it so the caller can show the code snippet.
|
|
271
|
+
if (segment.scriptContent) {
|
|
272
|
+
return {
|
|
273
|
+
line: null,
|
|
274
|
+
filePath: segment.filePath,
|
|
275
|
+
displayPath: segment.displayPath,
|
|
276
|
+
scriptContent: segment.scriptContent,
|
|
277
|
+
// segment.startLine points to the IIFE wrapper line (`await (async () => {`),
|
|
278
|
+
// so subtracting it yields a 1-based index into the user's script content.
|
|
279
|
+
lineInScript: scriptRelativeLine - segment.startLine
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
182
284
|
|
|
183
285
|
return {
|
|
184
286
|
line: blockStartLine + (scriptRelativeLine - segment.startLine) - 1,
|
|
185
287
|
filePath: segment.filePath,
|
|
186
|
-
displayPath: segment.displayPath
|
|
288
|
+
displayPath: segment.displayPath,
|
|
289
|
+
scriptContent: segment.scriptContent || null,
|
|
290
|
+
// segment.startLine points to the IIFE wrapper line (`await (async () => {`),
|
|
291
|
+
// so subtracting it yields a 1-based index into the user's script content.
|
|
292
|
+
lineInScript: scriptRelativeLine - segment.startLine
|
|
187
293
|
};
|
|
188
294
|
}
|
|
189
295
|
}
|
|
@@ -248,12 +354,10 @@ const parseErrorLocation = (error) => {
|
|
|
248
354
|
return parsed;
|
|
249
355
|
};
|
|
250
356
|
|
|
251
|
-
/**
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
if (!content) return null;
|
|
357
|
+
/** Build a context-lines object from an array of source lines around an error */
|
|
358
|
+
const buildContextLines = (lines, errorLine, contextLines) => {
|
|
359
|
+
if (errorLine < 1 || errorLine > lines.length) return null;
|
|
255
360
|
|
|
256
|
-
const lines = content.split('\n');
|
|
257
361
|
const startLine = Math.max(1, errorLine - contextLines);
|
|
258
362
|
const endLine = Math.min(lines.length, errorLine + contextLines);
|
|
259
363
|
|
|
@@ -269,6 +373,19 @@ const getSourceContext = (filePath, errorLine, contextLines = DEFAULT_CONTEXT_LI
|
|
|
269
373
|
return { lines: contextLinesArray, startLine, errorLine };
|
|
270
374
|
};
|
|
271
375
|
|
|
376
|
+
/** Read source file and extract context lines around the error location */
|
|
377
|
+
const getSourceContext = (filePath, errorLine, contextLines = DEFAULT_CONTEXT_LINES, cache = null) => {
|
|
378
|
+
const content = readFile(filePath, cache);
|
|
379
|
+
if (!content) return null;
|
|
380
|
+
return buildContextLines(content.split('\n'), errorLine, contextLines);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/** Extract context lines from in-memory script content (e.g. unsaved draft scripts) */
|
|
384
|
+
const getSourceContextFromContent = (content, errorLine, contextLines = DEFAULT_CONTEXT_LINES) => {
|
|
385
|
+
if (!content) return null;
|
|
386
|
+
return buildContextLines(content.split('\n'), errorLine, contextLines);
|
|
387
|
+
};
|
|
388
|
+
|
|
272
389
|
/** Build adjusted stack trace string from structured CallSite data */
|
|
273
390
|
const buildStackFromCallSites = (callSites, scriptType = null, cache = null, scriptMetadata = null) => {
|
|
274
391
|
return callSites.map((site) => {
|
|
@@ -280,7 +397,7 @@ const buildStackFromCallSites = (callSites, scriptType = null, cache = null, scr
|
|
|
280
397
|
if (adjusted === null && scriptMetadata?.segments) {
|
|
281
398
|
const parsed = { line: site.line, isQuickJS: false };
|
|
282
399
|
const resolved = resolveSegmentError(parsed, scriptMetadata, scriptType, cache);
|
|
283
|
-
if (resolved) {
|
|
400
|
+
if (resolved && resolved.line !== null) {
|
|
284
401
|
fileToUse = resolved.filePath;
|
|
285
402
|
lineToUse = resolved.line;
|
|
286
403
|
}
|
|
@@ -307,7 +424,7 @@ const adjustStackTrace = (stack, scriptType = null, cache = null, scriptMetadata
|
|
|
307
424
|
if (adjusted === null && scriptMetadata?.segments) {
|
|
308
425
|
const parsed = { line: match.line, isQuickJS };
|
|
309
426
|
const resolved = resolveSegmentError(parsed, scriptMetadata, scriptType, cache);
|
|
310
|
-
if (resolved) {
|
|
427
|
+
if (resolved && resolved.line !== null) {
|
|
311
428
|
const suffix = match.isQuickJS ? ')' : '';
|
|
312
429
|
return match.column !== null
|
|
313
430
|
? line.replace(`${match.filePath}:${match.line}:${match.column}${suffix}`, `${resolved.filePath}:${resolved.line}:${match.column}${suffix}`)
|
|
@@ -409,6 +526,210 @@ const formatErrorWithContext = (error, relativeFilePath = null, scriptType = nul
|
|
|
409
526
|
return lines.join('\n');
|
|
410
527
|
};
|
|
411
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Build a structured error context object for the desktop UI's ScriptError component.
|
|
531
|
+
*
|
|
532
|
+
* formatErrorWithContext (V1) returns a pre-formatted string for CLI output.
|
|
533
|
+
* This function returns a structured object so the desktop UI can render it
|
|
534
|
+
* with its own layout (CodeSnippet component, collapsible stack, etc.).
|
|
535
|
+
*
|
|
536
|
+
* Key difference: line numbers in the returned object are block-relative
|
|
537
|
+
* (i.e. relative to the script block, starting at 1) rather than absolute
|
|
538
|
+
* file line numbers, because users edit scripts in a CodeMirror editor that
|
|
539
|
+
* starts numbering at line 1.
|
|
540
|
+
*
|
|
541
|
+
* @example
|
|
542
|
+
* Given a .bru file at /home/user/my-collection/requests/get-user.bru:
|
|
543
|
+
*
|
|
544
|
+
* meta { ← file line 1
|
|
545
|
+
* name: get-user ← file line 2
|
|
546
|
+
* } ← file line 3
|
|
547
|
+
* ← file line 4
|
|
548
|
+
* script:post-response { ← file line 5
|
|
549
|
+
* const data = res.body; ← file line 6 (script line 1)
|
|
550
|
+
* data.missing.prop; ← file line 7 (script line 2) ← error
|
|
551
|
+
* console.log(data); ← file line 8 (script line 3)
|
|
552
|
+
* } ← file line 9
|
|
553
|
+
*
|
|
554
|
+
* formatErrorWithContextV2(error, 'post-response', null, '/home/user/my-collection')
|
|
555
|
+
* → {
|
|
556
|
+
* errorType: 'TypeError',
|
|
557
|
+
* filePath: 'requests/get-user.bru', ← relative to collectionPath
|
|
558
|
+
* errorLine: 2, ← block-relative, not file line 7
|
|
559
|
+
* lines: [
|
|
560
|
+
* { lineNumber: 1, content: ' const data = res.body;', isError: false },
|
|
561
|
+
* { lineNumber: 2, content: ' data.missing.prop;', isError: true },
|
|
562
|
+
* { lineNumber: 3, content: ' console.log(data);', isError: false }
|
|
563
|
+
* ],
|
|
564
|
+
* stack: ' at …/requests/get-user.bru:7:3'
|
|
565
|
+
* }
|
|
566
|
+
*
|
|
567
|
+
* V1 (formatErrorWithContext) returns a flat string for the same error:
|
|
568
|
+
* File: requests/get-user.bru
|
|
569
|
+
*
|
|
570
|
+
* 5 | const data = res.body;
|
|
571
|
+
* > 6 | data.missing.prop;
|
|
572
|
+
* 7 | console.log(data);
|
|
573
|
+
*
|
|
574
|
+
* TypeError: Cannot read properties of undefined
|
|
575
|
+
* at …/requests/get-user.bru:7:3
|
|
576
|
+
*
|
|
577
|
+
* @param {Error} error - The error to build context for
|
|
578
|
+
* @param {string} scriptType - 'pre-request' | 'post-response' | 'test'
|
|
579
|
+
* @param {object} scriptMetadata - Optional metadata for line mapping in combined scripts
|
|
580
|
+
* @param {string} collectionPath - Absolute path to the collection root (used to compute relative display paths)
|
|
581
|
+
* @returns {object|null} Structured error context or null
|
|
582
|
+
*/
|
|
583
|
+
/**
|
|
584
|
+
* Resolve error context, preferring in-memory draft content over disk.
|
|
585
|
+
*
|
|
586
|
+
* Three resolution paths (tried in order):
|
|
587
|
+
* 1. Request-level error with in-memory draft content
|
|
588
|
+
* 2. Segment (collection/folder) error with in-memory draft content
|
|
589
|
+
* 3. Disk-based file read (original behavior)
|
|
590
|
+
*
|
|
591
|
+
* @returns {{ context, fromMemory, draftOnlyBlock }|null}
|
|
592
|
+
*/
|
|
593
|
+
const resolveErrorContext = ({ adjustedLine, scriptRelativeLine, metadata, segmentResult, filePath, sourceFile, sourceLine, scriptType, cache }) => {
|
|
594
|
+
// Request-level error with in-memory draft content
|
|
595
|
+
if (adjustedLine !== null && metadata?.requestScriptContent) {
|
|
596
|
+
// Check whether the script block exists on disk. When the user added a brand-new
|
|
597
|
+
// script that hasn't been saved yet, findBlockStart returns null and adjustLineNumber
|
|
598
|
+
// returned scriptRelativeLine (not a real .bru file line), so stack frame adjustment
|
|
599
|
+
// would produce misleading results — flag it as draft-only.
|
|
600
|
+
const blockStartLine = findBlockStart(filePath, scriptType, cache);
|
|
601
|
+
const draftOnlyBlock = !blockStartLine && isAllowedSourceFile(filePath);
|
|
602
|
+
// requestStartLine points to the IIFE wrapper line (`await (async () => {`),
|
|
603
|
+
// so subtracting it yields a 1-based index into the user's script content.
|
|
604
|
+
const lineInScript = scriptRelativeLine - metadata.requestStartLine;
|
|
605
|
+
const context = getSourceContextFromContent(metadata.requestScriptContent, lineInScript, 3);
|
|
606
|
+
if (context) return { context, fromMemory: true, draftOnlyBlock };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Segment (collection/folder) error with in-memory draft content
|
|
610
|
+
if (adjustedLine === null && segmentResult?.scriptContent) {
|
|
611
|
+
const context = getSourceContextFromContent(segmentResult.scriptContent, segmentResult.lineInScript, 3);
|
|
612
|
+
// segmentResult.line is null when the block doesn't exist on disk
|
|
613
|
+
if (context) return { context, fromMemory: true, draftOnlyBlock: segmentResult.line === null };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Fall back to reading from disk
|
|
617
|
+
if (sourceLine !== null) {
|
|
618
|
+
const context = getSourceContext(sourceFile, sourceLine, 3, cache);
|
|
619
|
+
if (context) return { context, fromMemory: false, draftOnlyBlock: false };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return null;
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
const formatErrorWithContextV2 = (error, scriptType, scriptMetadata, collectionPath) => {
|
|
626
|
+
if (!error) return null;
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
const cache = new Map();
|
|
630
|
+
const metadata = (error.scriptMetadata && Object.keys(error.scriptMetadata).length > 0)
|
|
631
|
+
? error.scriptMetadata
|
|
632
|
+
: scriptMetadata;
|
|
633
|
+
const parsed = parseErrorLocation(error);
|
|
634
|
+
if (!parsed) return null;
|
|
635
|
+
|
|
636
|
+
const { filePath } = parsed;
|
|
637
|
+
const wrapperOffset = parsed.isQuickJS ? QUICKJS_SCRIPT_WRAPPER_OFFSET : NODEVM_SCRIPT_WRAPPER_OFFSET;
|
|
638
|
+
const scriptRelativeLine = parsed.line - wrapperOffset;
|
|
639
|
+
const adjustedLine = adjustLineNumber(filePath, parsed.line, parsed.isQuickJS, scriptType, cache, metadata);
|
|
640
|
+
|
|
641
|
+
let sourceFile = filePath;
|
|
642
|
+
let sourceLine = adjustedLine;
|
|
643
|
+
|
|
644
|
+
// Handle collection/folder script segments
|
|
645
|
+
let segmentResult = null;
|
|
646
|
+
if (adjustedLine === null) {
|
|
647
|
+
segmentResult = resolveSegmentError(parsed, metadata, scriptType, cache);
|
|
648
|
+
if (!segmentResult) return null;
|
|
649
|
+
sourceFile = segmentResult.filePath;
|
|
650
|
+
sourceLine = segmentResult.line;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Resolve context: prefer in-memory draft content, fall back to disk
|
|
654
|
+
const resolved = resolveErrorContext({
|
|
655
|
+
adjustedLine, scriptRelativeLine, metadata, segmentResult,
|
|
656
|
+
filePath, sourceFile, sourceLine, scriptType, cache
|
|
657
|
+
});
|
|
658
|
+
if (!resolved || resolved.context.lines.length === 0) return null;
|
|
659
|
+
|
|
660
|
+
const { context, fromMemory, draftOnlyBlock } = resolved;
|
|
661
|
+
|
|
662
|
+
const resolvedDisplayPath = posixifyPath(
|
|
663
|
+
collectionPath ? path.relative(collectionPath, sourceFile) : sourceFile
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
const errorType = getErrorTypeName(error);
|
|
667
|
+
let stack = null;
|
|
668
|
+
if (error.stack) {
|
|
669
|
+
// When the script block only exists as a draft (not on disk), adjustLineNumber
|
|
670
|
+
// cannot map to real .bru file lines — skip adjustment to preserve original frames.
|
|
671
|
+
const rawStack = draftOnlyBlock
|
|
672
|
+
? error.stack
|
|
673
|
+
: adjustStackTrace(error.stack, scriptType, cache, metadata, parsed.isQuickJS);
|
|
674
|
+
const stackLines = rawStack.split('\n').slice(1).filter((l) => l.trim().startsWith('at'));
|
|
675
|
+
stack = stackLines.length ? stackLines.map((l) => ` ${l.trim()}`).join('\n') : null;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// When context came from in-memory content, lines are already block-relative
|
|
679
|
+
if (fromMemory) {
|
|
680
|
+
return {
|
|
681
|
+
errorType,
|
|
682
|
+
filePath: resolvedDisplayPath,
|
|
683
|
+
errorLine: context.errorLine,
|
|
684
|
+
lines: context.lines,
|
|
685
|
+
stack
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Compute block-relative line numbers for the desktop UI.
|
|
690
|
+
// Users edit scripts in a CodeMirror editor starting at line 1,
|
|
691
|
+
// so show lines relative to the script block, not absolute .bru file lines.
|
|
692
|
+
const blockStartLine = findBlockStart(sourceFile, scriptType, cache);
|
|
693
|
+
|
|
694
|
+
const isBru = sourceFile.endsWith('.bru');
|
|
695
|
+
const isYml = sourceFile.endsWith('.yml');
|
|
696
|
+
const blockEndLine = isBru
|
|
697
|
+
? findScriptBlockEndLine(sourceFile, scriptType, cache)
|
|
698
|
+
: isYml
|
|
699
|
+
? findYmlScriptBlockEndLine(sourceFile, scriptType, cache)
|
|
700
|
+
: null;
|
|
701
|
+
|
|
702
|
+
// If this is a .bru/.yml file but the script block is missing or empty, there's nothing to show
|
|
703
|
+
if ((isBru || isYml) && !blockEndLine) return null;
|
|
704
|
+
|
|
705
|
+
const blockOffset = blockStartLine ? blockStartLine - 1 : 0;
|
|
706
|
+
|
|
707
|
+
const filteredLines = context.lines
|
|
708
|
+
.filter((l) => {
|
|
709
|
+
const rel = l.lineNumber - blockOffset;
|
|
710
|
+
return rel >= 1 && (!blockEndLine || l.lineNumber <= blockEndLine);
|
|
711
|
+
})
|
|
712
|
+
.map((l) => ({
|
|
713
|
+
lineNumber: l.lineNumber - blockOffset,
|
|
714
|
+
content: l.content,
|
|
715
|
+
isError: l.isError
|
|
716
|
+
}));
|
|
717
|
+
|
|
718
|
+
if (filteredLines.length === 0) return null;
|
|
719
|
+
|
|
720
|
+
return {
|
|
721
|
+
errorType,
|
|
722
|
+
filePath: resolvedDisplayPath,
|
|
723
|
+
errorLine: sourceLine - blockOffset,
|
|
724
|
+
lines: filteredLines,
|
|
725
|
+
stack
|
|
726
|
+
};
|
|
727
|
+
} catch (e) {
|
|
728
|
+
console.warn('formatErrorWithContextV2 failed:', e);
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
|
|
412
733
|
module.exports = {
|
|
413
734
|
SCRIPT_TYPES,
|
|
414
735
|
DEFAULT_CONTEXT_LINES,
|
|
@@ -416,11 +737,15 @@ module.exports = {
|
|
|
416
737
|
parseErrorLocation,
|
|
417
738
|
buildStackFromCallSites,
|
|
418
739
|
getSourceContext,
|
|
740
|
+
getSourceContextFromContent,
|
|
419
741
|
formatErrorWithContext,
|
|
742
|
+
formatErrorWithContextV2,
|
|
420
743
|
adjustLineNumber,
|
|
421
744
|
resolveSegmentError,
|
|
422
745
|
findScriptBlockStartLine,
|
|
746
|
+
findScriptBlockEndLine,
|
|
423
747
|
findYmlScriptBlockStartLine,
|
|
748
|
+
findYmlScriptBlockEndLine,
|
|
424
749
|
adjustStackTrace,
|
|
425
750
|
getErrorTypeName
|
|
426
751
|
};
|