@@ -24,7 +27,7 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, type })
hasBody ? (
{
};
const Response = ({ collection, response, item }) => {
- let { status, statusCode, statusText, dataBuffer, headers, data, error, duration, size } = response || {};
- if (!dataBuffer) {
+ let { status, statusCode, statusText, dataBuffer, headers, data, error, duration, size, bodyRef } = response || {};
+ // Large / file-backed bodies use bodyRef (protocol / download) instead of synthesizing a buffer.
+ if (!dataBuffer && data != null && !bodyRef) {
dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
}
@@ -64,6 +65,7 @@ const Response = ({ collection, response, item }) => {
headers={headers}
item={item}
type="response"
+ response={response}
/>
>
);
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/legacy-body.spec.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/legacy-body.spec.js
new file mode 100644
index 00000000000..112a4f98126
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/legacy-body.spec.js
@@ -0,0 +1,46 @@
+/**
+ * Characterization: legacy timeline entries without bodyRef still synthesize
+ * a base64 dataBuffer from inlined `data` so BodyBlock can preview them.
+ */
+describe('legacy timeline response body synthesis', () => {
+ const synthesizeLegacyDataBuffer = (response) => {
+ let { dataBuffer, data, bodyRef } = response || {};
+ if (!dataBuffer && data != null && !bodyRef) {
+ const safeStringifyJSONIfNotString = (obj) => {
+ if (obj === null || obj === undefined) return '';
+ if (typeof obj === 'string') return obj;
+ try {
+ return JSON.stringify(obj);
+ } catch (e) {
+ return '[Unserializable Object]';
+ }
+ };
+ dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
+ }
+ return dataBuffer;
+ };
+
+ test('synthesizes base64 from inline data when bodyRef is absent', () => {
+ const buf = synthesizeLegacyDataBuffer({ data: { hello: 'world' } });
+ expect(buf).toBe(Buffer.from(JSON.stringify({ hello: 'world' })).toString('base64'));
+ });
+
+ test('does not synthesize when bodyRef is present (file-backed path)', () => {
+ expect(
+ synthesizeLegacyDataBuffer({
+ data: null,
+ bodyRef: 'body-1',
+ dataBuffer: undefined
+ })
+ ).toBeUndefined();
+ });
+
+ test('preserves existing dataBuffer', () => {
+ expect(
+ synthesizeLegacyDataBuffer({
+ data: 'ignored',
+ dataBuffer: 'abc123'
+ })
+ ).toBe('abc123');
+ });
+});
diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js
index eea06d16e05..24a6e138046 100644
--- a/packages/bruno-app/src/components/ResponsePane/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/index.js
@@ -42,6 +42,7 @@ const ResponsePane = ({ item, collection }) => {
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
// Initialize format and tab only once when data loads.
+ // Prefer headers; dataBuffer is optional (legacy / magic-byte sniff only).
const { initialFormat, initialTab, contentType } = useInitialResponseFormat(response?.dataBuffer, response?.headers);
const previewFormatOptions = useResponsePreviewFormatOptions(response?.dataBuffer, response?.headers);
@@ -103,17 +104,8 @@ const ResponsePane = ({ item, collection }) => {
if (typeof response.size === 'number') {
return response.size;
}
-
- if (!response.dataBuffer) return 0;
-
- try {
- // dataBuffer is base64 encoded, so we need to calculate the actual size
- const buffer = Buffer.from(response.dataBuffer, 'base64');
- return buffer.length;
- } catch (error) {
- return 0;
- }
- }, [response.size, response.dataBuffer]);
+ return 0;
+ }, [response.size]);
const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0;
const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage;
@@ -162,7 +154,6 @@ const ResponsePane = ({ item, collection }) => {
item={item}
collection={collection}
data={response.data}
- dataBuffer={response.dataBuffer}
headers={response.headers}
error={response.error}
key={item.filename}
@@ -278,7 +269,6 @@ const ResponsePane = ({ item, collection }) => {
selectedFormat={selectedFormat}
selectedTab={selectedViewTab}
data={response.data}
- dataBuffer={response.dataBuffer}
/>
) : null}
diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
index 318f49487b8..6eb69b7b630 100644
--- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
+++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
@@ -44,12 +44,11 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
case 'response': {
return (
{
isReRunningRef.current = false;
clearStoredRunnerExchanges();
- dispatch(
- resetCollectionRunner({
- collectionUid: collection.uid
- })
- );
+ dispatch(resetCollectionRunner({ collectionUid: collection.uid }));
setDelay(null);
};
diff --git a/packages/bruno-app/src/hooks/useResponseBody.js b/packages/bruno-app/src/hooks/useResponseBody.js
new file mode 100644
index 00000000000..9f6b8a1dc17
--- /dev/null
+++ b/packages/bruno-app/src/hooks/useResponseBody.js
@@ -0,0 +1,83 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { getResponseBodyClient } from 'utils/response-body';
+
+/**
+ * Lazy read of a main-process response body by bodyRef.
+ * @param {{ bodyRef?: string|null, contentType?: string, mode?: 'text'|'blob' }} options
+ */
+export const useResponseBody = ({ bodyRef, contentType, mode = 'text' } = {}) => {
+ const [data, setData] = useState(null);
+ const [objectUrl, setObjectUrl] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const generationRef = useRef(0);
+ const objectUrlRef = useRef(null);
+
+ const revokeObjectUrl = useCallback(() => {
+ if (objectUrlRef.current) {
+ URL.revokeObjectURL(objectUrlRef.current);
+ objectUrlRef.current = null;
+ }
+ setObjectUrl(null);
+ }, []);
+
+ const reset = useCallback(() => {
+ generationRef.current += 1;
+ setData(null);
+ setError(null);
+ setLoading(false);
+ revokeObjectUrl();
+ }, [revokeObjectUrl]);
+
+ useEffect(() => {
+ reset();
+ }, [bodyRef, reset]);
+
+ useEffect(() => () => revokeObjectUrl(), [revokeObjectUrl]);
+
+ const load = useCallback(async () => {
+ if (!bodyRef) {
+ return false;
+ }
+
+ const gen = generationRef.current;
+ setLoading(true);
+ setError(null);
+
+ try {
+ if (mode === 'blob') {
+ const result = await getResponseBodyClient().read(bodyRef, { encoding: 'bytes' });
+ if (gen !== generationRef.current) {
+ return false;
+ }
+ const bytes = result?.bytes;
+ const blob = new Blob([bytes], {
+ type: contentType || result?.contentType || 'application/octet-stream'
+ });
+ revokeObjectUrl();
+ const url = URL.createObjectURL(blob);
+ objectUrlRef.current = url;
+ setObjectUrl(url);
+ } else {
+ const result = await getResponseBodyClient().read(bodyRef);
+ if (gen !== generationRef.current) {
+ return false;
+ }
+ setData(result?.data ?? '');
+ }
+ return true;
+ } catch (err) {
+ if (gen !== generationRef.current) {
+ return false;
+ }
+ setError(err);
+ return false;
+ } finally {
+ if (gen === generationRef.current) {
+ setLoading(false);
+ }
+ }
+ }, [bodyRef, contentType, mode, revokeObjectUrl]);
+
+ return { load, data, objectUrl, loading, error, reset };
+};
diff --git a/packages/bruno-app/src/hooks/useResponseBody.spec.js b/packages/bruno-app/src/hooks/useResponseBody.spec.js
new file mode 100644
index 00000000000..bb8cbe67e0f
--- /dev/null
+++ b/packages/bruno-app/src/hooks/useResponseBody.spec.js
@@ -0,0 +1,89 @@
+import { renderHook, act } from '@testing-library/react';
+import { useResponseBody } from './useResponseBody';
+
+const read = jest.fn();
+
+jest.mock('utils/response-body', () => ({
+ getResponseBodyClient: () => ({ read })
+}));
+
+describe('useResponseBody', () => {
+ beforeEach(() => {
+ read.mockReset();
+ global.URL.createObjectURL = jest.fn(() => 'blob:mock');
+ global.URL.revokeObjectURL = jest.fn();
+ });
+
+ it('does not read until load is called', () => {
+ const { result } = renderHook(() => useResponseBody({ bodyRef: 'b1', mode: 'text' }));
+ expect(read).not.toHaveBeenCalled();
+ expect(result.current.data).toBeNull();
+ });
+
+ it('loads text on demand', async () => {
+ read.mockResolvedValueOnce({ data: 'hello', size: 5 });
+ const { result } = renderHook(() => useResponseBody({ bodyRef: 'b1', mode: 'text' }));
+
+ let ok;
+ await act(async () => {
+ ok = await result.current.load();
+ });
+
+ expect(ok).toBe(true);
+ expect(result.current.data).toBe('hello');
+ });
+
+ it('creates blob object URL in blob mode', async () => {
+ read.mockResolvedValueOnce({ bytes: Uint8Array.from([1, 2, 3]), size: 3, contentType: 'image/png' });
+ const { result } = renderHook(() =>
+ useResponseBody({ bodyRef: 'b2', contentType: 'image/png', mode: 'blob' })
+ );
+
+ await act(async () => {
+ await result.current.load();
+ });
+
+ expect(URL.createObjectURL).toHaveBeenCalled();
+ expect(result.current.objectUrl).toBe('blob:mock');
+ });
+
+ it('ignores stale read after bodyRef change', async () => {
+ let resolveRead;
+ read.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveRead = () => resolve({ data: 'late', size: 4 });
+ })
+ );
+
+ const { result, rerender } = renderHook(
+ ({ bodyRef }) => useResponseBody({ bodyRef, mode: 'text' }),
+ { initialProps: { bodyRef: 'b-old' } }
+ );
+
+ await act(async () => {
+ result.current.load();
+ });
+
+ rerender({ bodyRef: 'b-new' });
+
+ await act(async () => {
+ resolveRead();
+ await Promise.resolve();
+ });
+
+ expect(result.current.data).toBeNull();
+ });
+
+ it('revokes object URL on unmount', async () => {
+ read.mockResolvedValueOnce({ bytes: Uint8Array.from([1]), size: 1 });
+ const { result, unmount } = renderHook(() => useResponseBody({ bodyRef: 'b3', mode: 'blob' }));
+
+ await act(async () => {
+ await result.current.load();
+ });
+
+ unmount();
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock');
+ });
+});
diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js
index 81a5830c91d..8ed5f773778 100644
--- a/packages/bruno-app/src/providers/App/useIpcEvents.js
+++ b/packages/bruno-app/src/providers/App/useIpcEvents.js
@@ -249,6 +249,9 @@ const useIpcEvents = () => {
dispatch(collectionRenamedEvent(val));
});
+ // Dispatch folder-run events synchronously first. Awaiting pins before dispatch
+ // reorders IPC (assertion/test results overtake response-received) and leaves
+ // items stuck in `running`, which under-counts passed runner results.
const removeRunFolderEventListener = ipcRenderer.on('main:run-folder-event', (val) => {
// Folder runs reuse the workspace baseline across N requests; clear it
// per request so request N's global-env update doesn't diff against
@@ -256,6 +259,7 @@ const useIpcEvents = () => {
if (val.type === 'testrun-started' || val.type === 'request-queued') {
dispatch(_clearScriptGlobalEnvBaseline());
}
+
dispatch(runFolderEvent(val));
});
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
index 69cad0c2565..fc874616fc8 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -34,7 +34,6 @@ import { uuid, waitForNextTick } from 'utils/common';
import { cancelNetworkRequest, connectWS, sendGrpcRequest, sendNetworkRequest, sendWsRequest } from 'utils/network/index';
import { callIpc } from 'utils/common/ipc';
import brunoClipboard from 'utils/bruno-clipboard';
-
import {
collectionAddEnvFileEvent as _collectionAddEnvFileEvent,
createCollection as _createCollection,
@@ -50,6 +49,9 @@ import {
requestCancelled,
resetRunResults,
responseReceived,
+ responseCleared,
+ clearRequestTimeline,
+ resetCollectionRunner,
updateLastAction,
setCollectionSecurityConfig,
updateCollectionVersion as _updateCollectionVersion,
@@ -676,7 +678,7 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
});
} else {
sendNetworkRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables)
- .then((response) => {
+ .then(async (response) => {
const { requestSent, ...responseData } = response;
// Ensure any timestamps in the response are converted to numbers
const serializedResponse = {
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
index 8289658fe9c..96cf9d4996e 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -681,6 +681,10 @@ export const collectionsSlice = createSlice({
? timelineRequest.timestamp.getTime()
: timelineRequest?.timestamp || Date.now();
+ if (action.payload.response?.postResponseScriptErrorMessage) {
+ item.postResponseScriptErrorMessage = action.payload.response.postResponseScriptErrorMessage;
+ }
+
// Append the new timeline entry with numeric timestamp
collection.timeline.push({
type: 'request',
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/timeline-routing.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/timeline-routing.spec.js
index d3d1c5c2754..85aa3a798d5 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/timeline-routing.spec.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/timeline-routing.spec.js
@@ -39,7 +39,7 @@ const scriptedRequestEvent = (overrides = {}) => ({
timestamp: 1000,
data: {
request: { method: 'GET', url: 'https://example.com/ping', headers: {}, data: undefined },
- response: { statusCode: 200, statusText: 'OK', headers: {}, data: 'ok', dataBuffer: '', size: 0, duration: 1 }
+ response: { statusCode: 200, statusText: 'OK', headers: {}, data: 'ok', bodyRef: null, size: 0, duration: 1 }
},
...overrides
});
diff --git a/packages/bruno-app/src/utils/common/format-response.spec.js b/packages/bruno-app/src/utils/common/format-response.spec.js
index 59e0aed6cf7..a20c06b5fd7 100644
--- a/packages/bruno-app/src/utils/common/format-response.spec.js
+++ b/packages/bruno-app/src/utils/common/format-response.spec.js
@@ -9,10 +9,9 @@ describe('formatResponse', () => {
};
describe('invalid inputs', () => {
- it('should return empty string for invalid inputs', () => {
+ it('should return empty string when data or mode is missing', () => {
const invalidCases = [
[undefined, 'dGVzdA==', 'json'],
- [{ test: 'data' }, null, 'json'],
[{ test: 'data' }, 'dGVzdA==', null],
[undefined, undefined, undefined]
];
@@ -23,6 +22,11 @@ describe('formatResponse', () => {
expect(typeof result).toBe('string');
});
});
+
+ it('should format from structured data when buffer is absent (bodyRef path)', () => {
+ const result = formatResponse({ test: 'data' }, null, 'json');
+ expect(result).toBe('{\n "test": "data"\n}');
+ });
});
describe('JSON mode', () => {
diff --git a/packages/bruno-app/src/utils/common/index.js b/packages/bruno-app/src/utils/common/index.js
index 3ba0ea1bcaa..8cd712135e8 100644
--- a/packages/bruno-app/src/utils/common/index.js
+++ b/packages/bruno-app/src/utils/common/index.js
@@ -237,7 +237,89 @@ const applyJSONPathFilter = (data, filter) => {
};
export const formatResponse = (data, dataBufferString, mode, filter, bufferThreshold = LARGE_BUFFER_THRESHOLD) => {
- if (data === undefined || !dataBufferString || !mode) {
+ if (data === undefined || !mode) {
+ return '';
+ }
+
+ // Prefer structured/string data when no buffer is available (bodyRef architecture)
+ if (!dataBufferString) {
+ if (mode.includes('hex')) {
+ if (typeof data === 'string' && isHexFormat(data)) {
+ return data;
+ }
+ try {
+ const asString = typeof data === 'string' ? data : safeStringifyJSON(data);
+ return formatHexView(Buffer.from(asString || '', 'utf8'));
+ } catch (_) {
+ return '';
+ }
+ }
+
+ if (mode.includes('base64')) {
+ if (typeof data === 'string') {
+ return Buffer.from(data, 'utf8').toString('base64');
+ }
+ if (data != null) {
+ const asString = safeStringifyJSON(data);
+ return Buffer.from(asString || '', 'utf8').toString('base64');
+ }
+ return '';
+ }
+
+ if (mode.includes('json')) {
+ try {
+ if (filter) {
+ const parsed = typeof data === 'string' ? safeParseJSON(data) : data;
+ return safeStringifyJSON(applyJSONPathFilter(parsed, filter), true);
+ }
+ } catch (error) { /* ignore */ }
+ if (typeof data === 'string') {
+ try {
+ return fastJsonFormat(data);
+ } catch (_) {
+ return data;
+ }
+ }
+ const stringified = safeStringifyJSON(data, true);
+ return typeof stringified === 'string' ? stringified : String(data);
+ }
+
+ if (mode.includes('xml')) {
+ if (typeof data === 'string') {
+ const parsed = safeParseXML(data, { collapseContent: true });
+ return typeof parsed === 'string' ? parsed : safeStringifyJSON(parsed, true);
+ }
+ return typeof data === 'string' ? data : safeStringifyJSON(data, true);
+ }
+
+ if (mode.includes('html')) {
+ if (typeof data === 'string') {
+ try {
+ return prettifyHtmlString(data);
+ } catch (_) {
+ return data;
+ }
+ }
+ return data != null ? String(typeof data === 'object' ? safeStringifyJSON(data) : data) : '';
+ }
+
+ if (mode.includes('javascript')) {
+ if (typeof data === 'string') {
+ try {
+ return prettifyJavaScriptString(data);
+ } catch (_) {
+ return data;
+ }
+ }
+ return data != null ? String(typeof data === 'object' ? safeStringifyJSON(data) : data) : '';
+ }
+
+ // raw / text / fallback
+ if (typeof data === 'string') return data;
+ if (data != null) {
+ const stringified = safeStringifyJSON(data, true);
+ return typeof stringified === 'string' ? stringified : String(data);
+ }
return '';
}
@@ -345,28 +427,47 @@ export const formatResponse = (data, dataBufferString, mode, filter, bufferThres
return data;
}
- // Data is not in hex format, encode it to hex
- try {
- const dataBuffer = Buffer.from(dataBufferString, 'base64');
- const hexView = formatHexView(dataBuffer);
- return hexView;
- } catch (error) {
- // If buffer conversion fails, try to encode the string data directly
- if (typeof data === 'string') {
- try {
- const stringBuffer = Buffer.from(data, 'utf8');
- return formatHexView(stringBuffer);
- } catch (stringError) {
- return '';
- }
+ // Prefer base64 buffer when present; otherwise encode structured/string data
+ if (dataBufferString) {
+ try {
+ const dataBuffer = Buffer.from(dataBufferString, 'base64');
+ return formatHexView(dataBuffer);
+ } catch (error) {
+ /* fall through */
+ }
+ }
+
+ if (typeof data === 'string') {
+ try {
+ return formatHexView(Buffer.from(data, 'utf8'));
+ } catch (stringError) {
+ return '';
+ }
+ }
+ if (data != null) {
+ try {
+ const asString = typeof data === 'string' ? data : safeStringifyJSON(data);
+ return formatHexView(Buffer.from(asString || '', 'utf8'));
+ } catch (_) {
+ return '';
}
- return '';
}
+ return '';
}
- // Handle base64 format - return base64 string as-is
+ // Handle base64 format — use dataBuffer when present; otherwise encode from data
if (mode.includes('base64')) {
- return dataBufferString;
+ if (dataBufferString) {
+ return dataBufferString;
+ }
+ if (typeof data === 'string') {
+ return Buffer.from(data, 'utf8').toString('base64');
+ }
+ if (data != null) {
+ const asString = typeof data === 'string' ? data : safeStringifyJSON(data);
+ return Buffer.from(asString || '', 'utf8').toString('base64');
+ }
+ return '';
}
// Handle raw format - return data as-is without any formatting
diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js
index 8a85c12ec26..9d78cffaeb5 100644
--- a/packages/bruno-app/src/utils/network/index.js
+++ b/packages/bruno-app/src/utils/network/index.js
@@ -6,20 +6,14 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
// if there is an error, we return the response object as is
if (response?.error) {
resolve(response);
+ return;
}
+ // bodyRef is source of truth; never put dataBuffer in Redux
+ const { mapNetworkResponseToRedux } = require('utils/response-body');
resolve({
state: 'success',
- data: response.data,
- // Note that the Buffer is encoded as a base64 string, because Buffers / TypedArrays are not allowed in the redux store
- dataBuffer: response.dataBuffer,
- headers: response.headers,
- size: response.size,
- status: response.status,
- statusText: response.statusText,
- duration: response.duration,
- timeline: response.timeline,
- stream: response.stream,
+ ...mapNetworkResponseToRedux(response),
requestSent: response.requestSent
});
})
diff --git a/packages/bruno-app/src/utils/response-body/client.js b/packages/bruno-app/src/utils/response-body/client.js
new file mode 100644
index 00000000000..910d33e1ae9
--- /dev/null
+++ b/packages/bruno-app/src/utils/response-body/client.js
@@ -0,0 +1,35 @@
+export const RESPONSE_BODY_CHANNELS = {
+ SAVE: 'renderer:response-body-save',
+ READ: 'renderer:response-body-read'
+};
+
+/**
+ * @param {{ invoke: (channel: string, ...args: any[]) => Promise }} ipcPort
+ */
+export const createResponseBodyClient = (ipcPort) => {
+ if (!ipcPort || typeof ipcPort.invoke !== 'function') {
+ throw new Error('createResponseBodyClient requires an IpcPort with invoke()');
+ }
+
+ return {
+ save(bodyRef, { url, pathname, headers } = {}) {
+ return ipcPort.invoke(RESPONSE_BODY_CHANNELS.SAVE, { bodyRef, url, pathname, headers });
+ },
+
+ read(bodyRef, options) {
+ return ipcPort.invoke(RESPONSE_BODY_CHANNELS.READ, bodyRef, options);
+ }
+ };
+};
+
+let defaultClient = null;
+
+export const getResponseBodyClient = () => {
+ if (!defaultClient) {
+ const { ipcRenderer } = window;
+ defaultClient = createResponseBodyClient({
+ invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args)
+ });
+ }
+ return defaultClient;
+};
diff --git a/packages/bruno-app/src/utils/response-body/client.spec.js b/packages/bruno-app/src/utils/response-body/client.spec.js
new file mode 100644
index 00000000000..7c6ffe0ded4
--- /dev/null
+++ b/packages/bruno-app/src/utils/response-body/client.spec.js
@@ -0,0 +1,40 @@
+import { createResponseBodyClient } from './client';
+import { mapNetworkResponseToRedux } from './index';
+
+describe('response-body client ring', () => {
+ test('mapNetworkResponseToRedux strips dataBuffer', () => {
+ const mapped = mapNetworkResponseToRedux({
+ status: 200,
+ bodyRef: 'r1',
+ data: { ok: true },
+ dataBuffer: 'aaaa',
+ size: 12
+ });
+ expect(mapped.dataBuffer).toBeUndefined();
+ expect(mapped.bodyRef).toBe('r1');
+ expect(mapped.data).toEqual({ ok: true });
+ });
+
+ test('client save/read forward to IpcPort', async () => {
+ const calls = [];
+ const ipc = {
+ invoke: async (channel, ...args) => {
+ calls.push([channel, ...args]);
+ if (channel.includes('read')) return { data: 'hello', size: 5 };
+ return { success: true };
+ }
+ };
+ const client = createResponseBodyClient(ipc);
+ await client.save('b1', { url: 'https://x' });
+ expect(await client.read('b1')).toEqual({ data: 'hello', size: 5 });
+ expect(await client.read('b1', { encoding: 'base64' })).toEqual({ data: 'hello', size: 5 });
+ expect(calls.map((c) => c[0])).toEqual([
+ 'renderer:response-body-save',
+ 'renderer:response-body-read',
+ 'renderer:response-body-read'
+ ]);
+ expect(calls[1][1]).toBe('b1');
+ expect(calls[2][1]).toBe('b1');
+ expect(calls[2][2]).toEqual({ encoding: 'base64' });
+ });
+});
diff --git a/packages/bruno-app/src/utils/response-body/index.js b/packages/bruno-app/src/utils/response-body/index.js
new file mode 100644
index 00000000000..13bca4f3402
--- /dev/null
+++ b/packages/bruno-app/src/utils/response-body/index.js
@@ -0,0 +1,24 @@
+import { createResponseBodyClient, getResponseBodyClient } from './client';
+
+/** Map IPC/network response into Redux-safe shape: never dataBuffer. */
+export const mapNetworkResponseToRedux = (response = {}) => {
+ if (!response || typeof response !== 'object') {
+ return response;
+ }
+
+ const {
+ dataBuffer, // eslint-disable-line no-unused-vars -- intentionally stripped
+ ...rest
+ } = response;
+
+ return {
+ ...rest,
+ bodyRef: response.bodyRef || null,
+ size: typeof response.size === 'number' ? response.size : 0
+ };
+};
+
+export {
+ createResponseBodyClient,
+ getResponseBodyClient
+};
diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js
index 3e0777ed75c..926d0156283 100644
--- a/packages/bruno-electron/src/index.js
+++ b/packages/bruno-electron/src/index.js
@@ -17,6 +17,7 @@ if (isDev) {
const { format } = require('url');
const { BrowserWindow, app, session, Menu, globalShortcut, ipcMain, nativeTheme, shell } = require('electron');
const { setContentSecurityPolicy } = require('electron-util');
+const { createResponseBodyService } = require('./services/response-body');
if (isDev && process.env.ELECTRON_USER_DATA_PATH) {
console.debug('`ELECTRON_USER_DATA_PATH` found, modifying `userData` path: \n'
@@ -509,6 +510,10 @@ app.on('ready', async () => {
});
});
+ // Response body store (spill / range-read) — before network IPC uses it
+ const responseBodyService = createResponseBodyService();
+ responseBodyService.registerIpc(mainWindow);
+
// register all ipc handlers
registerNetworkIpc(mainWindow);
registerGlobalEnvironmentsIpc(mainWindow, globalEnvironmentsManager);
diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js
index 77a860a6855..b06431068c6 100644
--- a/packages/bruno-electron/src/ipc/network/index.js
+++ b/packages/bruno-electron/src/ipc/network/index.js
@@ -5,7 +5,6 @@ const { applyOAuth1ToRequest } = require('@usebruno/requests');
const { buildScriptedEntry } = require('@usebruno/requests').scripting;
const qs = require('qs');
const decomment = require('decomment');
-const contentDispositionParser = require('content-disposition');
const mime = require('mime-types');
const { ipcMain } = require('electron');
const { each, get, extend, cloneDeep, merge } = require('lodash');
@@ -39,6 +38,84 @@ const { cookiesStore } = require('../../store/cookies');
const registerGrpcEventHandlers = require('./grpc-event-handlers');
const { registerWsEventHandlers } = require('./ws-event-handlers');
const { getCertsAndProxyConfig, buildCertsAndProxyConfig } = require('./cert-utils');
+const {
+ getResponseBodyService,
+ SHOW_INLINE_BYTES,
+ populateResponseDataForScripts
+} = require('../../services/response-body');
+
+const getContentTypeHeader = (headers = {}) => {
+ const entries = typeof headers === 'object' ? Object.entries(headers) : [];
+ const found = entries.find(([name]) => String(name).toLowerCase() === 'content-type');
+ return found ? found[1] : undefined;
+};
+
+const isTextLikeContentType = (contentType) => {
+ if (!contentType) return true;
+ const c = String(contentType).toLowerCase();
+ return (
+ c.includes('json')
+ || c.includes('text/')
+ || c.includes('xml')
+ || c.includes('javascript')
+ || c.includes('urlencoded')
+ || c.includes('graphql')
+ );
+};
+
+/**
+ * Ingest axios response stream into ResponseBodyStore; attach bodyRef + parsed data from dual-writer buffer.
+ */
+const ingestAxiosResponseBody = async (response, { disableParsingResponseJson } = {}) => {
+ const bodyService = getResponseBodyService();
+ const contentType = getContentTypeHeader(response.headers);
+ const ingested = await bodyService.store.ingestStream(response.data, {
+ contentType,
+ headers: response.headers
+ });
+
+ response.bodyRef = ingested.bodyRef;
+ response.size = ingested.size;
+
+ populateResponseDataForScripts(
+ bodyService.store,
+ response,
+ parseDataFromResponse,
+ disableParsingResponseJson
+ );
+
+ return ingested;
+};
+
+const shouldIncludeParsedDataInIpc = (response) => {
+ if (typeof response.size !== 'number' || response.size > SHOW_INLINE_BYTES) return false;
+ return isTextLikeContentType(getContentTypeHeader(response.headers));
+};
+
+/**
+ * Prefer raw UTF-8 from the store for IPC display so large JSON integers / exact bytes
+ * are preserved (JSON.parse would lose BigInt precision).
+ */
+const getDataForIpc = (response) => {
+ if (!shouldIncludeParsedDataInIpc(response)) {
+ return undefined;
+ }
+ try {
+ const buf = getResponseBodyService().store.getBufferForScripts(response.bodyRef);
+ return buf.toString('utf8');
+ } catch (_) {
+ return response.data;
+ }
+};
+
+const discardIngestedBodyBuffer = (response) => {
+ if (!response?.bodyRef) return;
+ try {
+ getResponseBodyService().store.discardBuffer(response.bodyRef);
+ } catch (_) {
+ /* ignore */
+ }
+};
const { easterEggResponse } = require('../../utils/woof');
const { createRunnerExchangeEmitters } = require('./runner-exchange');
const { buildFormUrlEncodedPayload, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils;
@@ -828,7 +905,7 @@ const registerNetworkIpc = (mainWindow) => {
statusText: res.statusText,
headers: res.headers,
data: res.data,
- dataBuffer: res.dataBuffer,
+ bodyRef: res.bodyRef || null,
size: res.size,
duration: res.duration,
timeline: res.timeline
@@ -1046,7 +1123,9 @@ const registerNetworkIpc = (mainWindow) => {
isResponseStream = hasStreamHeaders(response.headers);
if (!isResponseStream) {
- response.data = await promisifyStream(response.data);
+ await ingestAxiosResponseBody(response, {
+ disableParsingResponseJson: request.__brunoDisableParsingResponseJson
+ });
}
// Prevents the duration on leaking to the actual result
@@ -1074,7 +1153,9 @@ const registerNetworkIpc = (mainWindow) => {
response.headers.delete('request-duration');
isResponseStream = hasStreamHeaders(response.headers);
if (!isResponseStream) {
- response.data = await promisifyStream(response.data);
+ await ingestAxiosResponseBody(response, {
+ disableParsingResponseJson: request.__brunoDisableParsingResponseJson
+ });
}
} else {
await executeRequestOnFailHandler(request, error, (onFailScriptResult) => {
@@ -1097,11 +1178,11 @@ const registerNetworkIpc = (mainWindow) => {
axiosDataStream = response.data;
}
- const { data, dataBuffer } = isResponseStream
- ? { data: '', dataBuffer: Buffer.alloc(0) }
- : parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
- response.data = data;
- response.dataBuffer = dataBuffer;
+ if (isResponseStream) {
+ response.data = '';
+ response.dataBuffer = Buffer.alloc(0);
+ }
+ // non-stream: already parsed/attached in ingestAxiosResponseBody
response.responseTime = responseTime;
@@ -1276,20 +1357,28 @@ const registerNetworkIpc = (mainWindow) => {
await runPostScripts();
}
+ const ipcData = getDataForIpc(response);
+ if (!isResponseStream) {
+ discardIngestedBodyBuffer(response);
+ }
+
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
- data: response.data,
+ data: ipcData !== undefined ? ipcData : (isResponseStream ? response.data : undefined),
stream: isResponseStream ? axiosDataStream : null,
sseChunks: isResponseStream ? sseChunks : null,
cancelTokenUid: cancelTokenUid,
- dataBuffer: response.dataBuffer.toString('base64'),
- size: Buffer.byteLength(response.dataBuffer),
+ bodyRef: response.bodyRef || null,
+ size: typeof response.size === 'number'
+ ? response.size
+ : (response.dataBuffer ? Buffer.byteLength(response.dataBuffer) : 0),
duration: responseTime ?? 0,
url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null,
timeline: response.timeline,
- requestSent
+ requestSent,
+ postResponseScriptErrorMessage: response.postResponseScriptErrorMessage
};
} catch (error) {
deleteCancelToken(cancelTokenUid);
@@ -1526,7 +1615,7 @@ const registerNetworkIpc = (mainWindow) => {
statusText: res.statusText,
headers: res.headers,
data: res.data,
- dataBuffer: res.dataBuffer,
+ bodyRef: res.bodyRef || null,
size: res.size,
duration: res.duration,
timeline: res.timeline
@@ -1879,12 +1968,11 @@ const registerNetworkIpc = (mainWindow) => {
/** @type {import('axios').AxiosResponse} */
response = await axiosInstance(refreshExplicitHeaderNames(request));
- response.data = await promisifyStream(response.data, currentAbortController, false);
+ await ingestAxiosResponseBody(response, {
+ disableParsingResponseJson: request.__brunoDisableParsingResponseJson
+ });
timeEnd = Date.now();
- const { data, dataBuffer } = parseDataFromResponse(response, request.__brunoDisableParsingResponseJson);
- response.data = data;
- response.dataBuffer = dataBuffer;
response.responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
@@ -1905,9 +1993,9 @@ const registerNetworkIpc = (mainWindow) => {
statusText: response.statusText,
headers: response.headers,
duration: timeEnd - timeStart,
- dataBuffer: dataBuffer.toString('base64'),
- size: Buffer.byteLength(dataBuffer),
- data: response.data,
+ bodyRef: response.bodyRef || null,
+ size: response.size || 0,
+ data: getDataForIpc(response),
responseTime: response.responseTime,
timeline: response.timeline,
url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null
@@ -1921,12 +2009,9 @@ const registerNetworkIpc = (mainWindow) => {
}
if (error?.response) {
- error.response.data = await promisifyStream(error.response.data, currentAbortController, false);
- const { data, dataBuffer } = parseDataFromResponse(error.response);
+ await ingestAxiosResponseBody(error.response);
error.response.responseTime = error.response.headers.get('request-duration');
error.response.headers.delete('request-duration');
- error.response.data = data;
- error.response.dataBuffer = dataBuffer;
// save cookies (4XX/5XX responses can also set cookies)
if (preferencesUtil.shouldStoreCookies()) {
@@ -1934,23 +2019,26 @@ const registerNetworkIpc = (mainWindow) => {
}
timeEnd = Date.now();
- response = {
- status: error.response.status,
- statusText: error.response.statusText,
- headers: error.response.headers,
- duration: timeEnd - timeStart,
- dataBuffer: dataBuffer.toString('base64'),
- size: Buffer.byteLength(dataBuffer),
- data: error.response.data,
- responseTime: error.response.responseTime,
- timeline: error.response.timeline
- };
+ // Keep the ingested axios response for scripts/assertions (parsed `.data`).
+ // Only the runner IPC payload should use getDataForIpc().
+ response = error.response;
+ response.duration = timeEnd - timeStart;
// if we get a response from the server, we consider it as a success
sendRunnerResponseReceived({
requestUid,
error: error ? error.message : 'An error occurred while running the request',
- responseReceived: response,
+ responseReceived: {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers,
+ duration: response.duration,
+ bodyRef: response.bodyRef || null,
+ size: response.size || 0,
+ data: getDataForIpc(response),
+ responseTime: response.responseTime,
+ timeline: response.timeline
+ },
eventData
});
} else {
@@ -2116,6 +2204,8 @@ const registerNetworkIpc = (mainWindow) => {
const domainsWithCookiesTest = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest)));
}
+
+ discardIngestedBodyBuffer(response);
} catch (error) {
mainWindow.webContents.send('main:run-folder-event', {
type: 'error',
@@ -2180,55 +2270,36 @@ const registerNetworkIpc = (mainWindow) => {
}
);
- // save response to file
+ // save response to file — prefer bodyRef from ResponseBodyStore (no base64 round-trip)
ipcMain.handle('renderer:save-response-to-file', async (event, response, url, pathname) => {
try {
- const getHeaderValue = (headerName) => {
- const headersArray = typeof response.headers === 'object' ? Object.entries(response.headers) : [];
-
- if (headersArray.length > 0) {
- const header = headersArray.find((header) => header[0] === headerName);
- if (header && header.length > 1) {
- return header[1];
- }
- }
- };
-
- const getFileNameFromContentDispositionHeader = () => {
- const contentDisposition = getHeaderValue('content-disposition');
- try {
- const disposition = contentDispositionParser.parse(contentDisposition);
- return disposition && disposition.parameters['filename'];
- } catch (error) { }
- };
-
- const getFileNameFromUrlPath = () => {
- const lastPathLevel = new URL(url).pathname.split('/').pop();
- if (lastPathLevel && /\..+/.exec(lastPathLevel)) {
- return lastPathLevel;
+ const bodyService = getResponseBodyService();
+ if (response?.bodyRef) {
+ const { resolveResponseSaveDefaultPath } = require('../../utils/response-save-filename');
+ const defaultPath = resolveResponseSaveDefaultPath({
+ headers: response.headers,
+ url,
+ pathname
+ });
+ const filePath = await chooseFileToSave(mainWindow, defaultPath);
+ if (!filePath) {
+ return { success: false, cancelled: true };
}
- };
+ await bodyService.store.saveToPath(response.bodyRef, filePath);
+ return { success: true, filePath };
+ }
- const getFileNameBasedOnContentTypeHeader = () => {
- const contentType = getHeaderValue('content-type');
- const extension = (contentType && mime.extension(contentType)) || 'txt';
- return `response.${extension}`;
- };
+ // Legacy fallback: base64 dataBuffer (should not be used for new responses)
+ const { resolveResponseSaveFilename, getHeaderValue } = require('../../utils/response-save-filename');
const getEncodingFormat = () => {
- const contentType = getHeaderValue('content-type');
+ const contentType = getHeaderValue(response.headers, 'content-type');
const extension = mime.extension(contentType) || 'txt';
return ['json', 'xml', 'html', 'yml', 'yaml', 'txt'].includes(extension) ? 'utf-8' : 'base64';
};
- const determineFileName = () => {
- return (
- getFileNameFromContentDispositionHeader() || getFileNameFromUrlPath() || getFileNameBasedOnContentTypeHeader()
- );
- };
-
const dirPath = path.dirname(pathname);
- const fileName = determineFileName();
+ const fileName = resolveResponseSaveFilename({ headers: response.headers, url });
const filePath = await chooseFileToSave(mainWindow, path.join(dirPath, fileName));
if (filePath) {
const encoding = getEncodingFormat();
diff --git a/packages/bruno-electron/src/services/response-body/constants.js b/packages/bruno-electron/src/services/response-body/constants.js
new file mode 100644
index 00000000000..b57dea38357
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/constants.js
@@ -0,0 +1,10 @@
+/** Inline Show over IPC when response size is at or below this (bytes). */
+const SHOW_INLINE_BYTES = 10 * 1024 * 1024;
+
+/** View-from-disk allowed at or below this; above → Download only (bytes). */
+const VIEW_MAX_BYTES = 50 * 1024 * 1024;
+
+module.exports = {
+ SHOW_INLINE_BYTES,
+ VIEW_MAX_BYTES
+};
diff --git a/packages/bruno-electron/src/services/response-body/errors.js b/packages/bruno-electron/src/services/response-body/errors.js
new file mode 100644
index 00000000000..967390a7b4a
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/errors.js
@@ -0,0 +1,48 @@
+class ResponseBodyError extends Error {
+ constructor(message, code) {
+ super(message);
+ this.name = 'ResponseBodyError';
+ this.code = code;
+ }
+}
+
+class BodyNotFoundError extends ResponseBodyError {
+ constructor(bodyRef) {
+ super(`Response body not found: ${bodyRef}`, 'BODY_NOT_FOUND');
+ this.name = 'BodyNotFoundError';
+ this.bodyRef = bodyRef;
+ }
+}
+
+class BodyTooLargeForViewError extends ResponseBodyError {
+ constructor(bodyRef, size, maxBytes) {
+ super(
+ `Response body is too large to view in-app (${size} bytes; max ${maxBytes}). Download the response instead.`,
+ 'BODY_TOO_LARGE_FOR_VIEW'
+ );
+ this.name = 'BodyTooLargeForViewError';
+ this.bodyRef = bodyRef;
+ this.size = size;
+ this.maxBytes = maxBytes;
+ }
+}
+
+class BodyTooLargeForScriptError extends ResponseBodyError {
+ constructor(bodyRef, size, maxBytes) {
+ super(
+ `Response body is too large to use in scripts (${size} bytes; max ${maxBytes}). Download the response instead.`,
+ 'BODY_TOO_LARGE_FOR_SCRIPT'
+ );
+ this.name = 'BodyTooLargeForScriptError';
+ this.bodyRef = bodyRef;
+ this.size = size;
+ this.maxBytes = maxBytes;
+ }
+}
+
+module.exports = {
+ ResponseBodyError,
+ BodyNotFoundError,
+ BodyTooLargeForViewError,
+ BodyTooLargeForScriptError
+};
diff --git a/packages/bruno-electron/src/services/response-body/index.js b/packages/bruno-electron/src/services/response-body/index.js
new file mode 100644
index 00000000000..b6c46a918b4
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/index.js
@@ -0,0 +1,58 @@
+const { createResponseBodyStore } = require('./store');
+const { createNodeFileSystem } = require('./node-fs');
+const { purgeResponseBodiesDirectory } = require('./paths');
+const { registerResponseBodyIpc, CHANNELS } = require('./ipc');
+const { SHOW_INLINE_BYTES, VIEW_MAX_BYTES } = require('./constants');
+const {
+ BodyNotFoundError,
+ BodyTooLargeForViewError,
+ BodyTooLargeForScriptError,
+ ResponseBodyError
+} = require('./errors');
+const { populateResponseDataForScripts } = require('./script-access');
+
+let singleton = null;
+
+const createResponseBodyService = (options = {}) => {
+ if (singleton && !options.fresh) {
+ return singleton;
+ }
+
+ const spillDir = options.spillDir || purgeResponseBodiesDirectory();
+ const fs = options.fs || createNodeFileSystem();
+ const store = createResponseBodyStore({
+ fs,
+ spillDir,
+ idGen: options.idGen
+ });
+
+ singleton = {
+ store,
+ spillDir,
+ registerIpc(mainWindow) {
+ return registerResponseBodyIpc(mainWindow, store);
+ }
+ };
+
+ return singleton;
+};
+
+const getResponseBodyService = () => {
+ if (!singleton) {
+ return createResponseBodyService();
+ }
+ return singleton;
+};
+
+module.exports = {
+ createResponseBodyService,
+ getResponseBodyService,
+ CHANNELS,
+ SHOW_INLINE_BYTES,
+ VIEW_MAX_BYTES,
+ BodyNotFoundError,
+ BodyTooLargeForViewError,
+ BodyTooLargeForScriptError,
+ populateResponseDataForScripts,
+ ResponseBodyError
+};
diff --git a/packages/bruno-electron/src/services/response-body/ipc-protocol.spec.js b/packages/bruno-electron/src/services/response-body/ipc-protocol.spec.js
new file mode 100644
index 00000000000..e9fcd03030f
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/ipc-protocol.spec.js
@@ -0,0 +1,108 @@
+describe('response-body IPC adapter', () => {
+ let store;
+ let handlers;
+ let chooseFileToSave;
+
+ beforeEach(() => {
+ jest.resetModules();
+ handlers = {};
+ chooseFileToSave = jest.fn(async () => '/out/saved.bin');
+
+ jest.doMock('electron', () => ({
+ ipcMain: {
+ handle: (channel, fn) => {
+ handlers[channel] = fn;
+ }
+ }
+ }), { virtual: true });
+
+ jest.doMock('content-disposition', () => ({
+ parse: () => ({ parameters: {} })
+ }), { virtual: true });
+
+ jest.doMock('mime-types', () => ({
+ extension: () => 'txt'
+ }), { virtual: true });
+
+ jest.doMock('../../utils/filesystem', () => ({
+ chooseFileToSave: (...args) => chooseFileToSave(...args)
+ }));
+
+ const { createResponseBodyStore } = require('./store');
+ const { createMemoryFileSystem } = require('./memory-fs');
+ const { registerResponseBodyIpc } = require('./ipc');
+
+ store = createResponseBodyStore({
+ fs: createMemoryFileSystem(),
+ spillDir: '/spill',
+ idGen: (() => {
+ let n = 0;
+ return () => `b-${++n}`;
+ })()
+ });
+ registerResponseBodyIpc({}, store);
+ });
+
+ afterEach(() => {
+ jest.dontMock('electron');
+ jest.dontMock('content-disposition');
+ jest.dontMock('mime-types');
+ jest.dontMock('../../utils/filesystem');
+ });
+
+ test('save / read round-trip (utf8, base64, bytes)', async () => {
+ const { CHANNELS } = require('./ipc');
+ const { bodyRef } = await store.putBuffer(Buffer.from('hello'));
+
+ const readResult = await handlers[CHANNELS.READ]({}, bodyRef);
+ expect(readResult).toEqual({ data: 'hello', size: 5, contentType: null });
+
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
+ const { bodyRef: pngRef } = await store.putBuffer(png);
+ const base64Result = await handlers[CHANNELS.READ]({}, pngRef, { encoding: 'base64' });
+ expect(base64Result).toEqual({
+ dataBuffer: png.toString('base64'),
+ size: png.length,
+ contentType: null
+ });
+ expect(base64Result.data).toBeUndefined();
+
+ const bytesResult = await handlers[CHANNELS.READ]({}, pngRef, { encoding: 'bytes' });
+ expect(bytesResult.size).toBe(png.length);
+ expect(Buffer.from(bytesResult.bytes)).toEqual(png);
+
+ const saveResult = await handlers[CHANNELS.SAVE]({}, {
+ bodyRef,
+ url: 'https://example.com/a.txt',
+ headers: { 'content-type': 'text/plain' }
+ });
+ expect(saveResult).toEqual({ success: true, filePath: '/out/saved.bin' });
+ expect(chooseFileToSave).toHaveBeenCalled();
+ });
+
+ test('read works after discardBuffer (file-backed)', async () => {
+ const { CHANNELS } = require('./ipc');
+ const { bodyRef } = await store.putBuffer(Buffer.from('from-disk'));
+ store.discardBuffer(bodyRef);
+ expect(() => store.getBufferForScripts(bodyRef)).toThrow(/discarded/);
+
+ const readResult = await handlers[CHANNELS.READ]({}, bodyRef);
+ expect(readResult).toEqual({ data: 'from-disk', size: 8, contentType: null });
+ });
+
+ test('read rejects bodies larger than VIEW_MAX_BYTES', async () => {
+ const { CHANNELS } = require('./ipc');
+ const { VIEW_MAX_BYTES } = require('./constants');
+ const { BodyTooLargeForViewError } = require('./errors');
+ const { bodyRef } = await store.putBuffer(Buffer.alloc(VIEW_MAX_BYTES + 1, 0x61));
+ await expect(handlers[CHANNELS.READ]({}, bodyRef)).rejects.toBeInstanceOf(BodyTooLargeForViewError);
+ });
+
+ test('save cancelled returns cancelled flag', async () => {
+ const { CHANNELS } = require('./ipc');
+ chooseFileToSave.mockResolvedValueOnce(null);
+ const { bodyRef } = await store.putBuffer(Buffer.from('x'));
+ const result = await handlers[CHANNELS.SAVE]({}, { bodyRef });
+ expect(result).toEqual({ success: false, cancelled: true });
+ });
+});
diff --git a/packages/bruno-electron/src/services/response-body/ipc.js b/packages/bruno-electron/src/services/response-body/ipc.js
new file mode 100644
index 00000000000..9ef0e9ea6a3
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/ipc.js
@@ -0,0 +1,61 @@
+const { ipcMain } = require('electron');
+const { chooseFileToSave } = require('../../utils/filesystem');
+const { resolveResponseSaveDefaultPath } = require('../../utils/response-save-filename');
+const { VIEW_MAX_BYTES } = require('./constants');
+const { BodyTooLargeForViewError } = require('./errors');
+
+const CHANNELS = {
+ SAVE: 'renderer:response-body-save',
+ READ: 'renderer:response-body-read'
+};
+
+const registerResponseBodyIpc = (mainWindow, store) => {
+ ipcMain.handle(CHANNELS.READ, async (_event, bodyRef, options = {}) => {
+ const stat = store.getStat(bodyRef);
+ if (stat.size > VIEW_MAX_BYTES) {
+ throw new BodyTooLargeForViewError(bodyRef, stat.size, VIEW_MAX_BYTES);
+ }
+
+ const buf = await store.readRange(bodyRef, 0, stat.size);
+ const payload = {
+ size: stat.size,
+ contentType: stat.contentType || null
+ };
+
+ const encoding = options?.encoding;
+ if (encoding === 'base64') {
+ payload.dataBuffer = buf.toString('base64');
+ } else if (encoding === 'bytes') {
+ payload.bytes = Uint8Array.from(buf);
+ } else {
+ payload.data = buf.toString('utf8');
+ }
+
+ return payload;
+ });
+
+ ipcMain.handle(CHANNELS.SAVE, async (_event, { bodyRef, url, pathname, headers } = {}) => {
+ try {
+ store.getStat(bodyRef);
+ } catch (err) {
+ return Promise.reject(err);
+ }
+
+ const defaultPath = resolveResponseSaveDefaultPath({ headers, url, pathname });
+ const filePath = await chooseFileToSave(mainWindow, defaultPath);
+
+ if (!filePath) {
+ return { success: false, cancelled: true };
+ }
+
+ await store.saveToPath(bodyRef, filePath);
+ return { success: true, filePath };
+ });
+
+ return CHANNELS;
+};
+
+module.exports = {
+ CHANNELS,
+ registerResponseBodyIpc
+};
diff --git a/packages/bruno-electron/src/services/response-body/memory-fs.js b/packages/bruno-electron/src/services/response-body/memory-fs.js
new file mode 100644
index 00000000000..e15657aa085
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/memory-fs.js
@@ -0,0 +1,127 @@
+const fs = require('node:fs');
+const path = require('node:path');
+
+/**
+ * In-memory + optional disk FileSystemPort for unit tests.
+ * When `rootDir` is set, file ops use the real filesystem under that root.
+ */
+const createMemoryFileSystem = (rootDir) => {
+ const memFiles = new Map();
+
+ const resolve = (filePath) => {
+ if (!rootDir) return filePath;
+ return path.isAbsolute(filePath) ? filePath : path.join(rootDir, filePath);
+ };
+
+ return {
+ async mkdirp(dir) {
+ if (rootDir) {
+ await fs.promises.mkdir(resolve(dir), { recursive: true });
+ }
+ },
+
+ async writeFile(filePath, data) {
+ const p = resolve(filePath);
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
+ if (rootDir) {
+ await fs.promises.mkdir(path.dirname(p), { recursive: true });
+ await fs.promises.writeFile(p, buf);
+ } else {
+ memFiles.set(p, buf);
+ }
+ },
+
+ createWriteStream(filePath) {
+ const p = resolve(filePath);
+ if (rootDir) {
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ return fs.createWriteStream(p);
+ }
+
+ const chunks = [];
+ const { Writable } = require('node:stream');
+ const stream = new Writable({
+ write(chunk, _enc, cb) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ cb();
+ },
+ final(cb) {
+ memFiles.set(p, Buffer.concat(chunks));
+ cb();
+ }
+ });
+ return stream;
+ },
+
+ createReadStream(filePath) {
+ const p = resolve(filePath);
+ if (rootDir) {
+ return fs.createReadStream(p);
+ }
+ const { Readable } = require('node:stream');
+ const buf = memFiles.get(p) || Buffer.alloc(0);
+ return Readable.from(buf);
+ },
+
+ async readFileRange(filePath, { position = 0, length } = {}) {
+ const p = resolve(filePath);
+ let buf;
+ if (rootDir) {
+ const fh = await fs.promises.open(p, 'r');
+ try {
+ const size = length ?? (await fh.stat()).size - position;
+ buf = Buffer.alloc(Math.max(0, size));
+ await fh.read(buf, 0, buf.length, position);
+ } finally {
+ await fh.close();
+ }
+ } else {
+ const full = memFiles.get(p) || Buffer.alloc(0);
+ const len = length ?? full.length - position;
+ buf = full.subarray(position, position + Math.max(0, len));
+ }
+ return buf;
+ },
+
+ async readFile(filePath) {
+ const p = resolve(filePath);
+ if (rootDir) {
+ return fs.promises.readFile(p);
+ }
+ return memFiles.get(p) || Buffer.alloc(0);
+ },
+
+ async copyFile(src, dest) {
+ const s = resolve(src);
+ const d = resolve(dest);
+ if (rootDir) {
+ await fs.promises.copyFile(s, d);
+ return;
+ }
+ memFiles.set(d, Buffer.from(memFiles.get(s) || []));
+ },
+
+ async unlink(filePath) {
+ const p = resolve(filePath);
+ if (rootDir) {
+ await fs.promises.unlink(p);
+ return;
+ }
+ memFiles.delete(p);
+ },
+
+ existsSync(filePath) {
+ const p = resolve(filePath);
+ if (rootDir) {
+ return fs.existsSync(p);
+ }
+ return memFiles.has(p);
+ },
+
+ _memFiles: memFiles
+ };
+};
+
+module.exports = {
+ createMemoryFileSystem
+};
diff --git a/packages/bruno-electron/src/services/response-body/node-fs.js b/packages/bruno-electron/src/services/response-body/node-fs.js
new file mode 100644
index 00000000000..dd9fc23dd7b
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/node-fs.js
@@ -0,0 +1,61 @@
+const fs = require('node:fs');
+const fsp = require('node:fs/promises');
+const path = require('node:path');
+
+/**
+ * Real filesystem FileSystemPort for ResponseBodyStore.
+ */
+const createNodeFileSystem = () => ({
+ async mkdirp(dir) {
+ await fsp.mkdir(dir, { recursive: true });
+ },
+
+ async writeFile(filePath, data) {
+ await fsp.mkdir(path.dirname(filePath), { recursive: true });
+ await fsp.writeFile(filePath, data);
+ },
+
+ createWriteStream(filePath) {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ return fs.createWriteStream(filePath);
+ },
+
+ createReadStream(filePath) {
+ return fs.createReadStream(filePath);
+ },
+
+ async readFileRange(filePath, { position = 0, length } = {}) {
+ const fh = await fsp.open(filePath, 'r');
+ try {
+ const stat = await fh.stat();
+ const size = length == null ? Math.max(0, stat.size - position) : length;
+ const buf = Buffer.alloc(Math.max(0, size));
+ if (buf.length === 0) return buf;
+ const { bytesRead } = await fh.read(buf, 0, buf.length, position);
+ return bytesRead === buf.length ? buf : buf.subarray(0, bytesRead);
+ } finally {
+ await fh.close();
+ }
+ },
+
+ async readFile(filePath) {
+ return fsp.readFile(filePath);
+ },
+
+ async copyFile(src, dest) {
+ await fsp.mkdir(path.dirname(dest), { recursive: true });
+ await fsp.copyFile(src, dest);
+ },
+
+ async unlink(filePath) {
+ await fsp.unlink(filePath);
+ },
+
+ existsSync(filePath) {
+ return fs.existsSync(filePath);
+ }
+});
+
+module.exports = {
+ createNodeFileSystem
+};
diff --git a/packages/bruno-electron/src/services/response-body/paths.js b/packages/bruno-electron/src/services/response-body/paths.js
new file mode 100644
index 00000000000..3fde8140e0c
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/paths.js
@@ -0,0 +1,35 @@
+const path = require('node:path');
+const fs = require('node:fs');
+
+/**
+ * Own helpers for response-body spill dir. Do NOT reuse getTransient* from collection.js.
+ * @param {{ getUserDataPath?: () => string }} [deps]
+ */
+const getResponseBodiesDirectoryBase = (deps = {}) => {
+ const getUserDataPath
+ = deps.getUserDataPath
+ || (() => require('electron').app.getPath('userData'));
+ return path.join(getUserDataPath(), 'tmp', 'response-bodies');
+};
+
+
+const purgeResponseBodiesDirectory = (deps = {}) => {
+ const base = getResponseBodiesDirectoryBase(deps);
+ if (fs.existsSync(base)) {
+ fs.rmSync(base, { recursive: true, force: true });
+ }
+ fs.mkdirSync(base, { recursive: true });
+ return base;
+};
+
+const ensureResponseBodiesDirectory = (deps = {}) => {
+ const base = getResponseBodiesDirectoryBase(deps);
+ fs.mkdirSync(base, { recursive: true });
+ return base;
+};
+
+module.exports = {
+ getResponseBodiesDirectoryBase,
+ ensureResponseBodiesDirectory,
+ purgeResponseBodiesDirectory
+};
diff --git a/packages/bruno-electron/src/services/response-body/paths.spec.js b/packages/bruno-electron/src/services/response-body/paths.spec.js
new file mode 100644
index 00000000000..b201bf67ebf
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/paths.spec.js
@@ -0,0 +1,46 @@
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const {
+ getResponseBodiesDirectoryBase,
+ ensureResponseBodiesDirectory,
+ purgeResponseBodiesDirectory
+} = require('./paths');
+
+describe('response-body paths adapter', () => {
+ test('resolves under userData/tmp/response-bodies', () => {
+ expect(getResponseBodiesDirectoryBase({ getUserDataPath: () => '/mock-user-data' })).toBe(
+ path.join('/mock-user-data', 'tmp', 'response-bodies')
+ );
+ });
+
+ test('purgeResponseBodiesDirectory removes prior files and recreates empty dir', () => {
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-rb-purge-'));
+ const base = path.join(tmpRoot, 'tmp', 'response-bodies');
+ fs.mkdirSync(base, { recursive: true });
+ fs.writeFileSync(path.join(base, 'old-body'), 'stale');
+
+ const dir = purgeResponseBodiesDirectory({ getUserDataPath: () => tmpRoot });
+ expect(dir).toBe(base);
+ expect(fs.existsSync(dir)).toBe(true);
+ expect(fs.readdirSync(dir)).toEqual([]);
+
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ });
+
+ test('ensureResponseBodiesDirectory creates the directory', () => {
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-rb-paths-'));
+ const dir = ensureResponseBodiesDirectory({ getUserDataPath: () => tmpRoot });
+ expect(dir).toBe(path.join(tmpRoot, 'tmp', 'response-bodies'));
+ expect(fs.existsSync(dir)).toBe(true);
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
+ });
+
+ test('does not import collection transient helpers', () => {
+ const src = fs.readFileSync(require.resolve('./paths'), 'utf8');
+ expect(src).not.toMatch(/require\(['"].*collection/);
+ expect(src).not.toMatch(/\bgetTransientDirectory\b/);
+ expect(src).not.toMatch(/\bensureTransientDirectory\b/);
+ expect(src).not.toMatch(/\bgetTransientPath\b/);
+ });
+});
diff --git a/packages/bruno-electron/src/services/response-body/script-access.js b/packages/bruno-electron/src/services/response-body/script-access.js
new file mode 100644
index 00000000000..eb751185b37
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/script-access.js
@@ -0,0 +1,31 @@
+const { VIEW_MAX_BYTES } = require('./constants');
+const { BodyTooLargeForScriptError } = require('./errors');
+
+/**
+ * Fill response.data / dataBuffer for post-response scripts from the dual-writer RAM buffer.
+ * Bodies larger than VIEW_MAX_BYTES stay file-backed only: RAM is discarded and
+ * response.scriptBodyError is set so res.getBody() can throw a clear error.
+ */
+const populateResponseDataForScripts = (store, response, parseDataFromResponse, disableParsingResponseJson) => {
+ if (typeof response.size === 'number' && response.size > VIEW_MAX_BYTES) {
+ store.discardBuffer(response.bodyRef);
+ response.data = undefined;
+ response.dataBuffer = undefined;
+ response.scriptBodyError = new BodyTooLargeForScriptError(
+ response.bodyRef,
+ response.size,
+ VIEW_MAX_BYTES
+ ).message;
+ return;
+ }
+
+ const buffer = store.getBufferForScripts(response.bodyRef);
+ response.data = buffer;
+ const parsed = parseDataFromResponse(response, disableParsingResponseJson);
+ response.data = parsed.data;
+ response.dataBuffer = parsed.dataBuffer;
+};
+
+module.exports = {
+ populateResponseDataForScripts
+};
diff --git a/packages/bruno-electron/src/services/response-body/script-gate.spec.js b/packages/bruno-electron/src/services/response-body/script-gate.spec.js
new file mode 100644
index 00000000000..50f7fe9b933
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/script-gate.spec.js
@@ -0,0 +1,43 @@
+const { createResponseBodyStore } = require('./store');
+const { createMemoryFileSystem } = require('./memory-fs');
+const { VIEW_MAX_BYTES } = require('./constants');
+const { populateResponseDataForScripts } = require('./script-access');
+
+const parsePassthrough = (response) => ({
+ data: response.data?.toString?.('utf8') ?? response.data,
+ dataBuffer: Buffer.isBuffer(response.data) ? response.data.toString('base64') : ''
+});
+
+describe('script access (dual-writer)', () => {
+ test('bodies within VIEW_MAX remain script-accessible', async () => {
+ const store = createResponseBodyStore({
+ fs: createMemoryFileSystem(),
+ spillDir: '/spill',
+ idGen: () => 'ok-1'
+ });
+ const { bodyRef, size } = await store.putBuffer(Buffer.from('ok'));
+ const response = { bodyRef, size };
+ populateResponseDataForScripts(store, response, parsePassthrough);
+ expect(response.data).toBe('ok');
+ expect(response.scriptBodyError).toBeUndefined();
+ });
+
+ test('bodies larger than VIEW_MAX set scriptBodyError and discard RAM', async () => {
+ const store = createResponseBodyStore({
+ fs: createMemoryFileSystem(),
+ spillDir: '/spill',
+ idGen: () => 'big-1'
+ });
+ const { bodyRef, size } = await store.putBuffer(Buffer.alloc(VIEW_MAX_BYTES + 1, 0x61));
+ const response = { bodyRef, size };
+ populateResponseDataForScripts(store, response, parsePassthrough);
+
+ expect(response.data).toBeUndefined();
+ expect(response.dataBuffer).toBeUndefined();
+ expect(response.scriptBodyError).toMatch(/too large to use in scripts/);
+ expect(response.scriptBodyError).toMatch(String(VIEW_MAX_BYTES));
+ expect(() => store.getBufferForScripts(bodyRef)).toThrow(/discarded/);
+ // Spill file still readable for Download / View IPC paths that use readRange
+ expect((await store.readRange(bodyRef)).length).toBe(VIEW_MAX_BYTES + 1);
+ });
+});
diff --git a/packages/bruno-electron/src/services/response-body/store.js b/packages/bruno-electron/src/services/response-body/store.js
new file mode 100644
index 00000000000..78c3ff7aaf8
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/store.js
@@ -0,0 +1,203 @@
+const { randomUUID } = require('node:crypto');
+const path = require('node:path');
+const { BodyNotFoundError } = require('./errors');
+
+const defaultIdGen = () => randomUUID();
+
+const writeChunk = async (writeStream, buf) => {
+ if (!writeStream.write(buf)) {
+ await new Promise((resolve, reject) => {
+ writeStream.once('drain', resolve);
+ writeStream.once('error', reject);
+ });
+ }
+};
+
+/**
+ * Pure response-body store (no Electron).
+ * Every ingest writes to a spill file; RAM buffer is optional and may be discarded after request processing.
+ */
+const createResponseBodyStore = ({
+ fs,
+ spillDir,
+ idGen = defaultIdGen
+} = {}) => {
+ if (!fs) {
+ throw new Error('createResponseBodyStore requires a FileSystemPort');
+ }
+ if (!spillDir) {
+ throw new Error('createResponseBodyStore requires spillDir');
+ }
+
+ /** @type {Map} */
+ const entries = new Map();
+
+ const ensureSpillDir = async () => {
+ await fs.mkdirp(spillDir);
+ };
+
+ const filePathFor = (bodyRef) => path.join(spillDir, bodyRef);
+
+ const getEntry = (bodyRef) => {
+ const entry = entries.get(bodyRef);
+ if (!entry) {
+ throw new BodyNotFoundError(bodyRef);
+ }
+ return entry;
+ };
+
+ const destroyEntry = async (bodyRef) => {
+ const entry = entries.get(bodyRef);
+ if (!entry) return;
+ entries.delete(bodyRef);
+ if (entry.filePath && fs.existsSync(entry.filePath)) {
+ await fs.unlink(entry.filePath);
+ }
+ };
+
+ const finalizeEntry = (bodyRef, { buffer, filePath, size, contentType, headers }) => {
+ entries.set(bodyRef, {
+ filePath,
+ buffer,
+ size,
+ contentType,
+ headers
+ });
+ return {
+ bodyRef,
+ size,
+ contentType
+ };
+ };
+
+ /**
+ * Ingest a Node Readable via dual writers: file + in-memory response buffer.
+ */
+ const ingestStream = async (readable, { contentType, headers } = {}) => {
+ if (!readable) {
+ throw new Error('ingestStream requires a Readable stream');
+ }
+
+ const bodyRef = idGen();
+ const chunks = [];
+ let size = 0;
+ const destPath = filePathFor(bodyRef);
+
+ await ensureSpillDir();
+ const writeStream = fs.createWriteStream(destPath);
+
+ try {
+ for await (const chunk of readable) {
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ size += buf.length;
+ chunks.push(buf);
+ await writeChunk(writeStream, buf);
+ }
+
+ await new Promise((resolve, reject) => {
+ writeStream.end((err) => (err ? reject(err) : resolve()));
+ });
+
+ return finalizeEntry(bodyRef, {
+ buffer: Buffer.concat(chunks, size),
+ filePath: destPath,
+ size,
+ contentType,
+ headers
+ });
+ } catch (err) {
+ writeStream.destroy();
+ if (fs.existsSync(destPath)) {
+ try {
+ await fs.unlink(destPath);
+ } catch (_) {
+ /* ignore */
+ }
+ }
+ throw err;
+ }
+ };
+
+ const putBuffer = async (buffer, { contentType, headers } = {}) => {
+ const bodyRef = idGen();
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || []);
+ const size = buf.length;
+ const destPath = filePathFor(bodyRef);
+
+ await ensureSpillDir();
+ await fs.writeFile(destPath, buf);
+
+ return finalizeEntry(bodyRef, {
+ buffer: buf,
+ filePath: destPath,
+ size,
+ contentType,
+ headers
+ });
+ };
+
+ const getStat = (bodyRef) => {
+ const entry = getEntry(bodyRef);
+ return {
+ size: entry.size,
+ contentType: entry.contentType
+ };
+ };
+
+ const readRange = async (bodyRef, offset = 0, length) => {
+ const entry = getEntry(bodyRef);
+ const start = Math.max(0, offset | 0);
+ if (start >= entry.size) {
+ return Buffer.alloc(0);
+ }
+ const maxLen = entry.size - start;
+ const len = length == null ? maxLen : Math.min(Math.max(0, length | 0), maxLen);
+
+ if (entry.buffer) {
+ return entry.buffer.subarray(start, start + len);
+ }
+
+ return fs.readFileRange(entry.filePath, { position: start, length: len });
+ };
+
+ const getBufferForScripts = (bodyRef) => {
+ const entry = getEntry(bodyRef);
+ if (!entry.buffer) {
+ throw new Error(`Response body buffer discarded for ${bodyRef}`);
+ }
+ return entry.buffer;
+ };
+
+ const discardBuffer = (bodyRef) => {
+ const entry = entries.get(bodyRef);
+ if (!entry) return;
+ entry.buffer = null;
+ };
+
+ const saveToPath = async (bodyRef, destPath) => {
+ const entry = getEntry(bodyRef);
+ await fs.copyFile(entry.filePath, destPath);
+ };
+
+ const getFilePath = (bodyRef) => {
+ const entry = getEntry(bodyRef);
+ return entry.filePath || null;
+ };
+
+ return {
+ ingestStream,
+ putBuffer,
+ getStat,
+ readRange,
+ getBufferForScripts,
+ discardBuffer,
+ getFilePath,
+ saveToPath,
+ destroyEntry,
+ _entries: entries
+ };
+};
+
+module.exports = {
+ createResponseBodyStore
+};
diff --git a/packages/bruno-electron/src/services/response-body/store.spec.js b/packages/bruno-electron/src/services/response-body/store.spec.js
new file mode 100644
index 00000000000..02f65bb7277
--- /dev/null
+++ b/packages/bruno-electron/src/services/response-body/store.spec.js
@@ -0,0 +1,111 @@
+const { Readable } = require('node:stream');
+const path = require('node:path');
+const { createResponseBodyStore } = require('./store');
+const { createMemoryFileSystem } = require('./memory-fs');
+const { BodyNotFoundError } = require('./errors');
+
+describe('ResponseBodyStore', () => {
+ let idSeq;
+ let store;
+ let fs;
+ const spillDir = path.join(path.sep, 'spill');
+
+ beforeEach(() => {
+ idSeq = 0;
+ fs = createMemoryFileSystem();
+ store = createResponseBodyStore({
+ fs,
+ spillDir,
+ idGen: () => `body-${++idSeq}`
+ });
+ });
+
+ const streamFrom = (data) => Readable.from([Buffer.from(data)]);
+ const spillPath = (bodyRef) => path.join(spillDir, bodyRef);
+
+ test('dual-writes small bodies to memory and file', async () => {
+ const result = await store.ingestStream(streamFrom('hello world'), {
+ contentType: 'text/plain'
+ });
+
+ expect(result).toMatchObject({
+ bodyRef: 'body-1',
+ size: 11
+ });
+ expect(fs.existsSync(spillPath('body-1'))).toBe(true);
+ expect(store.getBufferForScripts(result.bodyRef)).toEqual(Buffer.from('hello world'));
+ expect(await store.readRange(result.bodyRef, 0, 5)).toEqual(Buffer.from('hello'));
+ expect(store.getFilePath(result.bodyRef)).toBe(spillPath('body-1'));
+ });
+
+ test('dual-writes large streams to memory and file', async () => {
+ const payload = 'x'.repeat(150);
+ const result = await store.ingestStream(streamFrom(payload));
+
+ expect(result.size).toBe(150);
+ expect(fs.existsSync(spillPath('body-1'))).toBe(true);
+ expect(store.getBufferForScripts(result.bodyRef)).toEqual(Buffer.from(payload));
+ expect(await store.readRange(result.bodyRef, 0, 10)).toEqual(Buffer.from('x'.repeat(10)));
+ expect(await store.readRange(result.bodyRef, 140, 20)).toEqual(Buffer.from('x'.repeat(10)));
+ });
+
+ test('putBuffer always writes file and keeps buffer', async () => {
+ const result = await store.putBuffer(Buffer.from('y'.repeat(120)));
+ expect(result.bodyRef).toBe('body-1');
+ expect(fs.existsSync(spillPath('body-1'))).toBe(true);
+ expect(store.getBufferForScripts(result.bodyRef)).toEqual(Buffer.from('y'.repeat(120)));
+ expect(await store.readRange(result.bodyRef)).toEqual(Buffer.from('y'.repeat(120)));
+ });
+
+ test('saveToPath copies from file', async () => {
+ const outMem = path.join(path.sep, 'out', 'mem.txt');
+ const outFile = path.join(path.sep, 'out', 'file.txt');
+ const mem = await store.putBuffer(Buffer.from('abc'));
+ await store.saveToPath(mem.bodyRef, outMem);
+ expect(await fs.readFile(outMem)).toEqual(Buffer.from('abc'));
+
+ const file = await store.putBuffer(Buffer.from('z'.repeat(150)));
+ await store.saveToPath(file.bodyRef, outFile);
+ expect(await fs.readFile(outFile)).toEqual(Buffer.from('z'.repeat(150)));
+ });
+
+ test('discardBuffer drops RAM but readRange still reads from file', async () => {
+ const { bodyRef } = await store.putBuffer(Buffer.from('spilled'));
+ store.discardBuffer(bodyRef);
+ expect(() => store.getBufferForScripts(bodyRef)).toThrow(/discarded/);
+ expect(store.getStat(bodyRef).size).toBe(7);
+ expect(await store.readRange(bodyRef)).toEqual(Buffer.from('spilled'));
+ });
+
+ test('scripts can read dual-written bodies of any size', async () => {
+ const { bodyRef } = await store.putBuffer(Buffer.from('z'.repeat(150)));
+ expect(store.getBufferForScripts(bodyRef)).toEqual(Buffer.from('z'.repeat(150)));
+ });
+
+ test('missing bodyRef throws BodyNotFoundError', async () => {
+ expect(() => store.getStat('missing')).toThrow(BodyNotFoundError);
+ });
+
+ test('readRange at EOF returns empty buffer', async () => {
+ const { bodyRef } = await store.putBuffer(Buffer.from('abc'));
+ expect(await store.readRange(bodyRef, 10, 5)).toEqual(Buffer.alloc(0));
+ });
+
+ test('ingestStream cleans up partial file on mid-stream error', async () => {
+ let chunkCount = 0;
+ const failing = new Readable({
+ read() {
+ chunkCount += 1;
+ if (chunkCount === 1) {
+ this.push(Buffer.from('x'.repeat(120)));
+ return;
+ }
+ this.destroy(new Error('boom'));
+ }
+ });
+
+ await expect(store.ingestStream(failing)).rejects.toThrow('boom');
+ expect(fs.existsSync(spillPath('body-1'))).toBe(false);
+ expect(() => store.getStat('body-1')).toThrow(BodyNotFoundError);
+ });
+});
diff --git a/packages/bruno-electron/src/utils/response-save-filename.js b/packages/bruno-electron/src/utils/response-save-filename.js
new file mode 100644
index 00000000000..e2ed4df1f15
--- /dev/null
+++ b/packages/bruno-electron/src/utils/response-save-filename.js
@@ -0,0 +1,48 @@
+const path = require('node:path');
+const contentDispositionParser = require('content-disposition');
+const mime = require('mime-types');
+
+const getHeaderValue = (headers, headerName) => {
+ const headersObj = headers && typeof headers === 'object' ? headers : {};
+ const entry = Object.entries(headersObj).find(([name]) => name === headerName);
+ return entry ? entry[1] : undefined;
+};
+
+/**
+ * Resolve a default download filename from response headers / request URL.
+ */
+const resolveResponseSaveFilename = ({ headers, url } = {}) => {
+ try {
+ const disposition = contentDispositionParser.parse(getHeaderValue(headers, 'content-disposition'));
+ if (disposition?.parameters?.filename) {
+ return disposition.parameters.filename;
+ }
+ } catch (_) {
+ /* ignore */
+ }
+
+ try {
+ const lastPathLevel = new URL(url).pathname.split('/').pop();
+ if (lastPathLevel && /\..+/.exec(lastPathLevel)) {
+ return lastPathLevel;
+ }
+ } catch (_) {
+ /* ignore */
+ }
+
+ const contentType = getHeaderValue(headers, 'content-type');
+ const extension = (contentType && mime.extension(contentType)) || 'txt';
+ return `response.${extension}`;
+};
+
+const resolveResponseSaveDefaultPath = ({ headers, url, pathname } = {}) => {
+ const fileName = resolveResponseSaveFilename({ headers, url });
+ if (!pathname) return fileName;
+ return path.join(path.dirname(pathname), fileName);
+};
+
+module.exports = {
+ getHeaderValue,
+ resolveResponseSaveFilename,
+ resolveResponseSaveDefaultPath
+};
diff --git a/packages/bruno-electron/src/utils/response-save-filename.spec.js b/packages/bruno-electron/src/utils/response-save-filename.spec.js
new file mode 100644
index 00000000000..7d352e5edee
--- /dev/null
+++ b/packages/bruno-electron/src/utils/response-save-filename.spec.js
@@ -0,0 +1,41 @@
+const {
+ resolveResponseSaveFilename,
+ resolveResponseSaveDefaultPath
+} = require('./response-save-filename');
+
+describe('resolveResponseSaveFilename', () => {
+ test('prefers content-disposition filename', () => {
+ expect(
+ resolveResponseSaveFilename({
+ headers: { 'content-disposition': 'attachment; filename="report.json"' },
+ url: 'https://example.com/ignored.bin'
+ })
+ ).toBe('report.json');
+ });
+
+ test('falls back to url path then content-type', () => {
+ expect(
+ resolveResponseSaveFilename({
+ headers: {},
+ url: 'https://example.com/files/data.csv'
+ })
+ ).toBe('data.csv');
+
+ expect(
+ resolveResponseSaveFilename({
+ headers: { 'content-type': 'application/json' },
+ url: 'https://example.com/api'
+ })
+ ).toBe('response.json');
+ });
+
+ test('resolveResponseSaveDefaultPath joins pathname directory', () => {
+ expect(
+ resolveResponseSaveDefaultPath({
+ headers: { 'content-type': 'text/plain' },
+ url: 'https://example.com/x',
+ pathname: '/collections/demo/req.bru'
+ })
+ ).toMatch(/response\.txt$/);
+ });
+});
diff --git a/packages/bruno-js/src/bruno-response.js b/packages/bruno-js/src/bruno-response.js
index 7757066d16e..08e44af8648 100644
--- a/packages/bruno-js/src/bruno-response.js
+++ b/packages/bruno-js/src/bruno-response.js
@@ -43,6 +43,9 @@ class BrunoResponse {
}
getBody() {
+ if (this.res?.scriptBodyError) {
+ throw new Error(this.res.scriptBodyError);
+ }
return this.res ? this.res.data : null;
}
@@ -118,6 +121,9 @@ class BrunoResponse {
}
getDataBuffer() {
+ if (this.res?.scriptBodyError) {
+ throw new Error(this.res.scriptBodyError);
+ }
return this.res ? this.res.dataBuffer : null;
}
}
diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js b/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js
index d2c807f02fa..9c8fb8017eb 100644
--- a/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js
+++ b/packages/bruno-js/src/sandbox/quickjs/shims/bruno-response.js
@@ -97,7 +97,11 @@ const addBrunoResponseShimToContext = (vm, res) => {
getHeaders.dispose();
let getBody = vm.newFunction('getBody', function () {
- return marshallToVm(res.getBody(), vm);
+ try {
+ return marshallToVm(res.getBody(), vm);
+ } catch (error) {
+ throw vm.newError(error.message || String(error));
+ }
});
vm.setProp(resFn, 'getBody', getBody);
getBody.dispose();
diff --git a/packages/bruno-js/tests/bruno-response-get-body.spec.js b/packages/bruno-js/tests/bruno-response-get-body.spec.js
new file mode 100644
index 00000000000..f8537f2652c
--- /dev/null
+++ b/packages/bruno-js/tests/bruno-response-get-body.spec.js
@@ -0,0 +1,37 @@
+const BrunoResponse = require('../src/bruno-response');
+
+describe('BrunoResponse getBody script size gate', () => {
+ it('returns data when scriptBodyError is absent', () => {
+ const res = new BrunoResponse({
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ data: { ok: true }
+ });
+ expect(res.getBody()).toEqual({ ok: true });
+ });
+
+ it('throws scriptBodyError from getBody', () => {
+ const message = 'Response body is too large to use in scripts (52428801 bytes; max 52428800). Download the response instead.';
+ const res = new BrunoResponse({
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ data: undefined,
+ scriptBodyError: message
+ });
+ expect(() => res.getBody()).toThrow(message);
+ });
+
+ it('throws scriptBodyError from getDataBuffer', () => {
+ const message = 'Response body is too large to use in scripts';
+ const res = new BrunoResponse({
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ data: undefined,
+ scriptBodyError: message
+ });
+ expect(() => res.getDataBuffer()).toThrow(message);
+ });
+});
diff --git a/packages/bruno-requests/src/scripting/scripted-entry.spec.ts b/packages/bruno-requests/src/scripting/scripted-entry.spec.ts
index c0959805d4d..3deaf88dcaa 100644
--- a/packages/bruno-requests/src/scripting/scripted-entry.spec.ts
+++ b/packages/bruno-requests/src/scripting/scripted-entry.spec.ts
@@ -62,7 +62,7 @@ describe('buildScriptedEntry', () => {
expect(entry.response?.headers).toEqual({ 'content-type': 'application/json', 'x-trace': 'abc' });
});
- test('encodes string body to base64 dataBuffer and derives size/duration when not supplied', () => {
+ test('derives size/duration from data when not supplied (no dataBuffer)', () => {
const entry = buildScriptedEntry({
request: { method: 'GET', url: 'https://example.com' },
response: { status: 200, statusText: 'OK', headers: {}, data: 'hello' },
@@ -71,12 +71,14 @@ describe('buildScriptedEntry', () => {
completedAt: 15
});
- expect(entry.response?.dataBuffer).toBe(Buffer.from('hello').toString('base64'));
+ expect(entry.response?.dataBuffer).toBeUndefined();
+ expect(entry.response?.data).toBe('hello');
+ expect(entry.response?.bodyRef).toBeNull();
expect(entry.response?.size).toBe(Buffer.from('hello').length);
expect(entry.response?.duration).toBe(10);
});
- test('JSON-stringifies object body for dataBuffer when not provided', () => {
+ test('JSON object body keeps data and derives size without dataBuffer', () => {
const body = { foo: 'bar' };
const entry = buildScriptedEntry({
request: { method: 'GET', url: 'https://example.com' },
@@ -86,19 +88,20 @@ describe('buildScriptedEntry', () => {
completedAt: 0
});
- expect(entry.response?.dataBuffer).toBe(Buffer.from(JSON.stringify(body)).toString('base64'));
+ expect(entry.response?.dataBuffer).toBeUndefined();
+ expect(entry.response?.data).toEqual(body);
+ expect(entry.response?.size).toBe(Buffer.byteLength(JSON.stringify(body)));
});
- test('honors explicit dataBuffer / size / duration on response', () => {
- const explicitBuffer = Buffer.from('payload').toString('base64');
+ test('honors bodyRef / size / duration on response and never embeds dataBuffer', () => {
const entry = buildScriptedEntry({
request: { method: 'GET', url: 'https://example.com' },
response: {
status: 200,
statusText: 'OK',
headers: {},
- data: 'ignored-for-size',
- dataBuffer: explicitBuffer,
+ data: undefined,
+ bodyRef: 'body-abc',
size: 999,
duration: 123
},
@@ -107,7 +110,8 @@ describe('buildScriptedEntry', () => {
completedAt: 50
});
- expect(entry.response?.dataBuffer).toBe(explicitBuffer);
+ expect(entry.response?.dataBuffer).toBeUndefined();
+ expect(entry.response?.bodyRef).toBe('body-abc');
expect(entry.response?.size).toBe(999);
expect(entry.response?.duration).toBe(123);
});
diff --git a/packages/bruno-requests/src/scripting/send-request.ts b/packages/bruno-requests/src/scripting/send-request.ts
index 55fcc145d4b..ed8a074eac8 100644
--- a/packages/bruno-requests/src/scripting/send-request.ts
+++ b/packages/bruno-requests/src/scripting/send-request.ts
@@ -20,7 +20,7 @@ type SendRequestEntry = {
statusText: string;
headers: Record;
data: any;
- dataBuffer: string;
+ bodyRef: string | null;
size: number;
duration: number;
timeline: any[] | null;
@@ -42,7 +42,7 @@ type ScriptedEntryResponseInput = {
statusText?: string;
headers?: any;
data?: any;
- dataBuffer?: string;
+ bodyRef?: string | null;
size?: number;
duration?: number;
timeline?: any[];
@@ -68,20 +68,20 @@ const toPlainHeaders = (headers: any): Record => {
return out;
};
-// Build dataBuffer eagerly so the Timeline's CodeMirror can size itself on mount.
-const toResponseDataBuffer = (data: any): string => {
+const deriveSizeFromData = (data: any): number => {
try {
- if (data === null || data === undefined) return '';
- if (typeof data === 'string') return Buffer.from(data).toString('base64');
- if (Buffer.isBuffer(data)) return data.toString('base64');
- if (data instanceof ArrayBuffer) return Buffer.from(new Uint8Array(data)).toString('base64');
- return Buffer.from(JSON.stringify(data)).toString('base64');
+ if (data === null || data === undefined) return 0;
+ if (typeof data === 'string') return Buffer.byteLength(data);
+ if (Buffer.isBuffer(data)) return data.length;
+ if (data instanceof ArrayBuffer) return data.byteLength;
+ return Buffer.byteLength(JSON.stringify(data));
} catch (_) {
- return '';
+ return 0;
}
};
// Shared with bruno-electron's runRequest so both produce identical entries.
+// Never embed dataBuffer — use bodyRef for store-backed bodies, otherwise inline `data` only.
const buildScriptedEntry = ({
request,
response,
@@ -91,16 +91,16 @@ const buildScriptedEntry = ({
}: BuildScriptedEntryArgs): SendRequestEntry => {
let respPayload: SendRequestEntry['response'] = null;
if (response) {
- const dataBuffer = response.dataBuffer ?? toResponseDataBuffer(response.data);
+ const bodyRef = response.bodyRef || null;
respPayload = {
statusCode: typeof response.status === 'number' ? response.status : 0,
statusText: response.statusText ?? '',
headers: toPlainHeaders(response.headers),
data: response.data,
- dataBuffer,
+ bodyRef,
size: typeof response.size === 'number'
? response.size
- : (dataBuffer ? Buffer.from(dataBuffer, 'base64').length : 0),
+ : deriveSizeFromData(response.data),
duration: typeof response.duration === 'number'
? response.duration
: (completedAt - startedAt),
diff --git a/packages/bruno-tests/src/large-payload/index.js b/packages/bruno-tests/src/large-payload/index.js
index 4b7e928dd55..ccf8facb448 100644
--- a/packages/bruno-tests/src/large-payload/index.js
+++ b/packages/bruno-tests/src/large-payload/index.js
@@ -3,16 +3,64 @@ const router = express.Router();
const FILLER = 'bruno large payload ';
const DEFAULT_SIZE = 1024 * 1024;
-const MAX_SIZE = 64 * 1024 * 1024;
+const MAX_SIZE = 128 * 1024 * 1024;
-// Responds with `?size` bytes of plain text (default 1 MB, clamped to 0-64 MB).
-// Useful for exercising large-response handling in the app and the sandboxes.
+const clampSize = (requested) => {
+ const n = parseInt(requested, 10);
+ return Math.min(Math.max(Number.isNaN(n) ? DEFAULT_SIZE : n, 0), MAX_SIZE);
+};
+
+const textPayload = (size) => FILLER.repeat(Math.ceil(size / FILLER.length)).slice(0, size);
+
+// Responds with `?size` bytes of plain text (default 1 MB, clamped to 0-128 MB).
router.get('/', (req, res) => {
- const requested = parseInt(req.query.size, 10);
- const size = Math.min(Math.max(Number.isNaN(requested) ? DEFAULT_SIZE : requested, 0), MAX_SIZE);
+ const size = clampSize(req.query.size);
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
+ res.send(textPayload(size));
+});
+
+// JSON body of approximately `?size` bytes.
+router.get('/json', (req, res) => {
+ const size = clampSize(req.query.size);
+ const prefix = '{"ok":true,"payload":"';
+ const suffix = '"}';
+ const inner = Math.max(0, size - prefix.length - suffix.length);
+ res.setHeader('content-type', 'application/json; charset=utf-8');
+ res.send(prefix + 'x'.repeat(inner) + suffix);
+});
+
+// Deterministic binary (application/octet-stream) for protocol / download tests.
+router.get('/bytes', (req, res) => {
+ const size = clampSize(req.query.size);
+ const buf = Buffer.alloc(size, 0xab);
+ res.setHeader('content-type', 'application/octet-stream');
+ res.setHeader('content-length', String(size));
+ res.end(buf);
+});
+
+// Minimal valid-ish PDF header + padding for media protocol preview.
+router.get('/pdf', (req, res) => {
+ const size = clampSize(req.query.size || 1024);
+ const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n');
+ const body = Buffer.alloc(Math.max(0, size - header.length), 0x20);
+ const buf = Buffer.concat([header, body]);
+ res.setHeader('content-type', 'application/pdf');
+ res.setHeader('content-length', String(buf.length));
+ res.end(buf);
+});
- res.setHeader('content-type', 'text/plain');
- res.send(FILLER.repeat(Math.ceil(size / FILLER.length)).slice(0, size));
+// Tiny PNG (1x1) — optional size pads trailing bytes (invalid PNG but fine for protocol smoke).
+router.get('/png', (req, res) => {
+ // 1x1 red PNG
+ const png = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
+ 'base64'
+ );
+ const size = clampSize(req.query.size || png.length);
+ const buf = size <= png.length ? png : Buffer.concat([png, Buffer.alloc(size - png.length, 0)]);
+ res.setHeader('content-type', 'image/png');
+ res.setHeader('content-length', String(buf.length));
+ res.end(buf);
});
module.exports = router;
diff --git a/tests/response/large-response-crash-prevention.spec.ts b/tests/response/large-response-crash-prevention.spec.ts
index ccb03e9bb7d..b0a825008b3 100644
--- a/tests/response/large-response-crash-prevention.spec.ts
+++ b/tests/response/large-response-crash-prevention.spec.ts
@@ -1,40 +1,60 @@
import { test, expect } from '../../playwright';
-import { closeAllCollections, createCollection, createRequest } from '../utils/page/actions';
+import { closeAllCollections, createCollection, createRequest, sendRequest } from '../utils/page/actions';
-test.describe('Large Response Crash/High Memory Usage Prevention', () => {
- // Increase timeout to 1 minute for all tests in this describe block, default is 30 seconds.
- // Prevents tests from failing due to timeout while waiting for the response, especially on slower internet connections.
- test.setTimeout(1 * 60 * 1000); // 1 minute
+const UNDER_10MB_URL = 'http://localhost:8081/api/large-payload?size=5242880'; // 5 MB
+const BETWEEN_10_50MB_URL = 'http://localhost:8081/api/large-payload?size=15728640'; // 15 MB
+const OVER_50MB_URL = 'http://localhost:8081/api/large-payload?size=57671680'; // ~55 MB
+
+test.describe('Large response handling', () => {
+ test.setTimeout(3 * 60 * 1000);
test.afterAll(async ({ page }) => {
- // cleanup: close all collections
await closeAllCollections(page);
});
- test('Show appropriate warning for responses over 10MB', async ({ page, createTmpDir }) => {
- const collectionName = 'size-warning-test';
- const requestName = 'large-response';
+ test('under 10MB text shows in response pane without Large Response Warning', async ({ page, createTmpDir }) => {
+ const collectionName = 'large-under-show';
+ await createCollection(page, collectionName, await createTmpDir(collectionName));
+ await createRequest(page, 'small-large', collectionName, { url: UNDER_10MB_URL });
+
+ await sendRequest(page, 200);
+
+ await expect(page.getByText('Large Response Warning')).toHaveCount(0);
+ await expect(page.getByTestId('response-preview-container')).toBeVisible({ timeout: 90000 });
+ await expect(page.getByTestId('response-preview-container')).toContainText('bruno large payload', { timeout: 90000 });
+ });
- // Create collection (auto-opens the collection)
+ test('10–50MB text shows Large Response Warning with View and Download', async ({ page, createTmpDir }) => {
+ const collectionName = 'large-viewable';
await createCollection(page, collectionName, await createTmpDir(collectionName));
+ await createRequest(page, 'mid-large', collectionName, { url: BETWEEN_10_50MB_URL });
+
+ await sendRequest(page, 200);
- // Create request using the dialog/modal flow
- await createRequest(page, requestName, collectionName, {
- url: 'https://samples.json-format.com/employees/json/employees_50MB.json'
- });
+ await expect(page.getByText('Large Response Warning')).toBeVisible({ timeout: 120000 });
+ const viewBtn = page.getByRole('button', { name: /^View$/i });
+ await expect(viewBtn).toBeVisible();
+ await expect(viewBtn).toBeEnabled();
+ await expect(page.getByTestId('large-response-download-btn')).toBeVisible();
+ await expect(page.getByTestId('response-preview-container')).toHaveCount(0);
- // Send request
- const sendButton = page.getByTestId('send-arrow-icon');
- await sendButton.click();
+ await viewBtn.click();
+ await expect(page.getByTestId('response-preview-container')).toBeVisible({ timeout: 90000 });
+ await expect(page.getByTestId('response-preview-container')).toContainText('bruno large payload', { timeout: 90000 });
+ });
- // Verify warning appears
- await expect(page.getByText('Large Response Warning')).toBeVisible({ timeout: 60000 });
+ test('over 50MB text shows Large Response Warning with Download only', async ({ page, createTmpDir }) => {
+ const collectionName = 'large-too-big';
+ await createCollection(page, collectionName, await createTmpDir(collectionName));
+ await createRequest(page, 'huge-text', collectionName, { url: OVER_50MB_URL });
- // Verify warning content
- await expect(page.getByText('Handling responses over')).toBeVisible();
- await expect(page.getByText('could degrade performance')).toBeVisible();
+ await sendRequest(page, 200);
- // Verify action button
- await expect(page.getByRole('button', { name: 'View', exact: true })).toBeVisible();
+ await expect(page.getByText('Large Response Warning')).toBeVisible({ timeout: 120000 });
+ await expect(page.getByTestId('large-response-download-btn')).toBeVisible();
+ const viewBtn = page.getByRole('button', { name: /^View$/i });
+ await expect(viewBtn).toBeVisible();
+ await expect(viewBtn).toBeDisabled();
+ await expect(page.getByTestId('response-preview-container')).toHaveCount(0);
});
});
diff --git a/tests/response/large-response-media.spec.ts b/tests/response/large-response-media.spec.ts
new file mode 100644
index 00000000000..141791a8d96
--- /dev/null
+++ b/tests/response/large-response-media.spec.ts
@@ -0,0 +1,25 @@
+import { test, expect } from '../../playwright';
+import { closeAllCollections, createCollection, createRequest, sendRequest } from '../utils/page/actions';
+
+test.describe('Large response media preview', () => {
+ test.setTimeout(2 * 60 * 1000);
+
+ test.afterAll(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test('PNG preview uses protocol path without Large Response Warning', async ({ page, createTmpDir }) => {
+ const collectionName = 'large-media-png';
+ await createCollection(page, collectionName, await createTmpDir(collectionName));
+ await createRequest(page, 'png', collectionName, {
+ url: 'http://localhost:8081/api/large-payload/png'
+ });
+
+ await sendRequest(page, 200);
+
+ await expect(page.getByText('Large Response Warning')).toHaveCount(0);
+ await expect(page.getByTestId('response-preview-container')).toBeVisible({ timeout: 60000 });
+ // Image preview should render an img (protocol or data URL)
+ await expect(page.getByTestId('response-preview-container').locator('img')).toBeVisible({ timeout: 60000 });
+ });
+});