Skip to content

Commit 2863e20

Browse files
committed
Merge remote-tracking branch 'origin/release/v0.3.0'
2 parents f16f5ed + d1d571d commit 2863e20

15 files changed

Lines changed: 498 additions & 17 deletions

File tree

‎packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export class ResponsePaneComponent extends BaseComponent {
1616
readonly errorBanner = this.bodyPanel.getByTestId('error-banner');
1717
readonly errorTitle = this.bodyPanel.getByTestId('error-title');
1818
readonly errorMessage = this.bodyPanel.getByTestId('error-message');
19+
readonly testsPanel = this.page.getByTestId('response-tabs-panel-tests');
20+
readonly scriptErrors = this.bodyPanel.getByTestId('response-script-errors');
21+
readonly testsScriptErrors = this.testsPanel.getByTestId('tests-script-errors');
22+
readonly testsScriptErrorsDismiss = this.testsScriptErrors.getByTestId('error-banner-dismiss');
1923
readonly formatSelector = this.page.getByTestId('response-format-selector');
2024
// The leading icon in the selector trigger (the eye when preview is on, else the format's icon).
2125
readonly formatSelectorIcon = this.page.getByTestId('response-format-selector-trigger-icon');

‎packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,21 @@ test('tv4 validates against a schema', function () {
2424
});
2525
`;
2626

27+
const REQUIRE_FS_TESTS_SCRIPT = `
28+
test('ran before the throw', function () { expect(1).to.equal(1); });
29+
require('fs');
30+
test('never reached', function () { expect(1).to.equal(1); });
31+
`;
32+
33+
const UNREACHABLE_HOST_POST_RESPONSE_SCRIPT = `
34+
const axios = require('axios');
35+
await axios.get('https://unreachable.invalid/get');
36+
`;
37+
38+
const REQUIRE_LODASH_PRE_REQUEST_SCRIPT = `
39+
const _ = require('lodash');
40+
`;
41+
2742
const setEditorScript = async (page: Page, editor: CodeEditorComponent, script: string): Promise<void> => {
2843
await editor.focus();
2944
await page.keyboard.press('ControlOrMeta+a');
@@ -54,4 +69,66 @@ test.describe('playground script execution', () => {
5469
await expect(page.getByText(/Passed: [1-9]\d*, Failed: 0/).first()).toBeVisible();
5570
await expect(page.getByText(/Failed: [1-9]/)).toHaveCount(0);
5671
});
72+
73+
test('a tests script that throws shows a dismissable Test Script Error card and keeps the tests that ran', async ({ page, playground, responsePane }) => {
74+
await responsePane.mockUsersResponse(JSON.stringify({ users: [] }));
75+
76+
await page.goto('/#/?pg=1&dock=bottom');
77+
await playground.openSidebarItem('get users');
78+
await playground.selectTab('tests');
79+
await setEditorScript(page, playground.testsEditor, REQUIRE_FS_TESTS_SCRIPT);
80+
81+
await responsePane.send();
82+
83+
await expect(responsePane.status).toContainText('200');
84+
await expect(responsePane.scriptErrors.getByTestId('error-title')).toHaveText('Test Script Error');
85+
await expect(responsePane.scriptErrors.getByTestId('error-message')).toContainText('\'fs\' is a Node.js builtin');
86+
87+
await responsePane.switchToTab('tests');
88+
await expect(responsePane.testsPanel.getByText('Tests (2), Passed: 1, Failed: 1')).toBeVisible();
89+
await expect(responsePane.testsPanel.getByText('ran before the throw')).toBeVisible();
90+
await expect(responsePane.testsPanel.getByText('never reached')).toHaveCount(0);
91+
await expect(responsePane.testsScriptErrors.getByTestId('error-title')).toHaveText('Test Script Error');
92+
93+
await responsePane.testsScriptErrorsDismiss.click();
94+
await expect(responsePane.testsScriptErrors).toHaveCount(0);
95+
await responsePane.switchToTab('response');
96+
await expect(responsePane.scriptErrors).toHaveCount(0);
97+
await expect(responsePane.bodyEditor.surface).toBeVisible();
98+
});
99+
100+
test('a pre-request script that throws shows a Pre-Request Script Error card instead of a response', async ({ page, playground, responsePane }) => {
101+
await page.goto('/#/?pg=1&dock=bottom');
102+
await playground.openSidebarItem('get users');
103+
await playground.selectTab('scripts');
104+
await setEditorScript(page, playground.preRequestScriptEditor, REQUIRE_LODASH_PRE_REQUEST_SCRIPT);
105+
106+
await responsePane.send();
107+
108+
await expect(responsePane.errorTitle).toHaveText('Pre-Request Script Error');
109+
await expect(responsePane.errorMessage).toContainText('\'lodash\' is only available in the Bruno desktop app\'s developer mode');
110+
await expect(responsePane.status).toHaveCount(0);
111+
});
112+
113+
test('a post-response script that throws shows a Post-Response Script Error card while the body still renders', async ({ page, playground, responsePane }) => {
114+
await responsePane.mockUsersResponse(JSON.stringify({ users: [] }));
115+
await page.route('https://unreachable.invalid/**', (route) => route.abort('namenotresolved'));
116+
117+
await page.goto('/#/?pg=1&dock=bottom');
118+
await playground.openSidebarItem('get users');
119+
await playground.selectTab('scripts');
120+
await page.getByTestId('scripts-tabs-tab-post-response').click();
121+
await setEditorScript(page, playground.postResponseScriptEditor, UNREACHABLE_HOST_POST_RESPONSE_SCRIPT);
122+
123+
await responsePane.send();
124+
125+
await expect(responsePane.status).toContainText('200');
126+
await expect(responsePane.scriptErrors.getByTestId('error-title')).toHaveText('Post-Response Script Error');
127+
await expect(responsePane.scriptErrors.getByTestId('error-message')).toHaveText('Network Error');
128+
await expect(responsePane.bodyEditor.surface).toBeVisible();
129+
130+
await responsePane.switchToTab('tests');
131+
await expect(responsePane.testsPanel.getByText('Tests (1), Passed: 0, Failed: 1')).toBeVisible();
132+
await expect(responsePane.testsPanel.getByText('Post-Response Script Error').first()).toBeVisible();
133+
});
57134
});

‎packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponsePane.tsx‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useResponseFormatter } from './ResponseFormatter/hooks/useResponseForma
1212
import type { ResponseBodyFormat } from '@/constants';
1313
import ResponseDuration from './ResponseInfo/ResponseDuration/ResponseDuration';
1414
import type { RunRequestResponse } from '@/runner';
15+
import { SCRIPT_ERROR_TITLES } from '@/runner/utils/script-errors';
1516
import ResponseStatus from './ResponseInfo/ResponseStatus/ResponseStatus';
1617
import ResponseSize from './ResponseInfo/ResponseSize/ResponseSize';
1718
import ResponseActions from './ResponseActions/ResponseActions';
@@ -26,6 +27,7 @@ interface ResponsePaneProps {
2627

2728
const ResponsePane: React.FC<ResponsePaneProps> = ({ response, isLoading, orientation, itemUuid }) => {
2829
const [activeTab, setActiveTab] = useState('response');
30+
const [dismissedScriptErrorsRequestId, setDismissedScriptErrorsRequestId] = useState<string | undefined>();
2931
const { actionsExpandedWidth, measureActions } = useResponseActions();
3032

3133
const {
@@ -71,8 +73,25 @@ const ResponsePane: React.FC<ResponsePaneProps> = ({ response, isLoading, orient
7173
</div>
7274
);
7375

76+
const scriptErrorsDismissed = dismissedScriptErrorsRequestId === response.requestId;
77+
const scriptErrors = scriptErrorsDismissed ? [] : (response.scriptErrors ?? []);
78+
const renderScriptErrors = (testId: string) =>
79+
scriptErrors.length ? (
80+
<div className="pb-4 space-y-3" data-testid={testId}>
81+
{scriptErrors.map((scriptError) => (
82+
<ErrorBanner
83+
key={scriptError.phase}
84+
title={SCRIPT_ERROR_TITLES[scriptError.phase]}
85+
message={scriptError.message}
86+
onDismiss={() => setDismissedScriptErrorsRequestId(response.requestId)}
87+
/>
88+
))}
89+
</div>
90+
) : null;
91+
7492
const renderResponseBody = () => (
7593
<div className="flex flex-col h-full">
94+
{renderScriptErrors('response-script-errors')}
7695
{response.warnings?.length ? (
7796
<div className="pb-4">
7897
<WarningBanner warnings={response.warnings} />
@@ -90,10 +109,13 @@ const ResponsePane: React.FC<ResponsePaneProps> = ({ response, isLoading, orient
90109
);
91110
const renderHeaders = () => <ResponseHeadersTab headers={response.headers} />;
92111
const renderTestResults = () => (
93-
<TestResultsTab
94-
testResults={response.testResults}
95-
assertionResults={response.assertionResults}
96-
/>
112+
<div className="flex flex-col h-full">
113+
{renderScriptErrors('tests-script-errors')}
114+
<TestResultsTab
115+
testResults={response.testResults}
116+
assertionResults={response.assertionResults}
117+
/>
118+
</div>
97119
);
98120

99121
const headersCount = response.headers ? Object.keys(response.headers).length : 0;

‎packages/bruno-api-docs/src/runner/index.spec.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,97 @@ items:
528528
global.fetch = originalFetch;
529529
});
530530

531+
describe('script errors after the response arrived', () => {
532+
const scriptErrorCollection = (scripts: string) => parseYaml(`
533+
opencollection: "1.0.0"
534+
info:
535+
name: "Script errors"
536+
items:
537+
- name: "req"
538+
type: "http"
539+
method: "GET"
540+
url: "https://example.com/"
541+
scripts:
542+
${scripts}
543+
`);
544+
545+
it('surfaces a tests-script throw as a scriptError and a failed row, keeping the tests that already ran', async () => {
546+
global.fetch = okFetch();
547+
const collection = scriptErrorCollection(`
548+
tests: |
549+
test("ran before the throw", function () { expect(1).to.equal(1); });
550+
require("fs");
551+
test("never reached", function () { expect(1).to.equal(1); });`);
552+
553+
const response = await new RequestRunner().runRequest({
554+
item: collection.items[0], collection, runtimeVariables: {}
555+
});
556+
557+
expect(response.status).toBe(200);
558+
expect(response.error).toBeUndefined();
559+
expect(response.scriptErrors).toHaveLength(1);
560+
expect(response.scriptErrors?.[0].phase).toBe('tests');
561+
expect(response.scriptErrors?.[0].message).toContain('\'fs\' is a Node.js builtin');
562+
expect(response.testResults?.results.map((r) => [r.status, r.description])).toEqual([
563+
['pass', 'ran before the throw'],
564+
['fail', 'Test Script Error']
565+
]);
566+
expect(response.testResults?.results[1].error).toContain('\'fs\' is a Node.js builtin');
567+
expect(response.testResults?.summary).toEqual({ total: 2, passed: 1, failed: 1, skipped: 0 });
568+
global.fetch = originalFetch;
569+
});
570+
571+
it('surfaces a post-response throw as a scriptError and a failed row while the response stays intact', async () => {
572+
global.fetch = okFetch();
573+
const collection = scriptErrorCollection(`
574+
postResponse: |
575+
throw new Error("post boom");`);
576+
577+
const response = await new RequestRunner().runRequest({
578+
item: collection.items[0], collection, runtimeVariables: {}
579+
});
580+
581+
expect(response.status).toBe(200);
582+
expect(response.scriptErrors).toEqual([{ phase: 'post-response', message: 'post boom' }]);
583+
expect(response.testResults?.results).toEqual([
584+
{ status: 'fail', description: 'Post-Response Script Error', error: 'post boom' }
585+
]);
586+
global.fetch = originalFetch;
587+
});
588+
589+
it('titles a pre-request throw like the desktop app and keeps the raw message', async () => {
590+
global.fetch = okFetch();
591+
const collection = scriptErrorCollection(`
592+
preRequest: |
593+
require("lodash");`);
594+
595+
const response = await new RequestRunner().runRequest({
596+
item: collection.items[0], collection, runtimeVariables: {}
597+
});
598+
599+
expect(response.status).toBeUndefined();
600+
expect(response.errorTitle).toBe('Pre-Request Script Error');
601+
expect(response.error).toContain('\'lodash\' is only available in the Bruno desktop app\'s developer mode');
602+
expect(response.error).not.toContain('Pre-request script error:');
603+
global.fetch = originalFetch;
604+
});
605+
606+
it('reports no scriptErrors when every script phase succeeds', async () => {
607+
global.fetch = okFetch();
608+
const collection = scriptErrorCollection(`
609+
tests: |
610+
test("ok", function () { expect(1).to.equal(1); });`);
611+
612+
const response = await new RequestRunner().runRequest({
613+
item: collection.items[0], collection, runtimeVariables: {}
614+
});
615+
616+
expect(response.scriptErrors).toBeUndefined();
617+
expect(response.testResults?.summary.passed).toBe(1);
618+
global.fetch = originalFetch;
619+
});
620+
});
621+
531622
it('should strip JSON comments from body before sending', async () => {
532623
// Mock fetch to capture what body was sent
533624
let sentBody: string | undefined;

‎packages/bruno-api-docs/src/runner/index.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import type { HttpRequest } from '@opencollection/types/requests/http';
22
import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types';
33
import type { Environment } from '@opencollection/types/config/environments';
44
import { RequestExecutor } from './RequestExecutor';
5-
import ScriptRuntime from '@/scripting/runtime/script-runtime';
5+
import ScriptRuntime, { type ScriptRunError } from '@/scripting/runtime/script-runtime';
6+
import { appendScriptErrorResult, SCRIPT_ERROR_TITLES, type ScriptError } from './utils/script-errors';
67
import type { RunRequestCallback } from '@/scripting/utils/bru';
78
import AssertRuntime, { type AssertionResult } from '@/scripting/runtime/assert-runtime';
89
import { getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, mergeAuth, interpolateVars, findItemByPath } from './utils';
@@ -18,6 +19,8 @@ import {
1819
import { getItemUuid } from '@/utils/itemUtils';
1920
import { cloneDeep, isEqual } from 'lodash-es';
2021

22+
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : 'Unknown script error');
23+
2124
const MAX_RUN_REQUEST_DEPTH = 25;
2225

2326
interface RunContext {
@@ -121,6 +124,7 @@ export interface RunRequestResponse {
121124
requestId?: string;
122125
assertionResults?: AssertionResultsResponse;
123126
testResults?: TestResultsResponse;
127+
scriptErrors?: ScriptError[];
124128
warnings?: string[] | null;
125129
environmentVariables?: { envName: string; variables: Variables; deleted: string[] };
126130
collectionVariables?: { variables: Variables; deleted: string[] };
@@ -255,6 +259,8 @@ export class RequestRunner {
255259
const scriptsObj = scriptsArrayToObject(getRequestScripts(processedRequest));
256260
const assertions = getRequestAssertions(processedRequest);
257261

262+
const scriptErrors: ScriptError[] = [];
263+
258264
// Pre-request script
259265
if (scriptsObj.preRequest) {
260266
try {
@@ -270,7 +276,8 @@ export class RequestRunner {
270276
} catch (scriptError) {
271277
return {
272278
requestId,
273-
error: `Pre-request script error: ${scriptError instanceof Error ? scriptError.message : 'Unknown script error'}`,
279+
error: errorMessage(scriptError),
280+
errorTitle: SCRIPT_ERROR_TITLES['pre-request'],
274281
warnings: warnings.length ? warnings : null
275282
};
276283
}
@@ -294,8 +301,8 @@ export class RequestRunner {
294301
runRequest
295302
});
296303
} catch (scriptError) {
297-
// Don't fail the request for post-response script errors, just log them
298304
console.warn('Post-response script error:', scriptError);
305+
scriptErrors.push({ phase: 'post-response', message: errorMessage(scriptError) });
299306
}
300307
}
301308
let assertionResults: AssertionResult[] | undefined;
@@ -340,16 +347,22 @@ export class RequestRunner {
340347
assertionResultsResponse = await bru.getAssertionResults();
341348
}
342349
} catch (scriptError) {
343-
// Don't fail the request for test script errors, just log them
344350
console.warn('Test script error:', scriptError);
351+
scriptErrors.push({ phase: 'tests', message: errorMessage(scriptError) });
352+
testResultsResponse = (scriptError as ScriptRunError).partialTestResults;
345353
}
346354
}
347355

356+
for (const scriptError of scriptErrors) {
357+
testResultsResponse = appendScriptErrorResult(testResultsResponse, scriptError.phase, scriptError.message);
358+
}
359+
348360
return {
349361
...response,
350362
requestId,
351363
assertionResults: assertionResultsResponse,
352364
testResults: testResultsResponse,
365+
scriptErrors: scriptErrors.length ? scriptErrors : undefined,
353366
warnings: warnings.length ? warnings : null
354367
};
355368
} catch (error) {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { appendScriptErrorResult } from './script-errors';
3+
4+
describe('appendScriptErrorResult', () => {
5+
it('adds a failed "Test Script Error" row after the tests that ran before the script threw', () => {
6+
const partial = {
7+
summary: { total: 1, passed: 1, failed: 0, skipped: 0 },
8+
results: [{ status: 'pass', description: 'first' }]
9+
};
10+
const result = appendScriptErrorResult(partial, 'tests', 'Cannot find module fs');
11+
expect(result.results).toEqual([
12+
{ status: 'pass', description: 'first' },
13+
{ status: 'fail', description: 'Test Script Error', error: 'Cannot find module fs' }
14+
]);
15+
expect(result.summary).toEqual({ total: 2, passed: 1, failed: 1, skipped: 0 });
16+
});
17+
18+
it('starts a fresh result set when no test ran before the post-response script threw', () => {
19+
const result = appendScriptErrorResult(undefined, 'post-response', 'Network Error');
20+
expect(result.results).toEqual([
21+
{ status: 'fail', description: 'Post-Response Script Error', error: 'Network Error' }
22+
]);
23+
expect(result.summary).toEqual({ total: 1, passed: 0, failed: 1, skipped: 0 });
24+
});
25+
26+
it('does not mutate the results it was given', () => {
27+
const partial = { summary: { total: 0, passed: 0, failed: 0, skipped: 0 }, results: [] };
28+
appendScriptErrorResult(partial, 'tests', 'boom');
29+
expect(partial.results).toEqual([]);
30+
});
31+
});

0 commit comments

Comments
 (0)