feat: enhance ScriptError with source context and remove auto-commenting of untranslated pm commands (#7449)

* feat: enhance ScriptError with source context, code snippets, and navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: remove auto-commenting of untranslated pm commands during import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update CodeSnippet styles to use theme colors for error and warning highlights

* fix: remove unused SCRIPT_TYPES import from network IPC module

* refactor: remove unused functions and clean up source-context utility

- Removed `getUnifiedScriptContext`, `getWarningSourceGroups`, and related helper functions from `source-context.js` to streamline the utility.
- Updated tests in `source-context.spec.js` to reflect the removal of unused functions, ensuring only relevant tests for `findLineInSource` and `getScriptContext` remain.

* refactor: simplify ScriptError component and update styles

* refactor: streamline tab management in Script components

* refactor: enhance tab management in ScriptError and add testsMetadata handling in prepare-request

* refactor: improve error source identification in ScriptError component

- Enhanced the logic for determining error source types by introducing separate checks for folder and collection files.
- Updated the handling of folder file names to ensure accurate UID retrieval and labeling.
- Streamlined the overall structure of the getErrorSourceInfo function for better readability and maintainability.

* refactor: improve ScriptError component and enhance styling

- Simplified the conditions for displaying the ScriptError component in ResponsePane.
- Updated navigation logic in ScriptErrorCard to ensure proper handling of source information.
- Adjusted styles in StyledWrapper to allow for visible overflow.
- Enhanced ScriptErrorIcon to accept additional class names for better styling flexibility.
- Minor layout adjustments in RunnerResults ResponsePane for improved UI consistency.

* refactor: simplify test description for untranslated pm commands and consolidate error formatter imports

* fixes

* refactor: update focusedTab logic in Script components to use collection and folder UIDthe respective collection and folder UID instead of the activeTabUid.

* feat: add buildErrorContext utility for enhanced error handling in network IPC

- Introduced a new utility function `buildErrorContext` to construct detailed error context from script errors.
- This function parses error locations, adjusts line numbers, and retrieves relevant source context, improving error reporting.
- Removed the previous inline error handling logic from the network IPC module to streamline the codebase.

* feat: added playwright test cases to test scriptError behavior

* refactor: enhance ScriptError component and improve error handling

- Updated labels for error source types in ScriptError to be more concise (e.g., "Request Script" to "Request").
- Improved navigation logic in ScriptErrorCard to ensure proper handling of navigable file paths.
- Enhanced styling in StyledWrapper for better visual consistency and user experience.
- Added tests to verify fallback behavior for missing error types and non-navigable file paths.
- Refined utility functions for better context extraction from script errors.

* refactor: normalize file paths for cross-platform compatibility in ScriptError component

* refactor: improve file path handling in ScriptError component

* refactor: enhance buildErrorContext for improved error handling

* docs: add detailed comments to build-error-context for better understanding of error handling

* refactor: enhance error block line detection in error formatter

- Updated `findScriptBlockEndLine` and `findYmlScriptBlockEndLine` functions to return null for empty or missing blocks, improving accuracy in line detection.
- Added comprehensive tests for both functions to ensure correct behavior across various scenarios, including handling of non-.bru and non-.yml files.

* refactor: improve error handling and testing in ScriptError and buildErrorContext

- Updated ScriptError component to streamline tab management by replacing focusTab with addTab for better request handling.
- Enhanced buildErrorContext to return null for empty or missing script blocks in .bru and .yml files, ensuring accurate error reporting.
- Added tests to validate behavior for empty script blocks and improved error context extraction in various scenarios.

* test: add new tests for script error navigation and handling

- Implemented tests for post-response file-path navigation to the Script tab and verification of active sub-tabs.
- Added keyboard navigation tests to trigger file-path navigation using the Enter key.
- Included a test for multiple error cards to ensure closing one does not affect others.
- Enhanced runner tests to verify navigation to the Tests tab from script error results.

* refactor: enhance CodeSnippet line rendering and remove unused source-context utilities

* refactor: update locators in script-errors tests for improved readability and maintainability

* test: enhance script-errors tests to verify error line content

* review fixes

* refactor: update RunnerResults component and enhance locators for improved testability

* refactor: remove buildErrorContext and replace with formatErrorWithContextV2

- Deleted the buildErrorContext function and its associated tests.
- Updated network IPC to utilize formatErrorWithContextV2 for improved error context handling.
- Enhanced error reporting by ensuring structured error context is returned for desktop UI.

* refactor: enhance tab components with data-testid attributes for improved testability

- Added data-testid attributes to tab elements in CollectionSettings and FolderSettings components for better integration with testing frameworks.
- Updated Tabs and ResponsiveTabs components to include data-testid attributes for tab triggers, enhancing the ability to select and verify tabs in tests.
- Modified script-errors tests to utilize new locators for improved readability and maintainability.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
sanish chirayath
2026-03-20 21:36:02 +05:30
committed by GitHub
parent 37be721922
commit 646c90819d
40 changed files with 2609 additions and 130 deletions

View File

@@ -0,0 +1,61 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.code-snippet {
font-family: monospace;
font-size: ${(props) => props.theme.font.size.xs};
line-height: 1.4;
overflow-x: auto;
border-radius: ${(props) => props.theme.border.radius.base};
background-color: ${(props) => props.theme.background.elevated};
border: 1px solid ${(props) => props.theme.border.border2};
}
.code-line {
display: flex;
align-items: stretch;
}
.code-line.highlighted-error {
background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
border-left: 3px solid ${(props) => props.theme.colors.text.danger};
}
.code-line.highlighted-warning {
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.1)};
border-left: 3px solid ${(props) => props.theme.colors.text.warning};
}
.code-line:not(.highlighted-error):not(.highlighted-warning) {
border-left: 3px solid transparent;
}
.code-line-number {
min-width: 2.5rem;
text-align: right;
padding: 0 0.5rem;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
flex-shrink: 0;
}
.code-line-content {
white-space: pre;
padding: 0 0.5rem;
flex: 1;
min-width: 0;
}
.code-line-separator {
border-left: 3px solid transparent;
}
.separator-content {
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
padding: 0 0.5rem;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const renderLine = (line, highlightClass, hunkIdx) => {
const isHighlighted = line.isHighlighted || line.isError;
const key = hunkIdx != null ? `${hunkIdx}-${line.lineNumber}` : line.lineNumber;
return (
<div
key={key}
className={`code-line ${isHighlighted ? highlightClass : ''}`}
data-testid={isHighlighted ? 'code-line-error' : 'code-line'}
>
<span className="code-line-number">{line.lineNumber}</span>
<span className="code-line-content">
{isHighlighted ? '> ' : ' '}{line.content}
</span>
</div>
);
};
const CodeSnippet = ({ lines, hunks, variant = 'error' }) => {
const highlightClass = variant === 'warning' ? 'highlighted-warning' : 'highlighted-error';
if (hunks?.length) {
return (
<StyledWrapper>
<div className="code-snippet" data-testid="code-snippet">
{hunks.map((hunk, idx) => (
<React.Fragment key={idx}>
{hunk.hasSeparatorBefore && (
<div className="code-line code-line-separator">
<span className="code-line-number"></span>
<span className="code-line-content separator-content">{'\u22EE'}</span>
</div>
)}
{hunk.lines.map((line) => renderLine(line, highlightClass, idx))}
</React.Fragment>
))}
</div>
</StyledWrapper>
);
}
if (!lines?.length) return null;
return (
<StyledWrapper>
<div className="code-snippet" data-testid="code-snippet">
{lines.map((line) => renderLine(line, highlightClass))}
</div>
</StyledWrapper>
);
};
export default CodeSnippet;

View File

@@ -0,0 +1,140 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import CodeSnippet from './index';
const theme = {
font: { size: { xs: '0.75rem' } },
background: { elevated: '#f5f5f5' },
border: { border2: '#e0e0e0', radius: { base: '4px' } },
colors: { text: { danger: '#ef4444', warning: '#f59e0b', muted: '#999' } }
};
const renderWithTheme = (component) => {
return render(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
const sampleLines = [
{ lineNumber: 3, content: 'const a = 1;', isHighlighted: false },
{ lineNumber: 4, content: 'undefinedVar.foo();', isHighlighted: true },
{ lineNumber: 5, content: 'const b = 2;', isHighlighted: false }
];
describe('CodeSnippet', () => {
it('should render nothing when lines is empty', () => {
const { container } = renderWithTheme(<CodeSnippet lines={[]} />);
expect(container.firstChild).toBeNull();
});
it('should render nothing when lines is null', () => {
const { container } = renderWithTheme(<CodeSnippet lines={null} />);
expect(container.firstChild).toBeNull();
});
it('should render all lines with line numbers', () => {
renderWithTheme(<CodeSnippet lines={sampleLines} />);
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
it('should apply error highlight class by default', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="error" />);
const highlightedLine = container.querySelector('.highlighted-error');
expect(highlightedLine).toBeInTheDocument();
});
it('should apply warning highlight class when variant is warning', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="warning" />);
const highlightedLine = container.querySelector('.highlighted-warning');
expect(highlightedLine).toBeInTheDocument();
expect(container.querySelector('.highlighted-error')).not.toBeInTheDocument();
});
it('should show > prefix on highlighted line for accessibility', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} />);
const codeLineContents = container.querySelectorAll('.code-line-content');
// The highlighted line (index 1) should start with "> "
expect(codeLineContents[1].textContent).toContain('> ');
// Non-highlighted lines should not have ">"
expect(codeLineContents[0].textContent).not.toContain('>');
});
it('should also support isError property for backward compatibility', () => {
const linesWithIsError = [
{ lineNumber: 1, content: 'line 1', isError: false },
{ lineNumber: 2, content: 'error line', isError: true },
{ lineNumber: 3, content: 'line 3', isError: false }
];
const { container } = renderWithTheme(<CodeSnippet lines={linesWithIsError} />);
expect(container.querySelector('.highlighted-error')).toBeInTheDocument();
});
describe('hunks prop', () => {
const sampleHunks = [
{
hasSeparatorBefore: false,
lines: [
{ lineNumber: 1, content: 'const a = true;', isHighlighted: false },
{ lineNumber: 2, content: 'pm.vault.get();', isHighlighted: true },
{ lineNumber: 3, content: 'const b = false;', isHighlighted: false }
]
},
{
hasSeparatorBefore: true,
lines: [
{ lineNumber: 10, content: 'const x = null;', isHighlighted: false },
{ lineNumber: 11, content: 'pm.cookies.jar();', isHighlighted: true },
{ lineNumber: 12, content: 'const y = undefined;', isHighlighted: false }
]
}
];
it('should render all lines from all hunks', () => {
renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
// line numbers
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('11')).toBeInTheDocument();
// content
expect(screen.getByText(/const a = true;/)).toBeInTheDocument();
expect(screen.getByText(/pm\.vault\.get\(\);/)).toBeInTheDocument();
expect(screen.getByText(/const x = null;/)).toBeInTheDocument();
expect(screen.getByText(/pm\.cookies\.jar\(\);/)).toBeInTheDocument();
});
it('should render separator between hunks when hasSeparatorBefore is true', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const separators = container.querySelectorAll('.code-line-separator');
expect(separators).toHaveLength(1);
// separator should appear between the two hunks, not before the first
const allRows = container.querySelectorAll('.code-line, .code-line-separator');
const separatorIndex = Array.from(allRows).findIndex((el) => el.classList.contains('code-line-separator'));
// first hunk has 3 lines (indices 0-2), separator should be at index 3
expect(separatorIndex).toBe(3);
});
it('should render the ellipsis character in separator', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const separator = container.querySelector('.separator-content');
expect(separator.textContent).toBe('\u22EE');
});
it('should apply warning highlights within hunks', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const highlighted = container.querySelectorAll('.highlighted-warning');
expect(highlighted).toHaveLength(2);
});
it('should render nothing when hunks is empty array', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={[]} />);
expect(container.firstChild).toBeNull();
});
});
});

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
@@ -18,27 +20,24 @@ const Script = ({ collection }) => {
const requestScript = collection.draft?.root ? get(collection, 'draft.root.request.script.req', '') : get(collection, 'root.request.script.req', '');
const responseScript = collection.draft?.root ? get(collection, 'draft.root.request.script.res', '') : get(collection, 'root.request.script.res', '');
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === collection.uid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
const getDefaultTab = () => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
return hasPreRequestScript ? 'pre-request' : 'post-response';
};
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevCollectionUidRef = useRef(collection.uid);
const activeTab = scriptPaneTab || getDefaultTab();
const setActiveTab = (tab) => {
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: tab }));
};
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Update active tab only when switching to a different collection
useEffect(() => {
if (prevCollectionUidRef.current !== collection.uid) {
prevCollectionUidRef.current = collection.uid;
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
}
}, [collection.uid, requestScript]);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {

View File

@@ -106,42 +106,42 @@ const CollectionSettings = ({ collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-hidden">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
<div className={getTabClassname('overview')} role="tab" data-testid="collection-settings-tab-overview" onClick={() => setTab('overview')}>
Overview
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
<div className={getTabClassname('headers')} role="tab" data-testid="collection-settings-tab-headers" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
<div className={getTabClassname('vars')} role="tab" data-testid="collection-settings-tab-vars" onClick={() => setTab('vars')}>
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
<div className={getTabClassname('auth')} role="tab" data-testid="collection-settings-tab-auth" onClick={() => setTab('auth')}>
Auth
{authMode !== 'none' && <StatusDot />}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
<div className={getTabClassname('script')} role="tab" data-testid="collection-settings-tab-script" onClick={() => setTab('script')}>
Script
{hasScripts && <StatusDot />}
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
<div className={getTabClassname('tests')} role="tab" data-testid="collection-settings-tab-tests" onClick={() => setTab('tests')}>
Tests
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
<div className={getTabClassname('presets')} role="tab" data-testid="collection-settings-tab-presets" onClick={() => setTab('presets')}>
Presets
{hasPresets && <StatusDot />}
</div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
<div className={getTabClassname('proxy')} role="tab" data-testid="collection-settings-tab-proxy" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
</div>
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
<div className={getTabClassname('clientCert')} role="tab" data-testid="collection-settings-tab-clientCert" onClick={() => setTab('clientCert')}>
Client Certificates
{clientCertConfig.length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
<div className={getTabClassname('protobuf')} role="tab" data-testid="collection-settings-tab-protobuf" onClick={() => setTab('protobuf')}>
Protobuf
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
</div>

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
@@ -18,27 +20,25 @@ const Script = ({ collection, folder }) => {
const requestScript = folder.draft ? get(folder, 'draft.request.script.req', '') : get(folder, 'root.request.script.req', '');
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === folder.uid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
const getDefaultTab = () => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
return hasPreRequestScript ? 'pre-request' : 'post-response';
};
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevFolderUidRef = useRef(folder.uid);
const activeTab = scriptPaneTab || getDefaultTab();
const setActiveTab = (tab) => {
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
};
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Update active tab only when switching to a different folder
useEffect(() => {
if (prevFolderUidRef.current !== folder.uid) {
prevFolderUidRef.current = folder.uid;
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
}
}, [folder.uid, requestScript]);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {

View File

@@ -77,27 +77,27 @@ const FolderSettings = ({ collection, folder }) => {
<StyledWrapper className="flex flex-col h-full overflow-auto">
<div className="flex flex-col h-full relative px-4 py-4">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
<div className={getTabClassname('headers')} role="tab" data-testid="folder-settings-tab-headers" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
<div className={getTabClassname('script')} role="tab" data-testid="folder-settings-tab-script" onClick={() => setTab('script')}>
Script
{hasScripts && <StatusDot />}
</div>
<div className={getTabClassname('test')} role="tab" onClick={() => setTab('test')}>
<div className={getTabClassname('test')} role="tab" data-testid="folder-settings-tab-test" onClick={() => setTab('test')}>
Test
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
<div className={getTabClassname('vars')} role="tab" data-testid="folder-settings-tab-vars" onClick={() => setTab('vars')}>
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
<div className={getTabClassname('auth')} role="tab" data-testid="folder-settings-tab-auth" onClick={() => setTab('auth')}>
Auth
{hasAuth && <StatusDot />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
<div className={getTabClassname('docs')} role="tab" data-testid="folder-settings-tab-docs" onClick={() => setTab('docs')}>
Docs
</div>
</div>

View File

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

View File

@@ -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 <ErrorBanner errors={[{ title, message }]} onClose={onClose} />;
}
return (
<StyledWrapper>
<div className="script-error-card" data-testid="script-error-card">
<div className="script-error-header">
<div className="error-title" data-testid="script-error-title">{title}</div>
{onClose && (
<button className="close-button flex-shrink-0 cursor-pointer" data-testid="script-error-close" onClick={onClose} aria-label="Close error">
<IconX size={16} strokeWidth={1.5} />
</button>
)}
</div>
{(sourceInfo || displayFilePath) && (
<div className="script-error-source-label" data-testid="script-error-source-label">
{sourceInfo && <span>{sourceInfo.label}</span>}
{displayFilePath && (
<span
className={`script-error-file-path${canNavigate ? ' navigable' : ''}`}
data-testid="script-error-file-path"
role={canNavigate ? 'button' : undefined}
tabIndex={canNavigate ? 0 : undefined}
onClick={handleNavigate}
onKeyDown={handleNavigateKeyDown}
title={canNavigate ? `Open ${displayFilePath}` : undefined}
>
<span>{displayFilePath}</span>
{canNavigate && <IconExternalLink size={12} className="flex-shrink-0" />}
</span>
)}
</div>
)}
<CodeSnippet lines={errorContext.lines} variant="error" />
<div className="script-error-message" data-testid="script-error-message">
{errorContext.errorType || 'Error'}: {message}
</div>
{errorContext.stack && (
<div>
<button
className="script-error-stack-toggle"
data-testid="script-error-stack-toggle"
onClick={() => setShowStack(!showStack)}
aria-expanded={showStack}
aria-label={`${showStack ? 'Hide' : 'Show'} stack trace`}
>
{showStack ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
<span>{showStack ? 'Hide' : 'Show'} stack trace</span>
</button>
{showStack && (
<pre className="script-error-stack" data-testid="script-error-stack">{errorContext.stack}</pre>
)}
</div>
)}
</div>
</StyledWrapper>
);
};
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 <ErrorBanner errors={errors} onClose={onClose} className="mb-2" />;
}
if (postResponseError) {
errors.push({
title: 'Post-Response Script Error',
message: postResponseError
});
}
if (testScriptError) {
errors.push({
title: 'Test Script Error',
message: testScriptError
});
}
return <ErrorBanner errors={errors} onClose={onClose} className="mt-4 mb-2" />;
return (
<div className="mb-2 flex flex-col gap-2">
{preRequestError && (
<ScriptErrorCard
title="Pre-Request Script Error"
message={preRequestError}
errorContext={preRequestContext}
item={item}
collection={collection}
scriptPhase="pre-request"
onClose={onClose}
/>
)}
{postResponseError && (
<ScriptErrorCard
title="Post-Response Script Error"
message={postResponseError}
errorContext={postResponseContext}
item={item}
collection={collection}
scriptPhase="post-response"
onClose={onClose}
/>
)}
{testScriptError && (
<ScriptErrorCard
title="Test Script Error"
message={testScriptError}
errorContext={testContext}
item={item}
collection={collection}
scriptPhase="test"
onClose={onClose}
/>
)}
</div>
);
};
export default ScriptError;

View File

@@ -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(
<Provider store={mockStore}>
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
</Provider>
);
};
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(<ScriptError item={{}} collection={mockCollection} onClose={jest.fn()} />);
expect(container.firstChild).toBeNull();
});
it('should fall back to ErrorBanner when no errorContext', () => {
const item = {
preRequestScriptErrorMessage: 'something broke'
};
renderWithProviders(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
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(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
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(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
expect(container.querySelector('.highlighted-error')).toBeInTheDocument();
});
it('should show error type and message', () => {
const item = {
preRequestScriptErrorMessage: 'undefinedVar is not defined',
preRequestScriptErrorContext: mockErrorContext
};
renderWithProviders(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
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(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
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(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
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(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
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(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
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(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
// 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(<ScriptError item={item} collection={mockCollection} onClose={onClose} />);
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(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
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(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
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(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
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(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
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(<ScriptError item={item} collection={mockCollection} onClose={jest.fn()} />);
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();
});
});

View File

@@ -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 (
<>
<div
id={toolhintId}
className="cursor-pointer ml-2"
className={classnames('cursor-pointer ml-2', className)}
data-testid="script-error-icon"
onClick={onClick}
>
<div className="flex items-center text-red-400">

View File

@@ -304,6 +304,7 @@ const ResponsePane = ({ item, collection }) => {
<ScriptError
item={item}
onClose={() => setShowScriptErrorCard(false)}
collection={collection}
/>
)}
<div className="flex-1 overflow-y-auto">

View File

@@ -120,6 +120,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
<div className="flex flex-grow justify-end items-center">
{hasScriptError && !showScriptErrorCard && (
<ScriptErrorIcon
className="mr-2"
itemUid={item.uid}
onClick={() => setShowScriptErrorCard(true)}
/>
@@ -134,6 +135,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
<ScriptError
item={item}
onClose={() => setShowScriptErrorCard(false)}
collection={collection}
/>
)}
<div className="flex-1">

View File

@@ -425,7 +425,7 @@ export default function RunnerResults({ collection }) {
{filteredItems.map((item) => {
return (
<div key={item.uid}>
<div className="item-path mt-2">
<div className="item-path mt-2" data-testid="runner-result-item">
<div className="flex items-center">
<span>
{allTestsPassed(item)

View File

@@ -23,6 +23,7 @@ export const TabsTrigger = ({ value: triggerValue, children, className = '' }) =
return (
<button
onClick={() => onValueChange(triggerValue)}
data-testid={`tab-trigger-${triggerValue}`}
className={classnames('tab-trigger', className, { active: isActive })}
>
{children}

View File

@@ -2938,6 +2938,9 @@ export const collectionsSlice = createSlice({
item.preRequestScriptErrorMessage = null;
item.postResponseScriptErrorMessage = null;
item.testScriptErrorMessage = null;
item.preRequestScriptErrorContext = null;
item.postResponseScriptErrorContext = null;
item.testScriptErrorContext = null;
},
runRequestEvent: (state, action) => {
const { itemUid, collectionUid, type, requestUid } = action.payload;
@@ -2951,14 +2954,17 @@ export const collectionsSlice = createSlice({
if (type === 'pre-request-script-execution') {
item.preRequestScriptErrorMessage = action.payload.errorMessage;
item.preRequestScriptErrorContext = action.payload.errorContext || null;
}
if (type === 'post-response-script-execution') {
item.postResponseScriptErrorMessage = action.payload.errorMessage;
item.postResponseScriptErrorContext = action.payload.errorContext || null;
}
if (type === 'test-script-execution') {
item.testScriptErrorMessage = action.payload.errorMessage;
item.testScriptErrorContext = action.payload.errorContext || null;
}
if (type === 'request-queued') {
@@ -3089,16 +3095,19 @@ export const collectionsSlice = createSlice({
if (type === 'post-response-script-execution') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.postResponseScriptErrorMessage = action.payload.errorMessage;
item.postResponseScriptErrorContext = action.payload.errorContext || null;
}
if (type === 'test-script-execution') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.testScriptErrorMessage = action.payload.errorMessage;
item.testScriptErrorContext = action.payload.errorContext || null;
}
if (type === 'pre-request-script-execution') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.preRequestScriptErrorMessage = action.payload.errorMessage;
item.preRequestScriptErrorContext = action.payload.errorContext || null;
}
}
},

View File

@@ -200,6 +200,7 @@ const ResponsiveTabs = ({
key={tab.key}
role="tab"
aria-selected={isActive}
data-testid={`responsive-tab-${tab.key}`}
className={classnames('tab select-none', tab.key, { active: isActive })}
onClick={() => handleTabSelect(tab.key)}
>

View File

@@ -107,35 +107,29 @@ const compiledReplacements = Object.entries(extendedReplacements).map(([pattern,
const processRegexReplacement = (code) => {
for (const { regex, replacement } of compiledReplacements) {
if (regex.test(code)) {
code = code.replace(regex, replacement);
}
}
if ((code.includes('pm.') || code.includes('postman.'))) {
code = code.replace(/^(.*(pm\.|postman\.).*)$/gm, '// $1');
code = code.replace(regex, replacement);
}
return code;
};
const postmanTranslation = (script, options = {}) => {
const postmanTranslation = (script) => {
let modifiedScript = Array.isArray(script) ? script.join('\n') : script;
let translatedScript;
try {
let translatedCode = translateCode(modifiedScript);
if ((translatedCode.includes('pm.') || translatedCode.includes('postman.'))) {
translatedCode = translatedCode.replace(/^(.*(pm\.|postman\.).*)$/gm, '// $1');
}
return translatedCode;
translatedScript = translateCode(modifiedScript);
} catch (e) {
console.warn('Error in postman translation:', e);
try {
return processRegexReplacement(modifiedScript);
translatedScript = processRegexReplacement(modifiedScript);
} catch (e) {
console.warn('Error in postman translation:', e);
return modifiedScript;
translatedScript = modifiedScript;
}
}
return translatedScript;
};
export default postmanTranslation;

View File

@@ -19,9 +19,9 @@ describe('postmanTranslations - comment handling', () => {
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should comment non-translated pm commands', () => {
test('should leave non-translated pm commands as-is', () => {
const inputScript = 'pm.test(\'random test\', () => pm.vault.get(secretPath));';
const expectedOutput = '// test(\'random test\', () => pm.vault.get(secretPath));';
const expectedOutput = 'test(\'random test\', () => pm.vault.get(secretPath));';
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});

View File

@@ -8,7 +8,7 @@ const mime = require('mime-types');
const { ipcMain } = require('electron');
const { each, get, extend, cloneDeep, merge } = require('lodash');
const { NtlmClient } = require('axios-ntlm');
const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js');
const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime, formatErrorWithContextV2 } = require('@usebruno/js');
const { encodeUrl } = require('@usebruno/common').utils;
const { extractPromptVariables } = require('@usebruno/common').utils;
const { interpolateString } = require('./interpolate-string');
@@ -447,12 +447,17 @@ const registerNetworkIpc = (mainWindow) => {
channel, // 'main:run-request-event' | 'main:run-folder-event'
basePayload, // request-level or runner-level identifiers
scriptType, // 'pre-request' | 'post-response' | 'test'
error // optional Error
error, // optional Error
collectionPath, // optional path to the collection root
scriptMetadata // optional metadata for line mapping
}) => {
const errorContext = error ? formatErrorWithContextV2(error, scriptType, scriptMetadata, collectionPath) : null;
mainWindow.webContents.send(channel, {
type: `${scriptType}-script-execution`,
...basePayload,
errorMessage: error ? (error.message || `An error occurred in ${scriptType.replace('-', ' ')} script`) : null
errorMessage: error ? (error.message || `An error occurred in ${scriptType.replace('-', ' ')} script`) : null,
errorContext
});
};
@@ -816,7 +821,9 @@ const registerNetworkIpc = (mainWindow) => {
channel: 'main:run-request-event',
basePayload: { requestUid, collectionUid, itemUid: item.uid },
scriptType: 'pre-request',
error: preRequestError
error: preRequestError,
collectionPath,
scriptMetadata: request.script?.reqMetadata
});
if (preRequestError) {
@@ -996,7 +1003,10 @@ const registerNetworkIpc = (mainWindow) => {
channel: 'main:run-request-event',
basePayload: { requestUid, collectionUid, itemUid: item.uid },
scriptType: 'post-response',
error: postResponseError
error: postResponseError,
itemPathname: item.pathname,
collectionPath,
scriptMetadata: request.script?.resMetadata
});
// run assertions
@@ -1089,7 +1099,10 @@ const registerNetworkIpc = (mainWindow) => {
channel: 'main:run-request-event',
basePayload: { requestUid, collectionUid, itemUid: item.uid },
scriptType: 'test',
error: testError
error: testError,
itemPathname: item.pathname,
collectionPath,
scriptMetadata: request.testsMetadata
});
const domainsWithCookiesTest = await getDomainsWithCookies();
@@ -1463,7 +1476,10 @@ const registerNetworkIpc = (mainWindow) => {
channel: 'main:run-folder-event',
basePayload: eventData,
scriptType: 'pre-request',
error: preRequestError
error: preRequestError,
itemPathname: item.pathname,
collectionPath,
scriptMetadata: request.script?.reqMetadata
});
const domainsWithCookiesPreRequest = await getDomainsWithCookies();
@@ -1691,7 +1707,10 @@ const registerNetworkIpc = (mainWindow) => {
channel: 'main:run-folder-event',
basePayload: eventData,
scriptType: 'post-response',
error: postResponseError
error: postResponseError,
itemPathname: item.pathname,
collectionPath,
scriptMetadata: request.script?.resMetadata
});
const domainsWithCookiesPostResponse = await getDomainsWithCookies();
@@ -1803,7 +1822,10 @@ const registerNetworkIpc = (mainWindow) => {
channel: 'main:run-folder-event',
basePayload: eventData,
scriptType: 'test',
error: testError
error: testError,
itemPathname: item.pathname,
collectionPath,
scriptMetadata: request.testsMetadata
});
const domainsWithCookiesTest = await getDomainsWithCookies();

View File

@@ -468,6 +468,10 @@ const prepareRequest = async (item, collection = {}, abortController) => {
axiosRequest.tests = request.tests;
}
if (request.testsMetadata) {
axiosRequest.testsMetadata = request.testsMetadata;
}
axiosRequest.vars = request.vars;
axiosRequest.collectionVariables = request.collectionVariables;
axiosRequest.folderVariables = request.folderVariables;

View File

@@ -1,12 +1,18 @@
const { get, each, find, compact, isString, filter } = require('lodash');
const { get, each, find, isString, filter } = require('lodash');
const fs = require('fs');
const { getRequestUid, getExampleUid } = require('../cache/requestUids');
const { uuid } = require('./common');
const { posixifyPath } = require('./filesystem');
const os = require('os');
const { preferencesUtil } = require('../store/preferences');
const path = require('path');
const { DEFAULT_COLLECTION_FORMAT } = require('@usebruno/filestore');
const FORMAT_CONFIG = {
yml: { ext: '.yml', collectionFile: 'opencollection.yml', folderFile: 'folder.yml' },
bru: { ext: '.bru', collectionFile: 'collection.bru', folderFile: 'folder.bru' }
};
const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
@@ -152,31 +158,132 @@ ${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.
*
* @param {string[]} scripts - Script segments in order (e.g. collection, folders, request).
* @param {number} requestIndex - Index in scripts of the request-level segment.
* @param {Array|null} segmentSources - Source file info for each segment (null for request segment).
* @returns {{ code: string, metadata: { requestStartLine: number, requestEndLine: number } | null }}
*
* @example
* ** Input **
* const scripts = ['let col = 1;', 'let fold = 2;', 'let req = 3;'];
* const requestIndex = 2;
* const segmentSources = [
* { source: 'collection', fileName: 'collection.bru' },
* { source: 'folder', fileName: 'folder.bru' },
* null // request segment — no source needed
* ];
*
* ** Output **
* {
* code:
* 'await (async () => {\n' // line 1
* + 'let col = 1;\n' // line 2
* + '})();\n' // line 3
* + '\n' // line 4 (blank separator)
* + 'await (async () => {\n' // line 5
* + 'let fold = 2;\n' // line 6
* + '})();\n' // line 7
* + '\n' // line 8 (blank separator)
* + 'await (async () => {\n' // line 9
* + 'let req = 3;\n' // line 10
* + '})();', // line 11
* metadata: {
* requestStartLine: 9,
* requestEndLine: 11,
* segments: [
* { startLine: 1, endLine: 3, source: 'collection', fileName: 'collection.bru' },
* { startLine: 5, endLine: 7, source: 'folder', fileName: 'folder.bru' }
* ]
* }
* }
*/
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: posixifyPath(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);
}
}
}
@@ -190,7 +297,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') {
@@ -199,7 +309,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 = [
@@ -207,7 +320,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
@@ -217,7 +333,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 = [
@@ -225,7 +344,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;
}
};
@@ -742,6 +864,7 @@ module.exports = {
mergeVars,
mergeScripts,
mergeAuth,
wrapAndJoinScripts,
getTreePathFromCollectionToItem,
flattenItems,
findItem,

View File

@@ -1,4 +1,5 @@
const { parseBruFileMeta } = require('../../src/utils/collection');
const path = require('path');
const { parseBruFileMeta, wrapAndJoinScripts, mergeScripts } = require('../../src/utils/collection');
describe('parseBruFileMeta', () => {
test('parses valid meta block correctly', () => {
@@ -286,3 +287,191 @@ describe('parseBruFileMeta', () => {
});
});
});
describe('wrapAndJoinScripts', () => {
test('returns empty code and null metadata for all empty scripts', () => {
const result = wrapAndJoinScripts(['', '', ''], 2);
expect(result.code).toBe('');
expect(result.metadata).toBeNull();
});
test('tracks request line range for single request script', () => {
const result = wrapAndJoinScripts(['', '', 'console.log("hello");'], 2);
expect(result.code).toContain('console.log("hello")');
expect(result.metadata).toEqual({
requestStartLine: 1,
requestEndLine: 3
});
});
test('tracks correct request line range with collection script before it', () => {
const result = wrapAndJoinScripts(['let x = 1;', '', 'let y = 2;'], 2);
// Collection script: 3 lines (await (async () => {\nlet x = 1;\n})();)
// Empty gap: 1 line (blank line separator)
// Request script starts at line 5
expect(result.metadata.requestStartLine).toBe(5);
expect(result.metadata.requestEndLine).toBe(7);
});
test('produces zero range metadata when request script is empty but others exist', () => {
const result = wrapAndJoinScripts(['let x = 1;', '', ''], 2);
expect(result.code).toContain('let x = 1');
expect(result.metadata).toEqual({
requestStartLine: 0,
requestEndLine: 0
});
});
test('builds segments array from segmentSources', () => {
const sources = [
{ filePath: '/col/collection.bru', displayPath: 'collection.bru' },
null,
null
];
const result = wrapAndJoinScripts(['let x = 1;', '', 'let y = 2;'], 2, sources);
expect(result.metadata.segments).toHaveLength(1);
expect(result.metadata.segments[0]).toMatchObject({
startLine: 1,
endLine: 3,
filePath: '/col/collection.bru',
displayPath: 'collection.bru'
});
});
test('builds segments for collection + folder + request', () => {
const sources = [
{ filePath: '/col/collection.bru', displayPath: 'collection.bru' },
{ filePath: '/col/sub/folder.bru', displayPath: 'sub/folder.bru' },
null
];
const result = wrapAndJoinScripts(
['let a = 1;', 'let b = 2;', 'let c = 3;'],
2,
sources
);
expect(result.metadata.segments).toHaveLength(2);
expect(result.metadata.segments[0].displayPath).toBe('collection.bru');
expect(result.metadata.segments[1].displayPath).toBe('sub/folder.bru');
expect(result.metadata.requestStartLine).toBeGreaterThan(result.metadata.segments[1].endLine);
});
});
describe('mergeScripts metadata', () => {
const makeCollection = (scripts = {}) => ({
pathname: '/test/collection',
format: 'bru',
root: {
request: {
script: {
req: scripts.preReq || '',
res: scripts.postRes || ''
},
tests: scripts.tests || ''
}
}
});
const makeRequest = (scripts = {}) => ({
script: {
req: scripts.preReq || '',
res: scripts.postRes || ''
},
tests: scripts.tests || ''
});
const makeFolder = (name, scripts = {}) => ({
type: 'folder',
pathname: `/test/collection/${name}`,
root: {
request: {
script: {
req: scripts.preReq || '',
res: scripts.postRes || ''
},
tests: scripts.tests || ''
}
}
});
test('produces null metadata when all scripts are empty', () => {
const collection = makeCollection();
const request = makeRequest();
mergeScripts(collection, request, [request], 'sequential');
expect(request.script.reqMetadata).toBeNull();
expect(request.script.resMetadata).toBeNull();
expect(request.testsMetadata).toBeNull();
});
test('produces metadata for request-only script', () => {
const collection = makeCollection();
const request = makeRequest({ preReq: 'console.log("req");' });
mergeScripts(collection, request, [request], 'sequential');
expect(request.script.reqMetadata).toEqual({
requestStartLine: 1,
requestEndLine: 3
});
});
test('produces segments for collection + request scripts', () => {
const collection = makeCollection({ preReq: 'let col = 1;' });
const request = makeRequest({ preReq: 'let req = 2;' });
mergeScripts(collection, request, [request], 'sequential');
expect(request.script.reqMetadata.segments).toHaveLength(1);
expect(request.script.reqMetadata.segments[0].displayPath).toBe('collection.bru');
expect(request.script.reqMetadata.segments[0].filePath).toBe(
path.join('/test/collection', 'collection.bru')
);
expect(request.script.reqMetadata.requestStartLine).toBeGreaterThan(
request.script.reqMetadata.segments[0].endLine
);
});
test('produces segments for collection + folder + request scripts', () => {
const collection = makeCollection({ preReq: 'let col = 1;' });
const folder = makeFolder('subfolder', { preReq: 'let fold = 2;' });
const request = makeRequest({ preReq: 'let req = 3;' });
mergeScripts(collection, request, [folder, request], 'sequential');
expect(request.script.reqMetadata.segments).toHaveLength(2);
expect(request.script.reqMetadata.segments[0].displayPath).toBe('collection.bru');
expect(request.script.reqMetadata.segments[1].displayPath).toBe(
path.join('subfolder', 'folder.bru')
);
});
test('non-sequential flow reverses post-res segment order', () => {
const collection = makeCollection({ postRes: 'let col = 1;' });
const folder = makeFolder('subfolder', { postRes: 'let fold = 2;' });
const request = makeRequest({ postRes: 'let req = 3;' });
mergeScripts(collection, request, [folder, request], 'non-sequential');
// In non-sequential, request comes first, then folder (reversed), then collection
expect(request.script.resMetadata.requestStartLine).toBe(1);
const segments = request.script.resMetadata.segments;
expect(segments).toHaveLength(2);
// Folder should come before collection in reversed order
expect(segments[0].displayPath).toBe(path.join('subfolder', 'folder.bru'));
expect(segments[1].displayPath).toBe('collection.bru');
});
test('handles tests metadata correctly', () => {
const collection = makeCollection({ tests: 'test("col", () => {});' });
const request = makeRequest({ tests: 'test("req", () => {});' });
mergeScripts(collection, request, [request], 'sequential');
expect(request.testsMetadata).toBeDefined();
expect(request.testsMetadata.segments).toHaveLength(1);
expect(request.testsMetadata.segments[0].displayPath).toBe('collection.bru');
expect(request.testsMetadata.requestStartLine).toBeGreaterThan(0);
});
test('defaults to bru format when collection.format is not set', () => {
const collection = makeCollection({ preReq: 'let x = 1;' });
delete collection.format;
const request = makeRequest({ preReq: 'let y = 2;' });
mergeScripts(collection, request, [request], 'sequential');
expect(request.script.reqMetadata.segments[0].displayPath).toBe('collection.bru');
});
});

View File

@@ -3,7 +3,21 @@ 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');
const {
formatErrorWithContext,
formatErrorWithContextV2,
SCRIPT_TYPES,
parseErrorLocation,
adjustLineNumber,
resolveSegmentError,
getSourceContext,
adjustStackTrace,
getErrorTypeName,
findScriptBlockStartLine,
findScriptBlockEndLine,
findYmlScriptBlockStartLine,
findYmlScriptBlockEndLine
} = require('./utils/error-formatter');
module.exports = {
ScriptRuntime,
@@ -12,5 +26,16 @@ module.exports = {
AssertRuntime,
runScriptInNodeVm,
formatErrorWithContext,
SCRIPT_TYPES
formatErrorWithContextV2,
SCRIPT_TYPES,
parseErrorLocation,
adjustLineNumber,
resolveSegmentError,
getSourceContext,
adjustStackTrace,
getErrorTypeName,
findScriptBlockStartLine,
findScriptBlockEndLine,
findYmlScriptBlockStartLine,
findYmlScriptBlockEndLine
};

View File

@@ -1,9 +1,12 @@
const fs = require('fs');
const path = require('path');
const YAML = require('yaml');
const { NODEVM_SCRIPT_WRAPPER_OFFSET, QUICKJS_SCRIPT_WRAPPER_OFFSET } = require('./sandbox');
const posixifyPath = (p) => (p ? p.replace(/\\/g, '/') : p);
const DEFAULT_CONTEXT_LINES = 5;
const ALLOWED_SOURCE_EXTENSIONS = ['.bru', '.yml', '.yaml'];
const ALLOWED_SOURCE_EXTENSIONS = ['.bru', '.yml'];
const isAllowedSourceFile = (filePath) =>
typeof filePath === 'string' && ALLOWED_SOURCE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
@@ -64,9 +67,45 @@ const findScriptBlockStartLine = (filePath, scriptType, cache = null) => {
return result;
};
/** Find the 1-indexed last content line of a script block in a .bru file (excludes closing }) */
const findScriptBlockEndLine = (filePath, scriptType, cache = null) => {
if (!filePath.endsWith('.bru')) return null;
const cacheKey = `bru-end:${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 inBlock = false;
let hasContent = false;
let result = null;
for (let i = 0; i < lines.length; i++) {
if (!inBlock && pattern.test(lines[i])) {
inBlock = true;
continue;
}
if (inBlock) {
if (/^\}/.test(lines[i])) {
// Closing brace at 0-indexed position i; last content line is at 0-indexed (i-1) = 1-indexed i
result = hasContent ? i : null;
break;
}
hasContent = true;
}
}
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;
if (!filePath.endsWith('.yml')) return null;
const cacheKey = `yml:${filePath}:${scriptType}`;
if (cache?.has(cacheKey)) return cache.get(cacheKey);
@@ -97,7 +136,52 @@ const findYmlScriptBlockStartLine = (filePath, scriptType, cache = null) => {
}
}
}
if (result) break;
if (result != null) break;
}
}
} catch {
// invalid YAML
}
if (cache) cache.set(cacheKey, result);
return result;
};
/** Find the 1-indexed last content line of a script block in a .yml file */
const findYmlScriptBlockEndLine = (filePath, scriptType, cache = null) => {
if (!filePath.endsWith('.yml')) return null;
const cacheKey = `yml-end:${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 });
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) {
// range[1] is the end offset; go back 1 to get the last content character
const endOffset = Math.max(codeNode.range[1] - 1, codeNode.range[0]);
result = lineCounter.linePos(endOffset).line;
break;
}
}
}
if (result != null) break;
}
}
} catch {
@@ -111,7 +195,7 @@ const findYmlScriptBlockStartLine = (filePath, scriptType, cache = null) => {
/** 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');
const isYmlFile = filePath.endsWith('.yml');
if (!isBruFile && !isYmlFile) {
return reportedLine;
@@ -172,7 +256,7 @@ const resolveSegmentError = (parsed, metadata, scriptType, cache) => {
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');
const isYml = segment.filePath.endsWith('.yml');
if (!isBru && !isYml) return null;
const blockStartLine = isBru
@@ -409,6 +493,150 @@ const formatErrorWithContext = (error, relativeFilePath = null, scriptType = nul
return lines.join('\n');
};
/**
* Build a structured error context object for the desktop UI's ScriptError component.
*
* formatErrorWithContext (V1) returns a pre-formatted string for CLI output.
* This function returns a structured object so the desktop UI can render it
* with its own layout (CodeSnippet component, collapsible stack, etc.).
*
* Key difference: line numbers in the returned object are block-relative
* (i.e. relative to the script block, starting at 1) rather than absolute
* file line numbers, because users edit scripts in a CodeMirror editor that
* starts numbering at line 1.
*
* @example
* Given a .bru file at /home/user/my-collection/requests/get-user.bru:
*
* meta { ← file line 1
* name: get-user ← file line 2
* } ← file line 3
* ← file line 4
* script:post-response { ← file line 5
* const data = res.body; ← file line 6 (script line 1)
* data.missing.prop; ← file line 7 (script line 2) ← error
* console.log(data); ← file line 8 (script line 3)
* } ← file line 9
*
* formatErrorWithContextV2(error, 'post-response', null, '/home/user/my-collection')
* → {
* errorType: 'TypeError',
* filePath: 'requests/get-user.bru', ← relative to collectionPath
* errorLine: 2, ← block-relative, not file line 7
* lines: [
* { lineNumber: 1, content: ' const data = res.body;', isError: false },
* { lineNumber: 2, content: ' data.missing.prop;', isError: true },
* { lineNumber: 3, content: ' console.log(data);', isError: false }
* ],
* stack: ' at …/requests/get-user.bru:7:3'
* }
*
* V1 (formatErrorWithContext) returns a flat string for the same error:
* File: requests/get-user.bru
*
* 5 | const data = res.body;
* > 6 | data.missing.prop;
* 7 | console.log(data);
*
* TypeError: Cannot read properties of undefined
* at …/requests/get-user.bru:7:3
*
* @param {Error} error - The error to build context for
* @param {string} scriptType - 'pre-request' | 'post-response' | 'test'
* @param {object} scriptMetadata - Optional metadata for line mapping in combined scripts
* @param {string} collectionPath - Absolute path to the collection root (used to compute relative display paths)
* @returns {object|null} Structured error context or null
*/
const formatErrorWithContextV2 = (error, scriptType, scriptMetadata, collectionPath) => {
if (!error) return null;
try {
const cache = new Map();
const metadata = (error.scriptMetadata && Object.keys(error.scriptMetadata).length > 0)
? error.scriptMetadata
: scriptMetadata;
const parsed = parseErrorLocation(error);
if (!parsed) return null;
const { filePath } = parsed;
const adjustedLine = adjustLineNumber(filePath, parsed.line, parsed.isQuickJS, scriptType, cache, metadata);
let sourceFile = filePath;
let sourceLine = adjustedLine;
// Handle collection/folder script segments
if (adjustedLine === null) {
const segmentResult = resolveSegmentError(parsed, metadata, scriptType, cache);
if (!segmentResult) return null;
sourceFile = segmentResult.filePath;
sourceLine = segmentResult.line;
}
const resolvedDisplayPath = posixifyPath(
collectionPath ? path.relative(collectionPath, sourceFile) : sourceFile
);
const context = getSourceContext(sourceFile, sourceLine, 3, cache);
if (!context) return null;
const errorType = getErrorTypeName(error);
let stack = null;
if (error.stack) {
stack = adjustStackTrace(error.stack, scriptType, cache, metadata, parsed.isQuickJS);
// Extract only the stack frames (skip the first line which is the error message)
const stackLines = stack.split('\n').slice(1).filter((l) => l.trim().startsWith('at'));
stack = stackLines.length ? stackLines.map((l) => ` ${l.trim()}`).join('\n') : null;
}
// Compute block-relative line numbers for the desktop UI.
// Users edit scripts in a CodeMirror editor starting at line 1,
// so show lines relative to the script block, not absolute .bru file lines.
const isBru = sourceFile.endsWith('.bru');
const isYml = sourceFile.endsWith('.yml');
const blockStartLine = isBru
? findScriptBlockStartLine(sourceFile, scriptType, cache)
: isYml
? findYmlScriptBlockStartLine(sourceFile, scriptType, cache)
: null;
const blockEndLine = isBru
? findScriptBlockEndLine(sourceFile, scriptType, cache)
: isYml
? findYmlScriptBlockEndLine(sourceFile, scriptType, cache)
: null;
// If this is a .bru/.yml file but the script block is missing or empty, there's nothing to show
if ((isBru || isYml) && !blockEndLine) return null;
const blockOffset = blockStartLine ? blockStartLine - 1 : 0;
const filteredLines = context.lines
.filter((l) => {
const rel = l.lineNumber - blockOffset;
return rel >= 1 && (!blockEndLine || l.lineNumber <= blockEndLine);
})
.map((l) => ({
lineNumber: l.lineNumber - blockOffset,
content: l.content,
isError: l.isError
}));
if (filteredLines.length === 0) return null;
return {
errorType,
filePath: resolvedDisplayPath,
errorLine: sourceLine - blockOffset,
lines: filteredLines,
stack
};
} catch (e) {
console.warn('formatErrorWithContextV2 failed:', e);
return null;
}
};
module.exports = {
SCRIPT_TYPES,
DEFAULT_CONTEXT_LINES,
@@ -417,10 +645,13 @@ module.exports = {
buildStackFromCallSites,
getSourceContext,
formatErrorWithContext,
formatErrorWithContextV2,
adjustLineNumber,
resolveSegmentError,
findScriptBlockStartLine,
findScriptBlockEndLine,
findYmlScriptBlockStartLine,
findYmlScriptBlockEndLine,
adjustStackTrace,
getErrorTypeName
};

View File

@@ -1,8 +1,11 @@
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
const {
formatErrorWithContext,
formatErrorWithContextV2,
findScriptBlockStartLine,
findScriptBlockEndLine,
findYmlScriptBlockStartLine,
findYmlScriptBlockEndLine,
adjustLineNumber,
parseStackTrace,
parseErrorLocation
@@ -154,6 +157,31 @@ describe('Error Formatter', () => {
});
});
describe('findScriptBlockEndLine', () => {
it('should find last content line for each block type', () => {
expect(findScriptBlockEndLine(bruFilePath, 'pre-request')).toBe(15);
expect(findScriptBlockEndLine(bruFilePath, 'post-response')).toBe(21);
expect(findScriptBlockEndLine(bruFilePath, 'test')).toBe(30);
});
it('should return null for empty block', () => {
const emptyBlockPath = path.join(testDir, 'empty.bru');
fs.writeFileSync(emptyBlockPath, 'script:pre-request {\n}');
expect(findScriptBlockEndLine(emptyBlockPath, 'pre-request')).toBeNull();
});
it('should return null for missing block', () => {
const noBlockPath = path.join(testDir, 'no-block.bru');
fs.writeFileSync(noBlockPath, 'meta {\n name: test\n}');
expect(findScriptBlockEndLine(noBlockPath, 'pre-request')).toBeNull();
});
it('should return null for non-.bru files', () => {
expect(findScriptBlockEndLine('/some/file.js', 'pre-request')).toBeNull();
expect(findScriptBlockEndLine(ymlFilePath, 'pre-request')).toBeNull();
});
});
describe('findYmlScriptBlockStartLine', () => {
it('should find each block type in .yml files', () => {
expect(findYmlScriptBlockStartLine(ymlFilePath, 'pre-request')).toBe(8);
@@ -173,6 +201,36 @@ describe('Error Formatter', () => {
});
});
describe('findYmlScriptBlockEndLine', () => {
it('should find last content line for each block type in runtime.scripts', () => {
expect(findYmlScriptBlockEndLine(ymlFilePath, 'pre-request')).toBe(9);
expect(findYmlScriptBlockEndLine(ymlFilePath, 'post-response')).toBe(13);
expect(findYmlScriptBlockEndLine(ymlFilePath, 'test')).toBe(18);
});
it('should find last content line in collection yml (request.scripts)', () => {
expect(findYmlScriptBlockEndLine(collectionYmlPath, 'pre-request')).toBe(8);
expect(findYmlScriptBlockEndLine(collectionYmlPath, 'test')).toBe(13);
});
it('should return null for missing block', () => {
const noRuntimePath = path.join(testDir, 'no-runtime.yml');
fs.writeFileSync(noRuntimePath, 'info:\n name: simple\n version: "1"\n');
expect(findYmlScriptBlockEndLine(noRuntimePath, 'pre-request')).toBeNull();
});
it('should return null for non-.yml files', () => {
expect(findYmlScriptBlockEndLine('/some/file.js', 'pre-request')).toBeNull();
expect(findYmlScriptBlockEndLine(bruFilePath, 'pre-request')).toBeNull();
});
it('should return null for invalid YAML', () => {
const invalidYmlPath = path.join(testDir, 'invalid.yml');
fs.writeFileSync(invalidYmlPath, ':\n - :\n bad: [unclosed');
expect(findYmlScriptBlockEndLine(invalidYmlPath, 'pre-request')).toBeNull();
});
});
describe('adjustLineNumber', () => {
it('should adjust QuickJS lines for .bru files', () => {
// VM line - offset(9) = scriptLine → blockStart + scriptLine - 1
@@ -385,4 +443,380 @@ describe('Error Formatter', () => {
expect(formatErrorWithContext(error)).toContain('Test error');
});
});
describe('formatErrorWithContextV2', () => {
const makeCallSiteError = (filePath, line, message = 'test error', name = 'Error') => {
const error = new Error(message);
error.name = name;
error.__callSites = [{ filePath, line, column: 1 }];
error.stack = `${name}: ${message}\n at Object.<anonymous> (${filePath}:${line}:1)`;
return error;
};
const makeQuickJSError = (filePath, line, message = 'test error', name = 'Error') => {
const error = new Error(message);
error.name = name;
error.__isQuickJS = true;
error.stack = `${name}: ${message}\n at <anonymous> (${filePath}:${line})`;
return error;
};
let consoleSpy;
beforeEach(() => {
consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
consoleSpy.mockRestore();
});
describe('null/falsy error input', () => {
it('should return null for null error', () => {
expect(formatErrorWithContextV2(null, 'pre-request')).toBeNull();
});
it('should return null for undefined error', () => {
expect(formatErrorWithContextV2(undefined, 'pre-request')).toBeNull();
});
it('should return null for error without parseable location', () => {
const error = new Error('no location');
error.stack = '';
expect(formatErrorWithContextV2(error, 'pre-request')).toBeNull();
});
});
describe('.bru file errors', () => {
it('should produce correct block-relative line numbers for pre-request (NodeVM)', () => {
const error = makeCallSiteError(bruFilePath, 3, 'token is not defined', 'ReferenceError');
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).not.toBeNull();
expect(result.errorType).toBe('ReferenceError');
expect(result.errorLine).toBe(1);
expect(result.filePath).toBe('test.bru');
expect(result.lines.length).toBeGreaterThan(0);
expect(result.lines.every((l) => l.lineNumber >= 1)).toBe(true);
});
it('should produce correct block-relative line numbers for post-response (NodeVM)', () => {
const error = makeCallSiteError(bruFilePath, 4, 'data is not defined', 'ReferenceError');
const result = formatErrorWithContextV2(error, 'post-response', null, testDir);
expect(result).not.toBeNull();
expect(result.errorLine).toBe(2);
expect(result.filePath).toBe('test.bru');
});
it('should produce correct block-relative line numbers for tests (NodeVM)', () => {
const error = makeCallSiteError(bruFilePath, 3, 'assertion failed');
const result = formatErrorWithContextV2(error, 'test', null, testDir);
expect(result).not.toBeNull();
expect(result.errorLine).toBe(1);
});
it('should handle QuickJS errors with correct offset', () => {
const error = makeQuickJSError(bruFilePath, 10, 'data is not defined', 'ReferenceError');
const result = formatErrorWithContextV2(error, 'post-response', null, testDir);
expect(result).not.toBeNull();
expect(result.errorLine).toBe(1);
expect(result.errorType).toBe('ReferenceError');
});
});
describe('.yml file errors', () => {
it('should produce correct block-relative line numbers for pre-request (NodeVM)', () => {
const error = makeCallSiteError(ymlFilePath, 3, 'token error');
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).not.toBeNull();
expect(result.errorLine).toBe(1);
expect(result.filePath).toBe('test.yml');
});
it('should produce correct block-relative line numbers for post-response (NodeVM)', () => {
const error = makeCallSiteError(ymlFilePath, 3, 'data error');
const result = formatErrorWithContextV2(error, 'post-response', null, testDir);
expect(result).not.toBeNull();
expect(result.errorLine).toBe(1);
});
it('should return null when yml script block has empty code', () => {
const emptyYml = [
'info:',
' name: empty-test',
' version: "1"',
'runtime:',
' scripts:',
' - type: before-request',
' code: ""'
].join('\n');
const emptyYmlPath = path.join(testDir, 'empty.yml');
fs.writeFileSync(emptyYmlPath, emptyYml);
const error = makeCallSiteError(emptyYmlPath, 3, 'error');
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).toBeNull();
});
});
describe('segment resolution (collection/folder scripts)', () => {
it('should resolve errors in collection script segments', () => {
const metadata = {
requestStartLine: 5,
requestEndLine: 8,
segments: [{
startLine: 1,
endLine: 4,
filePath: collectionYmlPath
}]
};
const error = makeCallSiteError(bruFilePath, 4, 'x is not defined', 'ReferenceError');
const result = formatErrorWithContextV2(error, 'pre-request', metadata, testDir);
expect(result).not.toBeNull();
expect(result.filePath).toBe('opencollection.yml');
expect(result.errorLine).toBe(1);
});
it('should return null when segment resolution fails', () => {
const metadata = {
requestStartLine: 5,
requestEndLine: 8,
segments: []
};
const error = makeCallSiteError(bruFilePath, 3, 'error');
const result = formatErrorWithContextV2(error, 'pre-request', metadata, testDir);
expect(result).toBeNull();
});
});
describe('stack trace extraction', () => {
it('should extract only "at" frames with indentation', () => {
const error = makeCallSiteError(bruFilePath, 3, 'test error', 'Error');
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).not.toBeNull();
if (result.stack) {
const lines = result.stack.split('\n');
lines.forEach((line) => {
expect(line).toMatch(/^\s+at /);
});
}
});
it('should return stack as null when error has no stack', () => {
const error = makeCallSiteError(bruFilePath, 3, 'test error');
delete error.stack;
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).not.toBeNull();
expect(result.stack).toBeNull();
});
it('should return stack as null when stack has no "at" frames', () => {
const error = makeCallSiteError(bruFilePath, 3, 'test error');
error.stack = 'Error: test error\nsome other info';
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).not.toBeNull();
expect(result.stack).toBeNull();
});
});
describe('context lines filtering', () => {
it('should filter lines to block boundaries', () => {
const error = makeCallSiteError(bruFilePath, 3, 'error');
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).not.toBeNull();
result.lines.forEach((l) => {
expect(l.lineNumber).toBeGreaterThanOrEqual(1);
});
});
it('should mark the error line correctly', () => {
const error = makeCallSiteError(bruFilePath, 3, 'error');
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).not.toBeNull();
const errorLines = result.lines.filter((l) => l.isError);
expect(errorLines.length).toBe(1);
expect(errorLines[0].lineNumber).toBe(result.errorLine);
});
it('should return null when all context lines are filtered out', () => {
const emptyBru = `meta {
name: empty-test
type: http
seq: 1
}
script:pre-request {
}`;
const emptyBruPath = path.join(testDir, 'empty.bru');
fs.writeFileSync(emptyBruPath, emptyBru);
const error = makeCallSiteError(emptyBruPath, 3, 'error');
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).toBeNull();
});
it('should return null when .bru file has no script block at all', () => {
const noScriptBru = `meta {
name: no-script-test
type: http
seq: 1
}
get {
url: https://example.com
}`;
const noScriptBruPath = path.join(testDir, 'no-script.bru');
fs.writeFileSync(noScriptBruPath, noScriptBru);
const error = makeCallSiteError(noScriptBruPath, 3, 'error');
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).toBeNull();
});
it('should return null when .yml file has no script section at all', () => {
const noScriptYml = [
'info:',
' name: no-script-test',
' version: "1"',
'runtime:',
' settings:',
' timeout: 5000'
].join('\n');
const noScriptYmlPath = path.join(testDir, 'no-script.yml');
fs.writeFileSync(noScriptYmlPath, noScriptYml);
const error = makeCallSiteError(noScriptYmlPath, 3, 'error');
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).toBeNull();
});
});
describe('external JS file errors (no block offset)', () => {
it('should use absolute line numbers for non-.bru/.yml files', () => {
const jsFilePath = path.join(testDir, 'helper.js');
fs.writeFileSync(jsFilePath, 'function foo() {\n throw new Error("oops");\n}\nfoo();\n');
const error = makeCallSiteError(jsFilePath, 2, 'oops');
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).not.toBeNull();
expect(result.errorLine).toBe(2);
expect(result.filePath).toBe('helper.js');
});
});
describe('scriptMetadata precedence', () => {
it('should prefer error.scriptMetadata over parameter when non-empty', () => {
const errorMetadata = {
requestStartLine: 1,
requestEndLine: 2
};
const paramMetadata = {
requestStartLine: 10,
requestEndLine: 20
};
const error = makeCallSiteError(bruFilePath, 3, 'error');
error.scriptMetadata = errorMetadata;
const result = formatErrorWithContextV2(error, 'pre-request', paramMetadata, testDir);
expect(result).not.toBeNull();
});
it('should fall back to parameter when error.scriptMetadata is empty object', () => {
const paramMetadata = {
requestStartLine: 1,
requestEndLine: 2
};
const error = makeCallSiteError(bruFilePath, 3, 'error');
error.scriptMetadata = {};
const result = formatErrorWithContextV2(error, 'pre-request', paramMetadata, testDir);
expect(result).not.toBeNull();
});
it('should fall back to parameter when error.scriptMetadata is null', () => {
const paramMetadata = {
requestStartLine: 1,
requestEndLine: 2
};
const error = makeCallSiteError(bruFilePath, 3, 'error');
error.scriptMetadata = null;
const result = formatErrorWithContextV2(error, 'pre-request', paramMetadata, testDir);
expect(result).not.toBeNull();
});
});
describe('error type extraction', () => {
it('should extract error type from error.name', () => {
const error = makeCallSiteError(bruFilePath, 3, 'x is not defined', 'ReferenceError');
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).not.toBeNull();
expect(result.errorType).toBe('ReferenceError');
});
it('should extract error type from error.cause.name', () => {
const error = makeCallSiteError(bruFilePath, 3, 'wrapped error');
error.cause = { name: 'TypeError', message: 'original error' };
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).not.toBeNull();
expect(result.errorType).toBe('TypeError');
});
});
describe('display path', () => {
it('should compute relative path from collectionPath', () => {
const error = makeCallSiteError(bruFilePath, 3, 'error');
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).not.toBeNull();
expect(result.filePath).toBe('test.bru');
});
it('should fall back to absolute filePath when collectionPath is undefined', () => {
const error = makeCallSiteError(bruFilePath, 3, 'error');
const result = formatErrorWithContextV2(error, 'pre-request');
expect(result).not.toBeNull();
expect(result.filePath).toBe(bruFilePath);
});
});
describe('catch block logging', () => {
it('should log a warning when an internal error occurs', () => {
const error = new Error('test');
error.__callSites = [{ filePath: bruFilePath, line: 3, column: 1 }];
Object.defineProperty(error, 'name', {
get() { throw new Error('property access bomb'); }
});
const result = formatErrorWithContextV2(error, 'pre-request', null, testDir);
expect(result).toBeNull();
expect(consoleSpy).toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "collection-script-error",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,8 @@
meta {
name: collection-script-error
}
script:pre-request {
const validLine = 1;
collectionUndefinedVar.doSomething();
}

View File

@@ -0,0 +1,11 @@
meta {
name: simple-request
type: http
seq: 1
}
get {
url: http://localhost:8081/ping
body: none
auth: none
}

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "script-errors-test",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,3 @@
meta {
name: script-errors-test
}

View File

@@ -0,0 +1,11 @@
meta {
name: folder-request
type: http
seq: 1
}
get {
url: http://localhost:8081/ping
body: none
auth: none
}

View File

@@ -0,0 +1,8 @@
meta {
name: error-subfolder
}
script:pre-request {
const folderData = "hello";
folderUndefinedVar.method();
}

View File

@@ -0,0 +1,23 @@
meta {
name: multiple-errors
type: http
seq: 4
}
get {
url: http://localhost:8081/ping
body: none
auth: none
}
script:pre-request {
console.log("this works fine");
}
script:post-response {
postResponseMissingVar();
}
tests {
testMissingVar();
}

View File

@@ -0,0 +1,17 @@
meta {
name: post-response-type-error
type: http
seq: 2
}
get {
url: http://localhost:8081/ping
body: none
auth: none
}
script:post-response {
const data = res.body;
const result = null;
result.nonExistentMethod();
}

View File

@@ -0,0 +1,17 @@
meta {
name: pre-request-ref-error
type: http
seq: 1
}
get {
url: http://localhost:8081/ping
body: none
auth: none
}
script:pre-request {
const data = "hello";
const result = undefinedVariable + data;
console.log(result);
}

View File

@@ -0,0 +1,16 @@
meta {
name: test-script-error
type: http
seq: 3
}
get {
url: http://localhost:8081/ping
body: none
auth: none
}
tests {
test("should pass", function() { expect(true).to.equal(true); });
nonExistentFunction();
}

View File

@@ -0,0 +1,12 @@
{
"lastOpenedCollections": [
"{{collectionPath}}/script-errors-test",
"{{collectionPath}}/collection-script-error"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
}
}
}

View File

@@ -0,0 +1,456 @@
import { test, expect, Page } from '../../playwright';
import { buildScriptErrorLocators, buildCommonLocators } from '../utils/page/locators';
import { openRequest, closeAllTabs } from '../utils/page/actions';
import { setSandboxMode, runCollection } from '../utils/page/runner';
/**
* Helper: click send and wait for at least one error card to appear.
*/
const sendAndWaitForErrorCard = async (page: Page) => {
const { request } = buildCommonLocators(page);
const scriptErrorLocators = buildScriptErrorLocators(page);
await request.sendButton().click();
await scriptErrorLocators.card().waitFor({ state: 'visible', timeout: 15000 });
};
/**
* Helper: click send and wait for a response status code to appear.
* Used for requests that succeed at HTTP level but may have post-response/test errors.
*/
const sendAndWaitForResponse = async (page: Page) => {
const { request, response } = buildCommonLocators(page);
await request.sendButton().click();
await response.statusCode().waitFor({ state: 'visible', timeout: 15000 });
};
/**
* Helper: expand a folder in the sidebar and open a nested request.
* Clicking the collection row is idempotent (only expands, never collapses).
* Clicking the folder row is idempotent (only expands, never collapses).
*/
const openFolderRequest = async (page: Page, collectionName: string, folderName: string, requestName: string) => {
await test.step(`Open folder request "${requestName}" in "${folderName}"`, async () => {
const { sidebar, tabs } = buildCommonLocators(page);
await sidebar.collectionRow(collectionName).click();
const folder = sidebar.folder(folderName);
await folder.waitFor({ state: 'visible' });
await folder.click();
const request = sidebar.request(requestName);
await request.waitFor({ state: 'visible' });
await request.click();
await expect(tabs.activeRequestTab()).toContainText(requestName);
});
};
for (const mode of ['safe', 'developer'] as const) {
test.describe.serial(`Script Error Display [${mode} mode]`, () => {
let scriptErrorLocators: ReturnType<typeof buildScriptErrorLocators>;
let commonLocators: ReturnType<typeof buildCommonLocators>;
test.beforeAll(async ({ pageWithUserData: page }) => {
scriptErrorLocators = buildScriptErrorLocators(page);
commonLocators = buildCommonLocators(page);
await setSandboxMode(page, 'script-errors-test', mode);
await setSandboxMode(page, 'collection-script-error', mode);
});
test('1. Pre-request ReferenceError shows error card with correct details', async ({ pageWithUserData: page }) => {
await test.step('Open request and send', async () => {
await openRequest(page, 'script-errors-test', 'pre-request-ref-error');
await sendAndWaitForErrorCard(page);
});
await test.step('Verify error card content', async () => {
const card = scriptErrorLocators.card();
await expect(scriptErrorLocators.title(card)).toContainText('Pre-Request Script Error');
await expect(scriptErrorLocators.sourceLabel(card)).toContainText('Request');
await expect(scriptErrorLocators.filePath(card)).toContainText('pre-request-ref-error.bru');
await expect(scriptErrorLocators.message(card)).toContainText('ReferenceError');
await expect(scriptErrorLocators.message(card)).toContainText('undefinedVariable');
await expect(scriptErrorLocators.codeSnippet(card)).toBeVisible();
await expect(scriptErrorLocators.errorLine(card)).toBeVisible();
await expect(scriptErrorLocators.errorLine(card)).toContainText('undefinedVariable');
});
await test.step('Verify response status shows Error', async () => {
await expect(commonLocators.response.statusCode()).toContainText('Error');
});
});
test('2. Post-response TypeError shows error card with HTTP 200', async ({ pageWithUserData: page }) => {
await test.step('Open request and send', async () => {
await openRequest(page, 'script-errors-test', 'post-response-type-error');
await sendAndWaitForResponse(page);
});
await test.step('Verify error card content', async () => {
const card = scriptErrorLocators.card();
await expect(card).toBeVisible();
await expect(scriptErrorLocators.title(card)).toContainText('Post-Response Script Error');
await expect(scriptErrorLocators.sourceLabel(card)).toContainText('Request');
await expect(scriptErrorLocators.filePath(card)).toContainText('post-response-type-error.bru');
await expect(scriptErrorLocators.message(card)).toContainText('TypeError');
await expect(scriptErrorLocators.errorLine(card)).toContainText('result.nonExistentMethod()');
});
await test.step('Verify HTTP 200 status', async () => {
await expect(commonLocators.response.statusCode()).toContainText('200');
});
});
test('3. Test script ReferenceError shows error card', async ({ pageWithUserData: page }) => {
await test.step('Open request and send', async () => {
await openRequest(page, 'script-errors-test', 'test-script-error');
await sendAndWaitForResponse(page);
});
await test.step('Verify error card content', async () => {
const card = scriptErrorLocators.card();
await expect(card).toBeVisible();
await expect(scriptErrorLocators.title(card)).toContainText('Test Script Error');
await expect(scriptErrorLocators.sourceLabel(card)).toContainText('Request');
await expect(scriptErrorLocators.filePath(card)).toContainText('test-script-error.bru');
await expect(scriptErrorLocators.message(card)).toContainText('ReferenceError');
await expect(scriptErrorLocators.message(card)).toContainText('nonExistentFunction');
await expect(scriptErrorLocators.errorLine(card)).toContainText('nonExistentFunction()');
});
});
test('4. Stack trace toggle shows and hides stack trace', async ({ pageWithUserData: page }) => {
await test.step('Open request and send', async () => {
await openRequest(page, 'script-errors-test', 'pre-request-ref-error');
await sendAndWaitForErrorCard(page);
});
await test.step('Verify stack toggle is visible and stack is hidden', async () => {
const card = scriptErrorLocators.card();
await expect(scriptErrorLocators.stackToggle(card)).toBeVisible();
await expect(scriptErrorLocators.stackToggle(card)).toContainText('Show stack trace');
await expect(scriptErrorLocators.stack(card)).not.toBeVisible();
});
await test.step('Click toggle to show stack trace', async () => {
const card = scriptErrorLocators.card();
await scriptErrorLocators.stackToggle(card).click();
await expect(scriptErrorLocators.stack(card)).toBeVisible();
await expect(scriptErrorLocators.stackToggle(card)).toContainText('Hide stack trace');
});
await test.step('Click toggle to hide stack trace again', async () => {
const card = scriptErrorLocators.card();
await scriptErrorLocators.stackToggle(card).click();
await expect(scriptErrorLocators.stack(card)).not.toBeVisible();
});
});
test('5. Close button hides card and ScriptErrorIcon restores it', async ({ pageWithUserData: page }) => {
await test.step('Open request and send', async () => {
await openRequest(page, 'script-errors-test', 'pre-request-ref-error');
await sendAndWaitForErrorCard(page);
});
await test.step('Close error card', async () => {
const card = scriptErrorLocators.card();
await expect(card).toBeVisible();
await scriptErrorLocators.closeButton(card).click();
await expect(scriptErrorLocators.cards()).toHaveCount(0);
});
await test.step('Click error icon to restore card', async () => {
await expect(scriptErrorLocators.errorIcon()).toBeVisible();
await scriptErrorLocators.errorIcon().click();
await expect(scriptErrorLocators.card()).toBeVisible();
});
});
test('6. Multiple error cards for post-response and test failures', async ({ pageWithUserData: page }) => {
await test.step('Open request and send', async () => {
await openRequest(page, 'script-errors-test', 'multiple-errors');
await sendAndWaitForResponse(page);
});
await test.step('Verify two error cards are displayed', async () => {
await expect(scriptErrorLocators.cards()).toHaveCount(2);
});
await test.step('Verify first card is post-response error', async () => {
const card0 = scriptErrorLocators.card(0);
await expect(scriptErrorLocators.title(card0)).toContainText('Post-Response Script Error');
await expect(scriptErrorLocators.message(card0)).toContainText('postResponseMissingVar');
await expect(scriptErrorLocators.errorLine(card0)).toContainText('postResponseMissingVar()');
});
await test.step('Verify second card is test script error', async () => {
const card1 = scriptErrorLocators.card(1);
await expect(scriptErrorLocators.title(card1)).toContainText('Test Script Error');
await expect(scriptErrorLocators.message(card1)).toContainText('testMissingVar');
await expect(scriptErrorLocators.errorLine(card1)).toContainText('testMissingVar()');
});
await test.step('Verify HTTP 200 status', async () => {
await expect(commonLocators.response.statusCode()).toContainText('200');
});
});
test('7. Folder-level script error shows folder source label', async ({ pageWithUserData: page }) => {
await test.step('Open folder request', async () => {
await openFolderRequest(page, 'script-errors-test', 'error-subfolder', 'folder-request');
});
await test.step('Send request and wait for error', async () => {
await sendAndWaitForErrorCard(page);
});
await test.step('Verify folder-level error card', async () => {
const card = scriptErrorLocators.card();
await expect(scriptErrorLocators.title(card)).toContainText('Pre-Request Script Error');
await expect(scriptErrorLocators.sourceLabel(card)).toContainText('Folder');
await expect(scriptErrorLocators.sourceLabel(card)).toContainText('error-subfolder');
await expect(scriptErrorLocators.filePath(card)).toContainText('folder.bru');
await expect(scriptErrorLocators.message(card)).toContainText('ReferenceError');
await expect(scriptErrorLocators.message(card)).toContainText('folderUndefinedVar');
await expect(scriptErrorLocators.errorLine(card)).toContainText('folderUndefinedVar');
});
});
test('8. Folder file-path navigation opens folder settings', async ({ pageWithUserData: page }) => {
await test.step('Open folder request and trigger error', async () => {
// Folder was expanded by test 7 (serial), so openRequest can find the nested request
await openRequest(page, 'script-errors-test', 'folder-request');
await sendAndWaitForErrorCard(page);
});
await test.step('Click file path to navigate', async () => {
const card = scriptErrorLocators.card();
await scriptErrorLocators.filePath(card).click();
});
await test.step('Verify navigation to folder settings with Script tab', async () => {
const activeTab = commonLocators.tabs.activeRequestTab();
await expect(activeTab).toContainText('error-subfolder');
const scriptTab = commonLocators.paneTabs.folderSettingsTab('script');
await expect(scriptTab).toHaveClass(/active/);
});
});
test('9. Collection-level script error shows collection source label', async ({ pageWithUserData: page }) => {
await test.step('Open request in collection-script-error', async () => {
await openRequest(page, 'collection-script-error', 'simple-request');
});
await test.step('Send request and wait for error', async () => {
await sendAndWaitForErrorCard(page);
});
await test.step('Verify collection-level error card', async () => {
const card = scriptErrorLocators.card();
await expect(scriptErrorLocators.title(card)).toContainText('Pre-Request Script Error');
await expect(scriptErrorLocators.sourceLabel(card)).toContainText('Collection');
await expect(scriptErrorLocators.filePath(card)).toContainText('collection.bru');
await expect(scriptErrorLocators.message(card)).toContainText('ReferenceError');
await expect(scriptErrorLocators.message(card)).toContainText('collectionUndefinedVar');
await expect(scriptErrorLocators.errorLine(card)).toContainText('collectionUndefinedVar');
});
});
test('10. Collection file-path navigation opens collection settings', async ({ pageWithUserData: page }) => {
await test.step('Open request and trigger collection error', async () => {
await openRequest(page, 'collection-script-error', 'simple-request');
await sendAndWaitForErrorCard(page);
});
await test.step('Click file path to navigate', async () => {
const card = scriptErrorLocators.card();
await scriptErrorLocators.filePath(card).click();
});
await test.step('Verify navigation to collection settings with Script tab', async () => {
const activeTab = commonLocators.tabs.activeRequestTab();
await expect(activeTab).toContainText('Collection');
const scriptTab = commonLocators.paneTabs.collectionSettingsTab('script');
await expect(scriptTab).toHaveClass(/active/);
});
});
test('11. Request file-path navigation opens Script tab for pre-request error', async ({ pageWithUserData: page }) => {
await test.step('Open request and trigger error', async () => {
await openRequest(page, 'script-errors-test', 'pre-request-ref-error');
await sendAndWaitForErrorCard(page);
});
await test.step('Click file path to navigate', async () => {
const card = scriptErrorLocators.card();
await scriptErrorLocators.filePath(card).click();
});
await test.step('Verify Script pane tab is active', async () => {
const activeTab = commonLocators.tabs.activeRequestTab();
await expect(activeTab).toContainText('pre-request-ref-error');
const scriptTab = commonLocators.paneTabs.responsiveTab('script');
await expect(scriptTab).toHaveClass(/active/);
});
});
test('12. Request file-path navigation opens Tests tab for test error', async ({ pageWithUserData: page }) => {
await test.step('Open request and trigger error', async () => {
await openRequest(page, 'script-errors-test', 'test-script-error');
await sendAndWaitForResponse(page);
});
await test.step('Click file path to navigate', async () => {
const card = scriptErrorLocators.card();
await expect(card).toBeVisible();
await scriptErrorLocators.filePath(card).click();
});
await test.step('Verify Tests pane tab is active', async () => {
const testsTab = commonLocators.paneTabs.responsiveTab('tests');
await expect(testsTab).toHaveClass(/active/);
});
});
test('13. Runner: clicking request error file path opens request tab', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await test.step('Close all existing request tabs', async () => {
await closeAllTabs(page);
});
await test.step('Run collection via runner', async () => {
await runCollection(page, 'script-errors-test');
});
await test.step('Click on failed request result to open detail pane', async () => {
const resultItem = commonLocators.runnerResults.itemPath('pre-request-ref-error');
await resultItem.locator('.danger').filter({ hasText: '(request failed)' }).click();
});
await test.step('Verify script error card in runner detail pane', async () => {
const card = scriptErrorLocators.card();
await card.waitFor({ state: 'visible', timeout: 10000 });
await expect(scriptErrorLocators.title(card)).toContainText('Pre-Request Script Error');
await expect(scriptErrorLocators.filePath(card)).toContainText('pre-request-ref-error.bru');
});
await test.step('Click file path to navigate to request', async () => {
const card = scriptErrorLocators.card();
await scriptErrorLocators.filePath(card).click();
});
await test.step('Verify request tab opened with Script sub-tab active', async () => {
const activeTab = commonLocators.tabs.activeRequestTab();
await expect(activeTab).toContainText('pre-request-ref-error');
const scriptTab = commonLocators.paneTabs.responsiveTab('script');
await expect(scriptTab).toHaveClass(/active/);
});
});
test('14. Post-response file-path navigation opens Script tab with Post Response sub-tab', async ({ pageWithUserData: page }) => {
await test.step('Open request and trigger post-response error', async () => {
await openRequest(page, 'script-errors-test', 'post-response-type-error');
await sendAndWaitForResponse(page);
});
await test.step('Click file path to navigate', async () => {
const card = scriptErrorLocators.card();
await expect(card).toBeVisible();
await scriptErrorLocators.filePath(card).click();
});
await test.step('Verify Script pane tab is active', async () => {
const scriptTab = commonLocators.paneTabs.responsiveTab('script');
await expect(scriptTab).toHaveClass(/active/);
});
await test.step('Verify Post Response sub-tab is active', async () => {
const postResponseSubTab = commonLocators.paneTabs.tabTrigger('post-response');
await expect(postResponseSubTab).toHaveClass(/active/);
});
});
test('15. Keyboard navigation (Enter key) triggers file-path navigation', async ({ pageWithUserData: page }) => {
await test.step('Open request and trigger error', async () => {
await openRequest(page, 'script-errors-test', 'pre-request-ref-error');
await sendAndWaitForErrorCard(page);
});
await test.step('Focus file path and press Enter', async () => {
const card = scriptErrorLocators.card();
await scriptErrorLocators.filePath(card).focus();
await page.keyboard.press('Enter');
});
await test.step('Verify Script pane tab is active (same as click navigation)', async () => {
const activeTab = commonLocators.tabs.activeRequestTab();
await expect(activeTab).toContainText('pre-request-ref-error');
const scriptTab = commonLocators.paneTabs.responsiveTab('script');
await expect(scriptTab).toHaveClass(/active/);
});
});
// Skip: currently closing one error card closes all cards. Unskip once independent card close is implemented.
test.skip('16. Multiple error cards — closing one preserves the other', async ({ pageWithUserData: page }) => {
await test.step('Open request and send', async () => {
await openRequest(page, 'script-errors-test', 'multiple-errors');
await sendAndWaitForResponse(page);
});
await test.step('Verify two error cards exist', async () => {
await expect(scriptErrorLocators.cards()).toHaveCount(2);
});
await test.step('Close the first card (post-response error)', async () => {
const card0 = scriptErrorLocators.card(0);
await scriptErrorLocators.closeButton(card0).click();
});
await test.step('Verify only one card remains and it is the test script error', async () => {
await expect(scriptErrorLocators.cards()).toHaveCount(1);
const remainingCard = scriptErrorLocators.card(0);
await expect(scriptErrorLocators.title(remainingCard)).toContainText('Test Script Error');
await expect(scriptErrorLocators.message(remainingCard)).toContainText('testMissingVar');
});
await test.step('Verify ScriptErrorIcon appears for the closed card', async () => {
await expect(scriptErrorLocators.errorIcon()).toBeVisible();
});
});
test('17. Runner: test error file-path navigation opens Tests tab', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await test.step('Close all existing request tabs', async () => {
await closeAllTabs(page);
});
await test.step('Run collection via runner', async () => {
await runCollection(page, 'script-errors-test');
});
await test.step('Click on test-script-error result to open detail pane', async () => {
const resultItem = commonLocators.runnerResults.itemPath('test-script-error');
await resultItem.locator('.link').click();
});
await test.step('Verify script error card in runner detail pane', async () => {
const card = scriptErrorLocators.card();
await card.waitFor({ state: 'visible', timeout: 10000 });
await expect(scriptErrorLocators.title(card)).toContainText('Test Script Error');
await expect(scriptErrorLocators.filePath(card)).toContainText('test-script-error.bru');
});
await test.step('Click file path to navigate to request', async () => {
const card = scriptErrorLocators.card();
await scriptErrorLocators.filePath(card).click();
});
await test.step('Verify request tab opened with Tests sub-tab active', async () => {
const activeTab = commonLocators.tabs.activeRequestTab();
await expect(activeTab).toContainText('test-script-error');
const testsTab = commonLocators.paneTabs.responsiveTab('tests');
await expect(testsTab).toHaveClass(/active/);
});
});
});
}

View File

@@ -1,4 +1,4 @@
import { Page } from '../../../playwright';
import { Page, Locator } from '../../../playwright';
export const buildCommonLocators = (page: Page) => ({
runner: () => page.getByTestId('run-button'),
@@ -18,9 +18,7 @@ export const buildCommonLocators = (page: Page) => ({
return folderWrapper.locator('.collection-item-name').filter({ hasText: requestName });
},
closeAllCollectionsButton: () => page.getByTestId('collections-header-actions-menu-close-all'),
collectionRow: (name: string) => page.locator('.collection-name').filter({
has: page.locator('#sidebar-collection-name', { hasText: name })
})
collectionRow: (name: string) => page.getByTestId('sidebar-collection-row').filter({ hasText: name })
},
actions: {
collectionActions: (collectionName: string) =>
@@ -41,6 +39,12 @@ export const buildCommonLocators = (page: Page) => ({
activeRequestTab: () => page.locator('.request-tab.active'),
closeTab: (requestName: string) => page.locator('.request-tab').filter({ hasText: requestName }).getByTestId('request-tab-close-icon')
},
paneTabs: {
responsiveTab: (key: string) => page.getByTestId(`responsive-tab-${key}`),
collectionSettingsTab: (key: string) => page.getByTestId(`collection-settings-tab-${key}`),
folderSettingsTab: (key: string) => page.getByTestId(`folder-settings-tab-${key}`),
tabTrigger: (key: string) => page.getByTestId(`tab-trigger-${key}`)
},
folder: {
chevron: (folderName: string) => page.locator('.collection-item-name').filter({ hasText: folderName }).getByTestId('folder-chevron')
},
@@ -83,6 +87,9 @@ export const buildCommonLocators = (page: Page) => ({
input: () => page.getByTestId('tag-input').getByRole('textbox'),
item: (tagName: string) => page.locator('.tag-item', { hasText: tagName })
},
runnerResults: {
itemPath: (name: string) => page.getByTestId('runner-result-item').filter({ hasText: name })
},
response: {
statusCode: () => page.getByTestId('response-status-code'),
pane: () => page.locator('.response-pane'),
@@ -207,6 +214,41 @@ export const buildGrpcCommonLocators = (page: Page) => ({
}
});
/**
* Builds locators for script error display elements
* @param page - The Playwright page object
* @returns Object with locators for script error elements
*/
export const buildScriptErrorLocators = (page: Page) => ({
/** All error cards on the page */
cards: () => page.getByTestId('script-error-card'),
/** Nth error card (0-indexed) */
card: (index?: number) => {
const cards = page.getByTestId('script-error-card');
return index !== undefined ? cards.nth(index) : cards.first();
},
/** Error title within a card */
title: (card?: Locator) => (card ?? page).getByTestId('script-error-title'),
/** Close button within a card */
closeButton: (card?: Locator) => (card ?? page).getByTestId('script-error-close'),
/** Source label within a card */
sourceLabel: (card?: Locator) => (card ?? page).getByTestId('script-error-source-label'),
/** File path link within a card */
filePath: (card?: Locator) => (card ?? page).getByTestId('script-error-file-path'),
/** Error message within a card */
message: (card?: Locator) => (card ?? page).getByTestId('script-error-message'),
/** Code snippet within a card */
codeSnippet: (card?: Locator) => (card ?? page).getByTestId('code-snippet'),
/** Error-highlighted code line within a card */
errorLine: (card?: Locator) => (card ?? page).getByTestId('code-line-error'),
/** Stack trace toggle within a card */
stackToggle: (card?: Locator) => (card ?? page).getByTestId('script-error-stack-toggle'),
/** Stack trace content within a card */
stack: (card?: Locator) => (card ?? page).getByTestId('script-error-stack'),
/** ScriptErrorIcon (the red alert button shown when card is dismissed) */
errorIcon: () => page.getByTestId('script-error-icon')
});
/**
* Builds locators for sandbox mode settings
* @param page - The Playwright page object