feat(app): scroll to and highlight error line on script error (#8183)

This commit is contained in:
Pooja
2026-06-15 13:01:19 +05:30
committed by GitHub
parent 9d8c0fd2a0
commit b73bf9d898
13 changed files with 485 additions and 1 deletions

View File

@@ -165,6 +165,32 @@ const StyledWrapper = styled.div`
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
}
@keyframes cm-error-line-flash {
0%, 60% {
background-color: ${(props) => props.theme.status.danger.background};
}
100% {
background-color: transparent;
}
}
.CodeMirror .cm-error-line-flash {
background-color: transparent;
animation: cm-error-line-flash 3s ease-in-out;
}
.CodeMirror .cm-error-line-flash-gutter {
color: ${(props) => props.theme.colors.text.danger} !important;
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
.CodeMirror .cm-error-line-flash {
animation: none;
background-color: ${(props) => props.theme.status.danger.background};
}
}
.cm-search-match {
background: rgba(255, 193, 7, 0.25);
}

View File

@@ -14,6 +14,7 @@ import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Script = ({ collection }) => {
const dispatch = useDispatch();
@@ -60,6 +61,20 @@ const Script = ({ collection }) => {
return () => clearTimeout(timer);
}, [activeTab]);
useFocusErrorLine({
uid: collection.uid,
editorRef: preRequestEditorRef,
scriptPhase: 'pre-request',
isVisible: activeTab === 'pre-request'
});
useFocusErrorLine({
uid: collection.uid,
editorRef: postResponseEditorRef,
scriptPhase: 'post-response',
isVisible: activeTab === 'post-response'
});
const onRequestScriptEdit = (value) => {
dispatch(
updateCollectionRequestScript({

View File

@@ -9,6 +9,7 @@ import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Tests = ({ collection }) => {
const dispatch = useDispatch();
@@ -30,6 +31,12 @@ const Tests = ({ collection }) => {
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
useFocusErrorLine({
uid: collection.uid,
editorRef: testsEditorRef,
scriptPhase: 'test'
});
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>

View File

@@ -14,6 +14,7 @@ import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Script = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -63,6 +64,20 @@ const Script = ({ collection, folder }) => {
return () => clearTimeout(timer);
}, [activeTab]);
useFocusErrorLine({
uid: folder.uid,
editorRef: preRequestEditorRef,
scriptPhase: 'pre-request',
isVisible: activeTab === 'pre-request'
});
useFocusErrorLine({
uid: folder.uid,
editorRef: postResponseEditorRef,
scriptPhase: 'post-response',
isVisible: activeTab === 'post-response'
});
const onRequestScriptEdit = (value) => {
dispatch(
updateFolderRequestScript({

View File

@@ -9,6 +9,7 @@ import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Tests = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -31,6 +32,12 @@ const Tests = ({ collection, folder }) => {
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
useFocusErrorLine({
uid: folder.uid,
editorRef: testsEditorRef,
scriptPhase: 'test'
});
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>

View File

@@ -12,6 +12,7 @@ import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Script = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -57,6 +58,20 @@ const Script = ({ item, collection }) => {
return () => clearTimeout(timer);
}, [activeTab]);
useFocusErrorLine({
uid: item.uid,
editorRef: preRequestEditorRef,
scriptPhase: 'pre-request',
isVisible: activeTab === 'pre-request'
});
useFocusErrorLine({
uid: item.uid,
editorRef: postResponseEditorRef,
scriptPhase: 'post-response',
isVisible: activeTab === 'post-response'
});
const onRequestScriptEdit = (value) => {
dispatch(
updateRequestScript({

View File

@@ -8,6 +8,7 @@ import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Tests = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -31,6 +32,12 @@ const Tests = ({ item, collection }) => {
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
useFocusErrorLine({
uid: item.uid,
editorRef: testsEditorRef,
scriptPhase: 'test'
});
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
return (

View File

@@ -5,7 +5,7 @@ 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 { addTab, updateRequestPaneTab, updateScriptPaneTab, setFocusErrorLine } from 'providers/ReduxStore/slices/tabs';
import { updateSettingsSelectedTab, updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
import StyledWrapper from './StyledWrapper';
@@ -114,18 +114,28 @@ const ScriptErrorCard = ({ title, message, errorContext, item, collection, scrip
const collectionSettingsTab = scriptPhase === 'test' ? 'tests' : 'script';
const folderSettingsTab = scriptPhase === 'test' ? 'test' : 'script';
const errorLine = errorContext?.errorLine;
const focusPayload = (uid) =>
typeof errorLine === 'number'
? { uid, scriptPhase, line: errorLine, requestedAt: Date.now() }
: null;
if (sourceInfo.sourceType === 'collection') {
dispatch(addTab({ uid: collection.uid, collectionUid: collection.uid, type: 'collection-settings' }));
dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: collectionSettingsTab }));
if (collectionSettingsTab === 'script') {
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: scriptPhase }));
}
const payload = focusPayload(collection.uid);
if (payload) dispatch(setFocusErrorLine(payload));
} else if (sourceInfo.sourceType === 'folder' && sourceInfo.sourceUid) {
dispatch(addTab({ uid: sourceInfo.sourceUid, collectionUid: collection.uid, type: 'folder-settings' }));
dispatch(updatedFolderSettingsSelectedTab({ collectionUid: collection.uid, folderUid: sourceInfo.sourceUid, tab: folderSettingsTab }));
if (folderSettingsTab === 'script') {
dispatch(updateScriptPaneTab({ uid: sourceInfo.sourceUid, scriptPaneTab: scriptPhase }));
}
const payload = focusPayload(sourceInfo.sourceUid);
if (payload) dispatch(setFocusErrorLine(payload));
} else if (sourceInfo.sourceType === 'request') {
dispatch(addTab({ uid: item.uid, collectionUid: collection.uid, type: 'request' }));
if (scriptPhase === 'test') {
@@ -134,6 +144,8 @@ const ScriptErrorCard = ({ title, message, errorContext, item, collection, scrip
dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: 'script' }));
dispatch(updateScriptPaneTab({ uid: item.uid, scriptPaneTab: scriptPhase }));
}
const payload = focusPayload(item.uid);
if (payload) dispatch(setFocusErrorLine(payload));
}
};

View File

@@ -0,0 +1,59 @@
import { useEffect, useRef } from 'react';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import { clearFocusErrorLine } from 'providers/ReduxStore/slices/tabs';
import { focusErrorLine } from 'utils/codemirror/focusErrorLine';
/**
* Subscribes a CodeMirror-hosting component to the tab's `focusErrorLine` signal.
* When the signal targets this host's `scriptPhase`, scrolls the editor to the
* line and flashes a red highlight that fades over ~3s. Re-firing for the same
* line is handled via the `requestedAt` token.
*
* @param {object} params
* @param {string} params.uid Tab uid (request/folder/collection uid)
* @param {React.RefObject} params.editorRef Ref to a CodeEditor component (exposes `.editor`)
* @param {string} params.scriptPhase 'pre-request' | 'post-response' | 'test'
* @param {boolean} [params.isVisible=true] Whether this editor's tab is currently shown
*/
export const useFocusErrorLine = ({ uid, editorRef, scriptPhase, isVisible = true }) => {
const dispatch = useDispatch();
const focusErrorLineState = useSelector((state) => {
const tab = find(state.tabs.tabs, (t) => t.uid === uid);
return tab?.focusErrorLine || null;
});
const disposeRef = useRef(null);
useEffect(() => {
if (!focusErrorLineState || !isVisible) return;
if (focusErrorLineState.scriptPhase !== scriptPhase) return;
const timer = setTimeout(() => {
const editor = editorRef.current?.editor;
if (!editor) return;
if (disposeRef.current) {
disposeRef.current();
disposeRef.current = null;
}
disposeRef.current = focusErrorLine(editor, focusErrorLineState.line);
dispatch(clearFocusErrorLine({ uid }));
}, 0);
return () => clearTimeout(timer);
}, [focusErrorLineState?.requestedAt, focusErrorLineState?.line, focusErrorLineState?.scriptPhase, isVisible, scriptPhase, uid]);
useEffect(() => {
return () => {
if (disposeRef.current) {
disposeRef.current();
disposeRef.current = null;
}
};
}, []);
};
export default useFocusErrorLine;

View File

@@ -280,6 +280,24 @@ export const tabsSlice = createSlice({
tab.scriptPaneTab = action.payload.scriptPaneTab;
}
},
setFocusErrorLine: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
if (tab) {
tab.focusErrorLine = {
scriptPhase: action.payload.scriptPhase,
line: action.payload.line,
requestedAt: action.payload.requestedAt
};
}
},
clearFocusErrorLine: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
if (tab) {
tab.focusErrorLine = null;
}
},
updateQueryBuilderOpen: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
@@ -519,6 +537,8 @@ export const {
updateGqlDocsOpen,
updateTableColumnWidths,
updateScriptPaneTab,
setFocusErrorLine,
clearFocusErrorLine,
closeTabs,
closeAllCollectionTabs,
makeTabPermanent,

View File

@@ -0,0 +1,40 @@
const LINE_CLASS_TARGET = 'background';
const LINE_CLASS_NAME = 'cm-error-line-flash';
const GUTTER_CLASS_TARGET = 'gutter';
const GUTTER_CLASS_NAME = 'cm-error-line-flash-gutter';
export const focusErrorLine = (editor, line1Based, { durationMs = 3000 } = {}) => {
if (!editor || typeof line1Based !== 'number' || Number.isNaN(line1Based)) {
return () => {};
}
const lineCount = editor.lineCount();
const line = Math.max(0, Math.min(line1Based - 1, lineCount - 1));
try {
editor.scrollIntoView({ line, ch: 0 }, 80);
editor.addLineClass(line, LINE_CLASS_TARGET, LINE_CLASS_NAME);
editor.addLineClass(line, GUTTER_CLASS_TARGET, GUTTER_CLASS_NAME);
} catch (e) {
return () => {};
}
let disposed = false;
const dispose = () => {
if (disposed) return;
disposed = true;
try {
editor.removeLineClass(line, LINE_CLASS_TARGET, LINE_CLASS_NAME);
editor.removeLineClass(line, GUTTER_CLASS_TARGET, GUTTER_CLASS_NAME);
} catch (e) {
// editor may have been swapped out; nothing to clean up
}
};
const timer = setTimeout(dispose, durationMs);
return () => {
clearTimeout(timer);
dispose();
};
};

View File

@@ -0,0 +1,86 @@
meta {
name: long-pre-request-error
type: http
seq: 12
}
get {
url: http://localhost:8081/ping
body: none
auth: none
}
script:pre-request {
console.log('line 1');
console.log('line 2');
console.log('line 3');
console.log('line 4');
console.log('line 5');
console.log('line 6');
console.log('line 7');
console.log('line 8');
console.log('line 9');
console.log('line 10');
console.log('line 11');
console.log('line 12');
console.log('line 13');
console.log('line 14');
console.log('line 15');
console.log('line 16');
console.log('line 17');
console.log('line 18');
console.log('line 19');
console.log('line 20');
console.log('line 21');
console.log('line 22');
console.log('line 23');
console.log('line 24');
console.log('line 25');
console.log('line 26');
console.log('line 27');
console.log('line 28');
console.log('line 29');
console.log('line 30');
console.log('line 31');
console.log('line 32');
console.log('line 33');
console.log('line 34');
console.log('line 35');
console.log('line 36');
console.log('line 37');
console.log('line 38');
console.log('line 39');
console.log('line 40');
console.log('line 41');
console.log('line 42');
console.log('line 43');
console.log('line 44');
console.log('line 45');
console.log('line 46');
console.log('line 47');
console.log('line 48');
console.log('line 49');
console.log('line 50');
console.log('line 51');
console.log('line 52');
console.log('line 53');
console.log('line 54');
console.log('line 55');
console.log('line 56');
console.log('line 57');
console.log('line 58');
console.log('line 59');
console.log('line 60');
console.log('line 61');
console.log('line 62');
console.log('line 63');
console.log('line 64');
console.log('line 65');
console.log('line 66');
console.log('line 67');
console.log('line 68');
console.log('line 69');
console.log('line 70');
longScriptUndefinedVar.boom();
console.log('after error');
}

View File

@@ -0,0 +1,175 @@
import { test, expect, Page } from '../../playwright';
import { buildScriptErrorLocators, buildCommonLocators } from '../utils/page/locators';
import { openRequest, sendAndWaitForErrorCard, sendAndWaitForResponse, closeAllTabs } from '../utils/page/actions';
import { setSandboxMode } from '../utils/page/runner';
/**
* Resolves the CodeMirror scroller `scrollTop` for an editor inside a given
* test-id container. Returns null if the container/scroller is not present.
*/
const getScrollerScrollTop = async (page: Page, dataTestId: string): Promise<number | null> => {
return page.evaluate((id) => {
const root = document.querySelector(`[data-testid="${id}"]`);
const scroller = root?.querySelector('.CodeMirror-scroll') as HTMLElement | null;
return scroller ? scroller.scrollTop : null;
}, dataTestId);
};
test.describe('Script Error — focus error line (highlight + scroll)', () => {
let scriptErrorLocators: ReturnType<typeof buildScriptErrorLocators>;
let commonLocators: ReturnType<typeof buildCommonLocators>;
test.beforeAll(async ({ pageWithUserData: page }) => {
scriptErrorLocators = buildScriptErrorLocators(page);
commonLocators = buildCommonLocators(page);
// Highlight/scroll is a pure UI concern — pick one sandbox mode.
await setSandboxMode(page, 'script-errors-test', 'developer');
});
test.beforeEach(async ({ pageWithUserData: page }) => {
await closeAllTabs(page);
});
test('Clicking file path adds the error-line highlight class', 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('Click file path to navigate', async () => {
const card = scriptErrorLocators.card();
await scriptErrorLocators.filePath(card).click();
});
await test.step('Pre-request editor shows flash class on the error line', async () => {
const scriptTab = commonLocators.paneTabs.responsiveTab('script');
await expect(scriptTab).toHaveClass(/active/);
const flashedLine = page
.getByTestId('pre-request-script-editor')
.locator('.CodeMirror .cm-error-line-flash');
await expect(flashedLine).toHaveCount(1);
});
});
test('Highlight clears automatically after the flash duration', async ({ pageWithUserData: page }) => {
await test.step('Open request, send, then navigate', async () => {
await openRequest(page, 'script-errors-test', 'pre-request-ref-error');
await sendAndWaitForErrorCard(page);
await scriptErrorLocators.filePath(scriptErrorLocators.card()).click();
});
await test.step('Flash class is present immediately', async () => {
const flashedLine = page
.getByTestId('pre-request-script-editor')
.locator('.CodeMirror .cm-error-line-flash');
await expect(flashedLine).toHaveCount(1);
});
await test.step('Flash class is gone after ~3s', async () => {
const flashedLine = page
.getByTestId('pre-request-script-editor')
.locator('.CodeMirror .cm-error-line-flash');
// Helper's default duration is 3000ms. Allow 4s for animation end + cleanup.
await expect(flashedLine).toHaveCount(0, { timeout: 5000 });
});
});
test('Re-clicking the file path re-triggers the highlight', async ({ pageWithUserData: page }) => {
await test.step('Open request and send', async () => {
await openRequest(page, 'script-errors-test', 'pre-request-ref-error');
await sendAndWaitForErrorCard(page);
});
const flashedLine = page
.getByTestId('pre-request-script-editor')
.locator('.CodeMirror .cm-error-line-flash');
await test.step('First click flashes the line, then it fades', async () => {
await scriptErrorLocators.filePath(scriptErrorLocators.card()).click();
await expect(flashedLine).toHaveCount(1);
await expect(flashedLine).toHaveCount(0, { timeout: 5000 });
});
await test.step('Second click flashes the line again', async () => {
await scriptErrorLocators.filePath(scriptErrorLocators.card()).click();
await expect(flashedLine).toHaveCount(1);
});
});
test('Post-response error navigates to post-response sub-tab and flashes', 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('Click file path to navigate', async () => {
const card = scriptErrorLocators.card();
await expect(card).toBeVisible();
await scriptErrorLocators.filePath(card).click();
});
await test.step('Post Response sub-tab is active', async () => {
const postResponseSubTab = commonLocators.paneTabs.tabTrigger('post-response');
await expect(postResponseSubTab).toHaveClass(/active/);
});
await test.step('Post-response editor shows flash class on the error line', async () => {
const flashedLine = page
.getByTestId('post-response-script-editor')
.locator('.CodeMirror .cm-error-line-flash');
await expect(flashedLine).toHaveCount(1);
});
});
test('Tests editor flashes the error line for test-script errors', 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('Click file path to navigate', async () => {
const card = scriptErrorLocators.card();
await expect(card).toBeVisible();
await scriptErrorLocators.filePath(card).click();
});
await test.step('Tests editor shows flash class on the error line', async () => {
const flashedLine = page
.getByTestId('test-script-editor')
.locator('.CodeMirror .cm-error-line-flash');
await expect(flashedLine).toHaveCount(1);
});
});
test('Long-script error scrolls the editor so the line is visible', async ({ pageWithUserData: page }) => {
await test.step('Open long-script request and send', async () => {
await openRequest(page, 'script-errors-test', 'long-pre-request-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('Editor scrolled — scrollTop is non-zero', async () => {
// Wait for the highlight to land, then sample the scroller's scrollTop.
const flashedLine = page
.getByTestId('pre-request-script-editor')
.locator('.CodeMirror .cm-error-line-flash');
await expect(flashedLine).toHaveCount(1);
const scrollTop = await getScrollerScrollTop(page, 'pre-request-script-editor');
expect(scrollTop).not.toBeNull();
expect(scrollTop!).toBeGreaterThan(0);
});
await test.step('The flashed line is the one carrying the error', async () => {
const flashedRow = page
.getByTestId('pre-request-script-editor')
.locator('.CodeMirror-code > div', { has: page.locator('.cm-error-line-flash') });
await expect(flashedRow).toContainText('longScriptUndefinedVar');
});
});
});