mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-05 02:18:32 +00:00
Compare commits
13 Commits
release/v3
...
v3.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77916019cd | ||
|
|
02aa669578 | ||
|
|
d8809e09e7 | ||
|
|
04fdd6f8a9 | ||
|
|
3097f3aa76 | ||
|
|
9c3eabdda2 | ||
|
|
7c4da8b8bc | ||
|
|
1e4c3464d2 | ||
|
|
5695f69430 | ||
|
|
d0bbac6b66 | ||
|
|
51e2c045ec | ||
|
|
b585c3e943 | ||
|
|
8150a21395 |
@@ -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
9153
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -7,7 +7,8 @@ export {
|
||||
|
||||
export {
|
||||
buildFormUrlEncodedPayload,
|
||||
isFormData
|
||||
isFormData,
|
||||
extractBoundaryFromContentType
|
||||
} from './form-data';
|
||||
|
||||
export {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
28
packages/bruno-electron/src/store/shell-env-state.js
Normal file
28
packages/bruno-electron/src/store/shell-env-state.js
Normal 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 };
|
||||
105
packages/bruno-electron/src/store/tests/shell-env-state.spec.js
Normal file
105
packages/bruno-electron/src/store/tests/shell-env-state.spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
177
tests/request/multipart-boundary/multipart-boundary.spec.ts
Normal file
177
tests/request/multipart-boundary/multipart-boundary.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user