Compare commits

...

13 Commits

Author SHA1 Message Date
shubh-bruno
77916019cd fix: status & statusText swap (#7589)
* fix: status & statusText swap

* chore: typo

* test: tests for swapping status and statusText

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-02 21:01:10 +05:30
Pooja
02aa669578 fix: convert non-string variable values to strings during postman import (#7476) 2026-04-02 21:00:39 +05:30
sanish chirayath
d8809e09e7 Fix: ensure string authvalues, string header processing (#7646)
* feat: add helper to ensure string conversion for non-string values in Postman to Bruno conversion

- Introduced `ensureString` function to convert numeric and non-string values to strings, defaulting null/undefined to an empty string.
- Updated request handling in `importPostmanV2CollectionItem` to utilize `ensureString` for headers, parameters, and body fields.
- Added tests to verify correct conversion of numeric values to strings in headers, query parameters, and body fields.

* test: add test for numeric value conversion in Postman to Bruno transformation

- Implemented a new test case to verify that numeric values in example request and response fields are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks various components including request headers, query parameters, path parameters, and body fields to ensure proper string conversion.

* test: add multipart form value test for numeric conversion in Postman to Bruno transformation

- Added a new test case to verify that numeric values in multipart form data are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks the conversion of numeric values in the request body to ensure proper handling in the transformation.

* feat: enhance header parsing in Postman to Bruno conversion

- Added `parseStringHeader` and `normalizeHeaders` functions to handle various header formats, including string headers and concatenated strings.
- Updated the request and response handling in `importPostmanV2CollectionItem` to utilize the new header normalization logic.
- Introduced tests to verify correct parsing of string headers, including cases with no values and concatenated headers.

* refactor: enhance ensureString function for flexible fallback values

- Updated the `ensureString` function to accept a fallback parameter, allowing for customizable default values instead of a fixed empty string for null/undefined inputs.
- Modified the usage of `ensureString` in the `processAuth` function to utilize the new fallback feature for various authentication fields, improving the handling of optional values.

* refactor: update ensureString function to handle empty values

- Modified the `ensureString` function to return the fallback for null, undefined, or empty string values, enhancing its flexibility in handling various input scenarios.

* chore: update ESLint configuration and enhance Postman to Bruno conversion tests

- Added 'no-case-declarations' rule to ESLint configuration to enforce stricter coding standards.
- Modified the `processAuth` function to ensure proper block scoping for OAuth2 case handling.
- Improved header parsing logic to check for string type in content-type header.
- Added new tests to verify conversion of numeric authentication values to strings in both array-backed and object-backed formats during Postman to Bruno transformation.

* chore: update ESLint configuration to enforce stricter rules

- Added 'no-case-declarations' rule to ESLint configuration to enhance code quality.
- Adjusted existing rules for consistency and clarity in the configuration.

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2026-04-02 20:59:32 +05:30
sanish chirayath
04fdd6f8a9 feat: add helper to ensure string conversion for non-string values in Postman to Bruno conversion (#7644)
* feat: add helper to ensure string conversion for non-string values in Postman to Bruno conversion

- Introduced `ensureString` function to convert numeric and non-string values to strings, defaulting null/undefined to an empty string.
- Updated request handling in `importPostmanV2CollectionItem` to utilize `ensureString` for headers, parameters, and body fields.
- Added tests to verify correct conversion of numeric values to strings in headers, query parameters, and body fields.

* test: add test for numeric value conversion in Postman to Bruno transformation

- Implemented a new test case to verify that numeric values in example request and response fields are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks various components including request headers, query parameters, path parameters, and body fields to ensure proper string conversion.

* test: add multipart form value test for numeric conversion in Postman to Bruno transformation

- Added a new test case to verify that numeric values in multipart form data are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks the conversion of numeric values in the request body to ensure proper handling in the transformation.
2026-04-02 20:58:24 +05:30
Sid
3097f3aa76 fix: update system proxy fetching to use finally (#7652)
* fix: update system proxy fetching to use finally for improved reliability

* Update packages/bruno-electron/src/index.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-02 20:56:34 +05:30
Sid
9c3eabdda2 chore: add a promise based wait group for the shell variables (#7647) 2026-04-02 20:56:34 +05:30
Sid
7c4da8b8bc security: fix all critical vuln dependency reports (#7645)
* chore: remove form-data vuln

* chore: stale aws in lock

* chore: other critical vulns

* chore: correct deps
2026-04-01 23:42:06 +05:30
Chirag Chandrashekhar
1e4c3464d2 fix: app crash on clicking close button (#7637)
* fix: app crash on clicking close button \n Added collection, workspace, and api spec watcher cleanup on app close method

* fix: close file watchers before app exit to prevent crash on macOS

Close all chokidar file watchers (collection, workspace, apiSpec) before
the Node environment is torn down. The native FSEvents watchers run on
their own threads and their cleanup races with FreeEnvironment, causing
an abort when fse_instance_destroy tries to lock a destroyed mutex.

Watchers are closed in both mainWindow.on('close') and app.on('before-quit')
to cover the native close button path and the app.exit() path.

* fix: move watcher cleanup from close handler to before-quit only

The close event is cancelable — if the user cancels the unsaved changes
dialog, watchers would remain closed for the rest of the session.
Move closeAllWatchers() to before-quit which only fires on actual quit.

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-01 23:39:53 +05:30
lohit
5695f69430 fix: recreate HTTP/HTTPS agents on redirect to prevent stale agent reuse (#7597) (#7615)
When a request redirected from HTTP to HTTPS (or vice versa), the
original httpAgent/httpsAgent leaked into the redirect config. The
httpsAgent — which carries custom CA certificates and TLS options — was
never created for the redirect URL, causing UNABLE_TO_VERIFY_LEAF_SIGNATURE.

Changes:
- setupProxyAgents (electron) now deletes stale agents at the top of
  every call so they are always recreated for the current URL
- setupProxyAgents extracted to bruno-cli/proxy-util.js (mirrors the
  electron version) and called on every redirect in the CLI path
- Removed the else-branch in bruno-requests/http-https-agents.ts that
  only created one agent based on initial protocol
- Added HTTP→HTTPS redirect test server and request to the
  custom-ca-certs SSL test suite
2026-04-01 23:39:29 +05:30
Sid
d0bbac6b66 fix(security): santize HTML before being rendered in documentation blocks (#7598)
* fix: purify markdown before rendering

* chore: resolve stale html
2026-04-01 23:35:53 +05:30
Abhishek S Lal
51e2c045ec fix: re-apply masking in MultiLineEditor and SingleLineEditor after setValue() to preserve CodeMirror marks (#7585) 2026-04-01 23:35:45 +05:30
Pooja
b585c3e943 fix: preserve query params without values by not appending = sign (#7567)
* fix: preserve query params without values by not appending = sign

* fix: parseCurlCommand test
2026-04-01 23:35:38 +05:30
Chirag Chandrashekhar
8150a21395 fix: preserve user-defined boundary in multipart/mixed Content-Type header (#7531)
* fix: preserve user-defined boundary in multipart/mixed Content-Type header

When users specify a boundary parameter in their Content-Type header for
multipart/mixed requests with TEXT body mode, Bruno now preserves the
user-defined boundary instead of generating a new one.

Fixes: https://github.com/usebruno/bruno/issues/7523

* updated the test to use local server and changed the request method to GET

* fix: handle quoted boundary values in Content-Type header extraction

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-01 23:35:00 +05:30
45 changed files with 6334 additions and 4428 deletions

View File

@@ -178,7 +178,8 @@ module.exports = runESMImports().then(() => defineConfig([
}
},
rules: {
'no-undef': 'error'
'no-undef': 'error',
'no-case-declarations': 'error'
}
},
{

9153
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,7 @@
"github-markdown-css": "^5.2.0",
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"graphql-request": "4.2.0",
"hexy": "^0.3.5",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
@@ -102,7 +102,7 @@
"@babel/preset-react": "^7.27.1",
"@rsbuild/core": "^1.1.2",
"@rsbuild/plugin-babel": "^1.0.3",
"@rsbuild/plugin-node-polyfill": "^1.2.0",
"@rsbuild/plugin-node-polyfill": "1.2.0",
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",

View File

@@ -3,6 +3,8 @@ import * as MarkdownItReplaceLink from 'markdown-it-replace-link';
import StyledWrapper from './StyledWrapper';
import React from 'react';
import { isValidUrl } from 'utils/url/index';
import DOMPurify from 'dompurify';
import { useMemo } from 'react';
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
const markdownItOptions = {
@@ -33,14 +35,14 @@ const Markdown = ({ collectionPath, onDoubleClick, content }) => {
};
const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
const htmlFromMarkdown = md.render(content || '');
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath]);
const cleanHTML = useMemo(() => DOMPurify.sanitize(htmlFromMarkdown), [htmlFromMarkdown]);
return (
<StyledWrapper>
<div
className="markdown-body"
dangerouslySetInnerHTML={{ __html: htmlFromMarkdown }}
dangerouslySetInnerHTML={{ __html: cleanHTML }}
onClick={handleOnClick}
onDoubleClick={handleOnDoubleClick}
/>

View File

@@ -164,6 +164,10 @@ class MultiLineEditor extends Component {
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
// Re-apply masking after setValue() since it destroys all CodeMirror marks
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
this.maskedEditor.update();
}
}
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {

View File

@@ -179,6 +179,10 @@ class SingleLineEditor extends Component {
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
// Re-apply masking after setValue() since it destroys all CodeMirror marks
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
this.maskedEditor.update();
}
// Update newline markers after value change
if (this.props.showNewlineArrow) {

View File

@@ -902,7 +902,7 @@ describe('parseCurlCommand', () => {
{ name: 'test', value: 'urlquery' },
{ name: 'name', value: 'John%20Doe' },
{ name: 'email', value: 'john@example.com' },
{ name: 'hello', value: '' }
{ name: 'hello', value: undefined }
]
});
});

View File

@@ -53,13 +53,13 @@
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
"@usebruno/requests": "^0.1.0",
"aws4-axios": "^3.3.0",
"axios": "^1.8.3",
"aws4-axios": "^3.3.15",
"axios": "1.13.6",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chalk": "^3.0.0",
"decomment": "^0.9.5",
"form-data": "^4.0.0",
"form-data": "4.0.4",
"fs-extra": "^10.1.0",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",

View File

@@ -2,20 +2,16 @@ const qs = require('qs');
const chalk = require('chalk');
const decomment = require('decomment');
const fs = require('fs');
const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lodash');
const { forOwn, each, extend, get, compact } = require('lodash');
const prepareRequest = require('./prepare-request');
const interpolateVars = require('./interpolate-vars');
const { interpolateString, interpolateObject } = require('./interpolate-string');
const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime, formatErrorWithContext, SCRIPT_TYPES } = require('@usebruno/js');
const { stripExtension } = require('../utils/filesystem');
const { getOptions } = require('../utils/bru');
const https = require('node:https');
const http = require('node:http');
const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { makeAxiosInstance } = require('../utils/axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
const { setupProxyAgents } = require('../utils/proxy-util');
const path = require('path');
const { parseDataFromResponse } = require('../utils/common');
const { getCookieStringForUrl, saveCookies } = require('../utils/cookies');
@@ -23,10 +19,10 @@ const { createFormData } = require('../utils/form-data');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2 } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig, getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { getCACertificates, transformProxyConfig } = require('@usebruno/requests');
const { getOAuth2Token, getFormattedOauth2Credentials } = require('../utils/oauth2');
const tokenStore = require('../store/tokenStore');
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils;
const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils;
const onConsoleLog = (type, args) => {
console[type](...args);
@@ -429,90 +425,15 @@ const runSingleRequest = async function (
}
// else: collection proxy is disabled, proxyMode stays 'off'
// Prepare TLS options for agent caching
const tlsOptions = {
...httpsAgentRequestFields
};
// HTTP agent options — separate from tlsOptions to avoid leaking TLS fields
const httpAgentOptions = { keepAlive: true };
const parsedRequestUrl = new URL(request.url);
const isHttpsRequest = parsedRequestUrl.protocol === 'https:';
const hostname = parsedRequestUrl.hostname || null;
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol.includes('socks');
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
let proxyUri;
if (proxyAuthEnabled) {
const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
} else {
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
// (e.g., ca certs) even for plain HTTP requests
const isHttpsProxy = proxyProtocol === 'https';
const httpProxyAgentOptions = isHttpsProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
// Only set the agent needed for the request protocol
if (socksEnabled) {
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
} else {
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
}
}
} else if (proxyMode === 'system') {
try {
const { http_proxy, https_proxy, no_proxy } = cachedSystemProxy || {};
const shouldUseSystemProxy = shouldUseProxy(request.url, no_proxy || '');
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length && !isHttpsRequest) {
const parsedHttpProxy = new URL(http_proxy);
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
request.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system http_proxy');
}
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
}
} catch (error) {}
}
if (!request.httpAgent && !request.httpsAgent) {
if (isHttpsRequest) {
request.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, disableCache, hostname });
} else {
request.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: httpAgentOptions, disableCache, hostname });
}
}
setupProxyAgents({
requestConfig: request,
proxyMode,
proxyConfig,
systemProxyConfig: cachedSystemProxy,
httpsAgentRequestFields,
interpolationOptions,
disableCache
});
// set cookies if enabled
if (!options.disableCookies) {
@@ -570,7 +491,12 @@ const runSingleRequest = async function (
if (contentType !== 'multipart/form-data') {
// Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched
const formHeaders = form.getHeaders();
formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`;
const existingBoundary = extractBoundaryFromContentType(contentType);
if (existingBoundary) {
formHeaders['content-type'] = contentType;
} else {
formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`;
}
form.getHeaders = function () {
return formHeaders;
};
@@ -688,7 +614,13 @@ const runSingleRequest = async function (
let axiosInstance = makeAxiosInstance({
requestMaxRedirects: requestMaxRedirects,
disableCookies: options.disableCookies,
followRedirects: followRedirects
followRedirects: followRedirects,
proxyMode,
proxyConfig,
systemProxyConfig: cachedSystemProxy,
httpsAgentRequestFields,
interpolationOptions,
disableCache
});
if (request.ntlmConfig) {

View File

@@ -2,6 +2,7 @@ const axios = require('axios');
const { CLI_VERSION } = require('../constants');
const { addCookieToJar, getCookieStringForUrl } = require('./cookies');
const { createFormData } = require('./form-data');
const { setupProxyAgents } = require('./proxy-util');
const redirectResponseCodes = [301, 302, 303, 307, 308];
const METHOD_CHANGING_REDIRECTS = [301, 302, 303];
@@ -71,7 +72,17 @@ const createRedirectConfig = (error, redirectUrl) => {
* @see https://github.com/axios/axios/issues/695
* @returns {axios.AxiosInstance}
*/
function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies, followRedirects = true } = {}) {
function makeAxiosInstance({
requestMaxRedirects = 5,
disableCookies,
followRedirects = true,
proxyMode,
proxyConfig,
systemProxyConfig,
httpsAgentRequestFields,
interpolationOptions,
disableCache
} = {}) {
let redirectCount = 0;
/** @type {axios.AxiosInstance} */
@@ -167,6 +178,16 @@ function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies, followRedi
const requestConfig = createRedirectConfig(error, redirectUrl);
setupProxyAgents({
requestConfig,
proxyMode,
proxyConfig,
systemProxyConfig,
httpsAgentRequestFields,
interpolationOptions,
disableCache
});
if (!disableCookies) {
const cookieString = getCookieStringForUrl(redirectUrl);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {

View File

@@ -1,6 +1,12 @@
const parseUrl = require('url').parse;
const { isEmpty } = require('lodash');
const http = require('node:http');
const https = require('node:https');
const { isEmpty, get, isUndefined, isNull } = require('lodash');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { HttpProxyAgent } = require('http-proxy-agent');
const { SocksProxyAgent } = require('socks-proxy-agent');
const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { interpolateString } = require('../runner/interpolate-string');
const DEFAULT_PORTS = {
ftp: 21,
@@ -96,7 +102,103 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
}
function setupProxyAgents({
requestConfig,
proxyMode = 'off',
proxyConfig,
systemProxyConfig,
httpsAgentRequestFields,
interpolationOptions,
disableCache = true
}) {
// Clear stale agents so we always recreate them for the current URL
// (handles protocol switches, host changes, and proxy-bypass rules on redirects).
delete requestConfig.httpAgent;
delete requestConfig.httpsAgent;
const tlsOptions = { ...httpsAgentRequestFields };
const httpAgentOptions = { keepAlive: true };
const parsedRequestUrl = new URL(requestConfig.url);
const isHttpsRequest = parsedRequestUrl.protocol === 'https:';
const hostname = parsedRequestUrl.hostname || null;
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(requestConfig.url, get(proxyConfig, 'bypassProxy', ''));
if (shouldProxy) {
const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions);
const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions);
const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions);
const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false);
const socksEnabled = proxyProtocol?.includes('socks') ?? false;
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
let proxyUri;
if (proxyAuthEnabled) {
const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
} else {
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
// When the proxy itself uses HTTPS, the agent connecting to it needs TLS options
// (e.g., ca certs) even for plain HTTP requests
const isHttpsProxy = proxyProtocol === 'https';
const httpProxyAgentOptions = isHttpsProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
// Only set the agent needed for the request protocol
if (socksEnabled) {
if (isHttpsRequest) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
} else {
if (isHttpsRequest) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions, proxyUri, disableCache, hostname });
}
}
}
} else if (proxyMode === 'system') {
try {
const { http_proxy, https_proxy, no_proxy } = systemProxyConfig || {};
const shouldUseSystemProxy = shouldUseProxy(requestConfig.url, no_proxy || '');
if (shouldUseSystemProxy) {
try {
if (http_proxy?.length && !isHttpsRequest) {
const parsedHttpProxy = new URL(http_proxy);
const isHttpsSystemProxy = parsedHttpProxy.protocol === 'https:';
const systemHttpProxyAgentOptions = isHttpsSystemProxy ? { ...httpAgentOptions, ...tlsOptions } : httpAgentOptions;
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: systemHttpProxyAgentOptions, proxyUri: http_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system http_proxy');
}
try {
if (https_proxy?.length && isHttpsRequest) {
new URL(https_proxy);
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri: https_proxy, disableCache, hostname });
}
} catch (error) {
throw new Error('Invalid system https_proxy');
}
}
} catch (error) {}
}
if (!requestConfig.httpAgent && !requestConfig.httpsAgent) {
if (isHttpsRequest) {
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions, disableCache, hostname });
} else {
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: http.Agent, options: httpAgentOptions, disableCache, hostname });
}
}
}
module.exports = {
shouldUseProxy,
PatchedHttpsProxyAgent
PatchedHttpsProxyAgent,
setupProxyAgents
};

View File

@@ -46,7 +46,7 @@
"@rollup/plugin-typescript": "^12.1.2",
"@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
"form-data": "^4.0.0",
"form-data": "4.0.4",
"is-ip": "^5.0.1",
"moment": "^2.29.4",
"rollup": "3.29.5",

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from '@jest/globals';
import { buildFormUrlEncodedPayload, isFormData } from './form-data';
import { buildFormUrlEncodedPayload, isFormData, extractBoundaryFromContentType } from './form-data';
import FormData from 'form-data';
describe('buildFormUrlEncodedPayload', () => {
@@ -161,3 +161,51 @@ describe('isFormData', () => {
expect(isFormData(formData)).toBe(true);
});
});
describe('extractBoundaryFromContentType', () => {
it('should extract boundary from Content-Type header', () => {
expect(extractBoundaryFromContentType('multipart/mixed; boundary=my-boundary')).toBe('my-boundary');
});
it('should extract boundary with dashes', () => {
expect(extractBoundaryFromContentType('multipart/mixed; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW')).toBe('----WebKitFormBoundary7MA4YWxkTrZu0gW');
});
it('should extract boundary case-insensitively', () => {
expect(extractBoundaryFromContentType('multipart/mixed; BOUNDARY=my-boundary')).toBe('my-boundary');
expect(extractBoundaryFromContentType('multipart/mixed; Boundary=my-boundary')).toBe('my-boundary');
});
it('should extract boundary when other params exist', () => {
expect(extractBoundaryFromContentType('multipart/mixed; charset=utf-8; boundary=my-boundary')).toBe('my-boundary');
expect(extractBoundaryFromContentType('multipart/mixed; boundary=my-boundary; charset=utf-8')).toBe('my-boundary');
});
it('should return null when no boundary exists', () => {
expect(extractBoundaryFromContentType('multipart/mixed')).toBeNull();
expect(extractBoundaryFromContentType('application/json')).toBeNull();
});
it('should return null for non-string input', () => {
expect(extractBoundaryFromContentType(null)).toBeNull();
expect(extractBoundaryFromContentType(undefined)).toBeNull();
expect(extractBoundaryFromContentType(123)).toBeNull();
expect(extractBoundaryFromContentType({})).toBeNull();
});
it('should handle empty string', () => {
expect(extractBoundaryFromContentType('')).toBeNull();
});
it('should extract boundary from quoted value', () => {
expect(extractBoundaryFromContentType('multipart/mixed; boundary="my-boundary"')).toBe('my-boundary');
});
it('should extract quoted boundary with spaces', () => {
expect(extractBoundaryFromContentType('multipart/mixed; boundary="my boundary value"')).toBe('my boundary value');
});
it('should extract quoted boundary when other params exist', () => {
expect(extractBoundaryFromContentType('multipart/mixed; charset=utf-8; boundary="my-boundary"')).toBe('my-boundary');
});
});

View File

@@ -43,3 +43,16 @@ export const isFormData = (obj: unknown): boolean => {
// todo: checking constructor.name can produce false positives for objects that have a constructor.name property set to 'FormData', but this is rare.
return obj?.constructor?.name === 'FormData';
};
/**
* Extracts boundary parameter from a Content-Type header value.
* @param contentType - The Content-Type header value (e.g., "multipart/mixed; boundary=my-boundary")
* @returns The boundary value if found, or null if not present
*/
export const extractBoundaryFromContentType = (contentType: unknown): string | null => {
if (typeof contentType !== 'string') {
return null;
}
const match = contentType.match(/boundary="([^"]+)"|boundary=([^;\s]+)/i);
return match ? (match[1] || match[2]) : null;
};

View File

@@ -7,7 +7,8 @@ export {
export {
buildFormUrlEncodedPayload,
isFormData
isFormData,
extractBoundaryFromContentType
} from './form-data';
export {

View File

@@ -50,6 +50,18 @@ describe('encodeUrl', () => {
expect(encodeUrl(url)).toBe(expected);
});
it('should handle query parameters without values (no = sign)', () => {
const url = 'https://example.com/api?flag&age=25&verbose';
const expected = 'https://example.com/api?flag&age=25&verbose';
expect(encodeUrl(url)).toBe(expected);
});
it('should handle mixed empty-value and no-value parameters', () => {
const url = 'https://example.com/api?seat=&table=2&flag';
const expected = 'https://example.com/api?seat=&table=2&flag';
expect(encodeUrl(url)).toBe(expected);
});
it('should encode query parameters with pipe operator', () => {
const url = 'https://example.com/api?filter=status|active&sort=name|asc&tags=frontend|backend|api';
const expected = 'https://example.com/api?filter=status%7Cactive&sort=name%7Casc&tags=frontend%7Cbackend%7Capi';
@@ -159,7 +171,7 @@ describe('parseQueryParams', () => {
expect(result).toEqual([]);
});
it('should handle query parameters with empty values', () => {
it('should handle query parameters with empty values (has = sign)', () => {
const queryString = 'name=&age=25&active=';
const result = parseQueryParams(queryString);
expect(result).toEqual([
@@ -169,6 +181,16 @@ describe('parseQueryParams', () => {
]);
});
it('should handle query parameters without values (no = sign)', () => {
const queryString = 'flag&age=25&verbose';
const result = parseQueryParams(queryString);
expect(result).toEqual([
{ name: 'flag', value: undefined },
{ name: 'age', value: '25' },
{ name: 'verbose', value: undefined }
]);
});
it('should extract query parameters with pipe operator', () => {
const queryString = 'filter=status|active&sort=name|asc&tags=frontend|backend';
const result = parseQueryParams(queryString);
@@ -218,4 +240,23 @@ describe('buildQueryString', () => {
const result = buildQueryString(params, { encode: false });
expect(result).toBe('filter=status|active&sort=name|asc');
});
it('should omit = for params with undefined value', () => {
const params = [
{ name: 'flag', value: undefined },
{ name: 'age', value: '25' },
{ name: 'verbose' }
];
const result = buildQueryString(params);
expect(result).toBe('flag&age=25&verbose');
});
it('should include = for params with empty string value', () => {
const params = [
{ name: 'seat', value: '' },
{ name: 'table', value: '2' }
];
const result = buildQueryString(params);
expect(result).toBe('seat=&table=2');
});
});

View File

@@ -16,8 +16,12 @@ function buildQueryString(paramsArray: QueryParam[], { encode = false }: BuildQu
.filter(({ name }) => typeof name === 'string' && name.trim().length > 0)
.map(({ name, value }) => {
const finalName = encode ? encodeURIComponent(name) : name;
const finalValue = encode ? encodeURIComponent(value ?? '') : (value ?? '');
if (value === undefined) {
return finalName;
}
const finalValue = encode ? encodeURIComponent(value) : value;
return `${finalName}=${finalValue}`;
})
.join('&');
@@ -39,9 +43,13 @@ function parseQueryParams(query: string, { decode = false }: ExtractQueryParamsO
return null;
}
// Distinguish between ?param (no '=' at all) and ?param= (has '=' with empty value)
const hasEqualsSign = pair.includes('=');
const value = hasEqualsSign ? (decode ? decodeURIComponent(valueParts.join('=')) : valueParts.join('=')) : undefined;
return {
name: decode ? decodeURIComponent(name) : name,
value: decode ? decodeURIComponent(valueParts.join('=')) : valueParts.join('=')
value
};
}).filter((param): param is NonNullable<typeof param> => param !== null);

View File

@@ -110,7 +110,7 @@ export const validateSchema = (collection = {}) => {
return collection;
} catch (err) {
console.log('Error validating schema', err);
throw new Error('The Collection has an invalid schema');
throw new Error(`The Collection has an invalid schema: ${err.message}`);
}
};

View File

@@ -73,6 +73,55 @@ const isItemAFolder = (item) => {
return !item.request;
};
/**
* Postman allows non-string values (e.g. numbers) in fields like header values,
* query param values, etc. Bruno expects these to be strings.
* Converts non-null/non-empty values to strings, returns fallback for null/undefined/empty.
*/
const ensureString = (value, fallback = '') => {
if (value == null || value === '') return fallback;
if (typeof value === 'string') return value;
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
};
/**
* Postman's schema allows headers as strings in the format "Key: Value".
* This parses a single string header into an object.
*/
const parseStringHeader = (header) => {
const colonIndex = header.indexOf(':');
if (colonIndex === -1) return { key: header.trim(), value: '' };
return {
key: header.substring(0, colonIndex).trim(),
value: header.substring(colonIndex + 1).trim()
};
};
/**
* Postman's schema allows the header field to be:
* 1. An array of objects (most common)
* 2. An array with mixed string and object items
* 3. A single concatenated string (e.g. "Key1: Value1\r\nKey2: Value2")
* 4. null
*
* This normalizes all forms into an array of header objects.
*/
const normalizeHeaders = (headers) => {
if (!headers) return [];
if (typeof headers === 'string') {
return headers.split(/\r?\n/).filter(Boolean).map(parseStringHeader);
}
if (!Array.isArray(headers)) return [];
return headers.map((header) => {
if (typeof header === 'string') return parseStringHeader(header);
return header;
});
};
const convertV21Auth = (array) => {
return array.reduce((accumulator, currentValue) => {
accumulator[currentValue.key] = currentValue.value;
@@ -159,7 +208,7 @@ const importCollectionLevelVariables = (variables, requestObject) => {
const vars = variables.filter((v) => !(v.key == null && v.value == null)).map((v) => ({
uid: uuid(),
name: (v.key ?? '').replace(invalidVariableCharacterRegex, '_'),
value: v.value ?? '',
value: v.value == null ? '' : typeof v.value === 'string' ? v.value : JSON.stringify(v.value),
enabled: true
}));
@@ -194,40 +243,40 @@ export const processAuth = (auth, requestObject, isCollection = false) => {
switch (auth.type) {
case AUTH_TYPES.BASIC:
requestObject.auth.basic = {
username: authValues.username || '',
password: authValues.password || ''
username: ensureString(authValues.username),
password: ensureString(authValues.password)
};
break;
case AUTH_TYPES.BEARER:
requestObject.auth.bearer = {
token: authValues.token || ''
token: ensureString(authValues.token)
};
break;
case AUTH_TYPES.AWSV4:
requestObject.auth.awsv4 = {
accessKeyId: authValues.accessKey || '',
secretAccessKey: authValues.secretKey || '',
sessionToken: authValues.sessionToken || '',
service: authValues.service || '',
region: authValues.region || '',
accessKeyId: ensureString(authValues.accessKey),
secretAccessKey: ensureString(authValues.secretKey),
sessionToken: ensureString(authValues.sessionToken),
service: ensureString(authValues.service),
region: ensureString(authValues.region),
profileName: ''
};
break;
case AUTH_TYPES.APIKEY:
requestObject.auth.apikey = {
key: authValues.key || '',
value: authValues.value?.toString() || '', // Convert the value to a string as Postman's schema does not rigidly define the type of it,
key: ensureString(authValues.key),
value: ensureString(authValues.value),
placement: 'header' // By default we are placing the apikey values in headers!
};
break;
case AUTH_TYPES.DIGEST:
requestObject.auth.digest = {
username: authValues.username || '',
password: authValues.password || ''
username: ensureString(authValues.username),
password: ensureString(authValues.password)
};
break;
case AUTH_TYPES.OAUTH2:
const findValueUsingKey = (key) => authValues[key] || '';
case AUTH_TYPES.OAUTH2: {
const findValueUsingKey = (key) => ensureString(authValues[key]);
// Maps Postman's grant_type to the Bruno's grantType string expected in the target object
const oauth2GrantTypeMaps = {
@@ -286,6 +335,7 @@ export const processAuth = (auth, requestObject, isCollection = false) => {
break;
}
break;
}
default:
requestObject.auth.mode = AUTH_TYPES.NONE;
console.warn('Unexpected auth.type:', auth.type, '- Mode set, but no specific config generated.');
@@ -470,12 +520,12 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
const isFile = param.type === 'file' || (param.type === 'default' && param.src);
const value = isFile
? (Array.isArray(param.src) ? param.src : param.src ? [param.src] : [])
: (Array.isArray(param.value) ? param.value.join('') : param.value ?? '');
: (Array.isArray(param.value) ? param.value.join('') : ensureString(param.value));
brunoRequestItem.request.body.multipartForm.push({
uid: uuid(),
type: isFile ? 'file' : 'text',
name: param.key ?? '',
name: ensureString(param.key),
value,
description: transformDescription(param.description),
enabled: !param.disabled,
@@ -490,8 +540,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
if (param.key == null && param.value == null) return;
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.key ?? '',
value: param.value ?? '',
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
enabled: !param.disabled
});
@@ -522,12 +572,12 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql);
}
each(i.request.header, (header) => {
each(normalizeHeaders(i.request.header), (header) => {
if (header.key == null && header.value == null) return;
brunoRequestItem.request.headers.push({
uid: uuid(),
name: header.key ?? '',
value: header.value ?? '',
name: ensureString(header.key),
value: ensureString(header.value),
description: transformDescription(header.description),
enabled: !header.disabled
});
@@ -542,8 +592,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
}
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key ?? '',
value: param.value ?? '',
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'query',
enabled: !param.disabled
@@ -558,8 +608,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.key,
value: param.value ?? '',
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'path',
enabled: true
@@ -611,13 +661,13 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
};
// Convert original request headers
if (originalRequest.header && Array.isArray(originalRequest.header)) {
originalRequest.header.forEach((header) => {
if (originalRequest.header) {
normalizeHeaders(originalRequest.header).forEach((header) => {
if (header.key == null && header.value == null) return;
example.request.headers.push({
uid: uuid(),
name: header.key ?? '',
value: header.value ?? '',
name: ensureString(header.key),
value: ensureString(header.value),
description: transformDescription(header.description),
enabled: !header.disabled
});
@@ -632,8 +682,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
}
example.request.params.push({
uid: uuid(),
name: param.key ?? '',
value: param.value ?? '',
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'query',
enabled: !param.disabled
@@ -646,8 +696,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
if (!param.key) return;
example.request.params.push({
uid: uuid(),
name: param.key,
value: param.value ?? '',
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'path',
enabled: true
@@ -666,12 +716,12 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
const isFile = param.type === 'file' || (param.type === 'default' && param.src);
const value = isFile
? (Array.isArray(param.src) ? param.src : param.src ? [param.src] : [])
: (Array.isArray(param.value) ? param.value.join('') : param.value ?? '');
: (Array.isArray(param.value) ? param.value.join('') : ensureString(param.value));
example.request.body.multipartForm.push({
uid: uuid(),
type: isFile ? 'file' : 'text',
name: param.key ?? '',
name: ensureString(param.key),
value,
description: transformDescription(param.description),
enabled: !param.disabled,
@@ -686,8 +736,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
if (param.key == null && param.value == null) return;
example.request.body.formUrlEncoded.push({
uid: uuid(),
name: param.key ?? '',
value: param.value ?? '',
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
enabled: !param.disabled
});
@@ -712,13 +762,13 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
}
// Convert response headers
if (response.header && Array.isArray(response.header)) {
response.header.forEach((header) => {
if (response.header) {
normalizeHeaders(response.header).forEach((header) => {
if (header.key == null && header.value == null) return;
example.response.headers.push({
uid: uuid(),
name: header.key ?? '',
value: header.value ?? '',
name: ensureString(header.key),
value: ensureString(header.value),
description: transformDescription(header.description),
enabled: true
});
@@ -736,8 +786,8 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
const searchLanguageByHeader = (headers) => {
let contentType;
each(headers, (header) => {
if (header.key.toLowerCase() === 'content-type' && !header.disabled) {
each(normalizeHeaders(headers), (header) => {
if (header.key?.toLowerCase() === 'content-type' && !header.disabled) {
if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(header.value)) {
contentType = 'json';
} else if (typeof header.value == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(header.value)) {
@@ -750,14 +800,14 @@ const searchLanguageByHeader = (headers) => {
};
const getBodyTypeFromContentTypeHeader = (headers) => {
// Check if headers is null, undefined, or not an array
if (!headers || !Array.isArray(headers)) {
const normalizedHeaders = normalizeHeaders(headers);
if (!normalizedHeaders.length) {
return 'text';
}
const contentTypeHeader = headers.find((header) => header.key.toLowerCase() === 'content-type');
if (contentTypeHeader) {
const contentType = contentTypeHeader.value?.toLowerCase();
const contentTypeHeader = normalizedHeaders.find((header) => header.key?.toLowerCase() === 'content-type');
if (contentTypeHeader && typeof contentTypeHeader.value === 'string') {
const contentType = contentTypeHeader.value.toLowerCase();
if (contentType?.includes('application/json')) {
return 'json';
} else if (contentType?.includes('application/xml') || contentType?.includes('text/xml')) {

View File

@@ -238,6 +238,43 @@ describe('postman-collection', () => {
]);
});
it('should convert non-string variable values to strings', async () => {
const collectionWithNonStringVars = {
info: {
name: 'Non-String Variable Demo',
_postman_id: 'abcd1234-5678-90ef-ghij-1234567890ab',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
variable: [
{ key: 'timeout', value: 5000 },
{ key: 'enabled', value: true },
{ key: 'user', value: { id: 1, name: 'Alice' } }
],
item: [
{
name: 'Sample Request',
request: {
method: 'GET',
url: {
raw: 'https://postman-echo.com/get',
protocol: 'https',
host: ['postman-echo', 'com'],
path: ['get']
}
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithNonStringVars);
const vars = brunoCollection.root.request.vars.req;
expect(vars).toHaveLength(3);
expect(vars[0]).toMatchObject({ name: 'timeout', value: '5000' });
expect(vars[1]).toMatchObject({ name: 'enabled', value: 'true' });
expect(vars[2]).toMatchObject({ name: 'user', value: '{"id":1,"name":"Alice"}' });
});
it('should handle empty variables', async () => {
const collectionWithEmptyVars = {
info: {
@@ -769,6 +806,337 @@ describe('postman-collection', () => {
expect(params[2].value).toBe('');
expect(params[2].type).toBe('query');
});
it('should convert numeric values to strings in headers, params, and body fields', async () => {
const collectionWithNumericValues = {
info: {
_postman_id: 'test-numeric-values',
name: 'collection with numeric values',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with numeric values',
request: {
method: 'POST',
header: [
{ key: 'X-Account-Id', value: 0 },
{ key: 'X-Retry-Count', value: 3 }
],
url: {
raw: 'https://example.com/api/:accountId',
protocol: 'https',
host: ['example', 'com'],
path: ['api', ':accountId'],
query: [
{ key: 'limit', value: 100 },
{ key: 'offset', value: 0 }
],
variable: [
{ key: 'accountId', value: 0 }
]
},
body: {
mode: 'urlencoded',
urlencoded: [
{ key: 'timeout', value: 5000 }
]
}
}
},
{
name: 'request with numeric multipart form values',
request: {
method: 'POST',
header: [],
url: { raw: 'https://example.com/upload' },
body: {
mode: 'formdata',
formdata: [
{ key: 'retries', value: 3, type: 'text' },
{ key: 'priority', value: 0, type: 'text' }
]
}
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithNumericValues);
const item = brunoCollection.items[0];
// Headers should have string values
expect(item.request.headers[0].value).toBe('0');
expect(item.request.headers[1].value).toBe('3');
// Query params should have string values
const queryParams = item.request.params.filter((p) => p.type === 'query');
expect(queryParams[0].value).toBe('100');
expect(queryParams[1].value).toBe('0');
// Path params should have string values
const pathParams = item.request.params.filter((p) => p.type === 'path');
expect(pathParams[0].value).toBe('0');
// Form URL-encoded should have string values
expect(item.request.body.formUrlEncoded[0].value).toBe('5000');
// Multipart form should have string values
const multipartItem = brunoCollection.items[1];
expect(multipartItem.request.body.multipartForm[0].value).toBe('3');
expect(multipartItem.request.body.multipartForm[1].value).toBe('0');
});
it('should convert numeric values to strings in example request and response fields', async () => {
const collectionWithNumericExamples = {
info: {
_postman_id: 'test-numeric-examples',
name: 'collection with numeric example values',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with numeric example',
request: {
method: 'GET',
header: [],
url: { raw: 'https://example.com/api' }
},
response: [
{
name: 'Example with numerics',
originalRequest: {
method: 'GET',
header: [
{ key: 'X-Account-Id', value: 42 }
],
url: {
raw: 'https://example.com/api/:id?page=1',
protocol: 'https',
host: ['example', 'com'],
path: ['api', ':id'],
query: [
{ key: 'page', value: 1 }
],
variable: [
{ key: 'id', value: 99 }
]
},
body: {
mode: 'urlencoded',
urlencoded: [
{ key: 'retries', value: 3 }
]
}
},
status: 'OK',
code: 200,
header: [
{ key: 'X-RateLimit-Remaining', value: 0 }
],
body: '{"ok": true}'
}
]
}
]
};
const brunoCollection = await postmanToBruno(collectionWithNumericExamples);
const example = brunoCollection.items[0].examples[0];
// Example request headers
expect(example.request.headers[0].value).toBe('42');
// Example request query params
const queryParams = example.request.params.filter((p) => p.type === 'query');
expect(queryParams[0].value).toBe('1');
// Example request path params
const pathParams = example.request.params.filter((p) => p.type === 'path');
expect(pathParams[0].value).toBe('99');
// Example request form URL-encoded
expect(example.request.body.formUrlEncoded[0].value).toBe('3');
// Example response headers
expect(example.response.headers[0].value).toBe('0');
});
it('should convert numeric auth values to strings (array-backed v2.1 format)', async () => {
const collectionWithNumericAuth = {
info: {
_postman_id: 'test-numeric-auth',
name: 'collection with numeric auth values',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with numeric bearer token',
request: {
method: 'GET',
header: [],
url: { raw: 'https://example.com/api' },
auth: {
type: 'bearer',
bearer: [
{ key: 'token', value: 123 }
]
}
}
},
{
name: 'request with numeric apikey values',
request: {
method: 'GET',
header: [],
url: { raw: 'https://example.com/api' },
auth: {
type: 'apikey',
apikey: [
{ key: 'key', value: 456 },
{ key: 'value', value: 789 }
]
}
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithNumericAuth);
// Bearer token should be stringified
expect(brunoCollection.items[0].request.auth.mode).toBe('bearer');
expect(brunoCollection.items[0].request.auth.bearer.token).toBe('123');
// API key fields should be stringified
expect(brunoCollection.items[1].request.auth.mode).toBe('apikey');
expect(brunoCollection.items[1].request.auth.apikey.key).toBe('456');
expect(brunoCollection.items[1].request.auth.apikey.value).toBe('789');
});
it('should convert numeric auth values to strings (object-backed format)', async () => {
const collectionWithObjectAuth = {
info: {
_postman_id: 'test-object-auth',
name: 'collection with object-backed auth',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with object-backed basic auth',
request: {
method: 'GET',
header: [],
url: { raw: 'https://example.com/api' },
auth: {
type: 'basic',
basic: {
username: 12345,
password: 67890
}
}
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithObjectAuth);
expect(brunoCollection.items[0].request.auth.mode).toBe('basic');
expect(brunoCollection.items[0].request.auth.basic.username).toBe('12345');
expect(brunoCollection.items[0].request.auth.basic.password).toBe('67890');
});
it('should parse string headers in request header arrays', async () => {
const collectionWithStringHeaders = {
info: {
_postman_id: 'test-string-headers',
name: 'collection with string headers',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with string headers',
request: {
method: 'GET',
header: [
'Content-Type: application/json',
{ key: 'X-Custom', value: 'test' },
'Authorization: Bearer token123'
],
url: { raw: 'https://example.com/api' }
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithStringHeaders);
const headers = brunoCollection.items[0].request.headers;
expect(headers).toHaveLength(3);
expect(headers[0].name).toBe('Content-Type');
expect(headers[0].value).toBe('application/json');
expect(headers[1].name).toBe('X-Custom');
expect(headers[1].value).toBe('test');
expect(headers[2].name).toBe('Authorization');
expect(headers[2].value).toBe('Bearer token123');
});
it('should parse a single concatenated string as the header field', async () => {
const collectionWithConcatenatedHeaders = {
info: {
_postman_id: 'test-concat-headers',
name: 'collection with concatenated header string',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with concatenated header',
request: {
method: 'GET',
header: 'Content-Type: application/json\r\nHost: example.com',
url: { raw: 'https://example.com/api' }
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithConcatenatedHeaders);
const headers = brunoCollection.items[0].request.headers;
expect(headers).toHaveLength(2);
expect(headers[0].name).toBe('Content-Type');
expect(headers[0].value).toBe('application/json');
expect(headers[1].name).toBe('Host');
expect(headers[1].value).toBe('example.com');
});
it('should handle string headers with no value', async () => {
const collectionWithNoValueHeader = {
info: {
_postman_id: 'test-no-value-header',
name: 'collection with no-value string header',
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
},
item: [
{
name: 'request with no-value header',
request: {
method: 'GET',
header: ['X-No-Value'],
url: { raw: 'https://example.com/api' }
}
}
]
};
const brunoCollection = await postmanToBruno(collectionWithNoValueHeader);
const headers = brunoCollection.items[0].request.headers;
expect(headers).toHaveLength(1);
expect(headers[0].name).toBe('X-No-Value');
expect(headers[0].value).toBe('');
});
});
// Simple Collection (postman)

View File

@@ -44,8 +44,8 @@
"about-window": "^1.15.2",
"adm-zip": "^0.5.16",
"archiver": "^7.0.1",
"aws4-axios": "^3.3.0",
"axios": "^1.8.3",
"aws4-axios": "^3.3.15",
"axios": "1.13.6",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chokidar": "^3.5.3",
@@ -58,7 +58,7 @@
"electron-store": "^8.1.0",
"electron-util": "^0.17.2",
"extract-zip": "^2.0.1",
"form-data": "^4.0.0",
"form-data": "4.0.4",
"fs-extra": "^10.1.0",
"graphql": "^16.6.0",
"hexy": "^0.3.5",

View File

@@ -141,6 +141,16 @@ class ApiSpecWatcher {
delete this.watcherWorkspaces[watchPath];
}
}
closeAllWatchers() {
for (const [watchPath, watcher] of Object.entries(this.watchers)) {
try {
watcher?.close();
} catch (err) {}
}
this.watchers = {};
this.watcherWorkspaces = {};
}
}
module.exports = ApiSpecWatcher;

View File

@@ -958,6 +958,15 @@ class CollectionWatcher {
.filter(([path, watcher]) => !!watcher)
.map(([path, _watcher]) => path);
}
closeAllWatchers() {
for (const [watchPath, watcher] of Object.entries(this.watchers)) {
try {
watcher?.close();
} catch (err) {}
}
this.watchers = {};
}
}
const collectionWatcher = new CollectionWatcher();

View File

@@ -224,6 +224,24 @@ class WorkspaceWatcher {
hasWatcher(workspacePath) {
return Boolean(this.watchers[workspacePath]);
}
closeAllWatchers() {
for (const [watchPath, watcher] of Object.entries(this.watchers)) {
try {
watcher?.close();
} catch (err) {}
}
this.watchers = {};
for (const [watchPath, watcher] of Object.entries(this.environmentWatchers)) {
try {
watcher?.close();
} catch (err) {}
}
this.environmentWatchers = {};
dotEnvWatcher.closeAll();
}
}
module.exports = WorkspaceWatcher;

View File

@@ -3,7 +3,7 @@ const path = require('path');
const { execSync } = require('node:child_process');
const isDev = require('electron-is-dev');
const os = require('os');
const { initializeShellEnv } = require('@usebruno/requests');
const { initializeShellEnv, waitForShellEnv } = require('./store/shell-env-state');
const { percentageToZoomLevel } = require('@usebruno/common');
if (isDev) {
@@ -122,6 +122,12 @@ const focusMainWindow = () => {
}
};
const closeAllWatchers = () => {
collectionWatcher.closeAllWatchers();
workspaceWatcher.closeAllWatchers();
apiSpecWatcher.closeAllWatchers();
};
// Parse protocol URL from command line arguments (if any)
appProtocolUrl = getAppProtocolUrlFromArgv(process.argv);
@@ -175,8 +181,7 @@ if (useSingleInstance && !gotTheLock) {
// Prepare the renderer once the app is ready
app.on('ready', async () => {
// Ensure shell environment is loaded before any operations that need it
await initializeShellEnv();
initializeShellEnv();
if (isDev) {
const { installExtension, REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer');
@@ -197,9 +202,18 @@ app.on('ready', async () => {
// Initialize system proxy cache early (non-blocking)
const { fetchSystemProxy } = require('./store/system-proxy');
fetchSystemProxy().catch((err) => {
console.warn('Failed to initialize system proxy cache:', err);
});
// Note: irrespective of the state of the shell,
// try to fetch the system proxy information
waitForShellEnv()
.catch((err) => {
console.warn('Shell env init failed:', err);
})
.finally(() => {
fetchSystemProxy().catch((err) => {
console.warn('Failed to initialize system proxy cache:', err);
});
});
Menu.setApplicationMenu(menu);
const { maximized, x, y, width, height } = loadWindowState();
@@ -455,6 +469,7 @@ app.on('ready', async () => {
// Quit the app once all windows are closed
app.on('before-quit', () => {
closeAllWatchers();
// Release single instance lock to allow other instances to take over
if (useSingleInstance && gotTheLock) {
app.releaseSingleInstanceLock();

View File

@@ -35,7 +35,7 @@ const { cookiesStore } = require('../../store/cookies');
const registerGrpcEventHandlers = require('./grpc-event-handlers');
const { registerWsEventHandlers } = require('./ws-event-handlers');
const { getCertsAndProxyConfig, buildCertsAndProxyConfig } = require('./cert-utils');
const { buildFormUrlEncodedPayload, isFormData } = require('@usebruno/common').utils;
const { buildFormUrlEncodedPayload, isFormData, extractBoundaryFromContentType } = require('@usebruno/common').utils;
const ERROR_OCCURRED_WHILE_EXECUTING_REQUEST = 'Error occurred while executing the request!';
@@ -604,7 +604,12 @@ const registerNetworkIpc = (mainWindow) => {
if (contentType !== 'multipart/form-data') {
// Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched
const formHeaders = form.getHeaders();
formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`;
const existingBoundary = extractBoundaryFromContentType(contentType);
if (existingBoundary) {
formHeaders['content-type'] = contentType;
} else {
formHeaders['content-type'] = `${contentType}; boundary=${form.getBoundary()}`;
}
form.getHeaders = function () {
return formHeaders;
};

View File

@@ -0,0 +1,28 @@
const { initializeShellEnv: _initializeShellEnv } = require('@usebruno/requests');
const TIMEOUT_MS = 60_000;
/** @type {null | Promise<any>} */
let _promise = null;
const _initWithTimeout = () => {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
_promise = null;
reject(new Error('Shell environment initialization timed out'));
}, TIMEOUT_MS);
});
return Promise.race([_initializeShellEnv(), timeout]).finally(() => clearTimeout(timer));
};
const initializeShellEnv = () => {
if (!_promise) _promise = _initWithTimeout();
};
const waitForShellEnv = () => {
if (!_promise) _promise = _initWithTimeout();
return _promise;
};
module.exports = { initializeShellEnv, waitForShellEnv };

View File

@@ -0,0 +1,105 @@
let mockInitialize;
jest.mock('@usebruno/requests', () => ({
initializeShellEnv: (...args) => mockInitialize(...args)
}));
describe('shell-env-state', () => {
let initializeShellEnv, waitForShellEnv;
beforeEach(() => {
jest.resetModules();
mockInitialize = jest.fn(() => Promise.resolve());
({ initializeShellEnv, waitForShellEnv } = require('../shell-env-state'));
});
describe('initializeShellEnv', () => {
it('calls the underlying initializer exactly once on first call', () => {
initializeShellEnv();
initializeShellEnv();
initializeShellEnv();
initializeShellEnv();
initializeShellEnv();
initializeShellEnv();
initializeShellEnv();
expect(mockInitialize).toHaveBeenCalledTimes(1);
});
it('returns undefined (fire-and-forget)', () => {
const result = initializeShellEnv();
expect(result).toBeUndefined();
});
});
describe('waitForShellEnv', () => {
it('returns a promise', () => {
const result = waitForShellEnv();
expect(result).toBeInstanceOf(Promise);
});
it('resolves when the underlying promise resolves', async () => {
mockInitialize = jest.fn(() => Promise.resolve('shell-ready'));
await expect(waitForShellEnv()).resolves.toBe('shell-ready');
});
it('returns the same promise on repeated calls', () => {
const p1 = waitForShellEnv();
const p2 = waitForShellEnv();
expect(p1).toBe(p2);
});
it('does not reinitialize if initializeShellEnv was already called', () => {
initializeShellEnv();
waitForShellEnv();
expect(mockInitialize).toHaveBeenCalledTimes(1);
});
it('propagates rejection from the underlying initializer', async () => {
const err = new Error('shell init failed');
mockInitialize = jest.fn(() => Promise.reject(err));
await expect(waitForShellEnv()).rejects.toThrow('shell init failed');
});
describe('timeout', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('rejects after 60 seconds', async () => {
mockInitialize = jest.fn(() => new Promise(() => {})); // never resolves
({ waitForShellEnv } = require('../shell-env-state'));
const p = waitForShellEnv();
jest.advanceTimersByTime(60_000);
await expect(p).rejects.toThrow('Shell environment initialization timed out');
});
it('resets the promise after timeout so next call retries', async () => {
mockInitialize = jest.fn(() => new Promise(() => {}));
({ initializeShellEnv, waitForShellEnv } = require('../shell-env-state'));
const p = waitForShellEnv();
jest.advanceTimersByTime(60_000);
await expect(p).rejects.toThrow('timed out');
// After timeout _promise is null — next call should reinitialize
mockInitialize = jest.fn(() => Promise.resolve('retry-ok'));
const p2 = waitForShellEnv();
await expect(p2).resolves.toBe('retry-ok');
expect(mockInitialize).toHaveBeenCalledTimes(1);
});
it('does not time out if the initializer resolves in time', async () => {
mockInitialize = jest.fn(() => Promise.resolve('fast'));
({ waitForShellEnv } = require('../shell-env-state'));
const p = waitForShellEnv();
jest.advanceTimersByTime(59_999);
await expect(p).resolves.toBe('fast');
});
});
});
});

View File

@@ -111,6 +111,11 @@ function setupProxyAgents({
interpolationOptions,
timeline
}) {
// Clear stale agents so we always recreate them for the current URL
// (handles protocol switches, host changes, and proxy-bypass rules on redirects).
delete requestConfig.httpAgent;
delete requestConfig.httpsAgent;
const disableCache = !preferencesUtil.isSslSessionCachingEnabled();
// Ensure TLS options are properly set

View File

@@ -367,6 +367,17 @@ export const bruExampleToJson = (data: string | any, parsed: boolean = false, pa
transformedType = 'http-request';
}
/**
* Backward compatibility (pre-v3.0.2 - v3.2.0): Postman imports before PR #6876 stored status/statusText swapped
* (code: "OK", text: "202" instead of code: 202, text: "OK"). Detect and swap back.
* TODO(Sid / Shubh): Remove after v5 — all collections should be migrated by then.
*/
let status = _.get(json, 'response.status', '200');
let statusText = _.get(json, 'response.statusText', 'OK');
if (isNaN(Number(status)) && !isNaN(Number(statusText))) {
[status, statusText] = [statusText, status];
}
// Follow the same structure as the main request, but with missing fields for examples
const transformedJson = {
type: transformedType,
@@ -388,8 +399,8 @@ export const bruExampleToJson = (data: string | any, parsed: boolean = false, pa
name: header.name,
value: header.value
})),
status: String(_.get(json, 'response.status', '200')),
statusText: _.get(json, 'response.statusText', 'OK'),
status: Number(status) || 200,
statusText: statusText || 'OK',
body: {
type: _.get(json, 'response.body.type', 'json'),
content: _.get(json, 'response.body.content', '')

View File

@@ -0,0 +1,109 @@
const { bruExampleToJson } = require('../index');
describe('bruExampleToJson - status/statusText swap fix', () => {
it('should parse normal status and statusText correctly', () => {
const parsed = {
name: 'Normal Example',
description: '',
request: { url: 'https://api.example.com/test', method: 'get' },
response: {
headers: [],
status: '200',
statusText: 'OK',
body: { type: 'json', content: '{}' }
}
};
const result = bruExampleToJson(parsed, true, 'http', 'GET');
expect(result.response.status).toBe(200);
expect(result.response.statusText).toBe('OK');
});
it('should swap back status and statusText when they are reversed (pre-fix Postman import)', () => {
const parsed = {
name: 'Swapped Example',
description: '',
request: { url: 'https://api.example.com/test', method: 'get' },
response: {
headers: [],
status: 'Accepted',
statusText: '202',
body: { type: 'json', content: '{}' }
}
};
const result = bruExampleToJson(parsed, true, 'http', 'GET');
expect(result.response.status).toBe(202);
expect(result.response.statusText).toBe('Accepted');
});
it('should swap back status OK and statusText 200', () => {
const parsed = {
name: 'Swapped OK Example',
description: '',
request: { url: 'https://api.example.com/test', method: 'get' },
response: {
headers: [],
status: 'OK',
statusText: '200',
body: { type: 'json', content: '{}' }
}
};
const result = bruExampleToJson(parsed, true, 'http', 'GET');
expect(result.response.status).toBe(200);
expect(result.response.statusText).toBe('OK');
});
it('should swap back status Not Found and statusText 404', () => {
const parsed = {
name: 'Swapped Not Found Example',
description: '',
request: { url: 'https://api.example.com/test', method: 'get' },
response: {
headers: [],
status: 'Not Found',
statusText: '404',
body: { type: 'json', content: '{}' }
}
};
const result = bruExampleToJson(parsed, true, 'http', 'GET');
expect(result.response.status).toBe(404);
expect(result.response.statusText).toBe('Not Found');
});
it('should not swap when status is already numeric and statusText is text (correct order)', () => {
const parsed = {
name: 'Correct Order Example',
description: '',
request: { url: 'https://api.example.com/test', method: 'get' },
response: {
headers: [],
status: '404',
statusText: 'Not Found',
body: { type: 'json', content: '{}' }
}
};
const result = bruExampleToJson(parsed, true, 'http', 'GET');
expect(result.response.status).toBe(404);
expect(result.response.statusText).toBe('Not Found');
});
it('should use defaults when response has no status', () => {
const parsed = {
name: 'No Status Example',
description: '',
request: { url: 'https://api.example.com/test', method: 'get' },
response: {
headers: [],
body: { type: 'json', content: '{}' }
}
};
const result = bruExampleToJson(parsed, true, 'http', 'GET');
expect(result.response.status).toBe(200);
expect(result.response.statusText).toBe('OK');
});
});

View File

@@ -23,7 +23,7 @@
"postcss": "8.4.47",
"react": "19.0.0",
"react-dom": "18.2.0",
"rollup":"3.29.5",
"rollup":"3.30.0",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",
@@ -33,8 +33,5 @@
"peerDependencies": {
"graphql": "^16.6.0",
"markdown-it": "^13.0.1"
},
"overrides": {
"rollup":"3.29.5"
}
}

View File

@@ -122,6 +122,14 @@ describe('Examples functionality', () => {
expect(output).toEqual(expected);
});
it('should parse swapped status/statusText from pre-fix Postman imports', () => {
const input = fs.readFileSync(path.join(__dirname, 'fixtures', 'bru', 'bruToJson-swapped-status.bru'), 'utf8');
const expected = require('./fixtures/json/bruToJson-swapped-status.json');
const output = bruToJson(input);
expect(output).toEqual(expected);
});
});
describe('jsonToBru conversion', () => {

View File

@@ -0,0 +1,36 @@
meta {
name: Test API
type: http
}
get {
url: https://api.example.com/test
}
example {
name: Swapped Status Example
description: Example with swapped status and statusText from pre-fix Postman import
request: {
url: https://api.example.com/users/123
method: get
mode: none
}
response: {
status: {
code: Accepted
text: 202
}
body: {
type: json
content: '''
{
"id": 123,
"name": "John Doe"
}
'''
}
}
}

View File

@@ -0,0 +1,32 @@
{
"meta": {
"name": "Test API",
"type": "http",
"seq": 1
},
"http": {
"method": "get",
"url": "https://api.example.com/test"
},
"examples": [
{
"name": "Swapped Status Example",
"description": "Example with swapped status and statusText from pre-fix Postman import",
"request": {
"url": "https://api.example.com/users/123",
"method": "get",
"body": {
"mode": "none"
}
},
"response": {
"status": "Accepted",
"statusText": "202",
"body": {
"type": "json",
"content": "{\n \"id\": 123,\n \"name\": \"John Doe\"\n}"
}
}
}
]
}

View File

@@ -24,13 +24,10 @@
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"rollup":"3.29.5",
"rollup":"3.30.0",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
},
"overrides": {
"rollup":"3.29.5"
}
}

View File

@@ -446,12 +446,6 @@ function createAgents({
httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: httpProxyAgentOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname });
}
}
} else {
// If proxy should not be used, only set HTTPS agent for HTTPS requests
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: https.Agent, options: tlsOptions as any, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
}
// HTTP requests without proxy don't need a custom agent
}
} else if (proxyMode === 'system') {
const http_proxy = get(systemProxyConfig, 'http_proxy');

View File

@@ -5,6 +5,15 @@ router.get('/path/*', (req, res) => {
return res.json({ url: req.url });
});
// Echo back request headers - useful for testing header manipulation
router.all('/headers', (req, res) => {
return res.json({
method: req.method,
headers: req.headers,
url: req.url
});
});
router.post('/json', (req, res) => {
return res.json(req.body);
});

View File

@@ -0,0 +1,177 @@
import { test, expect } from '../../../playwright';
import {
createCollection,
createRequest,
openRequest,
closeAllCollections,
selectRequestPaneTab,
sendRequest
} from '../../utils/page';
import { buildCommonLocators, getTableCell } from '../../utils/page/locators';
const selectAllShortcut = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
/**
* E2E test for multipart/mixed boundary preservation
* Regression test for: https://github.com/usebruno/bruno/issues/7523
*
* When a user specifies a boundary parameter in their Content-Type header
* for multipart/mixed requests with TEXT body mode, Bruno should preserve
* the user-defined boundary instead of generating a new one.
*/
test.describe.serial('Multipart boundary preservation', () => {
const collectionName = 'multipart-boundary-test';
const requestName = 'Boundary Test';
const testServerUrl = 'http://localhost:8081/headers';
const customBoundary = 'my-custom-boundary-12345';
test.beforeAll(async ({ page, createTmpDir }) => {
const collectionPath = await createTmpDir('multipart-boundary-collection');
await createCollection(page, collectionName, collectionPath);
await createRequest(page, requestName, collectionName, {
url: testServerUrl,
method: 'GET'
});
});
test.afterAll(async ({ page }) => {
await closeAllCollections(page);
});
test('should preserve user-defined boundary in multipart/mixed Content-Type header', async ({ page }) => {
const locators = buildCommonLocators(page);
await test.step('Open request and configure headers', async () => {
await openRequest(page, collectionName, requestName);
// Go to Headers tab and add Content-Type header with custom boundary
await selectRequestPaneTab(page, 'Headers');
// Find the first row in headers table (empty row for adding new headers)
const headerRow = page.locator('table tbody tr').first();
await headerRow.waitFor({ state: 'visible' });
// Get the name cell (first column after checkbox) and enter header name
const nameCell = getTableCell(headerRow, 0);
await nameCell.locator('.CodeMirror').click();
await nameCell.locator('textarea').fill('Content-Type');
// Get the value cell (second column) and enter header value with custom boundary
const valueCell = getTableCell(headerRow, 1);
await valueCell.locator('.CodeMirror').click();
await valueCell.locator('textarea').fill(`multipart/mixed; boundary=${customBoundary}`);
});
await test.step('Set body to TEXT mode with multipart content', async () => {
await selectRequestPaneTab(page, 'Body');
// Select Text body mode
await locators.request.bodyModeSelector().click();
await locators.dropdown.item('Text').click();
// Enter multipart body content using the custom boundary
const bodyCodeMirror = locators.request.bodyEditor().locator('.CodeMirror');
await bodyCodeMirror.click();
await page.keyboard.press(selectAllShortcut);
const multipartBody = `--${customBoundary}\r
Content-Disposition: form-data; name="field1"\r
\r
value1\r
--${customBoundary}--`;
await page.keyboard.type(multipartBody);
});
await test.step('Send request and verify boundary is preserved', async () => {
// Use longer timeout for external service (httpbin.org)
await sendRequest(page, 200, 30000);
// httpbin.org/post returns request headers in the response JSON
// We need to verify the Content-Type header contains our custom boundary
// and NOT a duplicate auto-generated boundary
const responseBody = locators.response.previewContainer();
// Verify the response contains our custom boundary
await expect(responseBody).toContainText(customBoundary, { timeout: 10000 });
// Verify there's only one boundary parameter (not duplicated)
// The response should show: "Content-Type": "multipart/mixed; boundary=my-custom-boundary-12345"
const responseText = await responseBody.innerText();
// Count occurrences of "boundary=" - should be exactly 1 (not duplicated)
const boundaryMatches = responseText.match(/boundary=/gi);
expect(boundaryMatches).not.toBeNull();
expect(boundaryMatches?.length).toBe(1);
});
});
test('should auto-generate boundary when none is specified in Content-Type header', async ({ page }) => {
const locators = buildCommonLocators(page);
const requestNameNoBoundary = 'No Boundary Test';
await test.step('Create a new request without boundary', async () => {
await createRequest(page, requestNameNoBoundary, collectionName, {
url: testServerUrl,
method: 'GET'
});
});
await test.step('Open request and configure headers without boundary', async () => {
await openRequest(page, collectionName, requestNameNoBoundary);
// Go to Headers tab and add Content-Type header WITHOUT boundary
await selectRequestPaneTab(page, 'Headers');
const headerRow = page.locator('table tbody tr').first();
await headerRow.waitFor({ state: 'visible' });
const nameCell = getTableCell(headerRow, 0);
await nameCell.locator('.CodeMirror').click();
await nameCell.locator('textarea').fill('Content-Type');
// Set Content-Type to multipart/mixed WITHOUT specifying a boundary
const valueCell = getTableCell(headerRow, 1);
await valueCell.locator('.CodeMirror').click();
await valueCell.locator('textarea').fill('multipart/mixed');
});
await test.step('Set body to Multipart Form mode and add a field', async () => {
await selectRequestPaneTab(page, 'Body');
// Select Multipart Form body mode so Bruno has data to create FormData from
await locators.request.bodyModeSelector().click();
await locators.dropdown.item('Multipart Form').click();
// Wait for the body editor to switch to multipart form mode
await page.waitForTimeout(500);
// The multipart form has an editable table - find and fill the first row
// The name column has placeholder "Key" (defined in MultipartFormParams columns)
const nameInput = page.locator('[data-testid="editable-table"] input[placeholder="Key"]').first();
await nameInput.waitFor({ state: 'visible', timeout: 5000 });
await nameInput.click();
await nameInput.fill('testField');
// Tab to value and fill it
await page.keyboard.press('Tab');
await page.keyboard.type('testValue');
});
await test.step('Send request and verify boundary was auto-generated', async () => {
await sendRequest(page, 200, 30000);
const responseBody = locators.response.previewContainer();
const responseText = await responseBody.innerText();
// Verify that a boundary parameter exists (was auto-generated)
const boundaryMatches = responseText.match(/boundary=/gi);
expect(boundaryMatches).not.toBeNull();
expect(boundaryMatches?.length).toBe(1);
// Verify the Content-Type contains multipart/mixed with a boundary
await expect(responseBody).toContainText('multipart/mixed', { timeout: 5000 });
await expect(responseBody).toContainText('boundary=', { timeout: 5000 });
});
});
});

View File

@@ -0,0 +1,16 @@
meta {
name: http-to-https-redirect
type: http
seq: 7
}
get {
url: http://localhost:8091
body: none
auth: inherit
}
assert {
res.status: eq 200
res.body: eq helloworld
}

View File

@@ -2,6 +2,7 @@
const path = require('node:path');
const fs = require('node:fs');
const http = require('node:http');
const https = require('node:https');
const WebSocket = require('ws');
const { killProcessOnPort } = require('./helpers/platform');
@@ -79,6 +80,21 @@ function createServer(certsDir, port = 8090) {
});
}
function createHttpRedirectServer(httpsPort, httpPort = 8091) {
const server = http.createServer((req, res) => {
const redirectUrl = `https://localhost:${httpsPort}${req.url}`;
res.writeHead(301, { Location: redirectUrl });
res.end();
});
return new Promise((resolve, reject) => {
server.listen(httpPort, (error) => {
if (error) reject(error);
else resolve(server);
});
});
}
function shutdownServer(server, cleanup) {
const shutdown = (signal) => {
console.log(`🛑 Received ${signal}, shutting down`);
@@ -104,11 +120,16 @@ async function startServer() {
try {
killProcessOnPort(port);
killProcessOnPort(8091);
console.log(`🌐 Creating server on port ${port}`);
const server = await createServer(certsDir, port);
console.log(`🌐 Creating HTTP redirect server on port 8091 → ${port}`);
const httpRedirectServer = await createHttpRedirectServer(port);
shutdownServer(server, () => {
httpRedirectServer.close();
console.log('✨ Server cleanup completed');
});
} catch (error) {

View File

@@ -13,8 +13,8 @@ test.describe('custom invalid ca cert added to the config and keep default ca ce
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});
@@ -31,8 +31,8 @@ test.describe('custom invalid ca cert added to the config and keep default ca ce
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});

View File

@@ -13,9 +13,9 @@ test.describe.serial('custom invalid ca cert added to the config and NO default
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
totalRequests: 2,
passed: 0,
failed: 1,
failed: 2,
skipped: 0
});
});
@@ -31,9 +31,9 @@ test.describe.serial('custom invalid ca cert added to the config and NO default
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
totalRequests: 2,
passed: 0,
failed: 1,
failed: 2,
skipped: 0
});
});

View File

@@ -13,8 +13,8 @@ test.describe('custom valid ca cert added to the config and keep default ca cert
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});
@@ -31,8 +31,8 @@ test.describe('custom valid ca cert added to the config and keep default ca cert
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});

View File

@@ -13,8 +13,8 @@ test.describe('custom valid ca cert added to the config and NO default ca certs'
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});
@@ -31,8 +31,8 @@ test.describe('custom valid ca cert added to the config and NO default ca certs'
// Validate test results
await validateRunnerResults(page, {
totalRequests: 1,
passed: 1,
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});