mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
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:
@@ -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;
|
||||
55
packages/bruno-app/src/components/CodeSnippet/index.js
Normal file
55
packages/bruno-app/src/components/CodeSnippet/index.js
Normal 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;
|
||||
140
packages/bruno-app/src/components/CodeSnippet/index.spec.js
Normal file
140
packages/bruno-app/src/components/CodeSnippet/index.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -304,6 +304,7 @@ const ResponsePane = ({ item, collection }) => {
|
||||
<ScriptError
|
||||
item={item}
|
||||
onClose={() => setShowScriptErrorCard(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "collection-script-error",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: collection-script-error
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const validLine = 1;
|
||||
collectionUndefinedVar.doSomething();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: simple-request
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://localhost:8081/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "script-errors-test",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
meta {
|
||||
name: script-errors-test
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: folder-request
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://localhost:8081/ping
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: error-subfolder
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const folderData = "hello";
|
||||
folderUndefinedVar.method();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
12
tests/script-errors/init-user-data/preferences.json
Normal file
12
tests/script-errors/init-user-data/preferences.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"lastOpenedCollections": [
|
||||
"{{collectionPath}}/script-errors-test",
|
||||
"{{collectionPath}}/collection-script-error"
|
||||
],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
}
|
||||
}
|
||||
}
|
||||
456
tests/script-errors/script-errors.spec.ts
Normal file
456
tests/script-errors/script-errors.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user