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
This commit is contained in:
gopu-bruno
2026-03-02 15:27:18 +05:30
committed by GitHub
parent 4797abbeff
commit 910581a627
17 changed files with 1140 additions and 84 deletions

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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;
}
};