Merge pull request #6074 from usebruno/feature/http-stream-internal

Feature: HTTP Streaming
This commit is contained in:
Sid
2025-11-17 13:44:53 +05:30
committed by GitHub
16 changed files with 367 additions and 167 deletions

1
package-lock.json generated
View File

@@ -26860,6 +26860,7 @@
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"hexy": "^0.3.5",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
"idb": "^7.0.0",

View File

@@ -37,6 +37,7 @@
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"hexy": "^0.3.5",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
"idb": "^7.0.0",

View File

@@ -41,9 +41,9 @@ const QueryUrl = ({ item, collection, handleRun }) => {
if (!editorRef.current?.editor) return;
const editor = editorRef.current.editor;
const cursor = editor.getCursor();
const finalUrl = value?.trim() ?? value;
dispatch(
requestUrlChanged({
itemUid: item.uid,
@@ -51,7 +51,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
url: finalUrl
})
);
// Restore cursor position only if URL was trimmed
if (finalUrl !== value) {
setTimeout(() => {
@@ -81,7 +81,9 @@ const QueryUrl = ({ item, collection, handleRun }) => {
}
};
const handleCancelRequest = () => {
const handleCancelRequest = (e) => {
e.preventDefault();
e.stopPropagation();
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
};
@@ -92,7 +94,6 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<div className="flex items-center justify-center h-full w-16">
<span className="text-xs text-indigo-500 font-bold">gRPC</span>
</div>
) : (
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
)}
@@ -126,15 +127,8 @@ const QueryUrl = ({ item, collection, handleRun }) => {
handleGenerateCode(e);
}}
>
<IconCode
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={'cursor-pointer'}
/>
<span className="infotiptext text-xs">
Generate Code
</span>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={22} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
title="Save Request"
@@ -155,10 +149,9 @@ const QueryUrl = ({ item, collection, handleRun }) => {
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isLoading ? (
{isLoading || item.response?.stream?.running ? (
<IconSquareRoundedX
color={theme.requestTabPanel.url.icon}
color={theme.requestTabPanel.url.iconDanger}
strokeWidth={1.5}
size={22}
data-testid="cancel-request-icon"
@@ -175,7 +168,11 @@ const QueryUrl = ({ item, collection, handleRun }) => {
</div>
</div>
{generateCodeItemModalOpen && (
<GenerateCodeItem collectionUid={collection.uid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
<GenerateCodeItem
collectionUid={collection.uid}
item={item}
onClose={() => setGenerateCodeItemModalOpen(false)}
/>
)}
</StyledWrapper>
);

View File

@@ -10,7 +10,7 @@ import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
import Welcome from 'components/Welcome';
import { findItemInCollection } from 'utils/collections';
import { updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import RequestNotFound from './RequestNotFound';
import QueryUrl from 'components/RequestPane/QueryUrl/index';
import GrpcQueryUrl from 'components/RequestPane/GrpcQueryUrl/index';
@@ -263,11 +263,17 @@ const RequestTabPanel = () => {
return;
}
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
})
);
if (item.response?.stream?.running) {
dispatch(cancelRequest(item.cancelTokenUid, item, collection)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
}));
} else if (item.requestState !== 'sending' && item.requestState !== 'queued') {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
}));
}
};
// TODO: reaper, improve selection of panes

View File

@@ -12,12 +12,25 @@ import { getInitialExampleName } from 'utils/collections/index';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
const getTitleText = ({ isResponseTooLarge, isStreamingResponse }) => {
if (isStreamingResponse) {
return 'Response Examples aren\'t supported in streaming responses yet.';
}
if (isResponseTooLarge) {
return 'Response size exceeds 5MB limit. Cannot save as example.';
}
return 'Save current response as example';
};
const ResponseBookmark = ({ item, collection, responseSize }) => {
const dispatch = useDispatch();
const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false);
const response = item.response || {};
const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB
const isStreamingResponse = response.stream;
// Only show for HTTP requests
if (item.type !== 'http-request') {
@@ -96,19 +109,22 @@ const ResponseBookmark = ({ item, collection, responseSize }) => {
toast.success(`Example "${name}" created successfully`);
};
const disabledMessage = getTitleText({
isResponseTooLarge,
isStreamingResponse
});
return (
<>
<StyledWrapper className="ml-2 flex items-center">
<button
onClick={handleSaveClick}
disabled={isResponseTooLarge}
disabled={isResponseTooLarge || isStreamingResponse}
title={
isResponseTooLarge
? 'Response size exceeds 5MB limit. Cannot save as example.'
: 'Save current response as example'
disabledMessage
}
className={classnames('p-1', {
'opacity-50 cursor-not-allowed': isResponseTooLarge
'opacity-50 cursor-not-allowed': isResponseTooLarge || isStreamingResponse
})}
data-testid="response-bookmark-btn"
>

View File

@@ -0,0 +1,10 @@
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 0.75rem;
font-weight: 600;
color: ${(props) => props.theme.requestTabPanel.responseStatus};
text-align: center;
`;
export default Wrapper;

View File

@@ -0,0 +1,27 @@
import React, { useState, useEffect } from 'react';
import StyledWrapper from './StyledWrapper';
const ResponseStopWatch = ({ startMillis }) => {
const [milliseconds, setMilliseconds] = useState(startMillis);
const tickInterval = 100;
const tick = () => {
setMilliseconds((_milliseconds) => _milliseconds + tickInterval);
};
useEffect(() => {
let timerID = setInterval(() => {
tick();
}, tickInterval);
return () => {
clearInterval(timerID);
};
}, []);
let seconds = milliseconds / 1000;
let secondsFormatted = `${seconds.toFixed(1)}s`;
let width = secondsFormatted.length * 0.4; // Calculate width manually to stop parent layout from "flickering" by changing width too fast
return <StyledWrapper className="ml-4" style={{ width: `${width}rem` }}>{secondsFormatted}</StyledWrapper>;
};
export default React.memo(ResponseStopWatch);

View File

@@ -4,7 +4,7 @@ import statusCodePhraseMap from './get-status-code-phrase';
import StyledWrapper from './StyledWrapper';
// Todo: text-error class is not getting pulled in for 500 errors
const StatusCode = ({ status, statusText }) => {
const StatusCode = ({ status, statusText, isStreaming }) => {
const getTabClassname = (status) => {
return classnames('ml-2', {
'text-ok': status >= 100 && status < 200,
@@ -17,7 +17,7 @@ const StatusCode = ({ status, statusText }) => {
return (
<StyledWrapper className={`response-status-code ${getTabClassname(status)}`} data-testid="response-status-code">
{status} {statusText || statusCodePhraseMap[status]}
{status} {statusText || statusCodePhraseMap[status]} {isStreaming ? ' - STREAMING' : null}
</StyledWrapper>
);
};

View File

@@ -195,7 +195,7 @@ const WSMessagesList = ({ order = -1, messages = [] }) => {
<StyledWrapper className="ws-messages-list mt-1 flex flex-col">
{ordered.map((msg, idx, src) => {
const inFocus = order === -1 ? src.length - 1 === idx : idx === 0;
return <WSMessageItem inFocus={inFocus} id={idx} message={msg} />;
return <WSMessageItem key={msg.timestamp} inFocus={inFocus} id={idx} message={msg} />;
})}
</StyledWrapper>
);

View File

@@ -23,6 +23,8 @@ import SkippedRequest from './SkippedRequest';
import ClearTimeline from './ClearTimeline/index';
import ResponseLayoutToggle from './ResponseLayoutToggle';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import ResponseStopWatch from 'components/ResponsePane/ResponseStopWatch';
import WSMessagesList from './WsResponsePane/WSMessagesList';
const ResponsePane = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -71,6 +73,10 @@ const ResponsePane = ({ item, collection }) => {
const getTabPanel = (tab) => {
switch (tab) {
case 'response': {
const isStream = item.response?.stream ?? false;
if (isStream) {
return <WSMessagesList order={-1} messages={item.response.data} />;
}
return (
<QueryResult
item={item}
@@ -184,8 +190,10 @@ const ResponsePane = ({ item, collection }) => {
<ResponseClear item={item} collection={collection} />
<ResponseSave item={item} />
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
<StatusCode status={response.status} />
<ResponseTime duration={response.duration} />
<StatusCode status={response.status} isStreaming={item.response?.stream?.running} />
{item.response?.stream?.running
? <ResponseStopWatch startMillis={response.duration} />
: <ResponseTime duration={response.duration} />}
<ResponseSize size={responseSize} />
</>
) : null}

View File

@@ -15,9 +15,11 @@ import {
collectionUnlinkEnvFileEvent,
collectionUnlinkFileEvent,
processEnvUpdateEvent,
requestCancelled,
runFolderEvent,
runRequestEvent,
scriptEnvironmentUpdateEvent
scriptEnvironmentUpdateEvent,
streamDataReceived
} from 'providers/ReduxStore/slices/collections';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
@@ -137,8 +139,8 @@ const useIpcEvents = () => {
dispatch(processEnvUpdateEvent(val));
});
const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => {
console[val.type](...val.args);
const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => {
console[val.type](...val.args);
dispatch(addLog({
type: val.type,
args: val.args,
@@ -188,6 +190,14 @@ const useIpcEvents = () => {
dispatch(collectionAddOauth2CredentialsByUrl(payload));
});
const removeHttpStreamNewDataListener = ipcRenderer.on('main:http-stream-new-data', (val) => {
dispatch(streamDataReceived(val));
});
const removeHttpStreamEndListener = ipcRenderer.on('main:http-stream-end', (val) => {
dispatch(requestCancelled(val));
});
const removeCollectionLoadingStateListener = ipcRenderer.on('main:collection-loading-state-updated', (val) => {
dispatch(updateCollectionLoadingState(val));
});
@@ -212,6 +222,8 @@ const useIpcEvents = () => {
removeGlobalEnvironmentsUpdatesListener();
removeSnapshotHydrationListener();
removeCollectionOauth2CredentialsUpdatesListener();
removeHttpStreamNewDataListener();
removeHttpStreamEndListener();
removeCollectionLoadingStateListener();
removePersistentEnvVariablesUpdateListener();
removeSystemResourcesListener();

View File

@@ -2,6 +2,7 @@ import { parseQueryParams, buildQueryString as stringifyQueryParams } from '@use
import { uuid } from 'utils/common';
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, findIndex } from 'lodash';
import { createSlice } from '@reduxjs/toolkit';
import { hexy as hexdump } from 'hexy';
import {
addDepth,
areItemsTheSameExceptSeqUpdate,
@@ -380,9 +381,17 @@ export const collectionsSlice = createSlice({
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
item.response = null;
if (item.response?.stream?.running) {
item.response.stream.running = null;
const startTimestamp = item.requestSent.timestamp;
item.response.duration = startTimestamp ? Date.now() - startTimestamp : item.response.duration;
item.response.data = [{ type: 'info', timestamp: Date.now(), message: 'Connection Closed' }].concat(item.response.data);
} else {
item.response = null;
item.requestUid = null;
}
item.cancelTokenUid = null;
item.requestUid = null;
item.requestStartTime = null;
}
}
@@ -395,7 +404,7 @@ export const collectionsSlice = createSlice({
if (item) {
item.requestState = 'received';
item.response = action.payload.response;
item.cancelTokenUid = null;
item.cancelTokenUid = item.response.stream?.running ? item.cancelTokenUid : null;
item.requestStartTime = null;
if (!collection.timeline) {
@@ -590,6 +599,11 @@ export const collectionsSlice = createSlice({
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item) {
if (item.response && item.response.stream?.running) {
item.response.data = '';
item.response.size = 0;
return;
}
item.response = null;
}
}
@@ -2936,7 +2950,25 @@ export const collectionsSlice = createSlice({
set(folder, 'draft.request.auth.mode', action.payload.mode);
}
},
streamDataReceived: (state, action) => {
const { itemUid, collectionUid, data } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (data.data) {
item.response.data ||= [];
item.response.data = [{
type: 'incoming',
message: data.data,
messageHexdump: hexdump(data.data),
timestamp: Date.now()
}].concat(item.response.data);
}
item.response.dataBuffer = Buffer.concat([Buffer.from(item.response.dataBuffer), Buffer.from(data.dataBuffer)]);
item.response.size = data.data?.length + (item.response.size || 0);
}
},
addRequestTag: (state, action) => {
const { tag, collectionUid, itemUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -3298,6 +3330,7 @@ export const {
updateRequestDocs,
updateFolderDocs,
moveCollection,
streamDataReceived,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl,
collectionGetOauth2CredentialsByUrl,

View File

@@ -115,6 +115,7 @@ const darkTheme = {
url: {
bg: '#3D3D3D',
icon: 'rgb(204, 204, 204)',
iconDanger: '#fa5343',
errorHoverBg: '#4a2a2a'
},
dragbar: {

View File

@@ -115,6 +115,7 @@ const lightTheme = {
url: {
bg: '#f3f3f3',
icon: '#515151',
iconDanger: '#d91f11',
errorHoverBg: '#fef2f2'
},
dragbar: {

View File

@@ -10,6 +10,7 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
if (response?.error) {
resolve(response)
}
resolve({
state: 'success',
data: response.data,
@@ -20,7 +21,8 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
status: response.status,
statusText: response.statusText,
duration: response.duration,
timeline: response.timeline
timeline: response.timeline,
stream: response.stream
});
})
.catch((err) => reject(err));

View File

@@ -69,6 +69,37 @@ const getJsSandboxRuntime = (collection) => {
return 'vm2';
};
const hasStreamHeaders = (headers) => {
const headerSplit = (headers.get('content-type') ?? '').split(';').map((d) => d.trim());
return headerSplit.indexOf('text/event-stream') > -1;
};
const promisifyStream = async (stream, abortController, closeOnFirst) => {
const chunks = [];
return new Promise((resolve, reject) => {
const doResolve = () => {
const fullBuffer = Buffer.concat(chunks);
resolve(fullBuffer.buffer.slice(fullBuffer.byteOffset, fullBuffer.byteOffset + fullBuffer.byteLength));
};
stream.on('data', (chunk) => {
chunks.push(chunk);
if (closeOnFirst) {
doResolve();
if (abortController) {
abortController.abort();
}
}
});
stream.on('close', doResolve);
stream.on('error', (err) => reject(err));
});
};
const configureRequest = async (
collectionUid,
collection,
@@ -595,6 +626,10 @@ const registerNetworkIpc = (mainWindow) => {
const abortController = new AbortController();
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'standalone';
request.responseType = 'stream';
// flag to see if the stream needs to be handled as an actual stream or
// is it just a data stream from axios
let isResponseStream = false;
const brunoConfig = getBrunoConfig(collectionUid, collection);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
@@ -668,7 +703,8 @@ const registerNetworkIpc = (mainWindow) => {
method: request.method,
headers: headersSent,
data: requestData,
dataBuffer: requestDataBuffer
dataBuffer: requestDataBuffer,
timestamp: Date.now()
}
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
@@ -691,10 +727,15 @@ const registerNetworkIpc = (mainWindow) => {
});
}
let response, responseTime;
let response, responseTime, axiosDataStream;
try {
/** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request);
isResponseStream = hasStreamHeaders(response.headers);
if (!isResponseStream) {
response.data = await promisifyStream(response.data);
}
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
@@ -719,6 +760,10 @@ const registerNetworkIpc = (mainWindow) => {
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
isResponseStream = hasStreamHeaders(response.headers);
if (!isResponseStream) {
response.data = await promisifyStream(response.data);
}
} else {
await executeRequestOnFailHandler(request, error);
@@ -734,8 +779,13 @@ const registerNetworkIpc = (mainWindow) => {
}
// Continue with the rest of the request lifecycle - post response vars, script, assertions, tests
if (isResponseStream) {
axiosDataStream = response.data;
}
const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
const { data, dataBuffer } = isResponseStream
? { data: '', dataBuffer: Buffer.alloc(0) }
: parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
response.data = data;
response.dataBuffer = dataBuffer;
@@ -752,140 +802,141 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
cookiesStore.saveCookieJar();
let postResponseScriptResult = null;
let postResponseError = null;
try {
postResponseScriptResult = await runPostResponse(
request,
response,
requestUid,
envVars,
collectionPath,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname
);
} catch (error) {
console.error('Post-response script error:', error);
postResponseError = error;
}
if (postResponseScriptResult?.results) {
mainWindow.webContents.send('main:run-request-event', {
type: 'test-results-post-response',
results: postResponseScriptResult.results,
itemUid: item.uid,
requestUid,
collectionUid
});
}
!runInBackground && notifyScriptExecution({
channel: 'main:run-request-event',
basePayload: { requestUid, collectionUid, itemUid: item.uid },
scriptType: 'post-response',
error: postResponseError
});
// run assertions
const assertions = get(request, 'assertions');
if (assertions) {
const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
const results = assertRuntime.runAssertions(
assertions,
request,
response,
envVars,
runtimeVariables,
processEnvVars
);
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'assertion-results',
results: results,
itemUid: item.uid,
requestUid,
collectionUid
});
}
const testFile = get(request, 'tests');
const collectionName = collection?.name
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
let testResults = null;
let testError = null;
const runPostScripts = async () => {
let postResponseScriptResult = null;
let postResponseError = null;
try {
testResults = await testRuntime.runTests(
decomment(testFile),
request,
postResponseScriptResult = await runPostResponse(request,
response,
requestUid,
envVars,
runtimeVariables,
collectionPath,
onConsoleLog,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname,
collectionName
);
runRequestByItemPathname);
} catch (error) {
testError = error;
if (error.partialResults) {
testResults = error.partialResults;
} else {
testResults = {
request,
envVariables: envVars,
runtimeVariables,
globalEnvironmentVariables: request?.globalEnvironmentVariables || {},
results: [],
nextRequestName: null
};
}
console.error('Post-response script error:', error);
postResponseError = error;
}
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'test-results',
results: testResults.results,
itemUid: item.uid,
requestUid,
collectionUid
});
mainWindow.webContents.send('main:script-environment-update', {
envVariables: testResults.envVariables,
runtimeVariables: testResults.runtimeVariables,
requestUid,
collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: testResults.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
if (postResponseScriptResult?.results) {
mainWindow.webContents.send('main:run-request-event', {
type: 'test-results-post-response',
results: postResponseScriptResult.results,
itemUid: item.uid,
requestUid,
collectionUid
});
}
!runInBackground && notifyScriptExecution({
channel: 'main:run-request-event',
basePayload: { requestUid, collectionUid, itemUid: item.uid },
scriptType: 'test',
error: testError
scriptType: 'post-response',
error: postResponseError
});
const domainsWithCookiesTest = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest)));
cookiesStore.saveCookieJar();
// run assertions
const assertions = get(request, 'assertions');
if (assertions) {
const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
const results = assertRuntime.runAssertions(assertions,
request,
response,
envVars,
runtimeVariables,
processEnvVars);
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'assertion-results',
results: results,
itemUid: item.uid,
requestUid,
collectionUid
});
}
const testFile = get(request, 'tests');
const collectionName = collection?.name;
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
let testResults = null;
let testError = null;
try {
testResults = await testRuntime.runTests(decomment(testFile),
request,
response,
envVars,
runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname,
collectionName);
} catch (error) {
testError = error;
if (error.partialResults) {
testResults = error.partialResults;
} else {
testResults = {
request,
envVariables: envVars,
runtimeVariables,
globalEnvironmentVariables: request?.globalEnvironmentVariables || {},
results: [],
nextRequestName: null
};
}
}
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'test-results',
results: testResults.results,
itemUid: item.uid,
requestUid,
collectionUid
});
mainWindow.webContents.send('main:script-environment-update', {
envVariables: testResults.envVariables,
runtimeVariables: testResults.runtimeVariables,
requestUid,
collectionUid
});
mainWindow.webContents.send('main:persistent-env-variables-update', {
persistentEnvVariables: testResults.persistentEnvVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
!runInBackground && notifyScriptExecution({
channel: 'main:run-request-event',
basePayload: { requestUid, collectionUid, itemUid: item.uid },
scriptType: 'test',
error: testError
});
const domainsWithCookiesTest = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest)));
cookiesStore.saveCookieJar();
}
};
if (isResponseStream) {
axiosDataStream.on('close', () => runPostScripts().then());
} else {
await runPostScripts();
}
return {
@@ -894,6 +945,8 @@ const registerNetworkIpc = (mainWindow) => {
headers: response.headers,
data: response.data,
dataBuffer: response.dataBuffer.toString('base64'),
stream: isResponseStream ? axiosDataStream : null,
cancelTokenUid: cancelTokenUid,
size: Buffer.byteLength(response.dataBuffer),
duration: responseTime ?? 0,
url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null,
@@ -917,7 +970,26 @@ const registerNetworkIpc = (mainWindow) => {
const collectionUid = collection.uid;
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
return await runRequest({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: false });
const response = await runRequest({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: false });
if (response.stream) {
const stream = response.stream;
response.stream = { running: response.status >= 200 && response.status < 300 };
stream.on('data', (newData) => {
const parsed = parseDataFromResponse({ data: newData, headers: {} });
mainWindow.webContents.send('main:http-stream-new-data', { collectionUid, itemUid: item.uid, data: parsed });
});
stream.on('close', () => {
if (!cancelTokens[response.cancelTokenUid]) {
return;
}
mainWindow.webContents.send('main:http-stream-end', { collectionUid, itemUid: item.uid });
deleteCancelToken(response.cancelTokenUid);
});
}
return response;
});
ipcMain.handle('clear-oauth2-cache', async (event, uid, url, credentialsId) => {
@@ -935,8 +1007,9 @@ const registerNetworkIpc = (mainWindow) => {
ipcMain.handle('cancel-http-request', async (event, cancelTokenUid) => {
return new Promise((resolve, reject) => {
if (cancelTokenUid && cancelTokens[cancelTokenUid]) {
cancelTokens[cancelTokenUid].abort();
const abortController = cancelTokens[cancelTokenUid];
deleteCancelToken(cancelTokenUid);
abortController.abort();
resolve();
} else {
reject(new Error('cancel token not found'));
@@ -960,10 +1033,17 @@ const registerNetworkIpc = (mainWindow) => {
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
let stopRunnerExecution = false;
let currentAbortController;
const abortController = new AbortController();
saveCancelToken(cancelTokenUid, abortController);
abortController.signal.addEventListener('abort', () => {
if (currentAbortController) {
currentAbortController.abort();
}
});
const runRequestByItemPathname = async (relativeItemPathname) => {
return new Promise(async (resolve, reject) => {
let itemPathname = path.join(collection?.pathname, relativeItemPathname);
@@ -1154,7 +1234,8 @@ const registerNetworkIpc = (mainWindow) => {
method: request.method,
headers: headersSent,
data: requestData,
dataBuffer: requestDataBuffer
dataBuffer: requestDataBuffer,
timestamp: Date.now()
}
// todo:
@@ -1166,7 +1247,9 @@ const registerNetworkIpc = (mainWindow) => {
...eventData
});
request.signal = abortController.signal;
currentAbortController = new AbortController();
request.signal = currentAbortController.signal;
request.responseType = 'stream';
const axiosInstance = await configureRequest(
collectionUid,
collection,
@@ -1213,6 +1296,7 @@ const registerNetworkIpc = (mainWindow) => {
/** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request);
response.data = await promisifyStream(response.data, currentAbortController, false);
timeEnd = Date.now();
const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
@@ -1254,6 +1338,7 @@ const registerNetworkIpc = (mainWindow) => {
}
if (error?.response) {
error.response.data = await promisifyStream(error.response.data, currentAbortController, true);
const { data, dataBuffer } = parseDataFromResponse(error.response);
error.response.responseTime = error.response.headers.get('request-duration');
error.response.headers.delete('request-duration');