From 13b4c937aee07a64ecb37caeb92d9bb5184df277 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Fri, 4 Sep 2026 15:48:22 +0530 Subject: [PATCH 1/2] fix(playground): resolve relative paths from the root in the script sandbox --- .../src/scripting/sandbox/quickjs/bundle-entry.ts | 11 ++++++++++- .../scripting/sandbox/quickjs/library-parity.spec.ts | 8 ++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts index bd737464..ed36a184 100644 --- a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts @@ -11,7 +11,16 @@ import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import * as uuid from 'uuid'; import * as nanoid from 'nanoid'; -import path from 'path-browserify'; +import browserPath from 'path-browserify'; + +// path-browserify reads process.cwd() to resolve relative paths and the sandbox has no process. +// Treat '/' as the working directory instead. +const path = { + ...browserPath, + resolve: (...segments: string[]) => browserPath.resolve('/', ...segments), + relative: (from: string, to: string) => + browserPath.relative(browserPath.resolve('/', from), browserPath.resolve('/', to)) +}; (globalThis as any).expect = expect; (globalThis as any).assert = assert; diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/library-parity.spec.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/library-parity.spec.ts index 43c9b63e..b5b1fdf5 100644 --- a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/library-parity.spec.ts +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/library-parity.spec.ts @@ -68,6 +68,14 @@ describe('sandbox library parity with desktop safe mode', () => { expect(inVm(`typeof require('axios').get`)).toBe('function'); }); + it('resolves relative-only path arguments from the root, since the sandbox has no working directory', () => { + expect(inVm(`require('path').resolve('a', 'b')`)).toBe('/a/b'); + expect(inVm(`require('path').resolve()`)).toBe('/'); + expect(inVm(`require('path').relative('a/b', 'a/c')`)).toBe('../c'); + expect(inVm(`globalThis.path.resolve('x')`)).toBe('/x'); + expect(inVm(`typeof globalThis.process`)).toBe('undefined'); + }); + it('gives explanatory errors for developer-mode-only and node builtin modules', () => { expect(errorMessageOf(`require('lodash')`)).toContain('only available in the Bruno desktop app\'s developer mode'); expect(errorMessageOf(`require('fs')`)).toContain('is a Node.js builtin'); From 336c2917ef7e62a413d50f2a965b31a5841e13d2 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Fri, 4 Sep 2026 15:48:26 +0530 Subject: [PATCH 2/2] fix(playground): show post-response and test script errors in the response pane --- .../playground/response-pane.component.ts | 4 + .../tests/playground/script-execution.spec.ts | 77 ++++++++++++++++ .../ResponsePane/ResponsePane.tsx | 30 +++++- .../bruno-api-docs/src/runner/index.spec.ts | 91 +++++++++++++++++++ packages/bruno-api-docs/src/runner/index.ts | 21 ++++- .../src/runner/utils/script-errors.spec.ts | 31 +++++++ .../src/runner/utils/script-errors.ts | 31 +++++++ .../src/scripting/runtime/script-runtime.ts | 22 +++-- .../src/ui/ErrorBanner/ErrorBanner.spec.tsx | 23 +++++ .../src/ui/ErrorBanner/ErrorBanner.tsx | 15 ++- .../src/ui/ErrorBanner/StyledWrapper.tsx | 18 ++++ 11 files changed, 348 insertions(+), 15 deletions(-) create mode 100644 packages/bruno-api-docs/src/runner/utils/script-errors.spec.ts create mode 100644 packages/bruno-api-docs/src/runner/utils/script-errors.ts create mode 100644 packages/bruno-api-docs/src/ui/ErrorBanner/ErrorBanner.spec.tsx diff --git a/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts b/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts index b4bee660..0aab8505 100644 --- a/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts +++ b/packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts @@ -16,6 +16,10 @@ export class ResponsePaneComponent extends BaseComponent { readonly errorBanner = this.bodyPanel.getByTestId('error-banner'); readonly errorTitle = this.bodyPanel.getByTestId('error-title'); readonly errorMessage = this.bodyPanel.getByTestId('error-message'); + readonly testsPanel = this.page.getByTestId('response-tabs-panel-tests'); + readonly scriptErrors = this.bodyPanel.getByTestId('response-script-errors'); + readonly testsScriptErrors = this.testsPanel.getByTestId('tests-script-errors'); + readonly testsScriptErrorsDismiss = this.testsScriptErrors.getByTestId('error-banner-dismiss'); readonly formatSelector = this.page.getByTestId('response-format-selector'); // The leading icon in the selector trigger (the eye when preview is on, else the format's icon). readonly formatSelectorIcon = this.page.getByTestId('response-format-selector-trigger-icon'); diff --git a/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts index 0a72f0db..a05d209d 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts @@ -24,6 +24,21 @@ test('tv4 validates against a schema', function () { }); `; +const REQUIRE_FS_TESTS_SCRIPT = ` +test('ran before the throw', function () { expect(1).to.equal(1); }); +require('fs'); +test('never reached', function () { expect(1).to.equal(1); }); +`; + +const UNREACHABLE_HOST_POST_RESPONSE_SCRIPT = ` +const axios = require('axios'); +await axios.get('https://unreachable.invalid/get'); +`; + +const REQUIRE_LODASH_PRE_REQUEST_SCRIPT = ` +const _ = require('lodash'); +`; + const setEditorScript = async (page: Page, editor: CodeEditorComponent, script: string): Promise => { await editor.focus(); await page.keyboard.press('ControlOrMeta+a'); @@ -54,4 +69,66 @@ test.describe('playground script execution', () => { await expect(page.getByText(/Passed: [1-9]\d*, Failed: 0/).first()).toBeVisible(); await expect(page.getByText(/Failed: [1-9]/)).toHaveCount(0); }); + + test('a tests script that throws shows a dismissable Test Script Error card and keeps the tests that ran', async ({ page, playground, responsePane }) => { + await responsePane.mockUsersResponse(JSON.stringify({ users: [] })); + + await page.goto('/#/?pg=1&dock=bottom'); + await playground.openSidebarItem('get users'); + await playground.selectTab('tests'); + await setEditorScript(page, playground.testsEditor, REQUIRE_FS_TESTS_SCRIPT); + + await responsePane.send(); + + await expect(responsePane.status).toContainText('200'); + await expect(responsePane.scriptErrors.getByTestId('error-title')).toHaveText('Test Script Error'); + await expect(responsePane.scriptErrors.getByTestId('error-message')).toContainText('\'fs\' is a Node.js builtin'); + + await responsePane.switchToTab('tests'); + await expect(responsePane.testsPanel.getByText('Tests (2), Passed: 1, Failed: 1')).toBeVisible(); + await expect(responsePane.testsPanel.getByText('ran before the throw')).toBeVisible(); + await expect(responsePane.testsPanel.getByText('never reached')).toHaveCount(0); + await expect(responsePane.testsScriptErrors.getByTestId('error-title')).toHaveText('Test Script Error'); + + await responsePane.testsScriptErrorsDismiss.click(); + await expect(responsePane.testsScriptErrors).toHaveCount(0); + await responsePane.switchToTab('response'); + await expect(responsePane.scriptErrors).toHaveCount(0); + await expect(responsePane.bodyEditor.surface).toBeVisible(); + }); + + test('a pre-request script that throws shows a Pre-Request Script Error card instead of a response', async ({ page, playground, responsePane }) => { + await page.goto('/#/?pg=1&dock=bottom'); + await playground.openSidebarItem('get users'); + await playground.selectTab('scripts'); + await setEditorScript(page, playground.preRequestScriptEditor, REQUIRE_LODASH_PRE_REQUEST_SCRIPT); + + await responsePane.send(); + + await expect(responsePane.errorTitle).toHaveText('Pre-Request Script Error'); + await expect(responsePane.errorMessage).toContainText('\'lodash\' is only available in the Bruno desktop app\'s developer mode'); + await expect(responsePane.status).toHaveCount(0); + }); + + test('a post-response script that throws shows a Post-Response Script Error card while the body still renders', async ({ page, playground, responsePane }) => { + await responsePane.mockUsersResponse(JSON.stringify({ users: [] })); + await page.route('https://unreachable.invalid/**', (route) => route.abort('namenotresolved')); + + await page.goto('/#/?pg=1&dock=bottom'); + await playground.openSidebarItem('get users'); + await playground.selectTab('scripts'); + await page.getByTestId('scripts-tabs-tab-post-response').click(); + await setEditorScript(page, playground.postResponseScriptEditor, UNREACHABLE_HOST_POST_RESPONSE_SCRIPT); + + await responsePane.send(); + + await expect(responsePane.status).toContainText('200'); + await expect(responsePane.scriptErrors.getByTestId('error-title')).toHaveText('Post-Response Script Error'); + await expect(responsePane.scriptErrors.getByTestId('error-message')).toHaveText('Network Error'); + await expect(responsePane.bodyEditor.surface).toBeVisible(); + + await responsePane.switchToTab('tests'); + await expect(responsePane.testsPanel.getByText('Tests (1), Passed: 0, Failed: 1')).toBeVisible(); + await expect(responsePane.testsPanel.getByText('Post-Response Script Error').first()).toBeVisible(); + }); }); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx index 35767c32..4237a9ec 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx @@ -12,6 +12,7 @@ import { useResponseFormatter } from './ResponseFormatter/hooks/useResponseForma import type { ResponseBodyFormat } from '@/constants'; import ResponseDuration from './ResponseInfo/ResponseDuration/ResponseDuration'; import type { RunRequestResponse } from '@/runner'; +import { SCRIPT_ERROR_TITLES } from '@/runner/utils/script-errors'; import ResponseStatus from './ResponseInfo/ResponseStatus/ResponseStatus'; import ResponseSize from './ResponseInfo/ResponseSize/ResponseSize'; import ResponseActions from './ResponseActions/ResponseActions'; @@ -26,6 +27,7 @@ interface ResponsePaneProps { const ResponsePane: React.FC = ({ response, isLoading, orientation, itemUuid }) => { const [activeTab, setActiveTab] = useState('response'); + const [dismissedScriptErrorsRequestId, setDismissedScriptErrorsRequestId] = useState(); const { actionsExpandedWidth, measureActions } = useResponseActions(); const { @@ -71,8 +73,25 @@ const ResponsePane: React.FC = ({ response, isLoading, orient ); + const scriptErrorsDismissed = dismissedScriptErrorsRequestId === response.requestId; + const scriptErrors = scriptErrorsDismissed ? [] : (response.scriptErrors ?? []); + const renderScriptErrors = (testId: string) => + scriptErrors.length ? ( +
+ {scriptErrors.map((scriptError) => ( + setDismissedScriptErrorsRequestId(response.requestId)} + /> + ))} +
+ ) : null; + const renderResponseBody = () => (
+ {renderScriptErrors('response-script-errors')} {response.warnings?.length ? (
@@ -90,10 +109,13 @@ const ResponsePane: React.FC = ({ response, isLoading, orient ); const renderHeaders = () => ; const renderTestResults = () => ( - +
+ {renderScriptErrors('tests-script-errors')} + +
); const headersCount = response.headers ? Object.keys(response.headers).length : 0; diff --git a/packages/bruno-api-docs/src/runner/index.spec.ts b/packages/bruno-api-docs/src/runner/index.spec.ts index 8429af3c..e12f4a6d 100644 --- a/packages/bruno-api-docs/src/runner/index.spec.ts +++ b/packages/bruno-api-docs/src/runner/index.spec.ts @@ -528,6 +528,97 @@ items: global.fetch = originalFetch; }); + describe('script errors after the response arrived', () => { + const scriptErrorCollection = (scripts: string) => parseYaml(` +opencollection: "1.0.0" +info: + name: "Script errors" +items: + - name: "req" + type: "http" + method: "GET" + url: "https://example.com/" + scripts: +${scripts} +`); + + it('surfaces a tests-script throw as a scriptError and a failed row, keeping the tests that already ran', async () => { + global.fetch = okFetch(); + const collection = scriptErrorCollection(` + tests: | + test("ran before the throw", function () { expect(1).to.equal(1); }); + require("fs"); + test("never reached", function () { expect(1).to.equal(1); });`); + + const response = await new RequestRunner().runRequest({ + item: collection.items[0], collection, runtimeVariables: {} + }); + + expect(response.status).toBe(200); + expect(response.error).toBeUndefined(); + expect(response.scriptErrors).toHaveLength(1); + expect(response.scriptErrors?.[0].phase).toBe('tests'); + expect(response.scriptErrors?.[0].message).toContain('\'fs\' is a Node.js builtin'); + expect(response.testResults?.results.map((r) => [r.status, r.description])).toEqual([ + ['pass', 'ran before the throw'], + ['fail', 'Test Script Error'] + ]); + expect(response.testResults?.results[1].error).toContain('\'fs\' is a Node.js builtin'); + expect(response.testResults?.summary).toEqual({ total: 2, passed: 1, failed: 1, skipped: 0 }); + global.fetch = originalFetch; + }); + + it('surfaces a post-response throw as a scriptError and a failed row while the response stays intact', async () => { + global.fetch = okFetch(); + const collection = scriptErrorCollection(` + postResponse: | + throw new Error("post boom");`); + + const response = await new RequestRunner().runRequest({ + item: collection.items[0], collection, runtimeVariables: {} + }); + + expect(response.status).toBe(200); + expect(response.scriptErrors).toEqual([{ phase: 'post-response', message: 'post boom' }]); + expect(response.testResults?.results).toEqual([ + { status: 'fail', description: 'Post-Response Script Error', error: 'post boom' } + ]); + global.fetch = originalFetch; + }); + + it('titles a pre-request throw like the desktop app and keeps the raw message', async () => { + global.fetch = okFetch(); + const collection = scriptErrorCollection(` + preRequest: | + require("lodash");`); + + const response = await new RequestRunner().runRequest({ + item: collection.items[0], collection, runtimeVariables: {} + }); + + expect(response.status).toBeUndefined(); + expect(response.errorTitle).toBe('Pre-Request Script Error'); + expect(response.error).toContain('\'lodash\' is only available in the Bruno desktop app\'s developer mode'); + expect(response.error).not.toContain('Pre-request script error:'); + global.fetch = originalFetch; + }); + + it('reports no scriptErrors when every script phase succeeds', async () => { + global.fetch = okFetch(); + const collection = scriptErrorCollection(` + tests: | + test("ok", function () { expect(1).to.equal(1); });`); + + const response = await new RequestRunner().runRequest({ + item: collection.items[0], collection, runtimeVariables: {} + }); + + expect(response.scriptErrors).toBeUndefined(); + expect(response.testResults?.summary.passed).toBe(1); + global.fetch = originalFetch; + }); + }); + it('should strip JSON comments from body before sending', async () => { // Mock fetch to capture what body was sent let sentBody: string | undefined; diff --git a/packages/bruno-api-docs/src/runner/index.ts b/packages/bruno-api-docs/src/runner/index.ts index fe057c35..d4024981 100644 --- a/packages/bruno-api-docs/src/runner/index.ts +++ b/packages/bruno-api-docs/src/runner/index.ts @@ -2,7 +2,8 @@ import type { HttpRequest } from '@opencollection/types/requests/http'; import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types'; import type { Environment } from '@opencollection/types/config/environments'; import { RequestExecutor } from './RequestExecutor'; -import ScriptRuntime from '@/scripting/runtime/script-runtime'; +import ScriptRuntime, { type ScriptRunError } from '@/scripting/runtime/script-runtime'; +import { appendScriptErrorResult, SCRIPT_ERROR_TITLES, type ScriptError } from './utils/script-errors'; import type { RunRequestCallback } from '@/scripting/utils/bru'; import AssertRuntime, { type AssertionResult } from '@/scripting/runtime/assert-runtime'; import { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeAuth, interpolateVars, findItemByPath } from './utils'; @@ -18,6 +19,8 @@ import { import { getItemUuid } from '@/utils/itemUtils'; import { cloneDeep, isEqual } from 'lodash-es'; +const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : 'Unknown script error'); + const MAX_RUN_REQUEST_DEPTH = 25; interface RunContext { @@ -121,6 +124,7 @@ export interface RunRequestResponse { requestId?: string; assertionResults?: AssertionResultsResponse; testResults?: TestResultsResponse; + scriptErrors?: ScriptError[]; warnings?: string[] | null; environmentVariables?: { envName: string; variables: Variables; deleted: string[] }; collectionVariables?: { variables: Variables; deleted: string[] }; @@ -255,6 +259,8 @@ export class RequestRunner { const scriptsObj = scriptsArrayToObject(getRequestScripts(processedRequest)); const assertions = getRequestAssertions(processedRequest); + const scriptErrors: ScriptError[] = []; + // Pre-request script if (scriptsObj.preRequest) { try { @@ -270,7 +276,8 @@ export class RequestRunner { } catch (scriptError) { return { requestId, - error: `Pre-request script error: ${scriptError instanceof Error ? scriptError.message : 'Unknown script error'}`, + error: errorMessage(scriptError), + errorTitle: SCRIPT_ERROR_TITLES['pre-request'], warnings: warnings.length ? warnings : null }; } @@ -294,8 +301,8 @@ export class RequestRunner { runRequest }); } catch (scriptError) { - // Don't fail the request for post-response script errors, just log them console.warn('Post-response script error:', scriptError); + scriptErrors.push({ phase: 'post-response', message: errorMessage(scriptError) }); } } let assertionResults: AssertionResult[] | undefined; @@ -340,16 +347,22 @@ export class RequestRunner { assertionResultsResponse = await bru.getAssertionResults(); } } catch (scriptError) { - // Don't fail the request for test script errors, just log them console.warn('Test script error:', scriptError); + scriptErrors.push({ phase: 'tests', message: errorMessage(scriptError) }); + testResultsResponse = (scriptError as ScriptRunError).partialTestResults; } } + for (const scriptError of scriptErrors) { + testResultsResponse = appendScriptErrorResult(testResultsResponse, scriptError.phase, scriptError.message); + } + return { ...response, requestId, assertionResults: assertionResultsResponse, testResults: testResultsResponse, + scriptErrors: scriptErrors.length ? scriptErrors : undefined, warnings: warnings.length ? warnings : null }; } catch (error) { diff --git a/packages/bruno-api-docs/src/runner/utils/script-errors.spec.ts b/packages/bruno-api-docs/src/runner/utils/script-errors.spec.ts new file mode 100644 index 00000000..e0e77d12 --- /dev/null +++ b/packages/bruno-api-docs/src/runner/utils/script-errors.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { appendScriptErrorResult } from './script-errors'; + +describe('appendScriptErrorResult', () => { + it('adds a failed "Test Script Error" row after the tests that ran before the script threw', () => { + const partial = { + summary: { total: 1, passed: 1, failed: 0, skipped: 0 }, + results: [{ status: 'pass', description: 'first' }] + }; + const result = appendScriptErrorResult(partial, 'tests', 'Cannot find module fs'); + expect(result.results).toEqual([ + { status: 'pass', description: 'first' }, + { status: 'fail', description: 'Test Script Error', error: 'Cannot find module fs' } + ]); + expect(result.summary).toEqual({ total: 2, passed: 1, failed: 1, skipped: 0 }); + }); + + it('starts a fresh result set when no test ran before the post-response script threw', () => { + const result = appendScriptErrorResult(undefined, 'post-response', 'Network Error'); + expect(result.results).toEqual([ + { status: 'fail', description: 'Post-Response Script Error', error: 'Network Error' } + ]); + expect(result.summary).toEqual({ total: 1, passed: 0, failed: 1, skipped: 0 }); + }); + + it('does not mutate the results it was given', () => { + const partial = { summary: { total: 0, passed: 0, failed: 0, skipped: 0 }, results: [] }; + appendScriptErrorResult(partial, 'tests', 'boom'); + expect(partial.results).toEqual([]); + }); +}); diff --git a/packages/bruno-api-docs/src/runner/utils/script-errors.ts b/packages/bruno-api-docs/src/runner/utils/script-errors.ts new file mode 100644 index 00000000..ef6728e7 --- /dev/null +++ b/packages/bruno-api-docs/src/runner/utils/script-errors.ts @@ -0,0 +1,31 @@ +import type { TestResultsResponse } from '@/scripting/utils/test'; + +export type ScriptErrorPhase = 'pre-request' | 'post-response' | 'tests'; + +export interface ScriptError { + phase: Exclude; + message: string; +} + +export const SCRIPT_ERROR_TITLES: Record = { + 'pre-request': 'Pre-Request Script Error', + 'post-response': 'Post-Response Script Error', + 'tests': 'Test Script Error' +}; + +const EMPTY_SUMMARY = { total: 0, passed: 0, failed: 0, skipped: 0 }; + +export const appendScriptErrorResult = ( + testResults: TestResultsResponse | undefined, + phase: ScriptErrorPhase, + message: string +): TestResultsResponse => { + const summary = testResults?.summary ?? EMPTY_SUMMARY; + return { + summary: { ...summary, total: summary.total + 1, failed: summary.failed + 1 }, + results: [ + ...(testResults?.results ?? []), + { status: 'fail', description: SCRIPT_ERROR_TITLES[phase], error: message } + ] + }; +}; diff --git a/packages/bruno-api-docs/src/scripting/runtime/script-runtime.ts b/packages/bruno-api-docs/src/scripting/runtime/script-runtime.ts index 9d0bb5af..10d5c46c 100644 --- a/packages/bruno-api-docs/src/scripting/runtime/script-runtime.ts +++ b/packages/bruno-api-docs/src/scripting/runtime/script-runtime.ts @@ -4,7 +4,7 @@ import BrunoRequest from '@/scripting/utils/bruno-request'; import BrunoResponse from '@/scripting/utils/bruno-response'; import { executeQuickJsVmAsync } from '@/scripting/sandbox/quickjs'; import type { AssertionResult } from '@/scripting/utils/test'; -import { createBruTestResultMethods, type BruTestResultMethods } from '@/scripting/utils/test'; +import { createBruTestResultMethods, type BruTestResultMethods, type TestResultsResponse } from '@/scripting/utils/test'; interface RunScriptOptions { script: string; @@ -18,6 +18,10 @@ interface RunScriptOptions { runRequest?: RunRequestCallback; } +export interface ScriptRunError extends Error { + partialTestResults?: TestResultsResponse; +} + class ScriptRuntime { constructor() { } @@ -74,11 +78,17 @@ class ScriptRuntime { } }; - await executeQuickJsVmAsync({ - script: script, - context: context, - collectionPath - }); + try { + await executeQuickJsVmAsync({ + script: script, + context: context, + collectionPath + }); + } catch (error) { + const scriptError: ScriptRunError = error instanceof Error ? error : new Error(String(error)); + if (bru.getTestResults) scriptError.partialTestResults = await bru.getTestResults(); + throw scriptError; + } // Return bru object so caller can access test results return bru; diff --git a/packages/bruno-api-docs/src/ui/ErrorBanner/ErrorBanner.spec.tsx b/packages/bruno-api-docs/src/ui/ErrorBanner/ErrorBanner.spec.tsx new file mode 100644 index 00000000..78d718aa --- /dev/null +++ b/packages/bruno-api-docs/src/ui/ErrorBanner/ErrorBanner.spec.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import ErrorBanner from './ErrorBanner'; + +describe('ErrorBanner', () => { + it('renders the title, message and hint', () => { + const root = useRenderToDom(); + expect(getByTestId(root, 'error-title').textContent).toBe('Request Failed'); + expect(getByTestId(root, 'error-message').textContent).toBe('Network Error'); + expect(getByTestId(root, 'error-hint').textContent).toBe('Check the URL.'); + }); + + it('shows a dismiss button only when onDismiss is given', () => { + const withDismiss = useRenderToDom( {}} />); + expect(getByTestId(withDismiss, 'error-banner-dismiss').getAttribute('aria-label')).toBe('Dismiss error'); + + const withoutDismiss = useRenderToDom(); + expect(queryByTestId(withoutDismiss, 'error-banner-dismiss')).toBeNull(); + expect(queryByTestId(withoutDismiss, 'error-hint')).toBeNull(); + }); +}); diff --git a/packages/bruno-api-docs/src/ui/ErrorBanner/ErrorBanner.tsx b/packages/bruno-api-docs/src/ui/ErrorBanner/ErrorBanner.tsx index 50a2aacd..c001e713 100644 --- a/packages/bruno-api-docs/src/ui/ErrorBanner/ErrorBanner.tsx +++ b/packages/bruno-api-docs/src/ui/ErrorBanner/ErrorBanner.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { CloseIcon } from '@/assets/icons'; import { StyledWrapper } from './StyledWrapper'; export interface ErrorBannerProps { @@ -6,6 +7,7 @@ export interface ErrorBannerProps { message: string; /** Optional one-line "what to do next" guidance shown beneath the message. */ hint?: string; + onDismiss?: () => void; className?: string; } @@ -13,8 +15,19 @@ export interface ErrorBannerProps { * Danger banner for a failed try-it request: bold title, monospace message, * and an optional next-step hint. Mirrors Bruno desktop's response error banner. */ -const ErrorBanner: React.FC = ({ title, message, hint, className = '' }) => ( +const ErrorBanner: React.FC = ({ title, message, hint, onDismiss, className = '' }) => ( + {onDismiss ? ( + + ) : null}
{title}
{message}
{hint ?
{hint}
: null} diff --git a/packages/bruno-api-docs/src/ui/ErrorBanner/StyledWrapper.tsx b/packages/bruno-api-docs/src/ui/ErrorBanner/StyledWrapper.tsx index 578eba21..cffb808f 100644 --- a/packages/bruno-api-docs/src/ui/ErrorBanner/StyledWrapper.tsx +++ b/packages/bruno-api-docs/src/ui/ErrorBanner/StyledWrapper.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; export const StyledWrapper = styled.div` + position: relative; background-color: var(--bg-secondary); border: 1px solid var(--oc-border-border2); border-left: 4px solid var(--oc-status-danger-border); @@ -22,6 +23,23 @@ export const StyledWrapper = styled.div` color: var(--text-primary); } + .error-dismiss { + position: absolute; + top: 0.5rem; + right: 0.5rem; + display: inline-flex; + padding: 0.25rem; + border: none; + border-radius: var(--oc-radius); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + + &:hover { + color: var(--text-primary); + } + } + .error-hint { margin-top: 0.75rem; font-size: 0.8125rem;