mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-26 06:05:45 +00:00
* 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
427 lines
15 KiB
JavaScript
427 lines
15 KiB
JavaScript
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 <anonymous> (/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.<anonymous> (/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
|
|
};
|