@usebruno/js 0.46.1 → 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.
@@ -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', '.yaml'];
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') && !filePath.endsWith('.yaml')) return null;
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') || filePath.endsWith('.yaml');
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
- 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;
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
- /** 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;
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
  };