From 910581a627f4bf5bacfd68b212030dc21c91740a Mon Sep 17 00:00:00 2001 From: gopu-bruno Date: Mon, 2 Mar 2026 15:27:18 +0530 Subject: [PATCH] feat: improve stack traces for script and test failures (#7181) * chore: update package-lock.json to include yaml dependency * feat: add script-aware stack traces and source context for script/test failures * chore: sync package-lock.json with yaml dependency in bruno-js * fix: handle null check in getErrorTypeName and align JSDoc style * refactor: derive script path from request.pathname and use SCRIPT_TYPES constant * fix: avoid showing source context for collection/folder script errors * feat: map collection/folder script errors to source file and line * fix: update error formatting and avoid undefined message * fix: resolve script block location in collection/folder yml files * refactor: use script wrapper utils and rename wrapper offsets * refactor: move script wrapper to utils, add wrapScriptInClosure fn --- package-lock.json | 29 +- .../bruno-cli/src/runner/prepare-request.js | 2 + .../src/runner/run-single-request.js | 45 +- packages/bruno-cli/src/utils/collection.js | 97 +++- .../bruno-electron/src/ipc/network/index.js | 8 +- .../src/ipc/network/prepare-request.js | 1 + packages/bruno-js/package.json | 3 +- packages/bruno-js/src/index.js | 5 +- .../bruno-js/src/runtime/script-runtime.js | 19 +- packages/bruno-js/src/runtime/test-runtime.js | 10 +- .../bruno-js/src/sandbox/node-vm/index.js | 74 ++- .../bruno-js/src/sandbox/node-vm/utils.js | 15 + .../bruno-js/src/sandbox/quickjs/index.js | 28 +- packages/bruno-js/src/test.js | 10 +- .../bruno-js/src/utils/error-formatter.js | 426 ++++++++++++++++++ .../src/utils/error-formatter.spec.js | 388 ++++++++++++++++ packages/bruno-js/src/utils/sandbox.js | 64 +++ 17 files changed, 1140 insertions(+), 84 deletions(-) create mode 100644 packages/bruno-js/src/utils/error-formatter.js create mode 100644 packages/bruno-js/src/utils/error-formatter.spec.js create mode 100644 packages/bruno-js/src/utils/sandbox.js diff --git a/package-lock.json b/package-lock.json index ba28f874f..6b6f4c0c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9356,17 +9356,6 @@ "node": "^18 || ^20 || >= 21" } }, - "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": { - "version": "0.22.4", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.4.tgz", - "integrity": "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "node-addon-api": "^8.3.0", - "node-gyp-build": "^4.8.4" - } - }, "node_modules/@swagger-api/apidom-reference": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.4.0.tgz", @@ -35255,7 +35244,8 @@ "tv4": "^1.3.0", "uuid": "^9.0.0", "xml-formatter": "^3.5.0", - "xml2js": "^0.6.2" + "xml2js": "^0.6.2", + "yaml": "^2.3.4" }, "devDependencies": { "@rollup/plugin-commonjs": "^23.0.2", @@ -35325,6 +35315,21 @@ "node": ">=10" } }, + "packages/bruno-js/node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "packages/bruno-lang": { "name": "@usebruno/lang", "version": "0.12.0", diff --git a/packages/bruno-cli/src/runner/prepare-request.js b/packages/bruno-cli/src/runner/prepare-request.js index 83c7ab86c..0c1a6d806 100644 --- a/packages/bruno-cli/src/runner/prepare-request.js +++ b/packages/bruno-cli/src/runner/prepare-request.js @@ -42,6 +42,7 @@ const prepareRequest = async (item = {}, collection = {}) => { url: request.url, headers: headers, name: item.name, + pathname: item.pathname, tags: item.tags || [], pathParams: request.params?.filter((param) => param.type === 'path'), settings: item.settings, @@ -398,6 +399,7 @@ const prepareRequest = async (item = {}, collection = {}) => { if (request.tests) { axiosRequest.tests = request.tests; + axiosRequest.testsMetadata = request.testsMetadata; } axiosRequest.vars = request.vars; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index a34f6383c..f0bd4f182 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -6,7 +6,7 @@ const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lod const prepareRequest = require('./prepare-request'); const interpolateVars = require('./interpolate-vars'); const { interpolateString, interpolateObject } = require('./interpolate-string'); -const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime } = require('@usebruno/js'); +const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, formatErrorWithContext, SCRIPT_TYPES } = require('@usebruno/js'); const { stripExtension } = require('../utils/filesystem'); const { getOptions } = require('../utils/bru'); const https = require('https'); @@ -102,7 +102,7 @@ const runSingleRequest = async function ( const { pathname: itemPathname } = item; const relativeItemPathname = path.relative(collectionPath, itemPathname); - const logResults = (results, title) => { + const logResults = (results, title, scriptType = null, request = null) => { if (results?.length) { if (title) { console.log(chalk.dim(title)); @@ -113,7 +113,18 @@ const runSingleRequest = async function ( console.log(chalk.green(` ✓ `) + chalk.dim(message)); } else { console.log(chalk.red(` ✕ `) + chalk.red(message)); - if (r.error) { + if (r.stack && scriptType) { + const errorObj = { + message: r.error || message, + stack: r.stack, + name: r.errorName || 'Error' + }; + const metadata = scriptType === SCRIPT_TYPES.PRE_REQUEST ? request?.script?.reqMetadata + : scriptType === SCRIPT_TYPES.POST_RESPONSE ? request?.script?.resMetadata + : scriptType === SCRIPT_TYPES.TEST ? request?.testsMetadata + : null; + console.log('\n' + formatErrorWithContext(errorObj, relativeItemPathname, scriptType, 5, metadata) + '\n'); + } else if (r.error) { console.log(chalk.red(` ${r.error}`)); } } @@ -208,7 +219,7 @@ const runSingleRequest = async function ( if (requestScriptFile?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); try { - const result = await scriptRuntime.runRequestScript(decomment(requestScriptFile), + const result = await scriptRuntime.runRequestScript(decomment(requestScriptFile, { space: true }), request, envVariables, runtimeVariables, @@ -263,8 +274,8 @@ const runSingleRequest = async function ( preRequestTestResults = result?.results || []; } catch (error) { // Pre-request errors are treated as request errors (we return early with status: 'error'), not as failures. Unlike post-response and test script errors, we do not add a synthetic fail and continue. - console.error('Pre-request script execution error:', error); - console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${error.message})`)); + console.error(chalk.red(`[${relativeItemPathname}] Pre-request script error:`)); + console.log('\n' + formatErrorWithContext(error, relativeItemPathname, SCRIPT_TYPES.PRE_REQUEST, 5, request.script?.reqMetadata) + '\n'); // Extract partial results from the error (tests that passed before the error) preRequestTestResults = error?.partialResults?.results || []; @@ -279,7 +290,7 @@ const runSingleRequest = async function ( shouldStopRunnerExecution = true; } - logResults(preRequestTestResults, 'Pre-Request Tests'); + logResults(preRequestTestResults, 'Pre-Request Tests', SCRIPT_TYPES.PRE_REQUEST, request); // Pre-request script error: execution didn't complete (request never sent). Return early so we don't run the HTTP request, post-response script, assertions, or tests. return { @@ -764,7 +775,7 @@ const runSingleRequest = async function ( ); // Log pre-request test results - logResults(preRequestTestResults, 'Pre-Request Tests'); + logResults(preRequestTestResults, 'Pre-Request Tests', SCRIPT_TYPES.PRE_REQUEST, request); // run post-response vars const postResponseVars = get(item, 'request.vars.res'); @@ -787,7 +798,7 @@ const runSingleRequest = async function ( const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); try { const result = await scriptRuntime.runResponseScript( - decomment(responseScriptFile), + decomment(responseScriptFile, { space: true }), request, response, envVariables, @@ -814,9 +825,10 @@ const runSingleRequest = async function ( } postResponseTestResults = result?.results || []; - logResults(postResponseTestResults, 'Post-Response Tests'); + logResults(postResponseTestResults, 'Post-Response Tests', SCRIPT_TYPES.POST_RESPONSE, request); } catch (error) { - console.error('Post-response script execution error:', error); + console.error(chalk.red(`[${relativeItemPathname}] Post-response script error:`)); + console.log('\n' + formatErrorWithContext(error, relativeItemPathname, SCRIPT_TYPES.POST_RESPONSE, 5, request.script?.resMetadata) + '\n'); const partialResults = error?.partialResults?.results || []; postResponseTestResults = [ @@ -837,7 +849,7 @@ const runSingleRequest = async function ( shouldStopRunnerExecution = true; } - logResults(postResponseTestResults, 'Post-Response Tests'); + logResults(postResponseTestResults, 'Post-Response Tests', SCRIPT_TYPES.POST_RESPONSE, request); } } @@ -862,7 +874,7 @@ const runSingleRequest = async function ( const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime }); try { const result = await testRuntime.runTests( - decomment(testFile), + decomment(testFile, { space: true }), request, response, envVariables, @@ -890,9 +902,10 @@ const runSingleRequest = async function ( } } - logResults(testResults, 'Tests'); + logResults(testResults, 'Tests', SCRIPT_TYPES.TEST, request); } catch (error) { - console.error('Test script execution error:', error); + console.error(chalk.red(`[${relativeItemPathname}] Test script error:`)); + console.log('\n' + formatErrorWithContext(error, relativeItemPathname, SCRIPT_TYPES.TEST, 5, request.testsMetadata) + '\n'); const partialResults = error?.partialResults?.results || []; testResults = [ @@ -913,7 +926,7 @@ const runSingleRequest = async function ( shouldStopRunnerExecution = true; } - logResults(testResults, 'Tests'); + logResults(testResults, 'Tests', SCRIPT_TYPES.TEST, request); } } diff --git a/packages/bruno-cli/src/utils/collection.js b/packages/bruno-cli/src/utils/collection.js index 8fcf1e167..d708b9f4b 100644 --- a/packages/bruno-cli/src/utils/collection.js +++ b/packages/bruno-cli/src/utils/collection.js @@ -1,4 +1,4 @@ -const { get, each, find, compact } = require('lodash'); +const { get, each, find } = require('lodash'); const os = require('os'); const fs = require('fs'); const path = require('path'); @@ -227,31 +227,101 @@ ${script} })();`; }; +/** + * Wraps each script segment in an async IIFE, joins them with double newlines, + * and records the line range of the "request" segment for stack-trace mapping. + * + * Merged scripts = collection + folders + request; the runtime runs one combined + * script, so we need requestStartLine/requestEndLine to map a VM line number + * back to the request's script in the .bru file. + * + * @param {string[]} scripts - Script segments in order (e.g. collection, folders, request). + * @param {number} requestIndex - Index in scripts of the request-level segment. + * @returns {{ code: string, metadata: { requestStartLine: number, requestEndLine: number } | null }} + */ +const wrapAndJoinScripts = (scripts, requestIndex, segmentSources = null) => { + const wrapped = scripts.map((s) => wrapScriptInClosure(s)); + const code = wrapped.filter(Boolean).join('\n\n'); + + let offset = 0; + let metadata = null; + const segments = []; + + for (let i = 0; i < scripts.length; i++) { + if (!wrapped[i]) continue; + const lineCount = wrapped[i].split('\n').length; + const startLine = offset + 1; + const endLine = offset + lineCount; + + if (i === requestIndex) { + metadata = { requestStartLine: startLine, requestEndLine: endLine }; + } + + if (segmentSources?.[i]) { + segments.push({ startLine, endLine, ...segmentSources[i] }); + } + + offset += lineCount + 1; + } + + // Request-level script was empty, but collection/folder scripts produced code. + // Use a zero line range to prevent stack traces from mapping to the request file. + if (!metadata && code) { + metadata = { requestStartLine: 0, requestEndLine: 0 }; + } + + if (metadata && segments.length > 0) { + metadata.segments = segments; + } + + return { code, metadata }; +}; + const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { const collectionRoot = collection?.draft?.root || collection?.root || {}; let collectionPreReqScript = get(collectionRoot, 'request.script.req', ''); let collectionPostResScript = get(collectionRoot, 'request.script.res', ''); let collectionTests = get(collectionRoot, 'request.tests', ''); + // Build source file info for error trace mapping + const format = collection.format || 'bru'; + const config = FORMAT_CONFIG[format]; + const collectionSource = { + filePath: path.join(collection.pathname, config.collectionFile), + displayPath: config.collectionFile + }; + let combinedPreReqScript = []; + let combinedPreReqSources = []; let combinedPostResScript = []; + let combinedPostResSources = []; let combinedTests = []; + let combinedTestsSources = []; + for (let i of requestTreePath) { if (i.type === 'folder') { const folderRoot = i?.draft || i?.root; + const folderSource = { + filePath: path.join(i.pathname, config.folderFile), + displayPath: path.relative(collection.pathname, path.join(i.pathname, config.folderFile)) + }; + let preReqScript = get(folderRoot, 'request.script.req', ''); if (preReqScript && preReqScript.trim() !== '') { combinedPreReqScript.push(preReqScript); + combinedPreReqSources.push(folderSource); } let postResScript = get(folderRoot, 'request.script.res', ''); if (postResScript && postResScript.trim() !== '') { combinedPostResScript.push(postResScript); + combinedPostResSources.push(folderSource); } let tests = get(folderRoot, 'request.tests', ''); if (tests && tests?.trim?.() !== '') { combinedTests.push(tests); + combinedTestsSources.push(folderSource); } } } @@ -265,7 +335,10 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { ...combinedPreReqScript, request?.script?.req || '' ]; - request.script.req = compact(preReqScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL); + const preReqSources = [collectionSource, ...combinedPreReqSources, null]; + const preReq = wrapAndJoinScripts(preReqScripts, preReqScripts.length - 1, preReqSources); + request.script.req = preReq.code; + request.script.reqMetadata = preReq.metadata; // Handle post-response scripts based on scriptFlow if (scriptFlow === 'sequential') { @@ -274,7 +347,10 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { ...combinedPostResScript, request?.script?.res || '' ]; - request.script.res = compact(postResScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL); + const postResSources = [collectionSource, ...combinedPostResSources, null]; + const postRes = wrapAndJoinScripts(postResScripts, postResScripts.length - 1, postResSources); + request.script.res = postRes.code; + request.script.resMetadata = postRes.metadata; } else { // Reverse order for non-sequential flow const postResScripts = [ @@ -282,7 +358,10 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { ...[...combinedPostResScript].reverse(), collectionPostResScript ]; - request.script.res = compact(postResScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL); + const postResSources = [null, ...[...combinedPostResSources].reverse(), collectionSource]; + const postRes = wrapAndJoinScripts(postResScripts, 0, postResSources); + request.script.res = postRes.code; + request.script.resMetadata = postRes.metadata; } // Handle tests based on scriptFlow @@ -292,7 +371,10 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { ...combinedTests, request?.tests || '' ]; - request.tests = compact(testScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL); + const testSources = [collectionSource, ...combinedTestsSources, null]; + const tests = wrapAndJoinScripts(testScripts, testScripts.length - 1, testSources); + request.tests = tests.code; + request.testsMetadata = tests.metadata; } else { // Reverse order for non-sequential flow const testScripts = [ @@ -300,7 +382,10 @@ const mergeScripts = (collection, request, requestTreePath, scriptFlow) => { ...[...combinedTests].reverse(), collectionTests ]; - request.tests = compact(testScripts.map(wrapScriptInClosure)).join(os.EOL + os.EOL); + const testSources = [null, ...[...combinedTestsSources].reverse(), collectionSource]; + const tests = wrapAndJoinScripts(testScripts, 0, testSources); + request.tests = tests.code; + request.testsMetadata = tests.metadata; } }; diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 1cadc8de0..5e8a935ba 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -528,7 +528,7 @@ const registerNetworkIpc = (mainWindow) => { if (requestScript?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); scriptResult = await scriptRuntime.runRequestScript( - decomment(requestScript), + decomment(requestScript, { space: true }), request, envVars, runtimeVariables, @@ -677,7 +677,7 @@ const registerNetworkIpc = (mainWindow) => { if (responseScript?.length) { const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime }); scriptResult = await scriptRuntime.runResponseScript( - decomment(responseScript), + decomment(responseScript, { space: true }), request, response, envVars, @@ -1027,7 +1027,7 @@ const registerNetworkIpc = (mainWindow) => { let testError = null; try { - testResults = await testRuntime.runTests(decomment(testFile), + testResults = await testRuntime.runTests(decomment(testFile, { space: true }), request, response, envVars, @@ -1744,7 +1744,7 @@ const registerNetworkIpc = (mainWindow) => { try { const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime }); testResults = await testRuntime.runTests( - decomment(testFile), + decomment(testFile, { space: true }), request, response, envVars, diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js index 7172b1454..c9bd22033 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-request.js @@ -353,6 +353,7 @@ const prepareRequest = async (item, collection = {}, abortController) => { url, headers, name: item.name, + pathname: item.pathname, tags: item.tags || [], pathParams: request.params?.filter((param) => param.type === 'path'), settings, diff --git a/packages/bruno-js/package.json b/packages/bruno-js/package.json index 96af47263..e3c4f8049 100644 --- a/packages/bruno-js/package.json +++ b/packages/bruno-js/package.json @@ -34,7 +34,8 @@ "tv4": "^1.3.0", "uuid": "^9.0.0", "xml-formatter": "^3.5.0", - "xml2js": "^0.6.2" + "xml2js": "^0.6.2", + "yaml": "^2.3.4" }, "devDependencies": { "@rollup/plugin-commonjs": "^23.0.2", diff --git a/packages/bruno-js/src/index.js b/packages/bruno-js/src/index.js index 06c3a6504..d3b91c97e 100644 --- a/packages/bruno-js/src/index.js +++ b/packages/bruno-js/src/index.js @@ -3,11 +3,14 @@ const TestRuntime = require('./runtime/test-runtime'); const VarsRuntime = require('./runtime/vars-runtime'); const AssertRuntime = require('./runtime/assert-runtime'); const { runScriptInNodeVm } = require('./sandbox/node-vm'); +const { formatErrorWithContext, SCRIPT_TYPES } = require('./utils/error-formatter'); module.exports = { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, - runScriptInNodeVm + runScriptInNodeVm, + formatErrorWithContext, + SCRIPT_TYPES }; diff --git a/packages/bruno-js/src/runtime/script-runtime.js b/packages/bruno-js/src/runtime/script-runtime.js index 7032eea73..8cb0c7f64 100644 --- a/packages/bruno-js/src/runtime/script-runtime.js +++ b/packages/bruno-js/src/runtime/script-runtime.js @@ -6,6 +6,7 @@ const { cleanJson } = require('../utils'); const { createBruTestResultMethods } = require('../utils/results'); const { runScriptInNodeVm } = require('../sandbox/node-vm'); const { executeQuickJsVmAsync } = require('../sandbox/quickjs'); +const { SANDBOX } = require('../utils/sandbox'); class ScriptRuntime { constructor(props) { @@ -34,6 +35,7 @@ class ScriptRuntime { const promptVariables = request?.promptVariables || {}; const assertionResults = request?.assertionResults || []; const certsAndProxyConfig = request?.certsAndProxyConfig; + const scriptPath = request?.pathname; const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables, certsAndProxyConfig); const req = new BrunoRequest(request); @@ -88,13 +90,14 @@ class ScriptRuntime { // Similar pattern to test-runtime.js which already handles this correctly let scriptError = null; - if (this.runtime === 'nodevm') { + if (this.runtime === SANDBOX.NODEVM) { try { await runScriptInNodeVm({ script, context, collectionPath, - scriptingConfig + scriptingConfig, + scriptPath }); } catch (error) { scriptError = error; @@ -115,7 +118,8 @@ class ScriptRuntime { await executeQuickJsVmAsync({ script: script, context: context, - collectionPath + collectionPath, + scriptPath }); } catch (error) { scriptError = error; @@ -150,6 +154,7 @@ class ScriptRuntime { const promptVariables = request?.promptVariables || {}; const assertionResults = request?.assertionResults || {}; const certsAndProxyConfig = request?.certsAndProxyConfig; + const scriptPath = request?.pathname; const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables, certsAndProxyConfig); const req = new BrunoRequest(request); const res = new BrunoResponse(response); @@ -206,13 +211,14 @@ class ScriptRuntime { // Similar pattern to test-runtime.js which already handles this correctly let scriptError = null; - if (this.runtime === 'nodevm') { + if (this.runtime === SANDBOX.NODEVM) { try { await runScriptInNodeVm({ script, context, collectionPath, - scriptingConfig + scriptingConfig, + scriptPath }); } catch (error) { scriptError = error; @@ -233,7 +239,8 @@ class ScriptRuntime { await executeQuickJsVmAsync({ script: script, context: context, - collectionPath + collectionPath, + scriptPath }); } catch (error) { scriptError = error; diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js index 894a028e1..921e06fe6 100644 --- a/packages/bruno-js/src/runtime/test-runtime.js +++ b/packages/bruno-js/src/runtime/test-runtime.js @@ -7,6 +7,7 @@ const { createBruTestResultMethods } = require('../utils/results'); const { runScriptInNodeVm } = require('../sandbox/node-vm'); const jsonwebtoken = require('jsonwebtoken'); const { executeQuickJsVmAsync } = require('../sandbox/quickjs'); +const { SANDBOX } = require('../utils/sandbox'); class TestRuntime { constructor(props) { @@ -34,6 +35,7 @@ class TestRuntime { const promptVariables = request?.promptVariables || {}; const assertionResults = request?.assertionResults || []; const certsAndProxyConfig = request?.certsAndProxyConfig; + const scriptPath = request?.pathname; const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables, certsAndProxyConfig); const req = new BrunoRequest(request); const res = new BrunoResponse(response); @@ -85,19 +87,21 @@ class TestRuntime { let scriptError = null; try { - if (this.runtime === 'nodevm') { + if (this.runtime === SANDBOX.NODEVM) { await runScriptInNodeVm({ script: testsFile, context, collectionPath, - scriptingConfig + scriptingConfig, + scriptPath }); } else { // default runtime is `quickjs` await executeQuickJsVmAsync({ script: testsFile, context: context, - collectionPath + collectionPath, + scriptPath }); } } catch (error) { diff --git a/packages/bruno-js/src/sandbox/node-vm/index.js b/packages/bruno-js/src/sandbox/node-vm/index.js index 011bb4fcd..a140b784a 100644 --- a/packages/bruno-js/src/sandbox/node-vm/index.js +++ b/packages/bruno-js/src/sandbox/node-vm/index.js @@ -3,10 +3,11 @@ const path = require('node:path'); const { get } = require('lodash'); const lodash = require('lodash'); const { wrapConsoleWithSerializers } = require('./console'); -const { ScriptError } = require('./utils'); +const { ScriptError, resolveVmFilename } = require('./utils'); const { createCustomRequire } = require('./cjs-loader'); const { safeGlobals } = require('./constants'); const { mixinTypedArrays } = require('../mixins/typed-arrays'); +const { wrapScriptInClosure, SANDBOX } = require('../../utils/sandbox'); /** * Executes a script in a Node.js VM context with enhanced security and module loading @@ -16,10 +17,17 @@ const { mixinTypedArrays } = require('../mixins/typed-arrays'); * @param {Object} options.context - The execution context with Bruno objects * @param {string} options.collectionPath - Path to the collection directory * @param {Object} options.scriptingConfig - Scripting configuration options - * @returns {Promise} + * @param {string} [options.scriptPath] - Path to the source file for accurate stack traces + * @returns {Promise} Execution results including variables and test results * @throws {ScriptError} When script execution fails */ -async function runScriptInNodeVm({ script, context, collectionPath, scriptingConfig }) { +async function runScriptInNodeVm({ + script, + context, + collectionPath, + scriptingConfig, + scriptPath +}) { if (script.trim().length === 0) { return; } @@ -58,13 +66,61 @@ async function runScriptInNodeVm({ script, context, collectionPath, scriptingCon additionalContextRootsAbsolute }); - // Execute the script in the isolated context - const wrappedScript = `(async function(){ ${script} \n})();`; - const compiledScript = new vm.Script(wrappedScript, { - filename: path.join(collectionPath, 'script.js') - }); + const vmFilename = resolveVmFilename(scriptPath, collectionPath); - await compiledScript.runInContext(isolatedContext); + // Execute the script in the isolated context + const wrappedScript = wrapScriptInClosure(script, SANDBOX.NODEVM); + let compiledScript; + try { + compiledScript = new vm.Script(wrappedScript, { + filename: vmFilename + }); + } catch (error) { + // V8 puts "filename:line" as the first line of syntax error stacks. + // Parse it so the error formatter can map to the correct source location. + const firstLine = error.stack?.split('\n')[0]; + const match = firstLine?.match(/^(.+):(\d+)$/); + if (match && match[1] === vmFilename) { + error.__callSites = [{ + filePath: vmFilename, + line: parseInt(match[2], 10), + column: null, + functionName: null + }]; + } + throw error; + } + + // Capture structured call sites for error-formatter line mapping + const originalPrepareStackTrace = Error.prepareStackTrace; + Error.prepareStackTrace = (error, callSites) => { + error.__callSites = callSites + .filter((site) => site.getFileName() === vmFilename) + .map((site) => ({ + filePath: site.getFileName(), + line: site.getLineNumber(), + column: site.getColumnNumber(), + functionName: site.getFunctionName() || null + })); + + return error.toString() + '\n' + callSites + .map((site) => ` at ${site}`) + .join('\n'); + }; + + try { + await compiledScript.runInContext(isolatedContext, { + displayErrors: true + }); + } catch (error) { + // V8 invokes prepareStackTrace lazily on first .stack access. + // Reading .stack here so custom handler runs and populates error.__callSites + // (used later by the error formatter to map stack frames to the .bru/.yml script) + void error.stack; + throw error; + } finally { + Error.prepareStackTrace = originalPrepareStackTrace; + } } catch (error) { throw new ScriptError(error, script); } diff --git a/packages/bruno-js/src/sandbox/node-vm/utils.js b/packages/bruno-js/src/sandbox/node-vm/utils.js index 3a8774ef4..7480e9182 100644 --- a/packages/bruno-js/src/sandbox/node-vm/utils.js +++ b/packages/bruno-js/src/sandbox/node-vm/utils.js @@ -25,6 +25,19 @@ function isPathWithinAllowedRoots(normalizedPath, additionalContextRootsAbsolute }); } +/** + * Resolve the VM filename for the script + * @param {string|null} scriptPath - Path to the source file + * @param {string} collectionPath - Path to the collection directory + * @returns {string} Absolute path to use as the VM filename + */ +function resolveVmFilename(scriptPath, collectionPath) { + if (scriptPath) { + return path.isAbsolute(scriptPath) ? scriptPath : path.join(collectionPath, scriptPath); + } + return path.join(collectionPath, 'script.js'); +} + class ScriptError extends Error { constructor(error, script) { super(error.message); @@ -32,11 +45,13 @@ class ScriptError extends Error { this.originalError = error; this.script = script; this.stack = error.stack; + this.__callSites = error.__callSites || null; } } module.exports = { isBuiltinModule, isPathWithinAllowedRoots, + resolveVmFilename, ScriptError }; diff --git a/packages/bruno-js/src/sandbox/quickjs/index.js b/packages/bruno-js/src/sandbox/quickjs/index.js index c2f8ba079..aa419b958 100644 --- a/packages/bruno-js/src/sandbox/quickjs/index.js +++ b/packages/bruno-js/src/sandbox/quickjs/index.js @@ -12,6 +12,7 @@ const getBundledCode = require('../bundle-browser-rollup'); const addPathShimToContext = require('./shims/lib/path'); const { marshallToVm } = require('./utils'); const addCryptoUtilsShimToContext = require('./shims/lib/crypto-utils'); +const { wrapScriptInClosure, SANDBOX } = require('../../utils/sandbox'); let QuickJSSyncContext; const loader = memoizePromiseFactory(() => newQuickJSWASMModule()); @@ -89,7 +90,7 @@ const executeQuickJsVm = ({ script: externalScript, context: externalContext, sc } }; -const executeQuickJsVmAsync = async ({ script: externalScript, context: externalContext, collectionPath }) => { +const executeQuickJsVmAsync = async ({ script: externalScript, context: externalContext, collectionPath, scriptPath }) => { if (!externalScript?.length || typeof externalScript !== 'string') { return externalScript; } @@ -157,26 +158,9 @@ const executeQuickJsVmAsync = async ({ script: externalScript, context: external test && __brunoTestResults && addTestShimToContext(vm, __brunoTestResults); - const script = ` - (async () => { - const setTimeout = async(fn, timer) => { - v = await bru.sleep(timer); - fn.apply(); - } + const script = wrapScriptInClosure(externalScript, SANDBOX.QUICKJS); - await bru.sleep(0); - try { - ${externalScript} - } - catch(error) { - console?.debug?.('quick-js:execution-end:with-error', error?.message); - throw new Error(error?.message); - } - return 'done'; - })() - `; - - const result = vm.evalCode(script); + const result = vm.evalCode(script, scriptPath); const promiseHandle = vm.unwrapResult(result); const resolvedResult = await vm.resolvePromise(promiseHandle); promiseHandle.dispose(); @@ -185,8 +169,8 @@ const executeQuickJsVmAsync = async ({ script: externalScript, context: external // vm.dispose(); return; } catch (error) { - console.error('Error executing the script!', error); - throw new Error(error); + error.__isQuickJS = true; + throw error; } }; diff --git a/packages/bruno-js/src/test.js b/packages/bruno-js/src/test.js index dd449e740..f83a121a8 100644 --- a/packages/bruno-js/src/test.js +++ b/packages/bruno-js/src/test.js @@ -3,7 +3,6 @@ const Test = (__brunoTestResults, chai) => async (description, callback) => { await callback(); __brunoTestResults.addResult({ description, status: 'pass' }); } catch (error) { - console.log(chai.AssertionError); if (error instanceof chai.AssertionError) { const { message, actual, expected } = error; __brunoTestResults.addResult({ @@ -11,16 +10,19 @@ const Test = (__brunoTestResults, chai) => async (description, callback) => { status: 'fail', error: message, actual, - expected + expected, + stack: error.stack || null, + errorName: error.name || 'AssertionError' }); } else { __brunoTestResults.addResult({ description, status: 'fail', - error: error.message || 'An unexpected error occurred.' + error: error.message || 'An unexpected error occurred.', + stack: error.stack || null, + errorName: error.name || 'Error' }); } - console.log(error); } }; diff --git a/packages/bruno-js/src/utils/error-formatter.js b/packages/bruno-js/src/utils/error-formatter.js new file mode 100644 index 000000000..93ce05eda --- /dev/null +++ b/packages/bruno-js/src/utils/error-formatter.js @@ -0,0 +1,426 @@ +const fs = require('fs'); +const YAML = require('yaml'); +const { NODEVM_SCRIPT_WRAPPER_OFFSET, QUICKJS_SCRIPT_WRAPPER_OFFSET } = require('./sandbox'); + +const DEFAULT_CONTEXT_LINES = 5; +const ALLOWED_SOURCE_EXTENSIONS = ['.bru', '.yml', '.yaml']; + +const isAllowedSourceFile = (filePath) => + typeof filePath === 'string' && ALLOWED_SOURCE_EXTENSIONS.some((ext) => filePath.endsWith(ext)); + +const SCRIPT_TYPES = Object.freeze({ + PRE_REQUEST: 'pre-request', + POST_RESPONSE: 'post-response', + TEST: 'test' +}); + +// Bruno script types → OpenCollection YAML script types +const SCRIPT_TYPE_TO_YML = { + [SCRIPT_TYPES.PRE_REQUEST]: 'before-request', + [SCRIPT_TYPES.POST_RESPONSE]: 'after-response', + [SCRIPT_TYPES.TEST]: 'tests' +}; + +const readFile = (filePath, cache = null) => { + if (cache?.has(filePath)) return cache.get(filePath); + try { + const content = fs.readFileSync(filePath, 'utf-8').replace(/\r\n/g, '\n'); + if (cache) cache.set(filePath, content); + return content; + } catch { + return null; + } +}; + +const BLOCK_PATTERNS = { + [SCRIPT_TYPES.PRE_REQUEST]: /^script:pre-request\s*\{/, + [SCRIPT_TYPES.POST_RESPONSE]: /^script:post-response\s*\{/, + [SCRIPT_TYPES.TEST]: /^tests\s*\{/ +}; + +/** Find the 1-indexed line where a script block's content starts in a .bru file */ +const findScriptBlockStartLine = (filePath, scriptType, cache = null) => { + if (!filePath.endsWith('.bru')) return null; + + const cacheKey = `bru:${filePath}:${scriptType}`; + if (cache?.has(cacheKey)) return cache.get(cacheKey); + + const content = readFile(filePath, cache); + if (!content) return null; + + const pattern = BLOCK_PATTERNS[scriptType]; + if (!pattern) return null; + + const lines = content.split('\n'); + let result = null; + for (let i = 0; i < lines.length; i++) { + if (pattern.test(lines[i])) { + result = i + 2; // +1 for 1-indexing, +1 for line after opening brace + break; + } + } + + if (cache) cache.set(cacheKey, result); + return result; +}; + +/** Find the 1-indexed line where a script block's content starts in a .yml file */ +const findYmlScriptBlockStartLine = (filePath, scriptType, cache = null) => { + if (!filePath.endsWith('.yml') && !filePath.endsWith('.yaml')) return null; + + const cacheKey = `yml:${filePath}:${scriptType}`; + if (cache?.has(cacheKey)) return cache.get(cacheKey); + + const content = readFile(filePath, cache); + if (!content) return null; + + const ymlType = SCRIPT_TYPE_TO_YML[scriptType]; + if (!ymlType) return null; + + let result = null; + try { + const lineCounter = new YAML.LineCounter(); + const doc = YAML.parseDocument(content, { lineCounter }); + + // Request yml files use runtime.scripts, collection/folder yml files use request.scripts + const scriptPaths = [['runtime', 'scripts'], ['request', 'scripts']]; + for (const scriptPath of scriptPaths) { + const scripts = doc.getIn(scriptPath, true); + if (YAML.isSeq(scripts)) { + for (const item of scripts.items) { + if (!YAML.isMap(item)) continue; + if (item.get('type') === ymlType) { + const codeNode = item.get('code', true); + if (codeNode && codeNode.range) { + result = lineCounter.linePos(codeNode.range[0]).line + 1; + break; + } + } + } + if (result) break; + } + } + } catch { + // invalid YAML + } + + if (cache) cache.set(cacheKey, result); + return result; +}; + +/** Adjust a runtime-reported line number to the actual line in the .bru/.yml file */ +const adjustLineNumber = (filePath, reportedLine, isQuickJS, scriptType = null, cache = null, scriptMetadata = null) => { + const isBruFile = filePath.endsWith('.bru'); + const isYmlFile = filePath.endsWith('.yml') || filePath.endsWith('.yaml'); + + if (!isBruFile && !isYmlFile) { + return reportedLine; + } + + const wrapperOffset = isQuickJS ? QUICKJS_SCRIPT_WRAPPER_OFFSET : NODEVM_SCRIPT_WRAPPER_OFFSET; + const scriptRelativeLine = reportedLine - wrapperOffset; + + if (scriptRelativeLine < 1) return reportedLine; + + // Use metadata if available to correctly map line numbers in combined scripts + if (scriptType && scriptMetadata) { + const { requestStartLine, requestEndLine } = scriptMetadata; + if (requestStartLine != null && requestEndLine != null) { + if (scriptRelativeLine >= requestStartLine && scriptRelativeLine <= requestEndLine) { + // Error is within the request script segment + const blockStartLine = isBruFile + ? findScriptBlockStartLine(filePath, scriptType, cache) + : findYmlScriptBlockStartLine(filePath, scriptType, cache); + + if (blockStartLine) { + return blockStartLine + (scriptRelativeLine - requestStartLine) - 1; + } + } else { + // Error is in a collection/folder-level script + // Cannot map to the request .bru/.yml file, return null to skip source context. + return null; + } + } + } + + // No segment metadata, map script-relative line to file line via block start. + if (scriptType) { + const blockStartLine = isBruFile + ? findScriptBlockStartLine(filePath, scriptType, cache) + : findYmlScriptBlockStartLine(filePath, scriptType, cache); + + if (blockStartLine) { + return blockStartLine + scriptRelativeLine - 1; + } + } + + return scriptRelativeLine; +}; + +/** + * Resolve an error in a collection/folder script segment to its source file and line. + * Uses the segments array in metadata to find which segment the error falls in, + * then maps to the actual line in that segment's source file. + */ +const resolveSegmentError = (parsed, metadata, scriptType, cache) => { + if (!metadata?.segments?.length || !parsed) return null; + + const wrapperOffset = parsed.isQuickJS ? QUICKJS_SCRIPT_WRAPPER_OFFSET : NODEVM_SCRIPT_WRAPPER_OFFSET; + const scriptRelativeLine = parsed.line - wrapperOffset; + if (scriptRelativeLine < 1) return null; + + for (const segment of metadata.segments) { + if (scriptRelativeLine >= segment.startLine && scriptRelativeLine <= segment.endLine) { + const isBru = segment.filePath.endsWith('.bru'); + const isYml = segment.filePath.endsWith('.yml') || segment.filePath.endsWith('.yaml'); + if (!isBru && !isYml) return null; + + const blockStartLine = isBru + ? findScriptBlockStartLine(segment.filePath, scriptType, cache) + : findYmlScriptBlockStartLine(segment.filePath, scriptType, cache); + if (!blockStartLine) return null; + + return { + line: blockStartLine + (scriptRelativeLine - segment.startLine) - 1, + filePath: segment.filePath, + displayPath: segment.displayPath + }; + } + } + return null; +}; + +/** Extract file path, line, column, and runtime type from a single stack trace line */ +const matchStackFrame = (line) => { + // QuickJS: "at (/path/to/file.bru:11)" or "at (/path/to/file.bru:11)" + const quickjsMatch = line.match(/at (?:<[^>]+>\s*)?\(((?:[A-Za-z]:)?[^:]+):(\d+)(?::(\d+))?\)/); + if (quickjsMatch && (quickjsMatch[1].includes('/') || quickjsMatch[1].includes('\\'))) { + return { + filePath: quickjsMatch[1], + line: parseInt(quickjsMatch[2], 10), + column: quickjsMatch[3] ? parseInt(quickjsMatch[3], 10) : null, + isQuickJS: true + }; + } + + // Node VM: "at /path/to/file.bru:11:5" or "at Object. (/path/to/file.bru:11:5)" + const nodeMatch = line.match(/at (?:.*?\()?((?:[A-Za-z]:)?[^:]+):(\d+)(?::(\d+))?\)?/); + if (nodeMatch && (nodeMatch[1].includes('/') || nodeMatch[1].includes('\\'))) { + return { + filePath: nodeMatch[1], + line: parseInt(nodeMatch[2], 10), + column: nodeMatch[3] ? parseInt(nodeMatch[3], 10) : null, + isQuickJS: false + }; + } + + return null; +}; + +/** Parse the first stack frame to extract file path, line, and column */ +const parseStackTrace = (stack) => { + if (!stack) return null; + + for (const line of stack.split('\n')) { + const match = matchStackFrame(line); + if (match) return match; + } + + return null; +}; + +const parseErrorLocation = (error) => { + if (error.__callSites?.length > 0) { + const first = error.__callSites[0]; + return { + filePath: first.filePath, + line: first.line, + column: first.column, + isQuickJS: false + }; + } + + /* falls back to string parsing */ + const parsed = parseStackTrace(error.stack); + if (parsed && error.__isQuickJS) { + parsed.isQuickJS = true; + } + return parsed; +}; + +/** Read source file and extract context lines around the error location */ +const getSourceContext = (filePath, errorLine, contextLines = DEFAULT_CONTEXT_LINES, cache = null) => { + const content = readFile(filePath, cache); + if (!content) return null; + + const lines = content.split('\n'); + const startLine = Math.max(1, errorLine - contextLines); + const endLine = Math.min(lines.length, errorLine + contextLines); + + const contextLinesArray = []; + for (let i = startLine; i <= endLine; i++) { + contextLinesArray.push({ + lineNumber: i, + content: lines[i - 1], + isError: i === errorLine + }); + } + + return { lines: contextLinesArray, startLine, errorLine }; +}; + +/** Build adjusted stack trace string from structured CallSite data */ +const buildStackFromCallSites = (callSites, scriptType = null, cache = null, scriptMetadata = null) => { + return callSites.map((site) => { + const adjusted = adjustLineNumber(site.filePath, site.line, false, scriptType, cache, scriptMetadata); + let fileToUse = site.filePath; + let lineToUse = adjusted !== null ? adjusted : site.line; + + // Try segment resolution for collection/folder frames + if (adjusted === null && scriptMetadata?.segments) { + const parsed = { line: site.line, isQuickJS: false }; + const resolved = resolveSegmentError(parsed, scriptMetadata, scriptType, cache); + if (resolved) { + fileToUse = resolved.filePath; + lineToUse = resolved.line; + } + } + + const loc = site.column ? `${fileToUse}:${lineToUse}:${site.column}` : `${fileToUse}:${lineToUse}`; + const name = site.functionName ? `${site.functionName} (${loc})` : loc; + return ` at ${name}`; + }).join('\n'); +}; + +/** Adjust all line numbers in a stack trace string */ +const adjustStackTrace = (stack, scriptType = null, cache = null, scriptMetadata = null, forceQuickJS = false) => { + if (!stack) return stack; + + return stack.split('\n').map((line) => { + const match = matchStackFrame(line); + if (!match) return line; + + const isQuickJS = forceQuickJS || match.isQuickJS; + const adjusted = adjustLineNumber(match.filePath, match.line, isQuickJS, scriptType, cache, scriptMetadata); + + // Try segment resolution for collection/folder frames + if (adjusted === null && scriptMetadata?.segments) { + const parsed = { line: match.line, isQuickJS }; + const resolved = resolveSegmentError(parsed, scriptMetadata, scriptType, cache); + if (resolved) { + const suffix = match.isQuickJS ? ')' : ''; + return match.column !== null + ? line.replace(`${match.filePath}:${match.line}:${match.column}${suffix}`, `${resolved.filePath}:${resolved.line}:${match.column}${suffix}`) + : line.replace(`${match.filePath}:${match.line}${suffix}`, `${resolved.filePath}:${resolved.line}${suffix}`); + } + return line; + } + + if (adjusted === null || adjusted === match.line) return line; + + const suffix = match.isQuickJS ? ')' : ''; + return match.column !== null + ? line.replace(`:${match.line}:${match.column}${suffix}`, `:${adjusted}:${match.column}${suffix}`) + : line.replace(`:${match.line}${suffix}`, `:${adjusted}${suffix}`); + }).join('\n'); +}; + +/** Resolve original error name from wrapped errors (QuickJS cause / Node VM ScriptError) */ +const getErrorTypeName = (error) => { + return error.cause?.name || error.originalError?.name || error.name || error.constructor?.name || 'Error'; +}; + +/** Format an error with source context and adjusted line numbers */ +const formatErrorWithContext = (error, relativeFilePath = null, scriptType = null, contextLines = DEFAULT_CONTEXT_LINES, scriptMetadata = null) => { + if (!error) return ''; + + const cache = new Map(); + // Use metadata from error object if available, otherwise use passed parameter + const metadata = error.scriptMetadata || scriptMetadata; + + const parsed = parseErrorLocation(error); + if (!parsed) { + return `${error.message}\n${error.stack || ''}`; + } + + const { filePath } = parsed; + const adjustedLine = adjustLineNumber(filePath, parsed.line, parsed.isQuickJS, scriptType, cache, metadata); + + // adjustedLine === null means the error is in a collection/folder script + // resolve to the collection/folder source file using segment metadata + let segmentResult = null; + if (adjustedLine === null) { + segmentResult = resolveSegmentError(parsed, metadata, scriptType, cache); + if (!segmentResult) { + // Fallback: no segment resolution possible, show message + stack only + const errorType = getErrorTypeName(error); + const parts = [`${errorType}: ${error.message}`]; + if (error.__callSites?.length > 0) { + parts.push(buildStackFromCallSites(error.__callSites, scriptType, cache, metadata)); + } else if (error.stack) { + const stackLines = error.stack.split('\n').slice(1); + for (const stackLine of stackLines) { + parts.push(` ${stackLine.trim()}`); + } + } + return parts.join('\n'); + } + } + + const sourceFile = segmentResult ? segmentResult.filePath : filePath; + const sourceLine = segmentResult ? segmentResult.line : adjustedLine; + const context = isAllowedSourceFile(sourceFile) ? getSourceContext(sourceFile, sourceLine, contextLines, cache) : null; + + if (!context) { + return `${error.message}\n${error.stack || ''}`; + } + + const displayPath = segmentResult ? segmentResult.displayPath : (relativeFilePath || filePath); + const lines = []; + + lines.push(`File: ${displayPath}`); + lines.push(''); + + const maxLineNumber = context.lines[context.lines.length - 1].lineNumber; + const lineNumberWidth = String(maxLineNumber).length; + + for (const lineInfo of context.lines) { + const lineNum = String(lineInfo.lineNumber).padStart(lineNumberWidth, ' '); + const prefix = lineInfo.isError ? '>' : ' '; + + lines.push(`${prefix} ${lineNum} | ${lineInfo.content}`); + } + + lines.push(''); + + const errorType = getErrorTypeName(error); + lines.push(`${errorType}: ${error.message}`); + + if (error.__callSites?.length > 0) { + lines.push(buildStackFromCallSites(error.__callSites, scriptType, cache, metadata)); + } else { + const stackToDisplay = adjustStackTrace(error.stack, scriptType, cache, metadata, parsed.isQuickJS); + const userStackLines = stackToDisplay.split('\n').slice(1); + for (const stackLine of userStackLines) { + lines.push(` ${stackLine.trim()}`); + } + } + + return lines.join('\n'); +}; + +module.exports = { + SCRIPT_TYPES, + DEFAULT_CONTEXT_LINES, + parseStackTrace, + parseErrorLocation, + buildStackFromCallSites, + getSourceContext, + formatErrorWithContext, + adjustLineNumber, + resolveSegmentError, + findScriptBlockStartLine, + findYmlScriptBlockStartLine, + adjustStackTrace, + getErrorTypeName +}; diff --git a/packages/bruno-js/src/utils/error-formatter.spec.js b/packages/bruno-js/src/utils/error-formatter.spec.js new file mode 100644 index 000000000..bc0afa114 --- /dev/null +++ b/packages/bruno-js/src/utils/error-formatter.spec.js @@ -0,0 +1,388 @@ +const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals'); +const { + formatErrorWithContext, + findScriptBlockStartLine, + findYmlScriptBlockStartLine, + adjustLineNumber, + parseStackTrace, + parseErrorLocation +} = require('./error-formatter'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// Line numbers annotated for reference: +// 13: script:pre-request { → blockStartLine = 14 +// 14: const token = ... → script line 1 +// 18: script:post-response { → blockStartLine = 19 +// 19: const data = res.body; → script line 1 +// 20: bru.setVar(...) → script line 2 +// 24: tests { → blockStartLine = 25 +// 25: test("status is 200"...) → script line 1 +const MULTI_BLOCK_BRU = `meta { + name: multi-block-test + type: http + seq: 1 +} + +get { + url: https://example.com + body: none + auth: none +} + +script:pre-request { + const token = bru.getEnvVar('token'); + req.setHeader('Authorization', token); +} + +script:post-response { + const data = res.body; + bru.setVar('userId', data.id); + console.log(data); +} + +tests { + test("status is 200", function() { + expect(res.status).to.equal(200); + }); + test("has body", function() { + expect(res.body).to.not.be.null; + }); +}`; + +// Fixture with JS comments to verify line mapping when comments are present. +// 11: script:post-response { → blockStartLine = 12 +// 12: // This is a comment → script line 1 +// 13: const data = res.body; → script line 2 +// 14: // Another comment → script line 3 +// 15: bru.setVar('userId', ...); → script line 4 +const BRU_WITH_COMMENTS = `meta { + name: comment-test + type: http + seq: 1 +} + +get { + url: https://example.com +} + +script:post-response { + // This is a comment + const data = res.body; + // Another comment + bru.setVar('userId', data.id); +}`; + +// YML fixture: blockStartLine = 8 (pre-request), 12 (post-response), 16 (tests) +const MULTI_BLOCK_YML = [ + 'info:', + ' name: yaml-test', + ' version: "1"', + 'runtime:', + ' scripts:', + ' - type: before-request', + ' code: |-', + ' const token = bru.getEnvVar(\'token\');', + ' req.setHeader(\'Authorization\', token);', + ' - type: after-response', + ' code: |-', + ' const data = res.body;', + ' bru.setVar(\'userId\', data.id);', + ' - type: tests', + ' code: |-', + ' test("status is 200", function() {', + ' expect(res.status).to.equal(200);', + ' });' +].join('\n'); + +// Collection/folder yml : scripts at request.scripts +// blockStartLine: before-request = 5, tests = 9 +const COLLECTION_YML = [ + 'info:', + ' name: test-collection', + 'request:', + ' scripts:', + ' - type: before-request', + ' code: |-', + ' const abc = fc()', + ' const x = bru.getVar(\'x\');', + ' - type: tests', + ' code: |-', + ' test("example", function() {', + ' expect(true).to.be.true;', + ' });' +].join('\n'); + +// Wrapper offsets: QuickJS = 9 (script line 1 = VM line 10), NodeVM = 2 (script line 1 = VM line 3) + +describe('Error Formatter', () => { + let testDir; + let bruFilePath; + let ymlFilePath; + let bruWithCommentsPath; + let collectionYmlPath; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-test-')); + bruFilePath = path.join(testDir, 'test.bru'); + ymlFilePath = path.join(testDir, 'test.yml'); + bruWithCommentsPath = path.join(testDir, 'comments.bru'); + collectionYmlPath = path.join(testDir, 'opencollection.yml'); + fs.writeFileSync(bruFilePath, MULTI_BLOCK_BRU); + fs.writeFileSync(ymlFilePath, MULTI_BLOCK_YML); + fs.writeFileSync(bruWithCommentsPath, BRU_WITH_COMMENTS); + fs.writeFileSync(collectionYmlPath, COLLECTION_YML); + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + describe('findScriptBlockStartLine', () => { + it('should find each block type in .bru files', () => { + expect(findScriptBlockStartLine(bruFilePath, 'pre-request')).toBe(14); + expect(findScriptBlockStartLine(bruFilePath, 'post-response')).toBe(19); + expect(findScriptBlockStartLine(bruFilePath, 'test')).toBe(25); + }); + + it('should return null for missing block or non-.bru files', () => { + const noBlockPath = path.join(testDir, 'no-block.bru'); + fs.writeFileSync(noBlockPath, 'meta {\n name: test\n}'); + expect(findScriptBlockStartLine(noBlockPath, 'post-response')).toBeNull(); + expect(findScriptBlockStartLine('/some/file.js', 'post-response')).toBeNull(); + }); + }); + + describe('findYmlScriptBlockStartLine', () => { + it('should find each block type in .yml files', () => { + expect(findYmlScriptBlockStartLine(ymlFilePath, 'pre-request')).toBe(8); + expect(findYmlScriptBlockStartLine(ymlFilePath, 'post-response')).toBe(12); + expect(findYmlScriptBlockStartLine(ymlFilePath, 'test')).toBe(16); + }); + + it('should find script blocks in collection/folder yml files (request.scripts path)', () => { + expect(findYmlScriptBlockStartLine(collectionYmlPath, 'pre-request')).toBe(7); + expect(findYmlScriptBlockStartLine(collectionYmlPath, 'test')).toBe(11); + }); + + it('should return null for missing block or non-.yml files', () => { + const noRuntimePath = path.join(testDir, 'no-runtime.yml'); + fs.writeFileSync(noRuntimePath, 'info:\n name: simple\n version: "1"\n'); + expect(findYmlScriptBlockStartLine(noRuntimePath, 'pre-request')).toBeNull(); + }); + }); + + describe('adjustLineNumber', () => { + it('should adjust QuickJS lines for .bru files', () => { + // VM line - offset(9) = scriptLine → blockStart + scriptLine - 1 + expect(adjustLineNumber(bruFilePath, 10, true, 'pre-request')).toBe(14); + expect(adjustLineNumber(bruFilePath, 11, true, 'post-response')).toBe(20); + expect(adjustLineNumber(bruFilePath, 10, true, 'test')).toBe(25); + }); + + it('should adjust NodeVM lines for .bru files', () => { + // VM line 4 - offset(2) = scriptLine 2 → blockStart(19) + 2 - 1 = 20 + expect(adjustLineNumber(bruFilePath, 4, false, 'post-response')).toBe(20); + }); + + it('should adjust lines for .yml files', () => { + expect(adjustLineNumber(ymlFilePath, 10, true, 'pre-request')).toBe(8); + expect(adjustLineNumber(ymlFilePath, 11, true, 'post-response')).toBe(13); + expect(adjustLineNumber(ymlFilePath, 4, false, 'post-response')).toBe(13); + }); + + it('should adjust lines correctly when script has comments', () => { + // VM line 12 - offset(9) = scriptLine 3 → blockStart(12) + 3 - 1 = 14 + expect(adjustLineNumber(bruWithCommentsPath, 12, true, 'post-response')).toBe(14); + }); + + it('should return reportedLine for non-.bru/.yml files or invalid offset', () => { + expect(adjustLineNumber('/some/file.js', 10, true, 'post-response')).toBe(10); + // VM line 5 - offset(9) = -4, which is < 1 + expect(adjustLineNumber(bruFilePath, 5, true, 'post-response')).toBe(5); + }); + + it('should use metadata for combined scripts', () => { + // scriptLine 5 within request range [5, 7] → blockStart(19) + (5-5) - 1 = 18 + const metadata = { requestStartLine: 5, requestEndLine: 7 }; + expect(adjustLineNumber(bruFilePath, 14, true, 'post-response', null, metadata)).toBe(18); + }); + + it('should return null for collection/folder segment errors', () => { + // scriptLine 3 is before requestStartLine(10) → cannot map to request file + const metadata = { requestStartLine: 10, requestEndLine: 15 }; + expect(adjustLineNumber(bruFilePath, 12, true, 'post-response', null, metadata)).toBeNull(); + }); + + it('should return null when request segment is empty', () => { + // requestStartLine: 0 indicates the request segment was empty + const metadata = { requestStartLine: 0, requestEndLine: 0 }; + expect(adjustLineNumber(bruFilePath, 12, true, 'post-response', null, metadata)).toBeNull(); + }); + }); + + describe('parseStackTrace', () => { + it('should detect QuickJS stack frame formats', () => { + expect(parseStackTrace('Error: test\n at (/path/file.bru:11)')) + .toMatchObject({ filePath: '/path/file.bru', line: 11, isQuickJS: true }); + expect(parseStackTrace('Error: test\n at (/path/file.bru:11)')) + .toMatchObject({ filePath: '/path/file.bru', line: 11, isQuickJS: true }); + expect(parseStackTrace('Error: test\n at (/path/file.bru:11:5)')) + .toMatchObject({ filePath: '/path/file.bru', line: 11, column: 5, isQuickJS: true }); + }); + + it('should detect NodeVM stack frame formats', () => { + expect(parseStackTrace('Error: test\n at /path/file.js:10:5')) + .toMatchObject({ filePath: '/path/file.js', line: 10, column: 5, isQuickJS: false }); + expect(parseStackTrace('Error: test\n at Object. (/path/file.js:10:5)')) + .toMatchObject({ filePath: '/path/file.js', line: 10, column: 5, isQuickJS: false }); + }); + + it('should return null for unparseable or null input', () => { + expect(parseStackTrace('just a plain string')).toBeNull(); + expect(parseStackTrace(null)).toBeNull(); + }); + }); + + describe('formatErrorWithContext', () => { + it('should format error with arrow pointing at the correct line', () => { + const error = new Error('data is not defined'); + error.name = 'ReferenceError'; + error.stack = `ReferenceError: data is not defined\n at (${bruFilePath}:10)`; + + const formatted = formatErrorWithContext(error, 'test.bru', 'post-response'); + expect(formatted).toContain('ReferenceError: data is not defined'); + + const arrowLine = formatted.split('\n').find((l) => l.startsWith('>')); + expect(arrowLine).toContain('const data = res.body;'); + }); + + it('should show original error type for wrapped QuickJS errors', () => { + const error = new Error('x is not defined'); + error.name = 'QuickJSUnwrapError'; + error.cause = { name: 'ReferenceError', message: 'x is not defined' }; + error.stack = `QuickJSUnwrapError: x is not defined\n at (${bruFilePath}:10)`; + + const formatted = formatErrorWithContext(error, 'test.bru', 'post-response'); + expect(formatted).toContain('ReferenceError:'); + expect(formatted).not.toContain('QuickJSUnwrapError'); + }); + + it('should use __callSites and adjust line numbers in stack', () => { + const error = new Error('data is not defined'); + error.name = 'ReferenceError'; + error.stack = `ReferenceError: data is not defined\n at ${bruFilePath}:4:5`; + error.__callSites = [{ filePath: bruFilePath, line: 4, column: 5, functionName: null }]; + + const formatted = formatErrorWithContext(error, 'test.bru', 'post-response'); + // VM line 4 → file line 20 + expect(formatted).toContain(`${bruFilePath}:20:5`); + + const arrowLine = formatted.split('\n').find((l) => l.startsWith('>')); + expect(arrowLine).toContain('bru.setVar'); + }); + + it('should show message-only output for collection/folder script errors', () => { + const error = new Error('x is not defined'); + error.name = 'ReferenceError'; + // scriptLine 3 (VM 12 - offset 9) is before requestStartLine(10) + error.stack = `ReferenceError: x is not defined\n at (${bruFilePath}:12)`; + + const metadata = { requestStartLine: 10, requestEndLine: 15 }; + const formatted = formatErrorWithContext(error, 'test.bru', 'post-response', 5, metadata); + + expect(formatted).toContain('ReferenceError: x is not defined'); + // Should NOT show source context from the request file + expect(formatted).not.toContain('File:'); + expect(formatted).not.toContain('meta {'); + expect(formatted).not.toContain('>'); + }); + + it('should show source context from collection.bru when segments are provided', () => { + const collectionBruPath = path.join(testDir, 'collection.bru'); + fs.writeFileSync(collectionBruPath, 'meta {\n name: My Collection\n}\n\nscript:pre-request {\n const x = undefined;\n x.foo();\n}'); + + const error = new Error('Cannot read properties of undefined'); + error.name = 'TypeError'; + // NodeVM offset=2, scriptRelativeLine = 5-2 = 3 → line 3 of wrapped segment = x.foo() + error.stack = `TypeError: Cannot read properties of undefined\n at ${bruFilePath}:5:5`; + + // Collection segment is lines 1-4 in combined script (3-line wrap of 2-line script) + const metadata = { + requestStartLine: 0, + requestEndLine: 0, + segments: [ + { startLine: 1, endLine: 4, filePath: collectionBruPath, displayPath: 'collection.bru' } + ] + }; + + const formatted = formatErrorWithContext(error, 'test.bru', 'pre-request', 5, metadata); + expect(formatted).toContain('File: collection.bru'); + expect(formatted).toContain('x.foo()'); + expect(formatted).toContain('TypeError: Cannot read properties of undefined'); + const arrowLine = formatted.split('\n').find((l) => l.startsWith('>')); + expect(arrowLine).toContain('x.foo()'); + }); + + it('should resolve error to correct folder when multiple segments exist', () => { + const folder1Dir = path.join(testDir, 'folder1'); + const folder2Dir = path.join(testDir, 'folder2'); + fs.mkdirSync(folder1Dir); + fs.mkdirSync(folder2Dir); + + const folder1Bru = path.join(folder1Dir, 'folder.bru'); + const folder2Bru = path.join(folder2Dir, 'folder.bru'); + fs.writeFileSync(folder1Bru, 'meta {\n name: Folder1\n}\n\nscript:pre-request {\n let a = 1;\n}'); + fs.writeFileSync(folder2Bru, 'meta {\n name: Folder2\n}\n\nscript:pre-request {\n let b = undefined;\n b.pop();\n}'); + + const error = new Error('Cannot read properties of undefined'); + error.name = 'TypeError'; + // NodeVM offset=2, scriptRelativeLine = 9-2 = 7, falls in folder2 segment [5,7] + error.stack = `TypeError: Cannot read properties of undefined\n at ${bruFilePath}:9:5`; + + const metadata = { + requestStartLine: 0, + requestEndLine: 0, + segments: [ + { startLine: 1, endLine: 3, filePath: folder1Bru, displayPath: 'folder1/folder.bru' }, + { startLine: 5, endLine: 7, filePath: folder2Bru, displayPath: 'folder2/folder.bru' } + ] + }; + + const formatted = formatErrorWithContext(error, 'test.bru', 'pre-request', 5, metadata); + expect(formatted).toContain('File: folder2/folder.bru'); + expect(formatted).toContain('b.pop()'); + }); + + it('should resolve collection yml segment errors to opencollection.yml', () => { + const error = new Error('\'fc\' is not defined'); + error.name = 'ReferenceError'; + error.__isQuickJS = true; + // QuickJS offset=9, scriptRelativeLine = 11-9 = 2 → falls in collection segment [1,4] + error.stack = `ReferenceError: 'fc' is not defined\n at (${ymlFilePath}:11)`; + + const metadata = { + requestStartLine: 6, + requestEndLine: 8, + segments: [ + { startLine: 1, endLine: 4, filePath: collectionYmlPath, displayPath: 'opencollection.yml' } + ] + }; + + const formatted = formatErrorWithContext(error, 'test.yml', 'pre-request', 5, metadata); + expect(formatted).toContain('File: opencollection.yml'); + expect(formatted).toContain('\'fc\' is not defined'); + const arrowLine = formatted.split('\n').find((l) => l.startsWith('>')); + expect(arrowLine).toContain('fc()'); + }); + + it('should handle edge cases gracefully', () => { + expect(formatErrorWithContext(null)).toBe(''); + + const error = new Error('Test error'); + error.stack = 'Invalid stack trace'; + expect(formatErrorWithContext(error)).toContain('Test error'); + }); + }); +}); diff --git a/packages/bruno-js/src/utils/sandbox.js b/packages/bruno-js/src/utils/sandbox.js new file mode 100644 index 000000000..b2a824b11 --- /dev/null +++ b/packages/bruno-js/src/utils/sandbox.js @@ -0,0 +1,64 @@ +// Sandbox script wrapping utilities for Node VM and QuickJS. +// Line offsets are computed from the prefix strings so error-formatter.js can map +// VM-reported line numbers back to the original .bru/.yml source lines. + +const SANDBOX = Object.freeze({ + NODEVM: 'nodevm', + QUICKJS: 'quickjs' +}); + +// -- Node VM -- + +const NODEVM_SCRIPT_PREFIX = ` + (async function(){ + `; + +const NODEVM_SCRIPT_SUFFIX = ` + })(); + `; + +// -- QuickJS -- + +const QUICKJS_SCRIPT_PREFIX = ` + (async () => { + const setTimeout = async(fn, timer) => { + v = await bru.sleep(timer); + fn.apply(); + } + + await bru.sleep(0); + try { + `; + +const QUICKJS_SCRIPT_SUFFIX = ` + } + catch(error) { + throw error; + } + return 'done'; + })() + `; + +// Computed offsets — number of newlines before user script in each wrapper +const NODEVM_SCRIPT_WRAPPER_OFFSET = NODEVM_SCRIPT_PREFIX.split('\n').length - 1; +const QUICKJS_SCRIPT_WRAPPER_OFFSET = QUICKJS_SCRIPT_PREFIX.split('\n').length - 1; + +/** + * Wraps a script in the appropriate sandbox closure. + * @param {string} script - The script code to wrap + * @param {'nodevm'|'quickjs'} sandbox - The sandbox runtime to wrap for + * @returns {string} The wrapped script + */ +const wrapScriptInClosure = (script, sandbox) => { + if (sandbox === SANDBOX.QUICKJS) { + return QUICKJS_SCRIPT_PREFIX + script + QUICKJS_SCRIPT_SUFFIX; + } + return NODEVM_SCRIPT_PREFIX + script + NODEVM_SCRIPT_SUFFIX; +}; + +module.exports = { + SANDBOX, + wrapScriptInClosure, + NODEVM_SCRIPT_WRAPPER_OFFSET, + QUICKJS_SCRIPT_WRAPPER_OFFSET +};