import React, { useState } from 'react'; import { useDispatch } from 'react-redux'; import { IconX, IconChevronDown, IconChevronRight, IconExternalLink } from '@tabler/icons'; import ErrorBanner from 'ui/ErrorBanner'; import CodeSnippet from 'components/CodeSnippet'; import { getTreePathFromCollectionToItem } from 'utils/collections'; import { normalizePath } from 'utils/common/path'; import { addTab, updateRequestPaneTab, updateScriptPaneTab, setFocusErrorLine } from 'providers/ReduxStore/slices/tabs'; import { updateSettingsSelectedTab, updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections'; import StyledWrapper from './StyledWrapper'; /** * Determines the source of a script error (request, folder, or collection) * based on the filePath from the error context. * * Bruno executes scripts at three levels in order: collection -> folder -> request. * When an error occurs, the filePath tells us which level it came from: * * filePath: "echo json.bru" -> request-level -> { sourceType: 'request', label: 'Request' } * filePath: "auth/folder.bru" -> folder-level -> { sourceType: 'folder', label: 'Folder: auth', sourceUid: 'f1' } * filePath: "collection.bru" -> collection-level -> { sourceType: 'collection', label: 'Collection' } * * For folder-level errors, this function walks the tree path from collection to * the current item to match the folder by its relative path, resolving its UID * and display name. If the folder can't be matched (e.g. missing tree data), * it falls back to a generic "Folder" label without a sourceUid. * * @param {string|undefined} filePath - Relative path from errorContext (e.g. "subfolder/folder.bru") * @param {object} item - The current request item * @param {object} collection - The parent collection (needs .pathname for folder matching) * @param {function} getTreePath - Function to get the tree path from collection root to item * @returns {{ sourceType: string, label: string, sourceUid?: string } | null} */ const getErrorSourceInfo = (filePath, item, collection, getTreePath) => { if (!filePath) return null; // Normalize backslashes to forward slashes for cross-platform compatibility. // On Windows, path.relative() produces backslash separators, but the renderer // logic and regexes expect forward slashes. const normalizedPath = normalizePath(filePath); const isFolderFile = /(?:^|\/)folder\.(?:bru|yml)$/.test(normalizedPath); const isCollectionFile = normalizedPath === 'collection.bru' || /^opencollection\.yml$/.test(normalizedPath); // Folder level (check before collection to avoid folder.yml matching as collection) if (isFolderFile) { const info = { sourceType: 'folder', label: 'Folder' }; const folderFileName = normalizedPath.split('/').pop(); // Try to find the folder UID and name from the tree path if (getTreePath && collection && item) { const collectionPathname = normalizePath(collection.pathname || ''); const treePath = getTreePath(collection, item); if (treePath?.length) { for (const node of treePath) { if (node?.type === 'folder') { const nodePath = normalizePath(node.pathname || ''); const folderRelPath = nodePath && nodePath.startsWith(collectionPathname) ? nodePath.slice(collectionPathname.length).replace(/^\//, '') + '/' + folderFileName : folderFileName; if (folderRelPath === normalizedPath) { info.sourceUid = node.uid; info.label = `Folder: ${node.name}`; break; } } } } } return info; } // Collection level if (isCollectionFile) { return { sourceType: 'collection', label: 'Collection' }; } // Request level return { sourceType: 'request', label: 'Request' }; }; const ScriptErrorCard = ({ title, message, errorContext, item, collection, scriptPhase, onClose }) => { const dispatch = useDispatch(); const [showStack, setShowStack] = useState(false); const displayFilePath = errorContext?.filePath ? normalizePath(errorContext.filePath) : null; const sourceInfo = getErrorSourceInfo( errorContext?.filePath, item, collection, getTreePathFromCollectionToItem ); const canNavigate = sourceInfo && collection?.uid && (sourceInfo.sourceType === 'collection' || (sourceInfo.sourceType === 'folder' && sourceInfo.sourceUid) || (sourceInfo.sourceType === 'request' && item?.uid)); const handleNavigateKeyDown = (e) => { if (!canNavigate) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleNavigate(); } }; const handleNavigate = () => { if (!canNavigate) return; // CollectionSettings expects 'tests', FolderSettings expects 'test' const collectionSettingsTab = scriptPhase === 'test' ? 'tests' : 'script'; const folderSettingsTab = scriptPhase === 'test' ? 'test' : 'script'; const errorLine = errorContext?.errorLine; const focusPayload = (uid) => typeof errorLine === 'number' ? { uid, scriptPhase, line: errorLine, requestedAt: Date.now() } : null; if (sourceInfo.sourceType === 'collection') { dispatch(addTab({ uid: collection.uid, collectionUid: collection.uid, type: 'collection-settings' })); dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: collectionSettingsTab })); if (collectionSettingsTab === 'script') { dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: scriptPhase })); } const payload = focusPayload(collection.uid); if (payload) dispatch(setFocusErrorLine(payload)); } else if (sourceInfo.sourceType === 'folder' && sourceInfo.sourceUid) { dispatch(addTab({ uid: sourceInfo.sourceUid, collectionUid: collection.uid, type: 'folder-settings' })); dispatch(updatedFolderSettingsSelectedTab({ collectionUid: collection.uid, folderUid: sourceInfo.sourceUid, tab: folderSettingsTab })); if (folderSettingsTab === 'script') { dispatch(updateScriptPaneTab({ uid: sourceInfo.sourceUid, scriptPaneTab: scriptPhase })); } const payload = focusPayload(sourceInfo.sourceUid); if (payload) dispatch(setFocusErrorLine(payload)); } else if (sourceInfo.sourceType === 'request') { dispatch(addTab({ uid: item.uid, collectionUid: collection.uid, type: 'request' })); if (scriptPhase === 'test') { dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: 'tests' })); } else { dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: 'script' })); dispatch(updateScriptPaneTab({ uid: item.uid, scriptPaneTab: scriptPhase })); } const payload = focusPayload(item.uid); if (payload) dispatch(setFocusErrorLine(payload)); } }; if (!errorContext) { return ; } return (
{title}
{onClose && ( )}
{(sourceInfo || displayFilePath) && (
{sourceInfo && {sourceInfo.label}} {displayFilePath && ( {displayFilePath} {canNavigate && } )}
)}
{errorContext.errorType || 'Error'}: {message}
{errorContext.stack && (
{showStack && (
{errorContext.stack}
)}
)}
); }; const ScriptError = ({ item, collection, onClose }) => { const preRequestError = item?.preRequestScriptErrorMessage; const postResponseError = item?.postResponseScriptErrorMessage; const testScriptError = item?.testScriptErrorMessage; if (!preRequestError && !postResponseError && !testScriptError) return null; const preRequestContext = item?.preRequestScriptErrorContext; const postResponseContext = item?.postResponseScriptErrorContext; const testContext = item?.testScriptErrorContext; const hasAnyContext = preRequestContext || postResponseContext || testContext; // If no error context available for any error, fall back to ErrorBanner if (!hasAnyContext) { const errors = []; if (preRequestError) errors.push({ title: 'Pre-Request Script Error', message: preRequestError }); if (postResponseError) errors.push({ title: 'Post-Response Script Error', message: postResponseError }); if (testScriptError) errors.push({ title: 'Test Script Error', message: testScriptError }); return ; } return (
{preRequestError && ( )} {postResponseError && ( )} {testScriptError && ( )}
); }; export default ScriptError;