Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
await editor.focus();
await page.keyboard.press('ControlOrMeta+a');
Expand Down Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +27,7 @@ interface ResponsePaneProps {

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

const {
Expand Down Expand Up @@ -71,8 +73,25 @@ const ResponsePane: React.FC<ResponsePaneProps> = ({ response, isLoading, orient
</div>
);

const scriptErrorsDismissed = dismissedScriptErrorsRequestId === response.requestId;
const scriptErrors = scriptErrorsDismissed ? [] : (response.scriptErrors ?? []);
const renderScriptErrors = (testId: string) =>
scriptErrors.length ? (
<div className="pb-4 space-y-3" data-testid={testId}>
{scriptErrors.map((scriptError) => (
<ErrorBanner
key={scriptError.phase}
title={SCRIPT_ERROR_TITLES[scriptError.phase]}
message={scriptError.message}
onDismiss={() => setDismissedScriptErrorsRequestId(response.requestId)}
/>
))}
</div>
) : null;

const renderResponseBody = () => (
<div className="flex flex-col h-full">
{renderScriptErrors('response-script-errors')}
{response.warnings?.length ? (
<div className="pb-4">
<WarningBanner warnings={response.warnings} />
Expand All @@ -90,10 +109,13 @@ const ResponsePane: React.FC<ResponsePaneProps> = ({ response, isLoading, orient
);
const renderHeaders = () => <ResponseHeadersTab headers={response.headers} />;
const renderTestResults = () => (
<TestResultsTab
testResults={response.testResults}
assertionResults={response.assertionResults}
/>
<div className="flex flex-col h-full">
{renderScriptErrors('tests-script-errors')}
<TestResultsTab
testResults={response.testResults}
assertionResults={response.assertionResults}
/>
</div>
);

const headersCount = response.headers ? Object.keys(response.headers).length : 0;
Expand Down
91 changes: 91 additions & 0 deletions packages/bruno-api-docs/src/runner/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 17 additions & 4 deletions packages/bruno-api-docs/src/runner/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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[] };
Expand Down Expand Up @@ -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 {
Expand All @@ -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
};
}
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
31 changes: 31 additions & 0 deletions packages/bruno-api-docs/src/runner/utils/script-errors.spec.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading
Loading