fix: add size and duration fields to CLI bru.runRequest() response (#7429)

* fix: add size and duration fields to CLI bru.runRequest() response

Add `size` and `duration` fields to the response object in CLI to match
GUI behavior, ensuring consistent API for bru.runRequest() across both
environments.

- `duration` is an alias for `responseTime` for GUI compatibility
- `size` is the byte length of the response buffer (0 for errors/skipped)

Fixes #7352

* fix: address PR review feedback for CLI response consistency

- Coerce responseTime header to number (was string from headers.get())
- Add comment explaining duration vs responseTime difference between
  GUI (wall-clock) and CLI (approximation using responseTime)
- Add integration tests for duration/size fields across skipped,
  success, and network error response paths

* fix: add missing setupProxyAgents mock in response-fields test

The success path calls setupProxyAgents which was missing from the
proxy-util mock, causing CI failure.

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
This commit is contained in:
Chirag Chandrashekhar
2026-04-02 17:55:42 +05:30
committed by GitHub
parent 7ddd2d3f17
commit 765c9f1060
2 changed files with 214 additions and 7 deletions

View File

@@ -161,7 +161,9 @@ const runSingleRequest = async function (
status: 'skipped',
statusText: errorMsg,
data: null,
responseTime: 0
responseTime: 0,
duration: 0,
size: 0
},
error: null,
status: 'skipped',
@@ -256,7 +258,9 @@ const runSingleRequest = async function (
status: 'skipped',
statusText: 'request skipped via pre-request script',
data: null,
responseTime: 0
responseTime: 0,
duration: 0,
size: 0
},
error: null,
status: 'skipped',
@@ -307,7 +311,9 @@ const runSingleRequest = async function (
headers: null,
data: null,
url: null,
responseTime: 0
responseTime: 0,
duration: 0,
size: 0
},
error: error?.message || 'An error occurred while executing the pre-request script.',
status: 'error',
@@ -667,7 +673,7 @@ const runSingleRequest = async function (
response.dataBuffer = dataBuffer;
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
responseTime = Number(response.headers.get('request-duration')) || 0;
response.headers.delete('request-duration');
// save cookies if enabled
@@ -707,7 +713,9 @@ const runSingleRequest = async function (
headers: null,
data: null,
url: null,
responseTime: 0
responseTime: 0,
duration: 0,
size: 0
},
error: err?.message || err?.errors?.map((e) => e?.message)?.at(0) || err?.code || 'Request Failed!',
status: 'error',
@@ -902,7 +910,11 @@ const runSingleRequest = async function (
headers: response.headers,
data: response.data,
url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null,
responseTime
responseTime,
// In the GUI, duration is wall-clock time (timeEnd - timeStart).
// In the CLI we use responseTime as a close approximation.
duration: responseTime,
size: response.dataBuffer ? Buffer.byteLength(response.dataBuffer) : 0
},
error: null,
status: 'pass',
@@ -931,7 +943,9 @@ const runSingleRequest = async function (
headers: null,
data: null,
url: null,
responseTime: 0
responseTime: 0,
duration: 0,
size: 0
},
status: 'error',
error: err.message,

View File

@@ -0,0 +1,193 @@
const { describe, it, expect, beforeEach } = require('@jest/globals');
// Mock all heavy dependencies before requiring the module
jest.mock('../../src/runner/prepare-request', () => jest.fn());
jest.mock('../../src/runner/interpolate-vars', () => jest.fn());
jest.mock('../../src/runner/interpolate-string', () => ({
interpolateString: jest.fn((s) => s),
interpolateObject: jest.fn((o) => o)
}));
jest.mock('@usebruno/js', () => ({
ScriptRuntime: jest.fn(),
TestRuntime: jest.fn(),
VarsRuntime: jest.fn(),
AssertRuntime: jest.fn(),
formatErrorWithContext: jest.fn(),
SCRIPT_TYPES: { PRE_REQUEST: 'pre-request', POST_RESPONSE: 'post-response', TEST: 'test' }
}));
jest.mock('../../src/utils/filesystem', () => ({
stripExtension: (p) => p.replace(/\.\w+$/, ''),
getOptions: jest.fn(() => ({}))
}));
jest.mock('../../src/utils/bru', () => ({
getOptions: jest.fn(() => ({}))
}));
jest.mock('../../src/utils/axios-instance', () => ({
makeAxiosInstance: jest.fn()
}));
jest.mock('../../src/runner/awsv4auth-helper', () => ({
addAwsV4Interceptor: jest.fn(),
resolveAwsV4Credentials: jest.fn()
}));
jest.mock('../../src/utils/proxy-util', () => ({
shouldUseProxy: jest.fn(() => false),
setupProxyAgents: jest.fn(),
PatchedHttpsProxyAgent: jest.fn()
}));
jest.mock('../../src/utils/common', () => ({
parseDataFromResponse: jest.fn((res) => ({
data: res.data,
dataBuffer: Buffer.from(JSON.stringify(res.data || ''))
}))
}));
jest.mock('../../src/utils/cookies', () => ({
getCookieStringForUrl: jest.fn(() => ''),
saveCookies: jest.fn()
}));
jest.mock('../../src/utils/form-data', () => ({
createFormData: jest.fn()
}));
jest.mock('@usebruno/requests', () => ({
addDigestInterceptor: jest.fn(),
getHttpHttpsAgents: jest.fn(() => ({})),
makeAxiosInstance: jest.fn(),
getCACertificates: jest.fn(() => ({ caCertificates: [] })),
transformProxyConfig: jest.fn(() => ({})),
getOrCreateHttpsAgent: jest.fn(() => ({})),
getOrCreateHttpAgent: jest.fn(() => ({}))
}));
jest.mock('../../src/utils/oauth2', () => ({
getOAuth2Token: jest.fn(),
getFormattedOauth2Credentials: jest.fn()
}));
jest.mock('../../src/store/tokenStore', () => ({
getAll: jest.fn(() => ({})),
put: jest.fn(),
clearAll: jest.fn()
}));
// Default: no prompt variables detected
const mockExtractPromptVariables = jest.fn(() => []);
jest.mock('@usebruno/common', () => ({
utils: {
encodeUrl: jest.fn((u) => u),
buildFormUrlEncodedPayload: jest.fn(),
extractPromptVariables: mockExtractPromptVariables,
isFormData: jest.fn(() => false)
}
}));
const prepareRequest = require('../../src/runner/prepare-request');
const { makeAxiosInstance } = require('../../src/utils/axios-instance');
const { runSingleRequest } = require('../../src/runner/run-single-request');
const baseItem = {
pathname: '/test-collection/request.bru',
request: {
method: 'GET',
url: 'http://example.com/api',
headers: [],
body: { mode: 'none' },
auth: { mode: 'none' },
vars: {},
script: {},
tests: ''
}
};
const baseArgs = [
baseItem, // item
'/test-collection', // collectionPath
{}, // runtimeVariables
{}, // envVariables
{}, // processEnvVars
{}, // brunoConfig
{}, // collectionRoot
'vm2', // runtime
{ items: [], pathname: '/test-collection' }, // collection
jest.fn(), // runSingleRequestByPathname
{} // globalEnvVars
];
describe('runSingleRequest: duration and size fields (issue #7352)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return duration=0 and size=0 when request is skipped due to prompt variables', async () => {
prepareRequest.mockResolvedValue({
method: 'GET',
url: 'http://example.com/api/test',
headers: {},
data: null
});
// Simulate prompt variable detection
mockExtractPromptVariables.mockReturnValueOnce(['prompt_var']);
const result = await runSingleRequest(...baseArgs);
expect(result.status).toBe('skipped');
expect(result.response.duration).toBe(0);
expect(result.response.size).toBe(0);
expect(result.response.responseTime).toBe(0);
expect(typeof result.response.duration).toBe('number');
expect(typeof result.response.size).toBe('number');
});
it('should return numeric duration and size on successful request', async () => {
const responseBody = JSON.stringify({ message: 'ok' });
const mockHeaders = new Map([['request-duration', '253']]);
mockHeaders.delete = function (key) { this.delete(key); };
// Use a plain object with get/delete to simulate axios headers
const headers = {
get: (key) => key === 'request-duration' ? '253' : null,
delete: jest.fn()
};
prepareRequest.mockResolvedValue({
method: 'GET',
url: 'http://example.com/api',
headers: {},
data: null,
settings: {}
});
const mockAxios = jest.fn().mockResolvedValue({
status: 200,
statusText: 'OK',
headers,
data: responseBody,
request: { protocol: 'http:', host: 'example.com', path: '/api' }
});
makeAxiosInstance.mockReturnValue(mockAxios);
const result = await runSingleRequest(...baseArgs);
expect(result.status).toBe('pass');
expect(result.response.responseTime).toBe(253);
expect(result.response.duration).toBe(253);
expect(typeof result.response.duration).toBe('number');
expect(typeof result.response.size).toBe('number');
expect(result.response.size).toBeGreaterThan(0);
});
it('should return duration=0 and size=0 on network error', async () => {
prepareRequest.mockResolvedValue({
method: 'GET',
url: 'http://example.com/api',
headers: {},
data: null,
settings: {}
});
const mockAxios = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
makeAxiosInstance.mockReturnValue(mockAxios);
const result = await runSingleRequest(...baseArgs);
expect(result.status).toBe('error');
expect(result.response.duration).toBe(0);
expect(result.response.size).toBe(0);
expect(result.response.responseTime).toBe(0);
});
});