setTab('auth')}>
+
setTab('auth')}>
Auth
{hasAuth && }
-
setTab('docs')}>
+
setTab('docs')}>
Docs
diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js
index 844f51f9b..f05196af2 100644
--- a/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js
+++ b/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js
@@ -1,44 +1,122 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
- max-height: 200px;
- min-height: 70px;
- overflow-y: auto;
- background-color: ${(props) => props.theme.background.base};
- border: solid 1px ${(props) => props.theme.border.border2};
- border-left: 4px solid ${(props) => props.theme.colors.text.danger};
- border-radius: ${(props) => props.theme.border.radius.base};
-
+ .script-error-card {
+ background-color: ${(props) => props.theme.background.base};
+ border: solid 1px ${(props) => props.theme.border.border2};
+ border-left: 4px solid ${(props) => props.theme.colors.text.danger};
+ border-radius: ${(props) => props.theme.border.radius.base};
+ padding: 0.75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ overflow-y: visible;
+ }
+
+ .script-error-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
.close-button {
+ all: unset;
opacity: 0.7;
transition: opacity 0.2s;
-
+ cursor: pointer;
+
&:hover {
opacity: 1;
}
-
+
svg {
color: ${(props) => props.theme.text};
}
}
-
+
.error-title {
font-weight: 500;
- margin-bottom: 0.375rem;
color: ${(props) => props.theme.colors.text.danger};
}
-
- .error-message {
+
+ .script-error-source-label {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ min-width: 0;
+ white-space: nowrap;
+ font-size: ${(props) => props.theme.font.size.xs};
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .script-error-file-path {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ min-width: 0;
+ max-width: 100%;
+ font-family: monospace;
+ font-size: ${(props) => props.theme.font.size.xs};
+ font-weight: 400;
+ text-transform: none;
+ letter-spacing: normal;
+ color: ${(props) => props.theme.colors.text.muted};
+ opacity: 0.8;
+ transition: opacity 0.15s, text-decoration 0.15s;
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &.navigable {
+ cursor: pointer;
+
+ &:hover {
+ opacity: 1;
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .script-error-message {
font-family: monospace;
font-size: ${(props) => props.theme.font.size.xs};
line-height: 1.25rem;
white-space: pre-wrap;
word-break: break-all;
- color: ${(props) => props.theme.text};
+ color: ${(props) => props.theme.colors.text.danger};
+ font-weight: 500;
}
- .separator {
- border-top: 1px solid ${(props) => props.theme.border.border1};
+ .script-error-stack-toggle {
+ all: unset;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ cursor: pointer;
+ font-size: ${(props) => props.theme.font.size.xs};
+ color: ${(props) => props.theme.colors.text.muted};
+ user-select: none;
+
+ &:hover {
+ color: ${(props) => props.theme.text};
+ }
+ }
+
+ .script-error-stack {
+ font-family: monospace;
+ font-size: ${(props) => props.theme.font.size.xs};
+ line-height: 1.4;
+ color: ${(props) => props.theme.colors.text.muted};
+ white-space: pre-wrap;
+ word-break: break-all;
+ margin: 0;
+ padding: 0.25rem 0;
}
`;
diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js
index c542fb438..1fee964c1 100644
--- a/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js
@@ -1,37 +1,261 @@
-import React from 'react';
+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 } from 'providers/ReduxStore/slices/tabs';
+import { updateSettingsSelectedTab, updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
+import StyledWrapper from './StyledWrapper';
-const ScriptError = ({ item, onClose }) => {
+/**
+ * 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';
+
+ 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 }));
+ }
+ } 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 }));
+ }
+ } 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 }));
+ }
+ }
+ };
+
+ 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 errors = [];
+ const preRequestContext = item?.preRequestScriptErrorContext;
+ const postResponseContext = item?.postResponseScriptErrorContext;
+ const testContext = item?.testScriptErrorContext;
- if (preRequestError) {
- errors.push({
- title: 'Pre-Request Script Error',
- message: preRequestError
- });
+ 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
;
}
- 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;
diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptError/index.spec.js b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.spec.js
new file mode 100644
index 000000000..3324d64d1
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.spec.js
@@ -0,0 +1,242 @@
+import '@testing-library/jest-dom';
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { ThemeProvider } from 'styled-components';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import ScriptError from './index';
+
+const theme = {
+ font: { size: { xs: '0.75rem' } },
+ text: '#333',
+ background: { base: '#fff', elevated: '#f5f5f5' },
+ border: { border1: '#e0e0e0', border2: '#d0d0d0', radius: { base: '4px' } },
+ colors: { text: { danger: '#ef4444', warning: '#f59e0b', muted: '#999' } }
+};
+
+const mockStore = configureStore({
+ reducer: {
+ tabs: (state = { tabs: [], activeTabUid: null }) => state,
+ collections: (state = { collections: [] }) => state
+ }
+});
+
+const renderWithProviders = (component) => {
+ return render(
+
+
+ {component}
+
+
+ );
+};
+
+const mockCollection = {
+ uid: 'col-1',
+ pathname: '/home/user/collection'
+};
+
+const mockErrorContext = {
+ errorType: 'ReferenceError',
+ filePath: 'echo json.bru',
+ errorLine: 4,
+ lines: [
+ { lineNumber: 3, content: 'const data = res.body;', isError: false },
+ { lineNumber: 4, content: 'console.log(undefinedVar);', isError: true },
+ { lineNumber: 5, content: '', isError: false }
+ ],
+ stack: ' at echo json.bru:4:5'
+};
+
+describe('ScriptError', () => {
+ it('should render nothing when no errors', () => {
+ const { container } = renderWithProviders(
);
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should fall back to ErrorBanner when no errorContext', () => {
+ const item = {
+ preRequestScriptErrorMessage: 'something broke'
+ };
+ renderWithProviders(
);
+ expect(screen.getByText('Pre-Request Script Error')).toBeInTheDocument();
+ expect(screen.getByText('something broke')).toBeInTheDocument();
+ });
+
+ it('should show CodeSnippet when errorContext is available', () => {
+ const item = {
+ preRequestScriptErrorMessage: 'undefinedVar is not defined',
+ preRequestScriptErrorContext: mockErrorContext
+ };
+ const { container } = renderWithProviders(
);
+ expect(screen.getByText('Pre-Request Script Error')).toBeInTheDocument();
+ expect(container.querySelector('.code-snippet')).toBeInTheDocument();
+ });
+
+ it('should show error line highlighted', () => {
+ const item = {
+ preRequestScriptErrorMessage: 'undefinedVar is not defined',
+ preRequestScriptErrorContext: mockErrorContext
+ };
+ const { container } = renderWithProviders(
);
+ expect(container.querySelector('.highlighted-error')).toBeInTheDocument();
+ });
+
+ it('should show error type and message', () => {
+ const item = {
+ preRequestScriptErrorMessage: 'undefinedVar is not defined',
+ preRequestScriptErrorContext: mockErrorContext
+ };
+ renderWithProviders(
);
+ expect(screen.getByText('ReferenceError: undefinedVar is not defined')).toBeInTheDocument();
+ });
+
+ it('should show file path with source label', () => {
+ const item = {
+ preRequestScriptErrorMessage: 'undefinedVar is not defined',
+ preRequestScriptErrorContext: mockErrorContext
+ };
+ const { container } = renderWithProviders(
);
+ expect(container.querySelector('.script-error-file-path')).toBeInTheDocument();
+ expect(screen.getByText('echo json.bru')).toBeInTheDocument();
+ expect(screen.getByText('Request')).toBeInTheDocument();
+ });
+
+ it('should show "Collection Script" label for collection-level errors', () => {
+ const item = {
+ preRequestScriptErrorMessage: 'collection error',
+ preRequestScriptErrorContext: {
+ ...mockErrorContext,
+ filePath: 'collection.bru'
+ }
+ };
+ renderWithProviders(
);
+ expect(screen.getByText('Collection')).toBeInTheDocument();
+ expect(screen.getByText('collection.bru')).toBeInTheDocument();
+ });
+
+ it('should show "Folder Script" label for folder-level errors', () => {
+ const item = {
+ preRequestScriptErrorMessage: 'folder error',
+ preRequestScriptErrorContext: {
+ ...mockErrorContext,
+ filePath: 'subfolder/folder.bru'
+ }
+ };
+ renderWithProviders(
);
+ expect(screen.getByText('Folder')).toBeInTheDocument();
+ expect(screen.getByText('subfolder/folder.bru')).toBeInTheDocument();
+ });
+
+ it('should show "Request Script" label for request-level errors', () => {
+ const item = {
+ postResponseScriptErrorMessage: 'request error',
+ postResponseScriptErrorContext: {
+ ...mockErrorContext,
+ filePath: 'my-request.bru'
+ }
+ };
+ renderWithProviders(
);
+ expect(screen.getByText('Request')).toBeInTheDocument();
+ expect(screen.getByText('my-request.bru')).toBeInTheDocument();
+ });
+
+ it('should toggle stack trace visibility', () => {
+ const item = {
+ preRequestScriptErrorMessage: 'undefinedVar is not defined',
+ preRequestScriptErrorContext: mockErrorContext
+ };
+ renderWithProviders(
);
+
+ // Stack should be hidden by default
+ expect(screen.queryByText(/at echo json\.bru/)).not.toBeInTheDocument();
+ expect(screen.getByText('Show stack trace')).toBeInTheDocument();
+
+ // Click to show
+ fireEvent.click(screen.getByText('Show stack trace'));
+ expect(screen.getByText(/at echo json\.bru/)).toBeInTheDocument();
+ expect(screen.getByText('Hide stack trace')).toBeInTheDocument();
+
+ // Click to hide
+ fireEvent.click(screen.getByText('Hide stack trace'));
+ expect(screen.queryByText(/at echo json\.bru/)).not.toBeInTheDocument();
+ });
+
+ it('should call onClose when close button is clicked', () => {
+ const onClose = jest.fn();
+ const item = {
+ preRequestScriptErrorMessage: 'error',
+ preRequestScriptErrorContext: mockErrorContext
+ };
+ const { container } = renderWithProviders(
);
+ const closeButton = container.querySelector('.close-button');
+ fireEvent.click(closeButton);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('should fallback to "Error" when errorType is missing', () => {
+ const item = {
+ preRequestScriptErrorMessage: 'something went wrong',
+ preRequestScriptErrorContext: {
+ ...mockErrorContext,
+ errorType: undefined
+ }
+ };
+ renderWithProviders(
);
+ expect(screen.getByText('Error: something went wrong')).toBeInTheDocument();
+ });
+
+ it('should not show pointer cursor on non-navigable file path', () => {
+ const item = {
+ preRequestScriptErrorMessage: 'error',
+ preRequestScriptErrorContext: mockErrorContext
+ };
+ // No item.uid means request-level navigation is disabled
+ const { container } = renderWithProviders(
);
+ const filePath = container.querySelector('.script-error-file-path');
+ expect(filePath).not.toHaveClass('navigable');
+ });
+
+ it('should detect folder-level errors with Windows backslash paths', () => {
+ const item = {
+ preRequestScriptErrorMessage: 'folder error',
+ preRequestScriptErrorContext: {
+ ...mockErrorContext,
+ filePath: 'subfolder\\folder.bru'
+ }
+ };
+ renderWithProviders(
);
+ expect(screen.getByText('Folder')).toBeInTheDocument();
+ expect(screen.getByText('subfolder/folder.bru')).toBeInTheDocument();
+ });
+
+ it('should detect request-level errors with Windows backslash paths', () => {
+ const item = {
+ postResponseScriptErrorMessage: 'request error',
+ postResponseScriptErrorContext: {
+ ...mockErrorContext,
+ filePath: 'subfolder\\my-request.bru'
+ }
+ };
+ renderWithProviders(
);
+ expect(screen.getByText('Request')).toBeInTheDocument();
+ expect(screen.getByText('subfolder/my-request.bru')).toBeInTheDocument();
+ });
+
+ it('should handle multiple errors with their own context', () => {
+ const item = {
+ preRequestScriptErrorMessage: 'pre error',
+ preRequestScriptErrorContext: mockErrorContext,
+ testScriptErrorMessage: 'test error',
+ testScriptErrorContext: {
+ ...mockErrorContext,
+ errorType: 'TypeError'
+ }
+ };
+ renderWithProviders(
);
+ expect(screen.getByText('Pre-Request Script Error')).toBeInTheDocument();
+ expect(screen.getByText('Test Script Error')).toBeInTheDocument();
+ expect(screen.getByText('ReferenceError: pre error')).toBeInTheDocument();
+ expect(screen.getByText('TypeError: test error')).toBeInTheDocument();
+ });
+});
diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js b/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js
index 41420eeb0..0c231aff9 100644
--- a/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js
@@ -1,15 +1,17 @@
import React from 'react';
+import classnames from 'classnames';
import { IconAlertCircle } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
-const ScriptErrorIcon = ({ itemUid, onClick }) => {
+const ScriptErrorIcon = ({ itemUid, onClick, className }) => {
const toolhintId = `script-error-icon-${itemUid}`;
return (
<>