HTTP stream enhancements (#6077)

* feat: add stop request button in api url bar

* docs: add farsi translation

* fix: handle escaped forward slashes by fast-json-format library upgrade

* refactor: change ui to use one from Websockets

* chore: cleanup

* fix: lint issues

* Replace IconPlayerStop with IconSquareRoundedX

* update json request and response formatting logic

* chore: format changes

* chore: remove un-needed diffs

* chore: sanitize

* bugfix(#5939): curl import fails for custom content-types

* chore: remove un-needed diffs

* chore: enhance response handling for streaming

* fix: disable requestid check for tests and assertions to be updated after streaming result

* chore: housekeeping

* fix: streamline loading and cancel request icon logic

* chore: formatting

* fix: multiple co-pilot changes

* fix: handle in folders

* feat: add WaitGroup utility for managing concurrent tasks

* refactor: remove WaitGroup utility and clean up network IPC logic

* refactor: remove unused setTimeout import and clean up post script execution

* refactor: clean up post-response script execution logic

* undiff

* re-align

* refactor: streamline post-response script execution

- Cleaned up formatting and improved readability of the post-response script execution logic.
- Consolidated parameters in function calls for consistency.

* fix: keep original dataBuffer for saving response

---------

Co-authored-by: adarshajit <adarshajit@gmail.com>
Co-authored-by: sajadoncode <sajadoncode@gmail.com>
Co-authored-by: lohit-bruno <lohit@usebruno.com>
Co-authored-by: Bijin A B <bijin@usebruno.com>
Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>
Co-authored-by: Anoop M D <anoop@usebruno.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
Co-authored-by: Dawid Góra <dawidgora@icloud.com>
This commit is contained in:
Siddharth Gelera (reaper)
2025-11-14 16:57:29 +05:30
committed by GitHub
parent fc5093eab4
commit efad149afc
27 changed files with 643 additions and 241 deletions

View File

@@ -28,7 +28,7 @@
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
"fast-fuzzy": "^1.12.0",
"fast-json-format": "~0.3.0",
"fast-json-format": "~0.4.0",
"file": "^0.2.2",
"file-dialog": "^0.0.8",
"file-saver": "^2.0.5",
@@ -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

@@ -6,9 +6,9 @@ import { updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/colle
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import { format, applyEdits } from 'jsonc-parser';
import { IconWand } from '@tabler/icons';
import toast from 'react-hot-toast';
import { prettifyJsonString } from 'utils/common/index';
const GraphQLVariables = ({ variables, item, collection }) => {
const dispatch = useDispatch();
@@ -19,8 +19,7 @@ const GraphQLVariables = ({ variables, item, collection }) => {
const onPrettify = () => {
if (!variables) return;
try {
const edits = format(variables, undefined, { tabSize: 2, insertSpaces: true });
const prettyVariables = applyEdits(variables, edits);
const prettyVariables = prettifyJsonString(variables);
dispatch(
updateRequestGraphqlVariables({
variables: prettyVariables,

View File

@@ -12,9 +12,9 @@ import StyledWrapper from './StyledWrapper';
import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash, IconChevronDown, IconChevronUp } from '@tabler/icons';
import ToolHint from 'components/ToolHint/index';
import { toastError } from 'utils/common/error';
import { format, applyEdits } from 'jsonc-parser';
import toast from 'react-hot-toast'
import { getAbsoluteFilePath } from 'utils/common/path';
import { prettifyJsonString } from 'utils/common/index';
const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => {
const dispatch = useDispatch();
@@ -130,8 +130,7 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCol
const onPrettify = () => {
try {
const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(content, edits);
const prettyBodyJson = prettifyJsonString(content);
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {

View File

@@ -2,10 +2,10 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { cancelRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import HttpMethodSelector from './HttpMethodSelector';
import { useTheme } from 'providers/Theme';
import { IconDeviceFloppy, IconArrowRight, IconCode, IconX } from '@tabler/icons';
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import { isMacOS } from 'utils/common/platform';
import { hasRequestChanges } from 'utils/collections';
@@ -22,6 +22,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S';
const editorRef = useRef(null);
const isGrpc = item.type === 'grpc-request';
const isLoading = ['queued', 'sending'].includes(item.requestState);
const [methodSelectorWidth, setMethodSelectorWidth] = useState(90);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
@@ -80,6 +81,10 @@ const QueryUrl = ({ item, collection, handleRun }) => {
}
};
const handleCancelRequest = () => {
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
};
return (
<StyledWrapper className="flex items-center">
<div className="flex flex-1 items-center h-full method-selector-container">
@@ -87,7 +92,7 @@ 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} />
)}
@@ -149,11 +154,23 @@ const QueryUrl = ({ item, collection, handleRun }) => {
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{
item.response?.hasStreamRunning ? (
<IconX color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} />
isLoading || item.response?.stream?.running ? (
<IconSquareRoundedX
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={22}
data-testid="cancel-request-icon"
onClick={handleCancelRequest}
/>
) : (
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} data-testid="send-arrow-icon" />
<IconArrowRight
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={22}
data-testid="send-arrow-icon"
/>
)
}
</div>

View File

@@ -74,7 +74,8 @@ const RequestTabPanel = () => {
const screenWidth = useSelector((state) => state.app.screenWidth);
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const [leftPaneWidth, setLeftPaneWidth] = useState(
focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2); // 2.2 is intentional to make both panes appear to be of equal width
focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2
); // 2.2 is intentional to make both panes appear to be of equal width
const [topPaneHeight, setTopPaneHeight] = useState(focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT);
const [dragging, setDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
@@ -140,10 +141,12 @@ const RequestTabPanel = () => {
setDragging(false);
if (!isVerticalLayout) {
const mainRect = mainSectionRef.current.getBoundingClientRect();
dispatch(updateRequestPaneTabWidth({
uid: activeTabUid,
requestPaneWidth: e.clientX - mainRect.left
}));
dispatch(
updateRequestPaneTabWidth({
uid: activeTabUid,
requestPaneWidth: e.clientX - mainRect.left
})
);
}
}
};
@@ -260,7 +263,7 @@ const RequestTabPanel = () => {
return;
}
if (item.response?.hasStreamRunning) {
if (item.response?.stream?.running) {
dispatch(cancelRequest(item.cancelTokenUid, item, collection)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000

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

@@ -6,22 +6,22 @@ const ResponseStopWatch = ({ startMillis }) => {
const tickInterval = 100;
const tick = () => {
setMilliseconds(_milliseconds => _milliseconds + tickInterval);
setMilliseconds((_milliseconds) => _milliseconds + tickInterval);
};
useEffect(() => {
let timerID = setInterval(() => {
tick()
tick();
}, tickInterval);
return () => {
clearTimeout(timerID);
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>;
return <StyledWrapper className="ml-4" style={{ width: `${width}rem` }}>{secondsFormatted}</StyledWrapper>;
};
export default React.memo(ResponseStopWatch);

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

@@ -21,10 +21,10 @@ import ResponseClear from 'src/components/ResponsePane/ResponseClear';
import ResponseBookmark from 'src/components/ResponsePane/ResponseBookmark';
import SkippedRequest from './SkippedRequest';
import ClearTimeline from './ClearTimeline/index';
import StopWatch from 'components/StopWatch';
import ResponseStopWatch from 'components/ResponsePane/ResponseStopWatch';
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();
@@ -73,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}
@@ -89,17 +93,15 @@ const ResponsePane = ({ item, collection }) => {
return <ResponseHeaders headers={response.headers} />;
}
case 'timeline': {
return <Timeline collection={collection} item={item} />;
return <Timeline collection={collection} item={item} />;
}
case 'tests': {
return (
<TestResults
results={item.testResults}
assertionResults={item.assertionResults}
preRequestTestResults={item.preRequestTestResults}
postResponseTestResults={item.postResponseTestResults}
/>
);
return <TestResults
results={item.testResults}
assertionResults={item.assertionResults}
preRequestTestResults={item.preRequestTestResults}
postResponseTestResults={item.postResponseTestResults}
/>;
}
default: {
@@ -188,11 +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} isStreaming={item.response?.hasStreamRunning} />
{item.response?.hasStreamRunning ? (
<ResponseStopWatch startMillis={response.duration} />
) : <ResponseTime duration={response.duration} />}
<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}
@@ -200,7 +201,7 @@ const ResponsePane = ({ item, collection }) => {
) : null}
</div>
<section
className="flex flex-col min-h-0 relative px-4 auto overflow-auto"
className={`flex flex-col min-h-0 relative px-4 auto overflow-auto`}
style={{
flex: '1 1 0',
height: hasScriptError && showScriptErrorCard ? 'auto' : '100%'
@@ -213,9 +214,9 @@ const ResponsePane = ({ item, collection }) => {
onClose={() => setShowScriptErrorCard(false)}
/>
)}
<div className="flex-1 overflow-y-auto">
<div className='flex-1 overflow-y-auto'>
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? (
<Timeline
collection={collection}
item={item}

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,
@@ -83,8 +84,8 @@ const initiatedGrpcResponse = {
isError: false,
duration: 0,
responses: [],
timestamp: Date.now()
};
timestamp: Date.now(),
}
const initiatedWsResponse = {
status: 'PENDING',
@@ -380,39 +381,39 @@ export const collectionsSlice = createSlice({
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
if (item.response?.hasStreamRunning) {
item.response.hasStreamRunning = 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;
}
}
},
responseReceived: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item) {
item.requestState = 'received';
item.response = action.payload.response;
item.cancelTokenUid = item.response.hasStreamRunning ? item.cancelTokenUid : null;
item.cancelTokenUid = item.response.stream?.running ? item.cancelTokenUid : null;
item.requestStartTime = null;
if (!collection.timeline) {
collection.timeline = [];
}
// Ensure timestamp is a number (milliseconds since epoch)
const timestamp = item?.requestSent?.timestamp instanceof Date
? item.requestSent.timestamp.getTime()
const timestamp = item?.requestSent?.timestamp instanceof Date
? item.requestSent.timestamp.getTime()
: item?.requestSent?.timestamp || Date.now();
// Append the new timeline entry with numeric timestamp
@@ -435,7 +436,7 @@ export const collectionsSlice = createSlice({
const { itemUid, collectionUid, eventType, eventData } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, itemUid);
if (!item) return;
const request = item.draft ? item.draft.request : item.request;
@@ -455,7 +456,7 @@ export const collectionsSlice = createSlice({
}
collection.timeline.push({
type: 'request',
type: "request",
eventType: eventType, // Add the specific gRPC event type
collectionUid: collection.uid,
folderUid: null,
@@ -464,34 +465,36 @@ export const collectionsSlice = createSlice({
data: {
request: eventData || item.requestSent || item.request,
timestamp: Date.now(),
eventData: eventData
eventData: eventData,
}
});
},
grpcResponseReceived: (state, action) => {
const { itemUid, collectionUid, eventType, eventData } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, itemUid);
if (!item) return;
// Get current response state or create initial state
const currentResponse = item.response || initiatedGrpcResponse;
const currentResponse = item.response || initiatedGrpcResponse
const timestamp = item?.requestSent?.timestamp;
let updatedResponse = { ...currentResponse, duration: Date.now() - (timestamp || Date.now()) };
// Process based on event type
switch (eventType) {
case 'response':
const { error, res } = eventData;
// Handle error if present
if (error) {
const errorCode = error.code || 2; // Default to UNKNOWN if no code
updatedResponse.error = error.details || 'gRPC error occurred';
updatedResponse.statusCode = errorCode;
updatedResponse.statusText = grpcStatusCodes[errorCode] || 'UNKNOWN';
@@ -500,72 +503,72 @@ export const collectionsSlice = createSlice({
}
// Add response to list
updatedResponse.responses = res
? [...(currentResponse?.responses || []), res]
updatedResponse.responses = res
? [...(currentResponse?.responses || []), res]
: [...(currentResponse?.responses || [])];
break;
case 'metadata':
updatedResponse.headers = eventData.metadata;
updatedResponse.metadata = eventData.metadata;
break;
case 'status':
// Extract status info
const statusCode = eventData.status?.code;
const statusDetails = eventData.status?.details;
const statusMetadata = eventData.status?.metadata;
// Set status based on actual code and details
updatedResponse.statusCode = statusCode;
updatedResponse.statusText = grpcStatusCodes[statusCode] || 'UNKNOWN';
updatedResponse.statusDescription = statusDetails;
updatedResponse.statusDetails = eventData.status;
// Store trailers (status metadata)
if (statusMetadata) {
updatedResponse.trailers = statusMetadata;
}
// Handle error status (non-zero code)
if (statusCode !== 0) {
updatedResponse.isError = true;
updatedResponse.error = statusDetails || `gRPC error with code ${statusCode} (${updatedResponse.statusText})`;
}
break;
case 'error':
// Extract error details
const errorCode = eventData.error?.code || 2; // Default to UNKNOWN if no code
const errorDetails = eventData.error?.details || eventData.error?.message;
const errorMetadata = eventData.error?.metadata;
updatedResponse.isError = true;
updatedResponse.error = errorDetails || 'Unknown gRPC error';
updatedResponse.statusCode = errorCode;
updatedResponse.statusText = grpcStatusCodes[errorCode] || 'UNKNOWN';
updatedResponse.statusDescription = errorDetails;
// Store error metadata as trailers if present
if (errorMetadata) {
updatedResponse.trailers = errorMetadata;
}
break;
case 'end':
state.activeConnections = state.activeConnections.filter((id) => id !== itemUid);
state.activeConnections = state.activeConnections.filter(id => id !== itemUid);
break;
case 'cancel':
updatedResponse.statusCode = 1; // CANCELLED
updatedResponse.statusText = 'CANCELLED';
updatedResponse.statusDescription = 'Stream cancelled by client or server';
state.activeConnections = state.activeConnections.filter((id) => id !== itemUid);
state.activeConnections = state.activeConnections.filter(id => id !== itemUid);
break;
}
item.requestState = 'received';
item.response = updatedResponse;
@@ -576,7 +579,7 @@ export const collectionsSlice = createSlice({
// Append the new timeline entry with specific gRPC event type
collection.timeline.push({
type: 'request',
type: "request",
eventType: eventType, // Add the specific gRPC event type
collectionUid: collection.uid,
folderUid: null,
@@ -586,7 +589,7 @@ export const collectionsSlice = createSlice({
request: item.requestSent || item.request,
response: updatedResponse,
eventData: eventData, // Store the original event data
timestamp: Date.now()
timestamp: Date.now(),
}
});
},
@@ -596,12 +599,11 @@ export const collectionsSlice = createSlice({
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item) {
if (item.response && item.response.hasStreamRunning) {
if (item.response && item.response.stream?.running) {
item.response.data = '';
item.response.size = 0;
return;
}
item.response = null;
}
}
@@ -928,7 +930,7 @@ export const collectionsSlice = createSlice({
if (!item.draft) {
item.draft = cloneDeep(item);
}
const existingOtherParams = item.draft.request.params?.filter((p) => p.type !== 'query') || [];
const existingOtherParams = item.draft.request.params?.filter(p => p.type !== 'query') || [];
const newQueryParams = map(params, ({ name = '', value = '', enabled = true }) => ({
uid: uuid(),
name,
@@ -942,7 +944,9 @@ export const collectionsSlice = createSlice({
// Update the request URL to reflect the new query params
const parts = splitOnFirst(item.draft.request.url, '?');
const query = stringifyQueryParams(filter(item.draft.request.params, (p) => p.enabled && p.type === 'query'));
const query = stringifyQueryParams(
filter(item.draft.request.params, (p) => p.enabled && p.type === 'query')
);
// If there are enabled query params, append them to the URL
if (query && query.length) {
@@ -1173,7 +1177,7 @@ export const collectionsSlice = createSlice({
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.headers = map(action.payload.headers, ({ name = '', value = '', enabled = true }) => ({
item.draft.request.headers = map(action.payload.headers, ({name = '', value = '', enabled = true}) => ({
uid: uuid(),
name: name,
value: value,
@@ -1215,8 +1219,8 @@ export const collectionsSlice = createSlice({
if (!folder || !isItemAFolder(folder)) {
return;
}
folder.root.request.headers = map(headers, ({ name = '', value = '', enabled = true }) => ({
folder.root.request.headers = map(headers, ({name = '', value = '', enabled = true}) => ({
uid: uuid(),
name: name,
value: value,
@@ -1497,7 +1501,7 @@ export const collectionsSlice = createSlice({
if (!item.draft) {
item.draft = cloneDeep(item);
}
switch (item.draft.request.body.mode) {
case 'json': {
item.draft.request.body.json = action.payload.content;
@@ -1634,7 +1638,7 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
const item = findItemInCollection(collection, action.payload.itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
@@ -1885,7 +1889,7 @@ export const collectionsSlice = createSlice({
break;
case 'ntlm':
set(collection, 'draft.root.request.auth.ntlm', action.payload.content);
break;
break;
case 'oauth2':
set(collection, 'draft.root.request.auth.oauth2', action.payload.content);
break;
@@ -2614,7 +2618,7 @@ export const collectionsSlice = createSlice({
const { requestUid, itemUid, collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, itemUid);
if (!item) return;
@@ -2647,7 +2651,7 @@ export const collectionsSlice = createSlice({
item.postResponseScriptErrorMessage = action.payload.errorMessage;
}
if (type === 'test-script-execution') {
if(type === 'test-script-execution') {
item.testScriptErrorMessage = action.payload.errorMessage;
}
@@ -2662,7 +2666,7 @@ export const collectionsSlice = createSlice({
if (type === 'request-sent') {
const { cancelTokenUid, requestSent } = action.payload;
item.requestSent = requestSent;
// sometimes the response is received before the request-sent event arrives
if (item.requestState === 'queued') {
item.requestState = 'sending';
@@ -2679,12 +2683,12 @@ export const collectionsSlice = createSlice({
const { results } = action.payload;
item.testResults = results;
}
if (type === 'test-results-pre-request') {
const { results } = action.payload;
item.preRequestTestResults = results;
}
if (type === 'test-results-post-response') {
const { results } = action.payload;
item.postResponseTestResults = results;
@@ -2798,7 +2802,7 @@ export const collectionsSlice = createSlice({
if (collection) {
collection.runnerResult = null;
collection.runnerTags = { include: [], exclude: [] };
collection.runnerTags = { include: [], exclude: [] }
collection.runnerTagsEnabled = false;
collection.runnerConfiguration = null;
}
@@ -2937,7 +2941,7 @@ export const collectionsSlice = createSlice({
updateFolderAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
@@ -2952,7 +2956,16 @@ export const collectionsSlice = createSlice({
if (collection) {
const item = findItemInCollection(collection, itemUid);
item.response.data = data.data + (item.response.data || '');
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);
}
},
@@ -2997,7 +3010,7 @@ export const collectionsSlice = createSlice({
updateCollectionTagsList: (state, action) => {
const { collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
collection.allTags = getUniqueTagsFromItems(collection.items);
}

View File

@@ -2,6 +2,7 @@ import { customAlphabet } from 'nanoid';
import xmlFormat from 'xml-formatter';
import { JSONPath } from 'jsonpath-plus';
import fastJsonFormat from 'fast-json-format';
import { format, applyEdits } from 'jsonc-parser';
import { patternHasher } from '@usebruno/common/utils';
// a customized version of nanoid without using _ and -
@@ -294,7 +295,7 @@ export const formatResponse = (data, dataBufferString, mode, filter, bufferThres
}
try {
return prettifyJsonString(rawData);
return fastJsonFormat(rawData);
} catch (error) {}
if (typeof data === 'string') {
@@ -326,9 +327,11 @@ export const formatResponse = (data, dataBufferString, mode, filter, bufferThres
export const prettifyJsonString = (jsonDataString) => {
if (typeof jsonDataString !== 'string') return jsonDataString;
try {
const { hashed, restore } = patternHasher(jsonDataString);
const formattedJsonDataStringHashed = fastJsonFormat(hashed);
const edits = format(hashed, undefined, { tabSize: 2, insertSpaces: true });
const formattedJsonDataStringHashed = applyEdits(hashed, edits);
const formattedJsonDataString = restore(formattedJsonDataStringHashed);
return formattedJsonDataString;
} catch (error) {

View File

@@ -218,16 +218,16 @@ describe('common utils', () => {
});
test('should format complex json string', () => {
const input = `{"id": 123456789123456789123456789,"name": "Test 'JSON' Data with "quotes" — Pretty Print ","active": true,"price": 199.9999999,"decimals": 1.00,"nullValue": null,"unicodeText": "こんにちは世界 ","escapedCharacters": "Line1\nLine2\tTabbed\"Quoted\" and 'single quoted' with 'code' style","nestedObject": { "level1": { "level2": { "emptyArray": [], "specialChars": "@#$%^&*()_+-=[]{}|;':,./<>?~", "booleanValues": [ true, false, true ], "numbers": [ 0, -1, 1.23e10, 3.1415926535 ] } }},"mixedArray": [ "string with 'apostrophe'", 42, false, null, { "innerObj": { "keyWithQuotes": "value containing \`backticks\` and 'single quotes'", "nestedArray": [ { "a": "O'Reilly" }{ "b": "'inline code'" }, [ "deep", "array", { "c": "contains 'quotes'" } ] ] } }],"nonStringVariable": {{nonStringVar}},"withBrunoVariable": "{{string}} '{{with}}' "{{variety}}" of '{{variables}}'","dateExample": "2025-11-07T12:34:56Z","regexExample": "^([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$","urls": { "website": "https://example.com?param='value'&flag='true'", "escapedURL": "https:\/\/escaped-url.com\/path\?q='search'\&debug='on'"},"multiLineString": "This is a long text\nthat spans multiple\nlines with \`backticks\` 'quotes' and 'code' snippets "}`;
const input = `{"id": 123456789123456789123456789,"name": "Test 'JSON' Data with \"quotes\" — Pretty Print ","active": true,"price": 199.9999999,"decimals": 1.00,"nullValue": null,"unicodeText": "こんにちは世界 ","escapedCharacters": "Line1\\nLine2\\tTabbed\"Quoted\" and 'single quoted' with 'code' style","nestedObject": { "level1": { "level2": { "emptyArray": [], "specialChars": "@#$%^&*()_+-=[]{}|;':,./<>?~", "booleanValues": [ true, false, true ], "numbers": [ 0, -1, 1.23e10, 3.1415926535 ] } }},"mixedArray": [ "string with 'apostrophe'", 42, false, null, { "innerObj": { "keyWithQuotes": "value containing \`backticks\` and 'single quotes'", "nestedArray": [ { "a": "O'Reilly" }{ "b": "'inline code'" }, [ "deep", "array", { "c": "contains 'quotes'" } ] ] } }],"nonStringVariable": {{nonStringVar}},"withBrunoVariable": "{{string}} '{{with}}' "{{variety}}" of '{{variables}}'","dateExample": "2025-11-07T12:34:56Z","regexExample": "^([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$","urls": { "website": "https://example.com?param='value'&flag='true'", "escapedURL": "https:\/\/escaped-url.com\/path\?q='search'\&debug='on'"},"multiLineString": "This is a long text\\nthat spans multiple\\nlines with \`backticks\` 'quotes' and 'code' snippets "}`;
const expectedOutput = `{
"id": 123456789123456789123456789,
"name": "Test 'JSON' Data with "quotes" — Pretty Print ",
"name": "Test 'JSON' Data with \"quotes\" — Pretty Print ",
"active": true,
"price": 199.9999999,
"decimals": 1.00,
"nullValue": null,
"unicodeText": "こんにちは世界 ",
"escapedCharacters": "Line1\nLine2\tTabbed\"Quoted\" and 'single quoted' with 'code' style",
"escapedCharacters": "Line1\\nLine2\\tTabbed\"Quoted\" and 'single quoted' with 'code' style",
"nestedObject": {
"level1": {
"level2": {
@@ -280,7 +280,7 @@ describe('common utils', () => {
"website": "https://example.com?param='value'&flag='true'",
"escapedURL": "https:\/\/escaped-url.com\/path\?q='search'\&debug='on'"
},
"multiLineString": "This is a long text\nthat spans multiple\nlines with \`backticks\` 'quotes' and 'code' snippets "
"multiLineString": "This is a long text\\nthat spans multiple\\nlines with \`backticks\` 'quotes' and 'code' snippets "
}`;
expect(prettifyJsonString(input)).toBe(expectedOutput);
});

View File

@@ -0,0 +1,29 @@
const normalizeContentType = (contentType) => {
if (!contentType || typeof contentType !== 'string') {
return '';
}
return contentType.toLowerCase();
};
export const isJsonLikeContentType = (contentType) => {
const normalized = normalizeContentType(contentType);
return normalized.includes('application/json') || normalized.includes('+json');
};
export const isXmlLikeContentType = (contentType) => {
const normalized = normalizeContentType(contentType);
return normalized.includes('application/xml') || normalized.includes('+xml') || normalized.includes('text/xml');
};
export const isPlainTextContentType = (contentType) => {
const normalized = normalizeContentType(contentType);
return normalized.includes('text/plain');
};
export const isStructuredContentType = (contentType) => {
return isJsonLikeContentType(contentType) || isXmlLikeContentType(contentType) || isPlainTextContentType(contentType);
};

View File

@@ -10,6 +10,7 @@ import parseCurlCommand from './parse-curl';
import * as querystring from 'query-string';
import * as jsesc from 'jsesc';
import { buildQueryString } from '@usebruno/common/utils';
import { isStructuredContentType } from './content-type';
function getContentType(headers = {}) {
const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type');
@@ -34,7 +35,7 @@ function getDataString(request) {
const contentType = getContentType(request.headers);
if (contentType && (contentType.includes('application/json') || contentType.includes('application/xml') || contentType.includes('text/plain'))) {
if (isStructuredContentType(contentType)) {
return { data: request.data };
}

View File

@@ -120,4 +120,37 @@ describe('curlToJson', () => {
]
});
});
it('should parse custom json content-types', () => {
const curlCommand = `curl 'https://api.example.com/test'
-H 'content-type: application/x.custom+json;version=1'
--data-raw '{"test":"data"}'
`;
const result = curlToJson(curlCommand);
expect(result).toEqual({
url: 'https://api.example.com/test',
raw_url: 'https://api.example.com/test',
method: 'post',
headers: {
'content-type': 'application/x.custom+json;version=1'
},
data: '{"test":"data"}'
});
});
it('should parse vendor tree json content-types', () => {
const curlCommand = `curl --request POST \\
--url https://api.example.com/orders/42/preferences \\
--header 'accept: */*' \\
--header 'content-type: application/vnd.vendor+json' \\
--data '{\\n "data": {\\n "type": "order-preferences",\\n "attributes": {\\n "notes": "Leave at door",\\n "priority": true\\n }\\n }\\n}'`;
const result = curlToJson(curlCommand);
expect(result.data).toContain('"type": "order-preferences"');
expect(result.data).toContain('"notes": "Leave at door"');
expect(result.data).toContain('"priority": true');
expect(result.headers['content-type']).toBe('application/vnd.vendor+json');
});
});

View File

@@ -1,6 +1,7 @@
import { forOwn } from 'lodash';
import curlToJson from './curl-to-json';
import { prettifyJsonString } from 'utils/common/index';
import { isJsonLikeContentType, isPlainTextContentType, isXmlLikeContentType } from './content-type';
export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-request') => {
const parseFormData = (parsedBody) => {
@@ -59,25 +60,27 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque
};
if (parsedBody && contentType && typeof contentType === 'string') {
if (requestType === 'graphql-request' && (contentType.includes('application/json') || contentType.includes('application/graphql'))) {
const normalizedContentType = contentType.toLowerCase();
if (requestType === 'graphql-request' && (isJsonLikeContentType(contentType) || normalizedContentType.includes('application/graphql'))) {
body.mode = 'graphql';
body.graphql = parseGraphQL(parsedBody);
} else if (requestType === 'http-request' && request.isDataBinary) {
body.mode = 'file';
body.file = parsedBody;
}else if (contentType.includes('application/json')) {
} else if (isJsonLikeContentType(contentType)) {
body.mode = 'json';
body.json = prettifyJsonString(parsedBody);
} else if (contentType.includes('xml')) {
} else if (isXmlLikeContentType(contentType) || normalizedContentType.includes('xml')) {
body.mode = 'xml';
body.xml = parsedBody;
} else if (contentType.includes('application/x-www-form-urlencoded')) {
} else if (normalizedContentType.includes('application/x-www-form-urlencoded')) {
body.mode = 'formUrlEncoded';
body.formUrlEncoded = parseFormData(parsedBody);
} else if (contentType.includes('multipart/form-data')) {
} else if (normalizedContentType.includes('multipart/form-data')) {
body.mode = 'multipartForm';
body.multipartForm = parsedBody;
} else if (contentType.includes('text/plain')) {
} else if (isPlainTextContentType(contentType)) {
body.mode = 'text';
body.text = parsedBody;
}

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,
@@ -21,7 +22,7 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
statusText: response.statusText,
duration: response.duration,
timeline: response.timeline,
hasStreamRunning: response.hasStreamRunning
stream: response.stream
});
})
.catch((err) => reject(err));
@@ -32,17 +33,19 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
export const sendGrpcRequest = async (item, collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
startGrpcRequest(item, collection, environment, runtimeVariables)
.then((initialState) => {
// Return an initial state object to update the UI
// The real response data will be handled by event listeners
resolve({
...initialState,
timeline: []
});
})
.catch((err) => reject(err));
.then((initialState) => {
// Return an initial state object to update the UI
// The real response data will be handled by event listeners
resolve({
...initialState,
timeline: []
});
})
.catch((err) => reject(err));
});
};
}
const sendHttpRequest = async (item, collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
@@ -82,19 +85,19 @@ export const startGrpcRequest = async (item, collection, environment, runtimeVar
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
const request = item.draft ? item.draft : item;
ipcRenderer.invoke('grpc:start-connection', {
request,
collection,
environment,
request,
collection,
environment,
runtimeVariables
})
.then(() => {
resolve();
})
.catch((err) => {
reject(err);
});
.then(() => {
resolve();
})
.catch(err => {
reject(err);
});
});
};
@@ -187,7 +190,7 @@ export const isGrpcConnectionActive = async (connectionId) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:is-connection-active', connectionId)
.then((response) => {
.then(response => {
if (response.success) {
resolve(response.isActive);
} else {
@@ -196,7 +199,7 @@ export const isGrpcConnectionActive = async (connectionId) => {
resolve(false);
}
})
.catch((err) => {
.catch(err => {
console.error('Failed to check connection status:', err);
// On error, assume the connection is not active
resolve(false);
@@ -214,14 +217,14 @@ export const isGrpcConnectionActive = async (connectionId) => {
export const generateGrpcSampleMessage = async (methodPath, existingMessage = null, options = {}) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('grpc:generate-sample-message', {
methodPath,
existingMessage,
options
ipcRenderer.invoke('grpc:generate-sample-message', {
methodPath,
existingMessage,
options
})
.then(resolve)
.catch(reject);
.then(resolve)
.catch(reject);
});
};

View File

@@ -69,8 +69,9 @@ const getJsSandboxRuntime = (collection) => {
return 'vm2';
};
const isStream = (headers) => {
return headers.get('content-type') === 'text/event-stream';
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) => {
@@ -95,18 +96,20 @@ const promisifyStream = async (stream, abortController, closeOnFirst) => {
});
stream.on('close', doResolve);
stream.on('error', err => reject(err));
stream.on('error', (err) => reject(err));
});
};
const configureRequest = async (collectionUid,
const configureRequest = async (
collectionUid,
collection,
request,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
globalEnvironmentVariables) => {
globalEnvironmentVariables
) => {
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
if (!protocolRegex.test(request.url)) {
request.url = `http://${request.url}`;
@@ -125,7 +128,7 @@ const configureRequest = async (collectionUid,
// Get followRedirects setting, default to true for backward compatibility
const followRedirects = request.settings?.followRedirects ?? true;
// Get maxRedirects from request settings, fallback to request.maxRedirects, then default to 5
let requestMaxRedirects = request.settings?.maxRedirects ?? request.maxRedirects ?? 5;
@@ -166,12 +169,14 @@ const configureRequest = async (collectionUid,
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header' && credentials?.access_token) {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim();
} else {
}
else {
try {
const url = new URL(request.url);
url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
request.url = url?.toString();
} catch (error) {}
}
catch(error) {}
}
break;
case 'implicit':
@@ -180,7 +185,8 @@ const configureRequest = async (collectionUid,
request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid };
if (tokenPlacement == 'header') {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials?.access_token}`;
} else {
}
else {
try {
const url = new URL(request.url);
url?.searchParams?.set(tokenQueryKey, credentials?.access_token);
@@ -242,7 +248,8 @@ const configureRequest = async (collectionUid,
if (preferencesUtil.shouldSendCookies()) {
const cookieString = getCookieStringForUrl(request.url);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
const existingCookieHeaderName = Object.keys(request.headers).find((name) => name.toLowerCase() === 'cookie'
const existingCookieHeaderName = Object.keys(request.headers).find(
name => name.toLowerCase() === 'cookie'
);
const existingCookieString = existingCookieHeaderName ? request.headers[existingCookieHeaderName] : '';
@@ -306,7 +313,8 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col
// Precedence: runtimeVars > requestVariables > folderVars > envVars > collectionVariables > globalEnvironmentVars
const processEnvVars = getProcessEnvVars(collection.uid);
const resolvedVars = merge({},
const resolvedVars = merge(
{},
globalEnvironmentVars,
collectionVariables,
envVars,
@@ -319,7 +327,8 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col
...processEnvVars
}
}
});
}
);
const collectionRoot = collection?.draft?.root || collection?.root || {};
const request = prepareGqlIntrospectionRequest(endpoint, resolvedVars, _request, collectionRoot);
@@ -336,14 +345,16 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col
const collectionPath = collection.pathname;
const axiosInstance = await configureRequest(collection.uid,
const axiosInstance = await configureRequest(
collection.uid,
collection,
request,
envVars,
collection.runtimeVariables,
processEnvVars,
collectionPath,
collection.globalEnvironmentVariables);
collection.globalEnvironmentVariables
);
const response = await axiosInstance(request);
@@ -378,10 +389,10 @@ const registerNetworkIpc = (mainWindow) => {
};
const notifyScriptExecution = ({
channel, // 'main:run-request-event' | 'main:run-folder-event'
basePayload, // request-level or runner-level identifiers
scriptType, // 'pre-request' | 'post-response' | 'test'
error // optional Error
channel, // 'main:run-request-event' | 'main:run-folder-event'
basePayload, // request-level or runner-level identifiers
scriptType, // 'pre-request' | 'post-response' | 'test'
error // optional Error
}) => {
mainWindow.webContents.send(channel, {
type: `${scriptType}-script-execution`,
@@ -390,7 +401,8 @@ const registerNetworkIpc = (mainWindow) => {
});
};
const runPreRequest = async (request,
const runPreRequest = async (
request,
requestUid,
envVars,
collectionPath,
@@ -399,10 +411,11 @@ const registerNetworkIpc = (mainWindow) => {
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname) => {
runRequestByItemPathname
) => {
// run pre-request script
let scriptResult;
const collectionName = collection?.name;
const collectionName = collection?.name
const requestScript = get(request, 'script.req');
if (requestScript?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
@@ -480,7 +493,8 @@ const registerNetworkIpc = (mainWindow) => {
return scriptResult;
};
const runPostResponse = async (request,
const runPostResponse = async (
request,
response,
requestUid,
envVars,
@@ -537,7 +551,7 @@ const registerNetworkIpc = (mainWindow) => {
// run post-response script
const responseScript = get(request, 'script.res');
let scriptResult;
const collectionName = collection?.name;
const collectionName = collection?.name
if (responseScript?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
scriptResult = await scriptRuntime.runResponseScript(
@@ -612,9 +626,11 @@ const registerNetworkIpc = (mainWindow) => {
const abortController = new AbortController();
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'standalone';
request.responseType = "stream";
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);
@@ -625,7 +641,8 @@ const registerNetworkIpc = (mainWindow) => {
let preRequestScriptResult = null;
let preRequestError = null;
try {
preRequestScriptResult = await runPreRequest(request,
preRequestScriptResult = await runPreRequest(
request,
requestUid,
envVars,
collectionPath,
@@ -634,7 +651,8 @@ const registerNetworkIpc = (mainWindow) => {
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname);
runRequestByItemPathname
);
} catch (error) {
preRequestError = error;
}
@@ -659,14 +677,16 @@ const registerNetworkIpc = (mainWindow) => {
if (preRequestError) {
return Promise.reject(preRequestError);
}
const axiosInstance = await configureRequest(collectionUid,
const axiosInstance = await configureRequest(
collectionUid,
collection,
request,
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
collection.globalEnvironmentVariables);
collection.globalEnvironmentVariables
);
const { data: requestData, dataBuffer: requestDataBuffer } = parseDataFromRequest(request);
@@ -685,7 +705,7 @@ const registerNetworkIpc = (mainWindow) => {
data: requestData,
dataBuffer: requestDataBuffer,
timestamp: Date.now()
};
}
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'request-sent',
@@ -707,13 +727,13 @@ const registerNetworkIpc = (mainWindow) => {
});
}
let response, responseTime;
let response, responseTime, axiosDataStream;
try {
/** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request);
request.isStream = isStream(response.headers);
isResponseStream = hasStreamHeaders(response.headers);
if (!request.isStream) {
if (!isResponseStream) {
response.data = await promisifyStream(response.data);
}
@@ -740,9 +760,8 @@ const registerNetworkIpc = (mainWindow) => {
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
request.isStream = isStream(response.headers);
if (!request.isStream) {
isResponseStream = hasStreamHeaders(response.headers);
if (!isResponseStream) {
response.data = await promisifyStream(response.data);
}
} else {
@@ -755,21 +774,21 @@ const registerNetworkIpc = (mainWindow) => {
statusText: error.statusText,
error: error.message || ERROR_OCCURRED_WHILE_EXECUTING_REQUEST,
timeline: error.timeline
};
}
}
}
// Continue with the rest of the request lifecycle - post response vars, script, assertions, tests
if (request.isStream) {
response.stream = response.data;
if (isResponseStream) {
axiosDataStream = response.data;
}
const { data, dataBuffer } = request.isStream
? { data: '', dataBuffer: new ArrayBuffer(0) }
const { data, dataBuffer } = isResponseStream
? { data: '', dataBuffer: Buffer.alloc(0) }
: parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
response.data = data;
response.dataBuffer = dataBuffer;
response.responseTime = responseTime;
// save cookies
@@ -783,9 +802,9 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
cookiesStore.saveCookieJar();
let postResponseScriptResult = null;
let postResponseError = null;
const runPostScripts = async () => {
let postResponseScriptResult = null;
let postResponseError = null;
try {
postResponseScriptResult = await runPostResponse(request,
response,
@@ -914,9 +933,8 @@ const registerNetworkIpc = (mainWindow) => {
cookiesStore.saveCookieJar();
}
};
if (request.isStream) {
response.stream.on('close', () => runPostScripts().then());
if (isResponseStream) {
axiosDataStream.on('close', () => runPostScripts().then());
} else {
await runPostScripts();
}
@@ -926,8 +944,10 @@ const registerNetworkIpc = (mainWindow) => {
statusText: response.statusText,
headers: response.headers,
data: response.data,
stream: request.isStream ? response.stream : null,
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,
timeline: response.timeline
@@ -953,12 +973,11 @@ const registerNetworkIpc = (mainWindow) => {
const response = await runRequest({ item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: false });
if (response.stream) {
const stream = response.stream;
response.stream = undefined;
response.hasStreamRunning = response.status >= 200 && response.status < 300;
response.stream = { running: response.status >= 200 && response.status < 300 };
stream.on('data', newData => {
stream.on('data', (newData) => {
const parsed = parseDataFromResponse({ data: newData, headers: {} });
mainWindow.webContents.send('main:http-stream-new-data', {collectionUid, itemUid: item.uid, data: parsed});
mainWindow.webContents.send('main:http-stream-new-data', { collectionUid, itemUid: item.uid, data: parsed });
});
stream.on('close', () => {
@@ -966,7 +985,7 @@ const registerNetworkIpc = (mainWindow) => {
return;
}
mainWindow.webContents.send('main:http-stream-end', {collectionUid, itemUid: item.uid});
mainWindow.webContents.send('main:http-stream-end', { collectionUid, itemUid: item.uid });
deleteCancelToken(response.cancelTokenUid);
});
}
@@ -990,7 +1009,7 @@ const registerNetworkIpc = (mainWindow) => {
if (cancelTokenUid && cancelTokens[cancelTokenUid]) {
const abortController = cancelTokens[cancelTokenUid];
deleteCancelToken(cancelTokenUid);
abortController.abort(); // Ensure the on stream end event is called after the token is deleted
abortController.abort();
resolve();
} else {
reject(new Error('cancel token not found'));
@@ -999,7 +1018,7 @@ const registerNetworkIpc = (mainWindow) => {
});
// handler for fetch-gql-schema
ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler);
ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler)
ipcMain.handle(
'renderer:run-collection-folder',
@@ -1033,7 +1052,7 @@ const registerNetworkIpc = (mainWindow) => {
}
const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname));
if(_item) {
const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
const res = await runRequest({ item: _item, collection, envVars, processEnvVars, runtimeVariables, runInBackground: true });
resolve(res);
}
reject(`bru.runRequest: invalid request path - ${itemPathname}`);
@@ -1065,8 +1084,9 @@ const registerNetworkIpc = (mainWindow) => {
}
});
// sort requests by seq property
folderRequests = sortByNameThenSequence(folderRequests);
folderRequests = sortByNameThenSequence(folderRequests)
}
// Filter requests based on tags
@@ -1075,7 +1095,7 @@ const registerNetworkIpc = (mainWindow) => {
const excludeTags = tags.exclude ? tags.exclude : [];
folderRequests = folderRequests.filter(({ tags: requestTags = [], draft }) => {
requestTags = draft?.tags || requestTags || [];
return isRequestTagsIncluded(requestTags, includeTags, excludeTags);
return isRequestTagsIncluded(requestTags, includeTags, excludeTags)
});
}
@@ -1128,14 +1148,15 @@ const registerNetworkIpc = (mainWindow) => {
const request = await prepareRequest(item, collection, abortController);
request.__bruno__executionMode = 'runner';
const requestUid = uuid();
try {
let preRequestScriptResult;
let preRequestError = null;
try {
preRequestScriptResult = await runPreRequest(request,
preRequestScriptResult = await runPreRequest(
request,
requestUid,
envVars,
collectionPath,
@@ -1144,7 +1165,8 @@ const registerNetworkIpc = (mainWindow) => {
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname);
runRequestByItemPathname
);
} catch (error) {
console.error('Pre-request script error:', error);
preRequestError = error;
@@ -1214,7 +1236,7 @@ const registerNetworkIpc = (mainWindow) => {
data: requestData,
dataBuffer: requestDataBuffer,
timestamp: Date.now()
};
}
// todo:
// i have no clue why electron can't send the request object
@@ -1228,8 +1250,8 @@ const registerNetworkIpc = (mainWindow) => {
currentAbortController = new AbortController();
request.signal = currentAbortController.signal;
request.responseType = 'stream';
const axiosInstance = await configureRequest(collectionUid,
const axiosInstance = await configureRequest(
collectionUid,
collection,
request,
envVars,
@@ -1246,7 +1268,7 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
credentialsId: request?.oauth2Credentials?.credentialsId,
...(request?.oauth2Credentials?.folderUid ? { folderUid: request.oauth2Credentials.folderUid } : { itemUid: item.uid }),
debugInfo: request?.oauth2Credentials?.debugInfo
debugInfo: request?.oauth2Credentials?.debugInfo,
});
collection.oauth2Credentials = updateCollectionOauth2Credentials({
@@ -1274,10 +1296,7 @@ const registerNetworkIpc = (mainWindow) => {
/** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request);
request.isStream = isStream(response.headers);
response.data = await promisifyStream(response.data, currentAbortController, true);
response.data = await promisifyStream(response.data, currentAbortController, false);
timeEnd = Date.now();
const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
@@ -1319,9 +1338,7 @@ const registerNetworkIpc = (mainWindow) => {
}
if (error?.response) {
request.isStream = isStream(error.response.headers);
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');
@@ -1338,7 +1355,7 @@ const registerNetworkIpc = (mainWindow) => {
size: Buffer.byteLength(dataBuffer),
data: error.response.data,
responseTime: error.response.responseTime,
timeline: error.response.timeline
timeline: error.response.timeline,
};
// if we get a response from the server, we consider it as a success
@@ -1359,7 +1376,8 @@ const registerNetworkIpc = (mainWindow) => {
let postResponseScriptResult;
let postResponseError = null;
try {
postResponseScriptResult = await runPostResponse(request,
postResponseScriptResult = await runPostResponse(
request,
response,
requestUid,
envVars,
@@ -1369,7 +1387,8 @@ const registerNetworkIpc = (mainWindow) => {
runtimeVariables,
processEnvVars,
scriptingConfig,
runRequestByItemPathname);
runRequestByItemPathname
);
} catch (error) {
console.error('Post-response script error:', error);
postResponseError = error;
@@ -1406,12 +1425,14 @@ const registerNetworkIpc = (mainWindow) => {
const assertions = get(item, 'request.assertions');
if (assertions) {
const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
const results = assertRuntime.runAssertions(assertions,
const results = assertRuntime.runAssertions(
assertions,
request,
response,
envVars,
runtimeVariables,
processEnvVars);
processEnvVars
);
mainWindow.webContents.send('main:run-folder-event', {
type: 'assertion-results',
@@ -1422,14 +1443,15 @@ const registerNetworkIpc = (mainWindow) => {
}
const testFile = get(request, 'tests');
const collectionName = collection?.name;
const collectionName = collection?.name
if (typeof testFile === 'string') {
let testResults = null;
let testError = null;
try {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
testResults = await testRuntime.runTests(decomment(testFile),
testResults = await testRuntime.runTests(
decomment(testFile),
request,
response,
envVars,
@@ -1439,10 +1461,11 @@ const registerNetworkIpc = (mainWindow) => {
processEnvVars,
scriptingConfig,
runRequestByItemPathname,
collectionName);
collectionName
);
} catch (error) {
testError = error;
if (error.partialResults) {
testResults = error.partialResults;
} else {
@@ -1476,7 +1499,7 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
notifyScriptExecution({
@@ -1505,7 +1528,7 @@ const registerNetworkIpc = (mainWindow) => {
collectionUid,
folderUid,
statusText: 'collection run was terminated!',
runCompletionTime: new Date().toISOString()
runCompletionTime: new Date().toISOString(),
});
break;
}
@@ -1522,7 +1545,7 @@ const registerNetworkIpc = (mainWindow) => {
if (nextRequestIdx >= 0) {
currentRequestIndex = nextRequestIdx;
} else {
console.error('Could not find request with name \'' + nextRequestName + '\'');
console.error("Could not find request with name '" + nextRequestName + "'");
currentRequestIndex++;
}
} else {
@@ -1535,10 +1558,10 @@ const registerNetworkIpc = (mainWindow) => {
type: 'testrun-ended',
collectionUid,
folderUid,
runCompletionTime: new Date().toISOString()
runCompletionTime: new Date().toISOString(),
});
} catch (error) {
console.log('error', error);
console.log("error", error);
deleteCancelToken(cancelTokenUid);
mainWindow.webContents.send('main:run-folder-event', {
type: 'testrun-ended',
@@ -1635,13 +1658,14 @@ const executeRequestOnFailHandler = async (request, error) => {
}
};
const registerAllNetworkIpc = (mainWindow) => {
registerNetworkIpc(mainWindow);
registerGrpcEventHandlers(mainWindow);
registerWsEventHandlers(mainWindow);
};
}
module.exports = registerAllNetworkIpc;
module.exports = registerAllNetworkIpc
module.exports.configureRequest = configureRequest;
module.exports.getCertsAndProxyConfig = getCertsAndProxyConfig;
module.exports.fetchGqlSchemaHandler = fetchGqlSchemaHandler;