Compare commits

..

1 Commits

Author SHA1 Message Date
naman-bruno
43fdf00ed0 fix: improve error handling modal for postman import 2026-04-06 18:12:10 +05:30
83 changed files with 1067 additions and 2294 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1 +1 @@
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno

View File

@@ -73,7 +73,7 @@ jobs:
- name: Post PR comment
if: hashFiles('pr-comment.md') != ''
uses: actions/github-script@v9
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');

View File

@@ -324,7 +324,7 @@ test('should create and execute HTTP request', async ({ page, createTmpDir }) =>
await page.getByRole('button', { name: 'Create' }).click();
// Execute request
await page.getByTestId('send-arrow-icon').click();
await page.locator('#send-request').getByRole('img').nth(2).click();
// Verify response
await expect(page.getByRole('main')).toContainText('200 OK');

130
package-lock.json generated
View File

@@ -30,7 +30,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.9.1",
"@opencollection/types": "0.9.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -9642,9 +9642,9 @@
}
},
"node_modules/@opencollection/types": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.9.1.tgz",
"integrity": "sha512-kYJvPSvR9XohCo7qACiCQEbWlvj4KgxM8igrTEhudIxTO1QAy8BBOEUeHLqYeSFz1MSSW1CuWkMJOyw/egr7Gg==",
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.9.0.tgz",
"integrity": "sha512-2p9Pb1cSpUBvtsnvsHtqxbzmJtUvkfE7r2R/BVWiVG0CRohvuhyClcgb061aa/95TEo0cXdXKLXmtZSGWvf1NA==",
"dev": true,
"license": "MIT"
},
@@ -17654,32 +17654,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/degenerator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
"integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
"license": "MIT",
"dependencies": {
"ast-types": "^0.13.4",
"escodegen": "^2.1.0",
"esprima": "^4.0.1"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/degenerator/node_modules/ast-types": {
"version": "0.13.4",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
"integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.1"
},
"engines": {
"node": ">=4"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -18586,6 +18560,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
"integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"esprima": "^4.0.1",
@@ -18607,6 +18582,7 @@
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
@@ -18989,6 +18965,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
@@ -24701,15 +24678,6 @@
"node": ">= 10"
}
},
"node_modules/netmask": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
"integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/new-github-issue-url": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/new-github-issue-url/-/new-github-issue-url-0.2.1.tgz",
@@ -25222,19 +25190,6 @@
"node": ">=6"
}
},
"node_modules/pac-resolver": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
"integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
"license": "MIT",
"dependencies": {
"degenerator": "^5.0.0",
"netmask": "^2.0.2"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -34995,7 +34950,7 @@
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@opencollection/types": "0.9.1",
"@opencollection/types": "0.9.0",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
@@ -36014,8 +35969,6 @@
"http-proxy-agent": "~7.0.2",
"https-proxy-agent": "~7.0.6",
"is-ip": "^5.0.1",
"pac-resolver": "^7.0.1",
"quickjs-emscripten": "^0.32.0",
"shell-env": "^4.0.1",
"socks-proxy-agent": "~8.0.5",
"system-ca": "^2.0.1",
@@ -36057,48 +36010,6 @@
"npm": ">=9.0.0"
}
},
"packages/bruno-requests/node_modules/@jitl/quickjs-ffi-types": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
"license": "MIT"
},
"packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz",
"integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-debug-sync": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz",
"integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-release-asyncify": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz",
"integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-requests/node_modules/@jitl/quickjs-wasmfile-release-sync": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz",
"integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-requests/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -36122,31 +36033,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"packages/bruno-requests/node_modules/quickjs-emscripten": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz",
"integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0",
"@jitl/quickjs-wasmfile-debug-sync": "0.32.0",
"@jitl/quickjs-wasmfile-release-asyncify": "0.32.0",
"@jitl/quickjs-wasmfile-release-sync": "0.32.0",
"quickjs-emscripten-core": "0.32.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"packages/bruno-requests/node_modules/quickjs-emscripten-core": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-requests/node_modules/tough-cookie": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",

View File

@@ -23,7 +23,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.9.1",
"@opencollection/types": "0.9.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",

View File

@@ -0,0 +1,86 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.import-error-content {
display: flex;
flex-direction: column;
gap: 14px;
}
.error-banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
padding-left: 0;
border-radius: 6px;
background-color: ${(props) => props.theme.colors.danger}11;
border: 1px solid ${(props) => props.theme.colors.danger}33;
.error-icon {
color: ${(props) => props.theme.colors.danger};
flex-shrink: 0;
margin-top: 2px;
}
.error-message {
color: ${(props) => props.theme.text};
font-size: 14px;
font-weight: 500;
line-height: 1.4;
word-break: break-word;
}
}
.error-raw {
pre {
margin: 0;
padding: 10px 12px;
border-radius: 6px;
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
font-size: 12px;
line-height: 1.5;
color: ${(props) => props.theme.text};
opacity: 0.8;
white-space: pre-wrap;
word-break: break-word;
max-height: 140px;
overflow-y: auto;
font-family: monospace;
}
}
.error-hint {
font-size: 12px;
color: ${(props) => props.theme.text};
opacity: 0.5;
line-height: 1.4;
}
.error-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
.action-button {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.text};
transition: background-color 0.15s ease;
&:hover {
background-color: ${(props) => props.theme.input.border};
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,97 @@
import React, { useState, useCallback } from 'react';
import { IconAlertTriangle, IconCopy, IconCheck, IconBrandGithub } from '@tabler/icons';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import StyledWrapper from './StyledWrapper';
const GITHUB_ISSUES_URL = 'https://github.com/usebruno/bruno/issues/new';
const ImportErrorModal = ({ title = 'Import Failed', error, onClose }) => {
const [copied, setCopied] = useState(false);
const errorMessage = error?.message || 'An unknown error occurred during import.';
const rawError = error?.rawError || null;
const copyErrorToClipboard = useCallback(() => {
navigator.clipboard.writeText(rawError || errorMessage).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [errorMessage, rawError]);
const reportOnGithub = useCallback(() => {
const body = [
'### Description',
'Postman collection import failed with the following error:',
'',
'### Error Details',
'```',
rawError || errorMessage,
'```',
'',
'### Additional Context',
'<!-- Attach your Postman collection JSON (with sensitive data redacted) if possible -->'
].join('\n');
const params = new URLSearchParams({
title: `Postman import failure: ${errorMessage.slice(0, 80)}`,
body,
labels: 'bug'
});
window.open(`${GITHUB_ISSUES_URL}?${params.toString()}`, '_blank');
}, [errorMessage, rawError]);
return (
<StyledWrapper>
<Portal>
<Modal
size="md"
title={title}
hideFooter={true}
handleCancel={onClose}
dataTestId="import-error-modal"
>
<div className="import-error-content">
<div className="error-banner">
<IconAlertTriangle size={20} className="error-icon" />
<div className="error-message">{errorMessage}</div>
</div>
{rawError && (
<div className="error-raw">
<pre>{rawError}</pre>
</div>
)}
<p className="error-hint">
Ensure your Postman collection is valid JSON and uses a supported schema (v2.0 or v2.1).
</p>
<div className="error-actions">
<button className="action-button" onClick={reportOnGithub}>
<IconBrandGithub size={14} />
<span>Report on GitHub</span>
</button>
<button className="action-button" onClick={copyErrorToClipboard}>
{copied ? (
<>
<IconCheck size={14} />
<span>Copied</span>
</>
) : (
<>
<IconCopy size={14} />
<span>Copy Error</span>
</>
)}
</button>
</div>
</div>
</Modal>
</Portal>
</StyledWrapper>
);
};
export default ImportErrorModal;

View File

@@ -5,7 +5,7 @@ const StyledWrapper = styled.div`
flex-direction: column;
gap: 1rem;
width: 100%;
.settings-label {
width: 100px;
}
@@ -26,57 +26,6 @@ const StyledWrapper = styled.div`
}
}
.pac-mode-toggle {
display: inline-flex;
flex-shrink: 0;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.base};
overflow: hidden;
margin-right: 12px;
}
.pac-mode-btn {
height: 34px;
padding: 0.1rem 0.6rem;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
transition: background 0.12s, color 0.12s;
white-space: nowrap;
&.active {
background: ${(props) => props.theme.button.secondary.bg};
color: ${(props) => props.theme.button.secondary.color};
}
&:hover:not(.active) {
color: ${(props) => props.theme.text};
}
}
.pac-source-input {
width: 265px;
}
.pac-file-btn {
text-align: left;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pac-hint {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
margin-top: 4px;
padding-left: 100px;
}
.system-proxy-settings {
label {
color: ${(props) => props.theme.colors.text.yellow};

View File

@@ -17,22 +17,7 @@ const ProxySettings = ({ close }) => {
const proxySchema = Yup.object({
disabled: Yup.boolean().optional(),
source: Yup.string().oneOf(['manual', 'pac', 'inherit']).required(),
pac: Yup.object({
source: Yup.string()
.optional()
.test('pac-url', 'Specify a valid PAC URL', (value) => {
if (!value) return true;
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'file:';
} catch {
return false;
}
})
.max(2048)
.nullable()
}).optional(),
inherit: Yup.boolean().required(),
config: Yup.object({
protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']),
hostname: Yup.string().max(1024),
@@ -54,10 +39,7 @@ const ProxySettings = ({ close }) => {
const formik = useFormik({
initialValues: {
disabled: preferences.proxy.disabled || false,
source: preferences.proxy.source || 'manual',
pac: {
source: preferences.proxy.pac?.source || ''
},
inherit: preferences.proxy.inherit || false,
config: {
protocol: preferences.proxy.config?.protocol || 'http',
hostname: preferences.proxy.config?.hostname || '',
@@ -104,26 +86,15 @@ const ProxySettings = ({ close }) => {
);
const [passwordVisible, setPasswordVisible] = useState(false);
const [proxyMode, setProxyMode] = useState(() => {
if (preferences.proxy.disabled) return 'off';
if (preferences.proxy.source === 'pac') return 'pac';
if (preferences.proxy.source === 'inherit') return 'inherit';
return 'manual';
});
const [pacInputMode, setPacInputMode] = useState(() =>
preferences.proxy.pac?.source?.startsWith('file://') ? 'file' : 'url'
);
useEffect(() => {
if (formik.dirty && formik.isValid) {
// Don't auto-save PAC mode until a URL or file is actually selected.
if (proxyMode === 'pac' && !formik.values.pac.source) return;
debouncedSave(formik.values);
}
return () => {
debouncedSave.flush();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave, proxyMode]);
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
return (
<StyledWrapper>
@@ -139,10 +110,10 @@ const ProxySettings = ({ close }) => {
type="radio"
name="mode"
value="off"
checked={proxyMode === 'off'}
checked={formik.values.disabled === true}
onChange={(e) => {
setProxyMode('off');
formik.setFieldValue('disabled', true);
formik.setFieldValue('inherit', false);
}}
className="mr-1 cursor-pointer"
/>
@@ -152,12 +123,11 @@ const ProxySettings = ({ close }) => {
<input
type="radio"
name="mode"
value="manual"
checked={proxyMode === 'manual'}
value="on"
checked={formik.values.disabled === false && formik.values.inherit === false}
onChange={(e) => {
setProxyMode('manual');
formik.setFieldValue('disabled', false);
formik.setFieldValue('source', 'manual');
formik.setFieldValue('inherit', false);
}}
className="mr-1 cursor-pointer"
/>
@@ -167,40 +137,24 @@ const ProxySettings = ({ close }) => {
<input
type="radio"
name="mode"
value="inherit"
checked={proxyMode === 'inherit'}
value="system"
checked={formik.values.disabled === false && formik.values.inherit === true}
onChange={(e) => {
setProxyMode('inherit');
formik.setFieldValue('disabled', false);
formik.setFieldValue('source', 'inherit');
formik.setFieldValue('inherit', true);
}}
className="mr-1 cursor-pointer"
/>
System Proxy
</label>
<label className="flex items-center ml-4 cursor-pointer">
<input
type="radio"
name="mode"
value="pac"
checked={proxyMode === 'pac'}
onChange={(e) => {
setProxyMode('pac');
formik.setFieldValue('disabled', false);
formik.setFieldValue('source', 'pac');
}}
className="mr-1 cursor-pointer"
/>
PAC
</label>
</div>
</div>
{proxyMode === 'inherit' ? (
{formik.values.disabled === false && formik.values.inherit === true ? (
<div className="mb-3 pt-1 text-muted system-proxy-settings">
<SystemProxy />
</div>
) : null}
{proxyMode === 'manual' ? (
{formik.values.disabled === false && formik.values.inherit === false ? (
<>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="protocol">
@@ -381,79 +335,6 @@ const ProxySettings = ({ close }) => {
</div>
</>
) : null}
{proxyMode === 'pac' ? (
<>
<div className="mb-3">
<div className="flex items-center">
<label className="settings-label">PAC</label>
<div className="pac-mode-toggle">
<button
type="button"
className={`pac-mode-btn ${pacInputMode === 'url' ? 'active' : ''}`}
onClick={() => {
setPacInputMode('url');
formik.setFieldValue('pac.source', '');
}}
>
URL
</button>
<button
type="button"
className={`pac-mode-btn ${pacInputMode === 'file' ? 'active' : ''}`}
onClick={() => {
setPacInputMode('file');
formik.setFieldValue('pac.source', '');
}}
>
File
</button>
</div>
{pacInputMode === 'url' ? (
<input
id="pac.source"
type="text"
name="pac.source"
className="block textbox pac-source-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.pac.source || ''}
placeholder="https://example.com/proxy.pac"
/>
) : (
<button
type="button"
className="textbox pac-source-input pac-file-btn"
onClick={() => {
window.ipcRenderer
.invoke('renderer:browse-pac-file')
.then((fileUrl) => {
if (fileUrl) {
formik.setFieldValue('pac.source', fileUrl);
}
})
.catch(() => toast.error('Failed to open file picker'));
}}
>
{formik.values.pac.source
? decodeURIComponent(formik.values.pac.source.split('/').pop())
: 'Choose file...'}
</button>
)}
{formik.touched.pac?.source && formik.errors.pac?.source ? (
<div className="ml-3 text-red-500">{formik.errors.pac.source}</div>
) : null}
</div>
<p className="pac-hint">
{pacInputMode === 'url'
? 'Enter the URL to your PAC file'
: 'Supports .pac files for automatic proxy configuration'}
</p>
</div>
</>
) : null}
</form>
</StyledWrapper>
);

View File

@@ -2,13 +2,9 @@ import styled from 'styled-components';
const Wrapper = styled.div`
height: 2.1rem;
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};
.url-input-group {
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};
flex: 1;
min-width: 0;
}
.infotip {
position: relative;
@@ -53,7 +49,6 @@ const Wrapper = styled.div`
.shortcut {
font-size: 0.625rem;
}
`;
export default Wrapper;

View File

@@ -16,9 +16,8 @@ import { saveRequest, cancelRequest } from 'providers/ReduxStore/slices/collecti
import { getRequestFromCurlCommand } from 'utils/curl';
import HttpMethodSelector from './HttpMethodSelector';
import { useTheme } from 'providers/Theme';
import { IconDeviceFloppy, IconCode } from '@tabler/icons';
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import SendButton from 'components/RequestPane/SendButton';
import { isMacOS } from 'utils/common/platform';
import { hasRequestChanges } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
@@ -385,67 +384,76 @@ const QueryUrl = ({ item, collection, handleRun }) => {
};
return (
<StyledWrapper className="flex items-center w-full">
<div className="flex items-center h-full url-input-group">
<div className="flex items-center h-full min-w-fit">
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
<div className="flex items-center h-full min-w-fit">
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
</div>
<div
id="request-url"
className="h-full w-full flex flex-row input-container overflow-auto"
>
<SingleLineEditor
ref={editorRef}
value={url}
placeholder="Enter URL or paste a cURL request"
onSave={(finalValue) => onSave(finalValue)}
theme={storedTheme}
onChange={(newValue) => onUrlChange(newValue)}
onRun={handleRun}
onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null}
collection={collection}
highlightPathParams={true}
item={item}
showNewlineArrow={true}
/>
</div>
<div className="flex items-center h-full mx-2 gap-3 cursor-pointer" id="send-request" onClick={handleRun}>
<div
title="Generate Code"
className="infotip"
onClick={(e) => {
handleGenerateCode(e);
}}
>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
id="request-url"
className="h-full w-full flex flex-row items-center input-container overflow-hidden"
title="Save Request"
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (!hasChanges) return;
onSave();
}}
>
<SingleLineEditor
ref={editorRef}
value={url}
placeholder="Enter URL or paste a cURL request"
onSave={(finalValue) => onSave(finalValue)}
theme={storedTheme}
onChange={(newValue) => onUrlChange(newValue)}
onRun={handleRun}
onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null}
collection={collection}
highlightPathParams={true}
item={item}
showNewlineArrow={true}
<IconDeviceFloppy
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<div className="flex items-center h-full mx-2 gap-3" id="request-actions">
<div
title="Generate Code"
className="infotip"
onClick={(e) => {
handleGenerateCode(e);
}}
>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
title="Save Request"
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (!hasChanges) return;
onSave();
}}
>
<IconDeviceFloppy
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotiptext text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
</div>
<span className="infotiptext text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isLoading || item.response?.stream?.running ? (
<IconSquareRoundedX
color={theme.requestTabPanel.url.iconDanger}
strokeWidth={1.5}
size={20}
data-testid="cancel-request-icon"
onClick={handleCancelRequest}
/>
) : (
<IconArrowRight
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={20}
data-testid="send-arrow-icon"
/>
)}
</div>
<SendButton
isLoading={isLoading || item.response?.stream?.running}
onSend={handleRun}
onCancel={handleCancelRequest}
testId="send-arrow-icon"
/>
{generateCodeItemModalOpen && (
<GenerateCodeItem
collectionUid={collection.uid}

View File

@@ -1,20 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
align-self: stretch;
min-width: 4.1rem;
flex-shrink: 0;
> div {
display: flex;
flex: 1;
}
button {
width: 100%;
height: 100%;
}
`;
export default StyledWrapper;

View File

@@ -1,22 +0,0 @@
import React from 'react';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
const SendButton = ({ isLoading = false, onSend, onCancel, testId = 'send-request-btn' }) => {
return (
<StyledWrapper className="ml-2">
<Button
size="sm"
variant={isLoading ? 'outline' : 'filled'}
color="primary"
data-testid={testId}
data-action={isLoading ? 'cancel' : 'send'}
onClick={isLoading ? onCancel : onSend}
>
{isLoading ? 'Cancel' : 'Send'}
</Button>
</StyledWrapper>
);
};
export default SendButton;

View File

@@ -3,12 +3,12 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 2.1rem;
position: relative;
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};
.input-container {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
border: ${(props) => props.theme.requestTabPanel.url.border};
border-radius: ${(props) => props.theme.border.radius.base};
position: relative;
input {
background-color: ${(props) => props.theme.requestTabPanel.url.bg};
@@ -99,7 +99,6 @@ const StyledWrapper = styled.div`
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,5 +1,4 @@
import { IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
import SendButton from 'components/RequestPane/SendButton';
import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
import classnames from 'classnames';
import SingleLineEditor from 'components/SingleLineEditor/index';
import { requestUrlChanged } from 'providers/ReduxStore/slices/collections';
@@ -124,7 +123,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
return (
<StyledWrapper>
<div className="flex items-center h-full">
<div className="flex items-center input-container flex-1 min-w-0 h-full relative">
<div className="flex items-center input-container flex-1 w-full h-full relative">
<div className="flex items-center justify-center px-[10px]">
<span className="text-xs font-medium method-ws">WS</span>
</div>
@@ -188,14 +187,15 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
</div>
</div>
)}
<div data-testid="run-button" className="cursor-pointer" onClick={handleRunClick}>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={20} />
</div>
</div>
{connectionStatus === CONNECTION_STATUS.CONNECTED && <div className="connection-status-strip"></div>}
</div>
<SendButton
onSend={handleRunClick}
testId="run-button"
/>
</div>
{connectionStatus === CONNECTION_STATUS.CONNECTED && <div className="connection-status-strip"></div>}
</StyledWrapper>
);
};

View File

@@ -25,11 +25,12 @@ const convertFileToObject = async (file) => {
const parsed = jsyaml.load(text);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error();
throw new Error('File content is not a valid object');
}
return parsed;
} catch {
throw new Error('Failed to parse the file ensure it is valid JSON or YAML');
} catch (err) {
const detail = err.message || '';
throw new Error(`Failed to parse the file: ensure it is valid JSON or YAML.\n${detail}`);
}
};

View File

@@ -12,9 +12,9 @@ import { convertOpenapiToBruno } from 'utils/importers/openapi-collection';
import { processBrunoCollection } from 'utils/importers/bruno-collection';
import { processOpenCollection } from 'utils/importers/opencollection';
import { wsdlToBruno } from '@usebruno/converters';
import { toastError } from 'utils/common/error';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
import Modal from 'components/Modal';
import ImportErrorModal from 'components/ImportErrorModal';
import Help from 'components/Help';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
@@ -87,7 +87,6 @@ const convertCollection = async (format, rawData, groupingType, collectionFormat
return collection;
} catch (err) {
console.error('Conversion error:', err);
toastError(err, 'Failed to convert collection');
throw err;
}
};
@@ -102,6 +101,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format, sour
const dispatch = useDispatch();
const [groupingType, setGroupingType] = useState('tags');
const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT);
const [importError, setImportError] = useState(null);
const isOpenAPISyncEnabled = useBetaFeature(BETA_FEATURES.OPENAPI_SYNC);
const [enableCheckForSpecUpdates, setEnableCheckForSpecUpdates] = useState(isOpenAPISyncEnabled);
const dropdownTippyRef = useRef();
@@ -135,7 +135,17 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format, sour
.required('Location is required')
}),
onSubmit: async (values) => {
const convertedCollection = await convertCollection(format, rawData, groupingType, collectionFormat);
let convertedCollection;
try {
convertedCollection = await convertCollection(format, rawData, groupingType, collectionFormat);
} catch (err) {
setImportError({
message: err.message || 'Failed to convert collection',
rawError: err.rawError || err.message || null
});
return;
}
const options = { format: collectionFormat };
if (showCheckForSpecUpdatesOption && enableCheckForSpecUpdates) {
@@ -345,6 +355,13 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format, sour
)}
</form>
</Modal>
{importError && (
<ImportErrorModal
title="Import Failed"
error={importError}
onClose={() => setImportError(null)}
/>
)}
</StyledWrapper>
);
};

View File

@@ -591,11 +591,10 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
} else {
sendNetworkRequest(itemCopy, collectionCopy, environment, collectionCopy.runtimeVariables)
.then((response) => {
const { requestSent, ...responseData } = response;
// Ensure any timestamps in the response are converted to numbers
const serializedResponse = {
...responseData,
timeline: responseData.timeline?.map((entry) => ({
...response,
timeline: response.timeline?.map((entry) => ({
...entry,
timestamp: entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp
}))
@@ -605,23 +604,18 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
responseReceived({
itemUid,
collectionUid,
response: serializedResponse,
requestSent
response: serializedResponse
})
);
})
.then(resolve)
.catch((err) => {
const request = itemCopy.draft?.request || itemCopy.request;
const requestSent = request ? { url: request.url, method: request.method } : undefined;
if (err && err.message === 'Error invoking remote method \'send-http-request\': Error: Request cancelled') {
dispatch(
responseReceived({
itemUid,
collectionUid,
response: null,
requestSent
response: null
})
);
return;
@@ -639,8 +633,7 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
responseReceived({
itemUid,
collectionUid,
response: errorResponse,
requestSent
response: errorResponse
})
);
});

View File

@@ -538,12 +538,10 @@ export const collectionsSlice = createSlice({
collection.timeline = [];
}
const timelineRequest = action.payload.requestSent || item.requestSent || item.request;
// Ensure timestamp is a number (milliseconds since epoch)
const timestamp = timelineRequest?.timestamp instanceof Date
? timelineRequest.timestamp.getTime()
: timelineRequest?.timestamp || Date.now();
const timestamp = item?.requestSent?.timestamp instanceof Date
? item.requestSent.timestamp.getTime()
: item?.requestSent?.timestamp || Date.now();
// Append the new timeline entry with numeric timestamp
collection.timeline.push({
@@ -554,7 +552,7 @@ export const collectionsSlice = createSlice({
requestUid: item.requestUid,
timestamp: timestamp,
data: {
request: timelineRequest,
request: item.requestSent || item.request,
response: action.payload.response,
timestamp: timestamp
}

View File

@@ -1,5 +1,4 @@
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import { BrunoError, formatIpcError } from 'utils/common/error';
import { safeParseJSON } from 'utils/common/index';
const readFile = (files) => {
@@ -11,13 +10,22 @@ const readFile = (files) => {
});
};
const parseConversionError = (rawMessage) => {
const full = rawMessage || 'Conversion failed';
const summary = full.split('\n')[0].replace(/^Import collection failed:\s*/, '').trim() || full;
return summary;
};
const postmanToBruno = (collection) => {
return new Promise((resolve, reject) => {
window.ipcRenderer.invoke('renderer:convert-postman-to-bruno', collection)
.then((result) => resolve(result))
.catch((err) => {
console.error('Error converting Postman to Bruno via Electron:', err);
reject(new BrunoError('Conversion failed'));
const rawError = formatIpcError(err) || err.message || 'Conversion failed';
const error = new BrunoError(parseConversionError(rawError));
error.rawError = rawError;
reject(error);
});
});
};

View File

@@ -19,8 +19,7 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
statusText: response.statusText,
duration: response.duration,
timeline: response.timeline,
stream: response.stream,
requestSent: response.requestSent
stream: response.stream
});
})
.catch((err) => reject(err));

View File

@@ -36,7 +36,6 @@
"watch": "rollup -c -w",
"prepack": "npm run test && npm run build"
},
"dependencies": {},
"devDependencies": {
"@babel/preset-env": "^7.26.9",
"@babel/preset-typescript": "^7.27.0",
@@ -44,7 +43,6 @@
"@jest/globals": "^29.7.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-terser": "^1.0.0",
"@rollup/plugin-typescript": "^12.1.2",
"@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
@@ -54,6 +52,7 @@
"rollup": "3.30.0",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"@rollup/plugin-terser": "^1.0.0",
"typescript": "^5.8.3"
},
"overrides": {

View File

@@ -7,11 +7,10 @@ const peerDepsExternal = require('rollup-plugin-peer-deps-external');
const packageJson = require('./package.json');
function createBuildConfig({ inputDir, input, cjsOutput, esmOutput, dtsOutput, external = [] }) {
function createBuildConfig({ inputDir, input, cjsOutput, esmOutput }) {
return [
{
input,
external,
output: [
{
file: cjsOutput,
@@ -37,38 +36,30 @@ function createBuildConfig({ inputDir, input, cjsOutput, esmOutput, dtsOutput, e
treeshake: {
moduleSideEffects: false
}
},
{
input,
external,
output: { file: dtsOutput, format: 'es' },
plugins: [dts.default({ tsconfig: './tsconfig.json' })]
}
];
}
// todo: configure declarations
module.exports = [
// Main package build
...createBuildConfig({
inputDir: 'src/**/*',
input: 'src/index.ts',
cjsOutput: packageJson.main,
esmOutput: packageJson.module,
dtsOutput: packageJson.types
esmOutput: packageJson.module
}),
// reports/html
...createBuildConfig({
inputDir: 'src/runner/**/*',
input: 'src/runner/index.ts',
cjsOutput: 'dist/runner/cjs/index.js',
esmOutput: 'dist/runner/esm/index.js',
dtsOutput: 'dist/runner/index.d.ts'
esmOutput: 'dist/runner/esm/index.js'
}),
...createBuildConfig({
inputDir: 'src/utils/**/*',
input: 'src/utils/index.ts',
cjsOutput: 'dist/utils/cjs/index.js',
esmOutput: 'dist/utils/esm/index.js',
dtsOutput: 'dist/utils/index.d.ts'
}),
esmOutput: 'dist/utils/esm/index.js'
})
];

View File

@@ -28,7 +28,7 @@
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@opencollection/types": "0.9.1",
"@opencollection/types": "0.9.0",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",

View File

@@ -292,7 +292,7 @@ export const fromOpenCollectionAuth = (auth: Auth | undefined): BrunoAuth => {
accessTokenSecret: oauth1Auth.accessTokenSecret || null,
callbackUrl: oauth1Auth.callbackUrl || null,
verifier: oauth1Auth.verifier || null,
signatureMethod: (oauth1Auth.signatureMethod as BrunoAuthOauth1['signatureMethod']) || 'HMAC-SHA1',
signatureMethod: (oauth1Auth.signatureEncoding as BrunoAuthOauth1['signatureMethod']) || 'HMAC-SHA1',
privateKey: (typeof oauth1Auth.privateKey === 'object' && oauth1Auth.privateKey ? oauth1Auth.privateKey.value : oauth1Auth.privateKey) || null,
privateKeyType: (typeof oauth1Auth.privateKey === 'object' && oauth1Auth.privateKey ? oauth1Auth.privateKey.type : 'text') as BrunoAuthOauth1['privateKeyType'],
timestamp: oauth1Auth.timestamp || null,
@@ -500,7 +500,7 @@ export const toOpenCollectionAuth = (auth: BrunoAuth | null | undefined): Auth |
accessTokenSecret: auth.oauth1?.accessTokenSecret || '',
callbackUrl: auth.oauth1?.callbackUrl || '',
verifier: auth.oauth1?.verifier || '',
signatureMethod: auth.oauth1?.signatureMethod || 'HMAC-SHA1',
signatureEncoding: auth.oauth1?.signatureMethod || 'HMAC-SHA1',
privateKey: auth.oauth1?.privateKeyType === 'file'
? { type: 'file' as const, value: auth.oauth1?.privateKey || '' }
: { type: 'text' as const, value: auth.oauth1?.privateKey || '' },

View File

@@ -363,34 +363,103 @@ export const processAuth = (auth, requestObject, isCollection = false) => {
}
};
const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false } = {}, scriptMap) => {
const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false } = {}, scriptMap, parentPath = '') => {
brunoParent.items = brunoParent.items || [];
const folderMap = {};
const requestMap = {};
item.forEach((i, index) => {
if (isItemAFolder(i)) {
const baseFolderName = i.name || 'Untitled Folder';
let folderName = baseFolderName;
let count = 1;
const itemName = (i && i.name) || `Item ${index + 1}`;
const itemPath = parentPath ? `${parentPath} / ${itemName}` : itemName;
while (folderMap[folderName]) {
folderName = `${baseFolderName}_${count}`;
count++;
}
try {
if (isItemAFolder(i)) {
const baseFolderName = i.name || 'Untitled Folder';
let folderName = baseFolderName;
let count = 1;
const brunoFolderItem = {
uid: uuid(),
name: folderName,
type: 'folder',
items: [],
seq: index + 1,
root: {
docs: transformDescription(i.description),
meta: {
name: folderName
},
while (folderMap[folderName]) {
folderName = `${baseFolderName}_${count}`;
count++;
}
const brunoFolderItem = {
uid: uuid(),
name: folderName,
type: 'folder',
items: [],
seq: index + 1,
root: {
docs: transformDescription(i.description),
meta: {
name: folderName
},
request: {
auth: {
mode: 'inherit',
basic: null,
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
},
headers: [],
script: {},
tests: '',
vars: {}
}
}
};
brunoParent.items.push(brunoFolderItem);
// Folder level auth
processAuth(i.auth, brunoFolderItem.root.request);
if (i.item && i.item.length) {
importPostmanV2CollectionItem(brunoFolderItem, i.item, { useWorkers }, scriptMap, itemPath);
}
if (i.event) {
if (useWorkers) {
scriptMap.set(brunoFolderItem.uid, {
events: i.event,
request: brunoFolderItem.root.request
});
} else {
importScriptsFromEvents(i.event, brunoFolderItem.root.request);
}
}
folderMap[folderName] = brunoFolderItem;
} else if (i.request) {
const method = i?.request?.method?.toUpperCase();
if (!method || typeof method !== 'string' || !method.trim()) {
console.warn('Missing or invalid request.method', method);
return;
}
const baseRequestName = i.name || 'Untitled Request';
let requestName = baseRequestName;
let count = 1;
while (requestMap[requestName]) {
requestName = `${baseRequestName}_${count}`;
count++;
}
const url = constructUrl(i.request.url);
const brunoRequestItem = {
uid: uuid(),
name: requestName,
type: 'http-request',
seq: index + 1,
request: {
url: url,
method: method,
auth: {
mode: 'inherit',
basic: null,
@@ -402,406 +471,345 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
digest: null
},
headers: [],
script: {},
tests: '',
vars: {}
}
}
};
brunoParent.items.push(brunoFolderItem);
// Folder level auth
processAuth(i.auth, brunoFolderItem.root.request);
if (i.item && i.item.length) {
importPostmanV2CollectionItem(brunoFolderItem, i.item, { useWorkers }, scriptMap);
}
if (i.event) {
if (useWorkers) {
scriptMap.set(brunoFolderItem.uid, {
events: i.event,
request: brunoFolderItem.root.request
});
} else {
importScriptsFromEvents(i.event, brunoFolderItem.root.request);
}
}
folderMap[folderName] = brunoFolderItem;
} else if (i.request) {
const method = i?.request?.method?.toUpperCase();
if (!method || typeof method !== 'string' || !method.trim()) {
console.warn('Missing or invalid request.method', method);
return;
}
const baseRequestName = i.name || 'Untitled Request';
let requestName = baseRequestName;
let count = 1;
while (requestMap[requestName]) {
requestName = `${baseRequestName}_${count}`;
count++;
}
const url = constructUrl(i.request.url);
const brunoRequestItem = {
uid: uuid(),
name: requestName,
type: 'http-request',
seq: index + 1,
request: {
url: url,
method: method,
auth: {
mode: 'inherit',
basic: null,
bearer: null,
awsv4: null,
apikey: null,
oauth1: null,
oauth2: null,
digest: null
},
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
},
docs: transformDescription(i.request.description)
}
};
const settings = {
encodeUrl: i.protocolProfileBehavior?.disableUrlEncoding !== true
};
// Handle followRedirects setting
if (i.protocolProfileBehavior?.followRedirects !== undefined) {
settings.followRedirects = i.protocolProfileBehavior.followRedirects;
}
// Handle maxRedirects setting
if (i.protocolProfileBehavior?.maxRedirects !== undefined) {
settings.maxRedirects = i.protocolProfileBehavior.maxRedirects;
}
brunoRequestItem.settings = settings;
brunoParent.items.push(brunoRequestItem);
if (i.event) {
if (useWorkers) {
scriptMap.set(brunoRequestItem.uid, {
events: i.event,
request: brunoRequestItem.request
});
} else {
i.event.forEach((event) => {
if (event.listen === 'prerequest' && event.script && event.script.exec) {
if (!brunoRequestItem.request?.script) {
brunoRequestItem.request.script = {};
}
if (event.script.exec && event.script.exec.length > 0) {
brunoRequestItem.request.script.req = postmanTranslation(event.script.exec);
} else {
brunoRequestItem.request.script.req = '';
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
if (event.listen === 'test' && event.script && event.script.exec) {
if (!brunoRequestItem.request?.script) {
brunoRequestItem.request.script = {};
}
if (event.script.exec && event.script.exec.length > 0) {
brunoRequestItem.request.script.res = postmanTranslation(event.script.exec);
} else {
brunoRequestItem.request.script.res = '';
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
});
}
}
const bodyMode = get(i, 'request.body.mode');
if (bodyMode) {
if (bodyMode === 'formdata') {
brunoRequestItem.request.body.mode = 'multipartForm';
each(i.request.body.formdata, (param) => {
if (param.key == null && param.value == null) return;
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('') : ensureString(param.value));
brunoRequestItem.request.body.multipartForm.push({
uid: uuid(),
type: isFile ? 'file' : 'text',
name: ensureString(param.key),
value,
description: transformDescription(param.description),
enabled: !param.disabled,
...(param.contentType && { contentType: param.contentType })
});
});
}
if (bodyMode === 'urlencoded') {
brunoRequestItem.request.body.mode = 'formUrlEncoded';
each(i.request.body.urlencoded, (param) => {
if (param.key == null && param.value == null) return;
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
enabled: !param.disabled
});
});
}
if (bodyMode === 'raw') {
let language = get(i, 'request.body.options.raw.language');
if (!language) {
language = searchLanguageByHeader(i.request.header);
}
if (language === 'json') {
brunoRequestItem.request.body.mode = 'json';
brunoRequestItem.request.body.json = i.request.body.raw;
} else if (language === 'xml') {
brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = i.request.body.raw;
} else {
brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = i.request.body.raw;
}
}
}
if (bodyMode === 'graphql') {
brunoRequestItem.type = 'graphql-request';
brunoRequestItem.request.body.mode = 'graphql';
brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql);
}
each(normalizeHeaders(i.request.header), (header) => {
if (header.key == null && header.value == null) return;
brunoRequestItem.request.headers.push({
uid: uuid(),
name: ensureString(header.key),
value: ensureString(header.value),
description: transformDescription(header.description),
enabled: !header.disabled
});
});
// Request-level auth
processAuth(i.request.auth, brunoRequestItem.request);
each(get(i, 'request.url.query'), (param) => {
if (param.key == null && param.value == null) {
return;
}
brunoRequestItem.request.params.push({
uid: uuid(),
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'query',
enabled: !param.disabled
});
});
each(get(i, 'request.url.variable', []), (param) => {
if (!param.key) {
// If no key, skip this iteration and discard the param
return;
}
brunoRequestItem.request.params.push({
uid: uuid(),
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'path',
enabled: true
});
});
// Handle Postman examples (responses)
if (i.response && Array.isArray(i.response)) {
brunoRequestItem.examples = [];
i.response.forEach((response, responseIndex) => {
const sanitized = String(response.name ?? '').replace(/\r?\n/g, ' ').trim();
const exampleName = sanitized || `Example ${responseIndex + 1}`;
// Convert originalRequest to Bruno request format
const originalRequest = response.originalRequest || {};
const exampleUrl = constructUrl(originalRequest.url);
const exampleMethod = originalRequest.method?.toUpperCase() || method;
const example = {
uid: uuid(),
itemUid: brunoRequestItem.uid,
name: exampleName,
description: '',
type: 'http-request',
request: {
url: exampleUrl,
method: exampleMethod,
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
}
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
},
response: {
status: response.code || null,
statusText: response.status || '',
headers: [],
body: {
type: getBodyTypeFromContentTypeHeader(response.header),
content: response.body || ''
}
}
};
docs: transformDescription(i.request.description)
}
};
// Convert original request headers
if (originalRequest.header) {
normalizeHeaders(originalRequest.header).forEach((header) => {
if (header.key == null && header.value == null) return;
example.request.headers.push({
const settings = {
encodeUrl: i.protocolProfileBehavior?.disableUrlEncoding !== true
};
// Handle followRedirects setting
if (i.protocolProfileBehavior?.followRedirects !== undefined) {
settings.followRedirects = i.protocolProfileBehavior.followRedirects;
}
// Handle maxRedirects setting
if (i.protocolProfileBehavior?.maxRedirects !== undefined) {
settings.maxRedirects = i.protocolProfileBehavior.maxRedirects;
}
brunoRequestItem.settings = settings;
brunoParent.items.push(brunoRequestItem);
if (i.event) {
if (useWorkers) {
scriptMap.set(brunoRequestItem.uid, {
events: i.event,
request: brunoRequestItem.request
});
} else {
i.event.forEach((event) => {
if (event.listen === 'prerequest' && event.script && event.script.exec) {
if (!brunoRequestItem.request?.script) {
brunoRequestItem.request.script = {};
}
if (event.script.exec && event.script.exec.length > 0) {
brunoRequestItem.request.script.req = postmanTranslation(event.script.exec);
} else {
brunoRequestItem.request.script.req = '';
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
if (event.listen === 'test' && event.script && event.script.exec) {
if (!brunoRequestItem.request?.script) {
brunoRequestItem.request.script = {};
}
if (event.script.exec && event.script.exec.length > 0) {
brunoRequestItem.request.script.res = postmanTranslation(event.script.exec);
} else {
brunoRequestItem.request.script.res = '';
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
});
}
}
const bodyMode = get(i, 'request.body.mode');
if (bodyMode) {
if (bodyMode === 'formdata') {
brunoRequestItem.request.body.mode = 'multipartForm';
each(i.request.body.formdata, (param) => {
if (param.key == null && param.value == null) return;
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('') : ensureString(param.value));
brunoRequestItem.request.body.multipartForm.push({
uid: uuid(),
name: ensureString(header.key),
value: ensureString(header.value),
description: transformDescription(header.description),
enabled: !header.disabled
type: isFile ? 'file' : 'text',
name: ensureString(param.key),
value,
description: transformDescription(param.description),
enabled: !param.disabled,
...(param.contentType && { contentType: param.contentType })
});
});
}
// Convert original request query parameters
if (originalRequest.url && originalRequest.url.query && Array.isArray(originalRequest.url.query)) {
originalRequest.url.query.forEach((param) => {
if (param.key == null && param.value == null) {
return;
}
example.request.params.push({
if (bodyMode === 'urlencoded') {
brunoRequestItem.request.body.mode = 'formUrlEncoded';
each(i.request.body.urlencoded, (param) => {
if (param.key == null && param.value == null) return;
brunoRequestItem.request.body.formUrlEncoded.push({
uid: uuid(),
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'query',
enabled: !param.disabled
});
});
}
if (originalRequest.url && originalRequest.url.variable && Array.isArray(originalRequest.url.variable)) {
originalRequest.url.variable.forEach((param) => {
if (!param.key) return;
example.request.params.push({
uid: uuid(),
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'path',
enabled: true
});
});
}
// Convert original request body
if (originalRequest.body) {
const bodyMode = originalRequest.body.mode;
if (bodyMode === 'formdata') {
example.request.body.mode = 'multipartForm';
if (originalRequest.body.formdata && Array.isArray(originalRequest.body.formdata)) {
originalRequest.body.formdata.forEach((param) => {
if (param.key == null && param.value == null) return;
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('') : ensureString(param.value));
example.request.body.multipartForm.push({
uid: uuid(),
type: isFile ? 'file' : 'text',
name: ensureString(param.key),
value,
description: transformDescription(param.description),
enabled: !param.disabled,
...(param.contentType && { contentType: param.contentType })
});
});
}
} else if (bodyMode === 'urlencoded') {
example.request.body.mode = 'formUrlEncoded';
if (originalRequest.body.urlencoded && Array.isArray(originalRequest.body.urlencoded)) {
originalRequest.body.urlencoded.forEach((param) => {
if (param.key == null && param.value == null) return;
example.request.body.formUrlEncoded.push({
uid: uuid(),
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
enabled: !param.disabled
});
});
}
} else if (bodyMode === 'raw') {
let language = get(originalRequest, 'body.options.raw.language');
if (!language) {
language = searchLanguageByHeader(originalRequest.header || []);
}
if (language === 'json') {
example.request.body.mode = 'json';
example.request.body.json = originalRequest.body.raw;
} else if (language === 'xml') {
example.request.body.mode = 'xml';
example.request.body.xml = originalRequest.body.raw;
} else {
example.request.body.mode = 'text';
example.request.body.text = originalRequest.body.raw;
}
if (bodyMode === 'raw') {
let language = get(i, 'request.body.options.raw.language');
if (!language) {
language = searchLanguageByHeader(i.request.header);
}
if (language === 'json') {
brunoRequestItem.request.body.mode = 'json';
brunoRequestItem.request.body.json = i.request.body.raw;
} else if (language === 'xml') {
brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = i.request.body.raw;
} else {
brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = i.request.body.raw;
}
}
}
// Convert response headers
if (response.header) {
normalizeHeaders(response.header).forEach((header) => {
if (header.key == null && header.value == null) return;
example.response.headers.push({
uid: uuid(),
name: ensureString(header.key),
value: ensureString(header.value),
description: transformDescription(header.description),
enabled: true
});
});
if (bodyMode === 'graphql') {
brunoRequestItem.type = 'graphql-request';
brunoRequestItem.request.body.mode = 'graphql';
brunoRequestItem.request.body.graphql = parseGraphQLRequest(i.request.body.graphql);
}
each(normalizeHeaders(i.request.header), (header) => {
if (header.key == null && header.value == null) return;
brunoRequestItem.request.headers.push({
uid: uuid(),
name: ensureString(header.key),
value: ensureString(header.value),
description: transformDescription(header.description),
enabled: !header.disabled
});
});
// Request-level auth
processAuth(i.request.auth, brunoRequestItem.request);
each(get(i, 'request.url.query'), (param) => {
if (param.key == null && param.value == null) {
return;
}
brunoRequestItem.request.params.push({
uid: uuid(),
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'query',
enabled: !param.disabled
});
});
each(get(i, 'request.url.variable', []), (param) => {
if (!param.key) {
// If no key, skip this iteration and discard the param
return;
}
brunoRequestItem.examples.push(example);
brunoRequestItem.request.params.push({
uid: uuid(),
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'path',
enabled: true
});
});
}
requestMap[requestName] = brunoRequestItem;
// Handle Postman examples (responses)
if (i.response && Array.isArray(i.response)) {
brunoRequestItem.examples = [];
i.response.forEach((response, responseIndex) => {
const sanitized = String(response.name ?? '').replace(/\r?\n/g, ' ').trim();
const exampleName = sanitized || `Example ${responseIndex + 1}`;
// Convert originalRequest to Bruno request format
const originalRequest = response.originalRequest || {};
const exampleUrl = constructUrl(originalRequest.url);
const exampleMethod = originalRequest.method?.toUpperCase() || method;
const example = {
uid: uuid(),
itemUid: brunoRequestItem.uid,
name: exampleName,
description: '',
type: 'http-request',
request: {
url: exampleUrl,
method: exampleMethod,
headers: [],
params: [],
body: {
mode: 'none',
json: null,
text: null,
xml: null,
formUrlEncoded: [],
multipartForm: []
}
},
response: {
status: response.code || null,
statusText: response.status || '',
headers: [],
body: {
type: getBodyTypeFromContentTypeHeader(response.header),
content: response.body || ''
}
}
};
// Convert original request headers
if (originalRequest.header) {
normalizeHeaders(originalRequest.header).forEach((header) => {
if (header.key == null && header.value == null) return;
example.request.headers.push({
uid: uuid(),
name: ensureString(header.key),
value: ensureString(header.value),
description: transformDescription(header.description),
enabled: !header.disabled
});
});
}
// Convert original request query parameters
if (originalRequest.url && originalRequest.url.query && Array.isArray(originalRequest.url.query)) {
originalRequest.url.query.forEach((param) => {
if (param.key == null && param.value == null) {
return;
}
example.request.params.push({
uid: uuid(),
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'query',
enabled: !param.disabled
});
});
}
if (originalRequest.url && originalRequest.url.variable && Array.isArray(originalRequest.url.variable)) {
originalRequest.url.variable.forEach((param) => {
if (!param.key) return;
example.request.params.push({
uid: uuid(),
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
type: 'path',
enabled: true
});
});
}
// Convert original request body
if (originalRequest.body) {
const bodyMode = originalRequest.body.mode;
if (bodyMode === 'formdata') {
example.request.body.mode = 'multipartForm';
if (originalRequest.body.formdata && Array.isArray(originalRequest.body.formdata)) {
originalRequest.body.formdata.forEach((param) => {
if (param.key == null && param.value == null) return;
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('') : ensureString(param.value));
example.request.body.multipartForm.push({
uid: uuid(),
type: isFile ? 'file' : 'text',
name: ensureString(param.key),
value,
description: transformDescription(param.description),
enabled: !param.disabled,
...(param.contentType && { contentType: param.contentType })
});
});
}
} else if (bodyMode === 'urlencoded') {
example.request.body.mode = 'formUrlEncoded';
if (originalRequest.body.urlencoded && Array.isArray(originalRequest.body.urlencoded)) {
originalRequest.body.urlencoded.forEach((param) => {
if (param.key == null && param.value == null) return;
example.request.body.formUrlEncoded.push({
uid: uuid(),
name: ensureString(param.key),
value: ensureString(param.value),
description: transformDescription(param.description),
enabled: !param.disabled
});
});
}
} else if (bodyMode === 'raw') {
let language = get(originalRequest, 'body.options.raw.language');
if (!language) {
language = searchLanguageByHeader(originalRequest.header || []);
}
if (language === 'json') {
example.request.body.mode = 'json';
example.request.body.json = originalRequest.body.raw;
} else if (language === 'xml') {
example.request.body.mode = 'xml';
example.request.body.xml = originalRequest.body.raw;
} else {
example.request.body.mode = 'text';
example.request.body.text = originalRequest.body.raw;
}
}
}
// Convert response headers
if (response.header) {
normalizeHeaders(response.header).forEach((header) => {
if (header.key == null && header.value == null) return;
example.response.headers.push({
uid: uuid(),
name: ensureString(header.key),
value: ensureString(header.value),
description: transformDescription(header.description),
enabled: true
});
});
}
brunoRequestItem.examples.push(example);
});
}
requestMap[requestName] = brunoRequestItem;
}
} catch (err) {
const contextMsg = `Error processing item "${itemName}" at path: ${itemPath}`;
throw new Error(`${contextMsg}\n Reason: ${err.message}`);
}
});
};

View File

@@ -1,6 +1,5 @@
const { ipcMain, dialog } = require('electron');
const { ipcMain } = require('electron');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const {
browseDirectory,
@@ -28,15 +27,6 @@ const registerFilesystemIpc = (mainWindow) => {
}
});
ipcMain.handle('renderer:browse-pac-file', async () => {
const { filePaths } = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [{ name: 'PAC Files', extensions: ['pac', 'js'] }]
});
if (!filePaths || filePaths.length === 0) return null;
return pathToFileURL(filePaths[0]).href;
});
ipcMain.handle('renderer:exists-sync', async (_, filePath) => {
try {
const normalizedPath = normalizeAndResolvePath(filePath);

View File

@@ -73,7 +73,6 @@ const checkConnection = (host, port) =>
*/
function makeAxiosInstance({
proxyMode = 'off',
proxyModeReason = '',
proxyConfig = {},
requestMaxRedirects = 5,
httpsAgentRequestFields = {},
@@ -203,17 +202,19 @@ function makeAxiosInstance({
};
try {
// Now call setupProxyAgents and pass the timeline (async - may perform PAC resolution)
await setupProxyAgents({
// Now call setupProxyAgents and pass the timeline
setupProxyAgents({
requestConfig: config,
proxyMode,
proxyModeReason,
proxyConfig,
proxyMode: proxyMode, // 'on', 'off', or 'system', depending on your settings
proxyConfig: proxyConfig,
httpsAgentRequestFields: agentOptions,
interpolationOptions,
interpolationOptions: interpolationOptions, // Provide your interpolation options
timeline
});
} catch (err) {
if (err.timeline) {
timeline = err.timeline;
}
timeline.push({
timestamp: new Date(),
type: 'error',
@@ -269,7 +270,7 @@ function makeAxiosInstance({
response.timeline = timeline;
return response;
},
async (error) => {
(error) => {
const config = error.config;
const timeline = config?.metadata?.timeline || [];
timeline?.push({
@@ -416,10 +417,9 @@ function makeAxiosInstance({
}
try {
await setupProxyAgents({
setupProxyAgents({
requestConfig,
proxyMode,
proxyModeReason,
proxyConfig,
httpsAgentRequestFields,
interpolationOptions,

View File

@@ -121,7 +121,6 @@ const getCertsAndProxyConfig = async ({
*/
let proxyMode = 'off';
let proxyConfig = {};
let proxyModeReason = '';
const collectionProxyConfig = get(brunoConfig, 'proxy', {});
const collectionProxyDisabled = get(collectionProxyConfig, 'disabled', false);
@@ -136,32 +135,24 @@ const getCertsAndProxyConfig = async ({
// Inherit from global preferences
const globalProxy = preferencesUtil.getGlobalProxyConfig();
const globalDisabled = get(globalProxy, 'disabled', false);
const globalProxySource = get(globalProxy, 'source', 'manual');
const globalProxyConfigData = get(globalProxy, 'config', {});
const globalInherit = get(globalProxy, 'inherit', false);
const globalProxyConfigData = get(globalProxy, 'config', globalProxy);
if (!globalDisabled) {
if (globalProxySource === 'pac') {
proxyMode = 'pac';
proxyConfig = {
pac: globalProxy.pac ?? {}
};
} else if (globalProxySource === 'inherit') {
proxyMode = 'system';
const systemProxyConfig = await getCachedSystemProxy();
proxyConfig = systemProxyConfig || { http_proxy: null, https_proxy: null, no_proxy: null, source: 'cache-miss' };
} else {
// source === 'manual'
proxyConfig = globalProxyConfigData;
proxyMode = 'on';
}
} else {
proxyModeReason = 'App-level proxy is disabled';
if (!globalDisabled && !globalInherit) {
// Use global custom proxy
proxyConfig = globalProxyConfigData;
proxyMode = 'on';
} else if (!globalDisabled && globalInherit) {
// Use system proxy (cached at app startup)
proxyMode = 'system';
const systemProxyConfig = await getCachedSystemProxy();
proxyConfig = systemProxyConfig || { http_proxy: null, https_proxy: null, no_proxy: null, source: 'cache-miss' };
}
} else {
proxyModeReason = 'Collection-level proxy is disabled';
// else: global proxy is disabled, proxyMode stays 'off'
}
// else: collection proxy is disabled, proxyMode stays 'off'
return { proxyMode, proxyModeReason, proxyConfig, httpsAgentRequestFields, interpolationOptions };
return { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions };
};
/**

View File

@@ -11,7 +11,6 @@ const prepareGrpcRequest = require('./prepare-grpc-request');
const { normalizeAndResolvePath } = require('../../utils/filesystem');
const { configureRequest } = require('./prepare-grpc-request');
const { shouldUseProxy } = require('../../utils/proxy-util');
const { getPacResolver } = require('@usebruno/requests');
// Creating grpcClient at module level so it can be accessed from window-all-closed event
let grpcClient;
@@ -30,37 +29,7 @@ let grpcClient;
* @param {Object} interpolationOptions - Variable interpolation options
* @returns {{ proxyUrl: string | null }}
*/
const resolveGrpcProxyConfig = async (proxyMode, proxyConfig, requestUrl, interpolationOptions) => {
if (proxyMode === 'pac') {
const pacSource = get(proxyConfig, 'pac.source');
if (!pacSource || !requestUrl) return { proxyUrl: null };
try {
const resolver = await getPacResolver({ pacSource });
const directives = await resolver.resolve(requestUrl);
if (!directives || !directives.length) return { proxyUrl: null };
for (const directive of directives) {
if (/^DIRECT$/i.test(directive)) return { proxyUrl: null };
if (/^(PROXY|HTTP)\s+/i.test(directive)) {
const hostPort = directive.split(/\s+/)[1];
return { proxyUrl: `http://${hostPort}` };
}
if (/^HTTPS\s+/i.test(directive)) {
console.warn('gRPC proxy: PAC returned an HTTPS proxy directive which is not supported for gRPC connections. Skipping.');
continue;
}
if (/^SOCKS/i.test(directive)) {
console.warn('gRPC proxy: PAC returned a SOCKS proxy directive which is not supported for gRPC connections. Skipping.');
continue;
}
}
} catch (e) {
console.warn('gRPC proxy: PAC resolution failed:', e.message);
}
return { proxyUrl: null };
}
const resolveGrpcProxyConfig = (proxyMode, proxyConfig, requestUrl, interpolationOptions) => {
if (proxyMode === 'on') {
const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', ''));
if (!shouldProxy) return { proxyUrl: null };
@@ -201,7 +170,7 @@ const registerGrpcEventHandlers = (window) => {
const pfx = httpsAgentRequestFields.pfx;
// Resolve proxy configuration for gRPC
const grpcProxyConfig = await resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions);
const grpcProxyConfig = resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions);
const requestSent = {
type: 'request',
@@ -361,7 +330,7 @@ const registerGrpcEventHandlers = (window) => {
const pfx = httpsAgentRequestFields.pfx;
// Resolve proxy configuration for gRPC
const grpcProxyConfig = await resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions);
const grpcProxyConfig = resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions);
// Send OAuth credentials update if available
if (preparedRequest?.oauth2Credentials) {

View File

@@ -8,139 +8,139 @@ const emptyInterpolationOptions = {};
describe('resolveGrpcProxyConfig', () => {
describe('proxyMode "off"', () => {
it('should return null proxyUrl', async () => {
await expect(resolveGrpcProxyConfig('off', {}, 'grpc://localhost:50051', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: null });
it('should return null proxyUrl', () => {
expect(resolveGrpcProxyConfig('off', {}, 'grpc://localhost:50051', emptyInterpolationOptions))
.toEqual({ proxyUrl: null });
});
});
describe('proxyMode "on"', () => {
it('should return proxy URL without auth', async () => {
it('should return proxy URL without auth', () => {
const proxyConfig = {
protocol: 'http',
hostname: 'proxy.example.com',
port: '8080',
auth: { disabled: true }
};
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: 'http://proxy.example.com:8080' });
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: 'http://proxy.example.com:8080' });
});
it('should return proxy URL with auth when auth is enabled', async () => {
it('should return proxy URL with auth when auth is enabled', () => {
const proxyConfig = {
protocol: 'http',
hostname: 'proxy.example.com',
port: '8080',
auth: { disabled: false, username: 'user', password: 'pass' }
};
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: 'http://user:pass@proxy.example.com:8080' });
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: 'http://user:pass@proxy.example.com:8080' });
});
it('should URL-encode special characters in credentials', async () => {
it('should URL-encode special characters in credentials', () => {
const proxyConfig = {
protocol: 'http',
hostname: 'proxy.example.com',
port: '8080',
auth: { disabled: false, username: 'user@domain', password: 'p@ss:word' }
};
const result = await resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions);
const result = resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions);
expect(result.proxyUrl).toBe('http://user%40domain:p%40ss%3Aword@proxy.example.com:8080');
});
it('should reject SOCKS proxy protocols', async () => {
it('should reject SOCKS proxy protocols', () => {
const proxyConfig = {
protocol: 'socks5',
hostname: 'proxy.example.com',
port: '1080'
};
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: null });
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: null });
});
it('should reject HTTPS proxy protocol', async () => {
it('should reject HTTPS proxy protocol', () => {
const proxyConfig = {
protocol: 'https',
hostname: 'proxy.example.com',
port: '8080'
};
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: null });
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: null });
});
it('should return null when request URL is in bypassProxy list', async () => {
it('should return null when request URL is in bypassProxy list', () => {
const proxyConfig = {
protocol: 'http',
hostname: 'proxy.example.com',
port: '8080',
bypassProxy: 'localhost,api.example.com'
};
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: null });
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: null });
});
it('should omit port when not provided', async () => {
it('should omit port when not provided', () => {
const proxyConfig = {
protocol: 'http',
hostname: 'proxy.example.com',
auth: { disabled: true }
};
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: 'http://proxy.example.com' });
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: 'http://proxy.example.com' });
});
});
describe('proxyMode "system"', () => {
it('should use https_proxy when available', async () => {
it('should use https_proxy when available', () => {
const proxyConfig = {
https_proxy: 'http://system-proxy.example.com:3128',
http_proxy: 'http://fallback-proxy.example.com:3128'
};
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: 'http://system-proxy.example.com:3128' });
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: 'http://system-proxy.example.com:3128' });
});
it('should fall back to http_proxy when https_proxy is not set', async () => {
it('should fall back to http_proxy when https_proxy is not set', () => {
const proxyConfig = {
http_proxy: 'http://fallback-proxy.example.com:3128'
};
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: 'http://fallback-proxy.example.com:3128' });
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: 'http://fallback-proxy.example.com:3128' });
});
it('should return null when no system proxy is configured', async () => {
await expect(resolveGrpcProxyConfig('system', {}, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: null });
it('should return null when no system proxy is configured', () => {
expect(resolveGrpcProxyConfig('system', {}, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: null });
});
it('should reject non-HTTP system proxy protocols', async () => {
it('should reject non-HTTP system proxy protocols', () => {
const proxyConfig = {
https_proxy: 'socks5://system-proxy.example.com:1080'
};
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: null });
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: null });
});
it('should return null when request URL matches no_proxy', async () => {
it('should return null when request URL matches no_proxy', () => {
const proxyConfig = {
https_proxy: 'http://system-proxy.example.com:3128',
no_proxy: 'api.example.com'
};
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: null });
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: null });
});
it('should return null for invalid system proxy URL', async () => {
it('should return null for invalid system proxy URL', () => {
const proxyConfig = {
https_proxy: 'not-a-valid-url'
};
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: null });
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: null });
});
it('should return null when proxyConfig is null', async () => {
await expect(resolveGrpcProxyConfig('system', null, 'grpc://api.example.com:443', emptyInterpolationOptions))
.resolves.toEqual({ proxyUrl: null });
it('should return null when proxyConfig is null', () => {
expect(resolveGrpcProxyConfig('system', null, 'grpc://api.example.com:443', emptyInterpolationOptions))
.toEqual({ proxyUrl: null });
});
});
});

View File

@@ -144,10 +144,9 @@ const configureRequest = async (
request.maxRedirects = 0;
const { promptVariables = {} } = collection;
let { proxyMode, proxyModeReason, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
let { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
let axiosInstance = makeAxiosInstance({
proxyMode,
proxyModeReason,
proxyConfig,
requestMaxRedirects,
httpsAgentRequestFields,
@@ -775,7 +774,6 @@ const registerNetworkIpc = (mainWindow) => {
// flag to see if the stream needs to be handled as an actual stream or
// is it just a data stream from axios
let isResponseStream = false;
let requestSent;
const brunoConfig = getBrunoConfig(collectionUid, collection);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
@@ -866,7 +864,7 @@ const registerNetworkIpc = (mainWindow) => {
}
});
requestSent = {
let requestSent = {
url: request.url,
method: request.method,
headers: headersSent,
@@ -1143,8 +1141,7 @@ const registerNetworkIpc = (mainWindow) => {
size: Buffer.byteLength(response.dataBuffer),
duration: responseTime ?? 0,
url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null,
timeline: response.timeline,
requestSent
timeline: response.timeline
};
} catch (error) {
deleteCancelToken(cancelTokenUid);
@@ -1154,8 +1151,7 @@ const registerNetworkIpc = (mainWindow) => {
return {
status: error?.status,
error: error?.message || ERROR_OCCURRED_WHILE_EXECUTING_REQUEST,
timeline: error?.timeline,
requestSent
timeline: error?.timeline
};
}
};

View File

@@ -30,8 +30,7 @@ const defaultPreferences = {
codeFontSize: 13
},
proxy: {
source: 'inherit',
pac: { source: '' },
inherit: true,
config: {
protocol: 'http',
hostname: '',
@@ -94,10 +93,7 @@ const preferencesSchema = Yup.object().shape({
}),
proxy: Yup.object({
disabled: Yup.boolean().optional(),
source: Yup.string().oneOf(['manual', 'pac', 'inherit']).required(),
pac: Yup.object({
source: Yup.string().optional().max(2048).nullable()
}).optional(),
inherit: Yup.boolean().required(),
config: Yup.object({
protocol: Yup.string().oneOf(['http', 'https', 'socks4', 'socks5']),
hostname: Yup.string().max(1024),
@@ -154,7 +150,7 @@ class PreferencesStore {
// New users (empty preferences) will get defaultPreferences.proxy via merge
if (Object.keys(preferences).length > 0 && !preferences.proxy) {
preferences.proxy = {
source: 'manual',
inherit: false,
disabled: true,
config: {
protocol: 'http',
@@ -177,8 +173,7 @@ class PreferencesStore {
if (hasOldFormat) {
let newProxy = {
source: 'inherit',
pac: { source: '' },
inherit: true,
config: {
protocol: proxy.protocol || 'http',
hostname: proxy.hostname || '',
@@ -193,17 +188,19 @@ class PreferencesStore {
// Handle old format 1: enabled (boolean)
if (proxy.hasOwnProperty('enabled') && typeof proxy.enabled === 'boolean') {
newProxy.source = 'manual';
newProxy.disabled = !proxy.enabled;
newProxy.inherit = false;
} else if (proxy.hasOwnProperty('mode')) {
// Handle old format 2: mode ('off' | 'on' | 'system')
if (proxy.mode === 'off') {
newProxy.source = 'manual';
newProxy.disabled = true;
newProxy.inherit = false;
} else if (proxy.mode === 'on') {
newProxy.source = 'manual';
newProxy.disabled = false;
newProxy.inherit = false;
} else if (proxy.mode === 'system') {
newProxy.source = 'inherit';
newProxy.disabled = false;
newProxy.inherit = true;
}
}
@@ -211,6 +208,7 @@ class PreferencesStore {
if (get(proxy, 'auth.enabled') === false) {
newProxy.config.auth.disabled = true;
}
// If auth.enabled is true or undefined, omit disabled (defaults to false)
// Omit disabled: false at top level (optional field)
if (newProxy.disabled === false) {
@@ -222,18 +220,6 @@ class PreferencesStore {
}
preferences.proxy = newProxy;
this.store.set('preferences', preferences);
}
// Migrate intermediate format: inherit boolean → source string
if (!hasOldFormat && proxy.hasOwnProperty('inherit')) {
if (proxy.inherit === true) {
preferences.proxy.source = 'inherit';
} else if (!proxy.source) {
preferences.proxy.source = 'manual';
}
delete preferences.proxy.inherit;
this.store.set('preferences', preferences);
}
}

View File

@@ -8,7 +8,6 @@ const { HttpProxyAgent } = require('http-proxy-agent');
const { isEmpty, get, isUndefined, isNull } = require('lodash');
const { getOrCreateHttpsAgent, getOrCreateHttpAgent } = require('@usebruno/requests');
const { preferencesUtil } = require('../store/preferences');
const { getPacResolver } = require('@usebruno/requests');
const DEFAULT_PORTS = {
ftp: 21,
@@ -104,23 +103,14 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
}
}
async function setupProxyAgents({
function setupProxyAgents({
requestConfig,
proxyMode = 'off',
proxyModeReason = '',
proxyConfig,
httpsAgentRequestFields,
interpolationOptions,
timeline
}) {
if (timeline) {
let modeMsg = `Proxy mode: ${proxyMode}`;
if (proxyMode === 'pac') modeMsg += ` | PAC URL: ${get(proxyConfig, 'pac.source') || '(empty)'}`;
else if (proxyMode === 'on') modeMsg += ` | ${get(proxyConfig, 'protocol')}://${get(proxyConfig, 'hostname')}:${get(proxyConfig, 'port')}`;
else if (proxyMode === 'off' && proxyModeReason) modeMsg += ` (${proxyModeReason})`;
timeline.push({ timestamp: new Date(), type: 'info', message: modeMsg });
}
// 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;
@@ -163,6 +153,14 @@ async function setupProxyAgents({
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;
}
if (timeline) {
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Using proxy: ${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';
@@ -220,38 +218,6 @@ async function setupProxyAgents({
throw new Error(`Invalid system https_proxy "${https_proxy}": ${error.message}`);
}
}
} else if (proxyMode === 'pac') {
const pacSource = get(proxyConfig, 'pac.source');
if (pacSource) {
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `Resolving PAC: ${pacSource}` });
try {
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields });
const directives = await resolver.resolve(requestConfig.url);
if (directives && directives.length) {
const first = directives[0];
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: `PAC directives: ${directives.join('; ')}` });
if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
const parts = first.split(/\s+/);
const keyword = parts[0].toUpperCase();
const hostPort = parts[1];
const scheme = keyword === 'HTTPS' ? 'https' : 'http';
const proxyUri = `${scheme}://${hostPort}`;
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline, disableCache, hostname });
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
} else if (/^SOCKS/i.test(first)) {
const hostPort = first.split(/\s+/)[1];
const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
const proxyUri = `${proto}://${hostPort}`;
requestConfig.httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline, disableCache, hostname });
requestConfig.httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions, proxyUri, timeline, disableCache, hostname });
}
} else {
if (timeline) timeline.push({ timestamp: new Date(), type: 'info', message: 'PAC resolved: DIRECT (no proxy)' });
}
} catch (err) {
if (timeline) timeline.push({ timestamp: new Date(), type: 'error', message: `PAC resolution failed: ${err.message}` });
}
}
}
if (!requestConfig.httpAgent && !requestConfig.httpsAgent) {

View File

@@ -1,148 +0,0 @@
const jestClearModules = () => {
jest.resetModules();
jest.clearAllMocks();
};
/** Mock every external dependency that proxy-util pulls in so tests are isolated. */
const setupMocks = ({ pacDirectives = ['PROXY p.example:8080'] } = {}) => {
// Preferences — controls SSL session cache flag
jest.doMock('../src/store/preferences', () => ({
preferencesUtil: {
isSslSessionCachingEnabled: () => false
}
}));
// @usebruno/requests — agent factories + pac resolver
jest.doMock('@usebruno/requests', () => ({
getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
getPacResolver: jest.fn(async () => ({
resolve: async () => pacDirectives,
dispose: () => {}
})),
clearPacCache: jest.fn()
}));
};
describe('proxy-util', () => {
beforeEach(() => jestClearModules());
afterEach(() => jestClearModules());
test('shouldUseProxy respects wildcard bypass', () => {
const { shouldUseProxy } = require('../src/utils/proxy-util');
expect(shouldUseProxy('http://example.com', '*')).toBe(false);
});
test('setupProxyAgents: PAC PROXY directive sets http and https agents', async () => {
setupMocks({ pacDirectives: ['PROXY p.example:8080', 'DIRECT'] });
const { setupProxyAgents } = require('../src/utils/proxy-util');
const { getOrCreateHttpAgent, getOrCreateHttpsAgent } = require('@usebruno/requests');
const requestConfig = { url: 'http://example.com/resource' };
const timeline = [];
await setupProxyAgents({
requestConfig,
proxyMode: 'pac',
proxyConfig: { pac: { source: 'http://pac-server/proxy.pac' } },
httpsAgentRequestFields: {},
interpolationOptions: {},
timeline
});
expect(requestConfig.httpsAgent).toBeDefined();
expect(requestConfig.httpAgent).toBeDefined();
expect(getOrCreateHttpsAgent).toHaveBeenCalledWith(expect.objectContaining({ proxyUri: 'http://p.example:8080' }));
expect(getOrCreateHttpAgent).toHaveBeenCalledWith(expect.objectContaining({ proxyUri: 'http://p.example:8080' }));
const hasPacInfo = timeline.some((t) => t.type === 'info' && /PAC directives/.test(t.message));
expect(hasPacInfo).toBe(true);
});
test('setupProxyAgents: PAC DIRECT directive bypasses proxy and uses fallback agent', async () => {
setupMocks({ pacDirectives: ['DIRECT'] });
const { setupProxyAgents } = require('../src/utils/proxy-util');
const { getOrCreateHttpAgent, getOrCreateHttpsAgent } = require('@usebruno/requests');
const requestConfig = { url: 'http://example.com/resource' };
const timeline = [];
await setupProxyAgents({
requestConfig,
proxyMode: 'pac',
proxyConfig: { pac: { source: 'http://pac-server/proxy.pac' } },
httpsAgentRequestFields: {},
interpolationOptions: {},
timeline
});
// DIRECT → no proxy agents set inside PAC block, fallback sets httpAgent for http request
expect(requestConfig.httpAgent).toBeDefined();
// httpsAgent should NOT have been set (http request, not https)
expect(requestConfig.httpsAgent).toBeUndefined();
// Fallback agent called with null proxyUri
expect(getOrCreateHttpAgent).toHaveBeenCalledWith(expect.objectContaining({ proxyUri: null }));
expect(getOrCreateHttpsAgent).not.toHaveBeenCalled();
});
test('setupProxyAgents: PAC SOCKS directive sets socks agents', async () => {
setupMocks({ pacDirectives: ['SOCKS5 socks.example:1080'] });
const { setupProxyAgents } = require('../src/utils/proxy-util');
const { getOrCreateHttpAgent, getOrCreateHttpsAgent } = require('@usebruno/requests');
const requestConfig = { url: 'http://example.com/resource' };
const timeline = [];
await setupProxyAgents({
requestConfig,
proxyMode: 'pac',
proxyConfig: { pac: { source: 'http://pac-server/proxy.pac' } },
httpsAgentRequestFields: {},
interpolationOptions: {},
timeline
});
expect(requestConfig.httpsAgent).toBeDefined();
expect(requestConfig.httpAgent).toBeDefined();
expect(getOrCreateHttpsAgent).toHaveBeenCalledWith(
expect.objectContaining({ proxyUri: 'socks5://socks.example:1080' })
);
expect(getOrCreateHttpAgent).toHaveBeenCalledWith(
expect.objectContaining({ proxyUri: 'socks5://socks.example:1080' })
);
});
test('setupProxyAgents: PAC resolution error logs to timeline and falls back to direct agent', async () => {
jest.doMock('../src/store/preferences', () => ({
preferencesUtil: { isSslSessionCachingEnabled: () => false }
}));
jest.doMock('@usebruno/requests', () => ({
getOrCreateHttpsAgent: jest.fn(() => ({ type: 'https-agent' })),
getOrCreateHttpAgent: jest.fn(() => ({ type: 'http-agent' })),
getPacResolver: jest.fn(async () => { throw new Error('PAC fetch timeout'); }),
clearPacCache: jest.fn()
}));
const { setupProxyAgents } = require('../src/utils/proxy-util');
const { getOrCreateHttpAgent } = require('@usebruno/requests');
const requestConfig = { url: 'http://example.com/resource' };
const timeline = [];
await setupProxyAgents({
requestConfig,
proxyMode: 'pac',
proxyConfig: { pac: { source: 'http://unreachable/proxy.pac' } },
httpsAgentRequestFields: {},
interpolationOptions: {},
timeline
});
// Error should be logged to timeline
const hasError = timeline.some((t) => t.type === 'error' && /PAC resolution failed/.test(t.message));
expect(hasError).toBe(true);
// Fallback direct agent should be set for the http request
expect(requestConfig.httpAgent).toBeDefined();
expect(getOrCreateHttpAgent).toHaveBeenCalledWith(expect.objectContaining({ proxyUri: null }));
});
});

View File

@@ -15,35 +15,44 @@ const { getPreferences, savePreferences } = require('../../src/store/preferences
describe('Proxy Preferences Migration', () => {
beforeEach(() => {
// Reset mock store data before each test
mockStoreData = {};
});
describe('Default Proxy Settings', () => {
it('should default to source: inherit for new users (empty preferences)', () => {
it('should default to inherit: true for new users (empty preferences)', () => {
// New user - no preferences.json exists, store returns empty object
mockStoreData['preferences'] = {};
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('inherit');
// New users get the default proxy settings with inherit: true
expect(preferences.proxy.inherit).toBe(true);
expect(preferences.proxy.disabled).toBeUndefined();
expect(preferences.proxy.inherit).toBeUndefined();
expect(preferences.proxy.config).toBeDefined();
expect(preferences.proxy.config.protocol).toBe('http');
expect(preferences.proxy.config.hostname).toBe('');
expect(preferences.proxy.config.port).toBeNull();
});
it('should default to source: manual, disabled: true for existing users without proxy settings', () => {
it('should default to disabled: true, inherit: false for existing users without proxy settings', () => {
// Existing user - has preferences but no proxy property
mockStoreData['preferences'] = {
request: { sslVerification: true },
font: { codeFont: 'default', codeFontSize: 13 }
request: {
sslVerification: true
},
font: {
codeFont: 'default',
codeFontSize: 13
}
};
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('manual');
// Existing users without proxy get disabled proxy by default
expect(preferences.proxy.disabled).toBe(true);
expect(preferences.proxy.inherit).toBeUndefined();
expect(preferences.proxy.inherit).toBe(false);
expect(preferences.proxy.config).toBeDefined();
expect(preferences.proxy.config.protocol).toBe('http');
expect(preferences.proxy.config.hostname).toBe('');
expect(preferences.proxy.config.port).toBeNull();
@@ -53,295 +62,279 @@ describe('Proxy Preferences Migration', () => {
});
});
describe('v3 Format (no migration needed)', () => {
it('should handle source: manual', () => {
mockStoreData['preferences'] = {
describe('New Format (no migration needed)', () => {
it('should handle new format with inherit: false', () => {
const newFormatProxy = {
proxy: {
source: 'manual',
inherit: false,
config: {
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: { username: 'user', password: 'pass' },
auth: {
username: 'user',
password: 'pass'
},
bypassProxy: 'localhost'
}
}
};
mockStoreData['preferences'] = newFormatProxy;
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('manual');
expect(preferences.proxy.inherit).toBeUndefined();
// Verify key fields are preserved from stored preferences
expect(preferences.proxy.inherit).toBe(false);
expect(preferences.proxy.config.protocol).toBe('http');
expect(preferences.proxy.config.hostname).toBe('proxy.example.com');
expect(preferences.proxy.config.port).toBe(8080);
expect(preferences.proxy.config.auth.username).toBe('user');
expect(preferences.proxy.config.auth.password).toBe('pass');
expect(preferences.proxy.config.bypassProxy).toBe('localhost');
});
it('should handle source: inherit', () => {
mockStoreData['preferences'] = {
it('should handle new format with inherit: true', () => {
const newFormatProxy = {
proxy: {
source: 'inherit',
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
}
};
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('inherit');
expect(preferences.proxy.inherit).toBeUndefined();
});
it('should handle source: pac', () => {
mockStoreData['preferences'] = {
proxy: {
source: 'pac',
pac: { source: 'http://internal/proxy.pac' },
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
}
};
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('pac');
expect(preferences.proxy.pac.source).toBe('http://internal/proxy.pac');
expect(preferences.proxy.inherit).toBeUndefined();
});
it('should handle disabled: true with source: manual', () => {
mockStoreData['preferences'] = {
proxy: {
disabled: true,
source: 'manual',
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
}
};
const preferences = getPreferences();
expect(preferences.proxy.disabled).toBe(true);
expect(preferences.proxy.source).toBe('manual');
expect(preferences.proxy.inherit).toBeUndefined();
});
it('should handle auth.disabled: true', () => {
mockStoreData['preferences'] = {
proxy: {
source: 'manual',
inherit: true,
config: {
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: { disabled: true, username: 'user', password: 'pass' },
hostname: '',
port: null,
auth: {
username: '',
password: ''
},
bypassProxy: ''
}
}
};
mockStoreData['preferences'] = newFormatProxy;
const preferences = getPreferences();
expect(preferences.proxy.inherit).toBe(true);
expect(preferences.proxy.config).toBeDefined();
});
it('should handle new format with disabled: true', () => {
const newFormatProxy = {
proxy: {
disabled: true,
inherit: false,
config: {
protocol: 'http',
hostname: '',
port: null,
auth: {
username: '',
password: ''
},
bypassProxy: ''
}
}
};
mockStoreData['preferences'] = newFormatProxy;
const preferences = getPreferences();
// disabled: true is preserved from stored preferences
expect(preferences.proxy.disabled).toBe(true);
expect(preferences.proxy.inherit).toBe(false);
expect(preferences.proxy.config).toBeDefined();
});
it('should handle new format with auth.disabled: true', () => {
const newFormatProxy = {
proxy: {
inherit: false,
config: {
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: {
disabled: true,
username: 'user',
password: 'pass'
},
bypassProxy: ''
}
}
};
mockStoreData['preferences'] = newFormatProxy;
const preferences = getPreferences();
// auth.disabled: true is preserved from stored preferences
expect(preferences.proxy.config.auth.disabled).toBe(true);
expect(preferences.proxy.config.auth.username).toBe('user');
expect(preferences.proxy.config.auth.password).toBe('pass');
});
});
describe('v2 → v3 Migration (inherit boolean → source string)', () => {
it('should migrate inherit: true → source: system', () => {
mockStoreData['preferences'] = {
proxy: {
inherit: true,
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
}
};
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('inherit');
expect(preferences.proxy.inherit).toBeUndefined();
});
it('should migrate inherit: false (no source) → source: manual', () => {
mockStoreData['preferences'] = {
proxy: {
inherit: false,
config: { protocol: 'http', hostname: 'proxy.example.com', port: 8080, auth: { username: 'user', password: 'pass' }, bypassProxy: '' }
}
};
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('manual');
expect(preferences.proxy.inherit).toBeUndefined();
expect(preferences.proxy.config.hostname).toBe('proxy.example.com');
});
it('should migrate inherit: false, source: pac → source: pac (preserved)', () => {
mockStoreData['preferences'] = {
proxy: {
inherit: false,
source: 'pac',
pac: { source: 'http://internal/proxy.pac' },
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
}
};
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('pac');
expect(preferences.proxy.pac.source).toBe('http://internal/proxy.pac');
expect(preferences.proxy.inherit).toBeUndefined();
});
it('should save migrated v2 → v3 back to disk', () => {
mockStoreData['preferences'] = {
proxy: {
inherit: true,
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
}
};
getPreferences();
expect(mockStoreData['preferences'].proxy.inherit).toBeUndefined();
expect(mockStoreData['preferences'].proxy.source).toBe('inherit');
});
});
describe('v1 → v3 Migration (enabled boolean)', () => {
it('should migrate enabled: true → source: manual', () => {
mockStoreData['preferences'] = {
describe('Old Format 1: enabled (boolean)', () => {
it('should migrate enabled: true to disabled: false, inherit: false', () => {
const oldFormatProxy = {
proxy: {
enabled: true,
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: { enabled: true, username: 'user', password: 'pass' },
auth: {
enabled: true,
username: 'user',
password: 'pass'
},
bypassProxy: 'localhost'
}
};
mockStoreData['preferences'] = oldFormatProxy;
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('manual');
expect(preferences.proxy.disabled).toBeUndefined();
expect(preferences.proxy.inherit).toBeUndefined();
// After migration, inherit should be false (old enabled: true maps to inherit: false)
expect(preferences.proxy.inherit).toBe(false);
// Values are preserved from stored preferences
expect(preferences.proxy.config.protocol).toBe('http');
expect(preferences.proxy.config.hostname).toBe('proxy.example.com');
expect(preferences.proxy.config.port).toBe(8080);
expect(preferences.proxy.config.auth.username).toBe('user');
expect(preferences.proxy.config.auth.password).toBe('pass');
expect(preferences.proxy.config.bypassProxy).toBe('localhost');
expect(preferences.proxy.disabled).toBeUndefined(); // disabled: false is omitted
});
it('should migrate enabled: false → source: manual, disabled: true', () => {
mockStoreData['preferences'] = {
it('should migrate enabled: false to disabled: true, inherit: false', () => {
const oldFormatProxy = {
proxy: {
enabled: false,
protocol: 'http',
hostname: '',
port: null,
auth: { enabled: false, username: '', password: '' },
hostname: 'proxy.example.com',
port: 8080,
auth: {
enabled: false,
username: '',
password: ''
},
bypassProxy: ''
}
};
mockStoreData['preferences'] = oldFormatProxy;
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('manual');
// After migration, enabled: false becomes disabled: true, inherit: false
expect(preferences.proxy.disabled).toBe(true);
expect(preferences.proxy.inherit).toBeUndefined();
expect(preferences.proxy.inherit).toBe(false);
});
it('should migrate auth.enabled: false auth.disabled: true', () => {
mockStoreData['preferences'] = {
it('should migrate auth.enabled: false to auth.disabled: true', () => {
const oldFormatProxy = {
proxy: {
enabled: true,
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: { enabled: false, username: 'user', password: 'pass' },
auth: {
enabled: false,
username: 'user',
password: 'pass'
},
bypassProxy: ''
}
};
mockStoreData['preferences'] = oldFormatProxy;
const preferences = getPreferences();
// auth.disabled: true is preserved from stored preferences
expect(preferences.proxy.config.auth.disabled).toBe(true);
expect(preferences.proxy.config.auth.username).toBe('user');
});
it('should save migrated v1 → v3 back to disk', () => {
mockStoreData['preferences'] = {
proxy: {
enabled: true,
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: { enabled: true, username: 'user', password: 'pass' },
bypassProxy: ''
}
};
getPreferences();
expect(mockStoreData['preferences'].proxy.enabled).toBeUndefined();
expect(mockStoreData['preferences'].proxy.source).toBe('manual');
});
});
describe('v1 → v3 Migration (mode string)', () => {
it('should migrate mode: off → source: manual, disabled: true', () => {
mockStoreData['preferences'] = {
describe('Old Format 2: mode (string)', () => {
it('should migrate mode: "off" to disabled: true, inherit: false', () => {
const oldFormatProxy = {
proxy: {
mode: 'off',
protocol: 'http',
hostname: '',
port: null,
auth: { enabled: false, username: '', password: '' },
auth: {
enabled: false,
username: '',
password: ''
},
bypassProxy: ''
}
};
mockStoreData['preferences'] = oldFormatProxy;
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('manual');
// disabled: true is preserved from migration
expect(preferences.proxy.disabled).toBe(true);
expect(preferences.proxy.inherit).toBeUndefined();
expect(preferences.proxy.inherit).toBe(false);
});
it('should migrate mode: on → source: manual', () => {
mockStoreData['preferences'] = {
it('should migrate mode: "on" to disabled: false, inherit: false', () => {
const oldFormatProxy = {
proxy: {
mode: 'on',
protocol: 'https',
hostname: 'proxy.example.com',
port: 8443,
auth: { enabled: true, username: 'user', password: 'pass' },
auth: {
enabled: true,
username: 'user',
password: 'pass'
},
bypassProxy: '*.local'
}
};
mockStoreData['preferences'] = oldFormatProxy;
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('manual');
expect(preferences.proxy.disabled).toBeUndefined();
expect(preferences.proxy.inherit).toBeUndefined();
expect(preferences.proxy.disabled).toBeUndefined(); // disabled: false is omitted
expect(preferences.proxy.inherit).toBe(false);
// Values are preserved from stored preferences
expect(preferences.proxy.config.protocol).toBe('https');
expect(preferences.proxy.config.hostname).toBe('proxy.example.com');
expect(preferences.proxy.config.port).toBe(8443);
});
it('should migrate mode: system → source: inherit', () => {
mockStoreData['preferences'] = {
it('should migrate mode: "system" to disabled: false, inherit: true', () => {
const oldFormatProxy = {
proxy: {
mode: 'system',
protocol: 'http',
hostname: '',
port: null,
auth: { enabled: false, username: '', password: '' },
auth: {
enabled: false,
username: '',
password: ''
},
bypassProxy: ''
}
};
mockStoreData['preferences'] = oldFormatProxy;
const preferences = getPreferences();
expect(preferences.proxy.source).toBe('inherit');
expect(preferences.proxy.disabled).toBeUndefined();
expect(preferences.proxy.inherit).toBeUndefined();
expect(preferences.proxy.disabled).toBeUndefined(); // disabled: false is omitted
expect(preferences.proxy.inherit).toBe(true);
});
});
});

View File

@@ -129,7 +129,7 @@ const buildOAuth1Auth = (config?: BrunoAuth['oauth1']): AuthOAuth1 => {
if (isString(config.accessTokenSecret)) auth.accessTokenSecret = config.accessTokenSecret;
if (isString(config.callbackUrl)) auth.callbackUrl = config.callbackUrl;
if (isString(config.verifier)) auth.verifier = config.verifier;
if (isString(config.signatureMethod)) auth.signatureMethod = config.signatureMethod;
if (isString(config.signatureMethod)) auth.signatureEncoding = config.signatureMethod;
if (isString(config.privateKey)) {
auth.privateKey = config.privateKeyType === 'file'
? { type: 'file' as const, value: config.privateKey }
@@ -272,7 +272,7 @@ export const toBrunoAuth = (auth: Auth | null | undefined): BrunoAuth | null =>
accessTokenSecret: auth.accessTokenSecret || null,
callbackUrl: auth.callbackUrl || null,
verifier: auth.verifier || null,
signatureMethod: (auth.signatureMethod as BrunoAuthOauth1['signatureMethod']) || 'HMAC-SHA1',
signatureMethod: (auth.signatureEncoding as BrunoAuthOauth1['signatureMethod']) || 'HMAC-SHA1',
privateKey: (typeof auth.privateKey === 'object' && auth.privateKey ? auth.privateKey.value : auth.privateKey) || null,
privateKeyType: (typeof auth.privateKey === 'object' && auth.privateKey ? auth.privateKey.type : 'text') as BrunoAuthOauth1['privateKeyType'],
timestamp: auth.timestamp || null,

View File

@@ -25,8 +25,6 @@
"@grpc/proto-loader": "^0.7.15",
"@types/qs": "^6.9.18",
"axios": "1.13.6",
"pac-resolver": "^7.0.1",
"quickjs-emscripten": "^0.32.0",
"debug": "^4.4.3",
"google-protobuf": "^4.0.0",
"grpc-js-reflection-client": "^1.3.0",

View File

@@ -39,6 +39,6 @@ module.exports = [
typescript({ tsconfig: './tsconfig.json' }),
terser()
],
external: (id) => isBuiltin(id) || ['axios', 'qs', 'ws', 'debug', 'shell-env', 'pac-resolver', 'quickjs-emscripten'].includes(id)
external: (id) => isBuiltin(id) || ['axios', 'qs', 'ws', 'debug', 'shell-env'].includes(id)
}
];

View File

@@ -10,8 +10,6 @@ export type { VaultClient, VaultConfig, VaultRequestOptions } from './utils/node
export { getHttpHttpsAgents } from './utils/http-https-agents';
export { initializeShellEnv } from './utils/shell-env';
export { getOrCreateHttpsAgent, getOrCreateHttpAgent, clearAgentCache, getAgentCacheSize } from './utils/agent-cache';
export { getPacResolver, clearPacCache } from './utils/pac-resolver';
export type { PacWrapper, GetPacResolverParams } from './utils/pac-resolver';
export * as scripting from './scripting';

View File

@@ -12,7 +12,6 @@ import { isEmpty, get, isUndefined, isNull } from 'lodash';
import { getCACertificates } from './ca-cert';
import { transformProxyConfig } from './proxy-util';
import { getOrCreateHttpsAgent, getOrCreateHttpAgent } from './agent-cache';
import { getPacResolver } from './pac-resolver';
import type { TimelineEntry } from './timeline-agent';
const DEFAULT_PORTS: Record<string, number> = {
@@ -24,7 +23,7 @@ const DEFAULT_PORTS: Record<string, number> = {
wss: 443
};
type ProxyMode = 'on' | 'off' | 'system' | 'pac';
type ProxyMode = 'on' | 'off' | 'system';
type ProxyAuth = {
enabled: boolean;
@@ -40,9 +39,6 @@ type ProxyConfig = {
auth?: ProxyAuth;
bypassProxy?: string;
mode?: ProxyMode;
pac?: {
source: string;
};
};
type SystemProxyConfig = {
@@ -313,7 +309,7 @@ const getCertsAndProxyConfig = ({
/**
* Proxy configuration
*
* Preferences proxyMode has four possible values: on, off, system, pac
* Preferences proxyMode has three possible values: on, off, system
* Collection proxyMode has three possible values: true, false, global
*
* When collection proxyMode is true, it overrides the app-level proxy settings
@@ -341,22 +337,18 @@ const getCertsAndProxyConfig = ({
// Inherit from app-level proxy settings
if (appLevelProxyConfig) {
const globalDisabled = get(appLevelProxyConfig, 'disabled', false);
const globalProxySource = get(appLevelProxyConfig, 'source', 'inherit');
const globalProxyConfigData = get(appLevelProxyConfig, 'config', {});
const globalInherit = get(appLevelProxyConfig, 'inherit', false);
const globalProxyConfigData = get(appLevelProxyConfig, 'config', appLevelProxyConfig);
if (!globalDisabled) {
if (globalProxySource === 'pac') {
proxyConfig = { pac: get(appLevelProxyConfig, 'pac.source') };
proxyMode = 'pac';
} else if (globalProxySource === 'inherit') {
const { http_proxy, https_proxy } = systemProxyConfig || {};
if (http_proxy?.length || https_proxy?.length) {
proxyMode = 'system';
}
} else {
// source === 'manual'
proxyConfig = globalProxyConfigData;
proxyMode = 'on';
if (!globalDisabled && !globalInherit) {
// Use app-level custom proxy
proxyConfig = globalProxyConfigData;
proxyMode = 'on';
} else if (!globalDisabled && globalInherit) {
// App-level also inherits, fall through to system proxy
const { http_proxy, https_proxy } = systemProxyConfig || {};
if (http_proxy?.length || https_proxy?.length) {
proxyMode = 'system';
}
}
// else: app-level proxy is disabled, proxyMode stays 'off'
@@ -382,7 +374,7 @@ function extractHostname(url: string | undefined): string | null {
}
}
async function createAgents({
function createAgents({
requestUrl,
proxyMode,
proxyConfig,
@@ -391,7 +383,7 @@ async function createAgents({
httpsAgentRequestFields,
timeline,
disableCache = true
}: CreateAgentsParams): Promise<AgentResult> {
}: CreateAgentsParams): AgentResult {
// Ensure TLS options are properly set
const tlsOptions: TlsOptions = {
...httpsAgentRequestFields,
@@ -455,40 +447,6 @@ async function createAgents({
}
}
}
} else if (proxyMode === 'pac') {
const pacSource = get(proxyConfig, 'pac.source');
if (pacSource && requestUrl) {
try {
const resolver = await getPacResolver({ pacSource, httpsAgentRequestFields: { ca: tlsOptions.ca, rejectUnauthorized: tlsOptions.rejectUnauthorized, minVersion: tlsOptions.minVersion } });
const directives = await resolver.resolve(requestUrl);
if (directives && directives.length) {
const first = directives[0];
if (/^(PROXY|HTTPS?)\s+/i.test(first)) {
const parts = first.split(/\s+/);
const keyword = parts[0].toUpperCase();
const hostPort = parts[1];
const scheme = keyword === 'HTTPS' ? 'https' : 'http';
const proxyUri = `${scheme}://${hostPort}`;
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: PatchedHttpsProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
} else {
httpAgent = getOrCreateHttpAgent({ AgentClass: HttpProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
}
} else if (/^SOCKS/i.test(first)) {
const hostPort = first.split(/\s+/)[1];
const proto = /^SOCKS4\s/i.test(first) ? 'socks4' : 'socks5';
const proxyUri = `${proto}://${hostPort}`;
if (isHttpsRequest) {
httpsAgent = getOrCreateHttpsAgent({ AgentClass: SocksProxyAgent, options: tlsOptions as any, proxyUri, timeline: timeline || null, disableCache, hostname }) as HttpsAgent;
} else {
httpAgent = getOrCreateHttpAgent({ AgentClass: SocksProxyAgent, options: { keepAlive: true }, proxyUri, timeline: timeline || null, disableCache, hostname });
}
}
}
} catch {
// PAC resolution failed — fall through to direct connection
}
}
} else if (proxyMode === 'system') {
const http_proxy = get(systemProxyConfig, 'http_proxy');
const https_proxy = get(systemProxyConfig, 'https_proxy');
@@ -556,7 +514,7 @@ const getHttpHttpsAgents = async ({
httpsAgentRequestFields.rejectUnauthorized = false;
}
const { httpAgent, httpsAgent } = await createAgents({
const { httpAgent, httpsAgent } = createAgents({
requestUrl,
proxyMode,
proxyConfig,

View File

@@ -188,7 +188,6 @@ function createVaultClient(config: VaultConfig = {}): VaultClient {
method: method as any,
url: uri,
headers,
proxy: false,
validateStatus: () => true // Don't throw on non-2xx status
};

View File

@@ -1,281 +0,0 @@
describe('pac-resolver (shared)', () => {
beforeEach(() => {
jest.resetModules();
});
afterEach(() => {
const { clearPacCache } = require('./pac-resolver');
clearPacCache();
jest.clearAllMocks();
});
/** Mock pac-resolver (v7: { createPacResolver }) and quickjs-emscripten */
const setupPacMocks = (resolverFn: (...args: any[]) => Promise<any> = async () => 'PROXY p.example:8080; DIRECT') => {
jest.doMock('quickjs-emscripten', () => ({
getQuickJS: jest.fn(async () => ({}))
}));
const createPacResolverMock = jest.fn((_qjs: any, _script: any) => resolverFn);
jest.doMock('pac-resolver', () => ({ createPacResolver: createPacResolverMock }));
return { createPacResolverMock };
};
const mockFsReadSuccess = (content: string) => {
jest.doMock('fs/promises', () => ({
readFile: jest.fn().mockResolvedValue(content)
}));
};
const mockFsReadError = (err: Error) => {
jest.doMock('fs/promises', () => ({
readFile: jest.fn().mockRejectedValue(err)
}));
};
const mockAxiosSuccess = (text: string) => {
jest.doMock('axios', () => ({ get: jest.fn().mockResolvedValue({ data: text }) }));
};
const mockAxiosHttpError = (status: number) => {
const err = Object.assign(new Error(`Request failed with status code ${status}`), {
response: { status }
});
jest.doMock('axios', () => ({ get: jest.fn().mockRejectedValue(err) }));
};
const mockAxiosNetworkError = (message: string) => {
jest.doMock('axios', () => ({ get: jest.fn().mockRejectedValue(new Error(message)) }));
};
test('throws when pacSource is not provided', async () => {
const { getPacResolver } = require('./pac-resolver');
await expect(getPacResolver({})).rejects.toThrow('pacSource must be provided');
});
test('downloads PAC via axios and returns resolver that splits directives', async () => {
const pacScript = 'function FindProxyForURL(url, host) { return "PROXY p.example:8080; DIRECT"; }';
mockAxiosSuccess(pacScript);
const { createPacResolverMock } = setupPacMocks(async () => 'PROXY p.example:8080; DIRECT');
const { getPacResolver } = require('./pac-resolver');
const { get: axiosGet } = require('axios');
const pacSource = 'http://example.com/proxy.pac';
const wrapper = await getPacResolver({ pacSource });
const directives = await wrapper.resolve('http://foo.example/');
expect(directives).toEqual(['PROXY p.example:8080', 'DIRECT']);
expect(createPacResolverMock).toHaveBeenCalledWith(expect.any(Object), pacScript);
expect(axiosGet).toHaveBeenCalledWith(pacSource, expect.objectContaining({ proxy: false }));
});
test('passes TLS options to https.Agent for HTTPS pac URLs', async () => {
mockAxiosSuccess('script');
setupPacMocks(async () => 'DIRECT');
const mockAgentConstructor = jest.fn();
jest.doMock('https', () => ({ Agent: mockAgentConstructor }));
const { getPacResolver } = require('./pac-resolver');
const httpsAgentRequestFields = {
ca: 'ca-cert-data',
rejectUnauthorized: false,
minVersion: 'TLSv1.2'
};
await getPacResolver({ pacSource: 'https://secure.example.com/proxy.pac', httpsAgentRequestFields });
expect(mockAgentConstructor).toHaveBeenCalledWith({
ca: 'ca-cert-data',
rejectUnauthorized: false,
minVersion: 'TLSv1.2'
});
});
test('does not create https.Agent for HTTP pac URLs', async () => {
mockAxiosSuccess('script');
setupPacMocks(async () => 'DIRECT');
const mockAgentConstructor = jest.fn();
jest.doMock('https', () => ({ Agent: mockAgentConstructor }));
const { getPacResolver } = require('./pac-resolver');
await getPacResolver({ pacSource: 'http://example.com/proxy.pac' });
expect(mockAgentConstructor).not.toHaveBeenCalled();
});
test('caches resolver and returns same wrapper on repeated calls', async () => {
mockAxiosSuccess('script');
const { createPacResolverMock } = setupPacMocks(async () => 'DIRECT');
const { getPacResolver, _CACHE } = require('./pac-resolver');
const pacSource = 'http://example.com/proxy.pac';
const w1 = await getPacResolver({ pacSource });
const w2 = await getPacResolver({ pacSource });
expect(w1).toBe(w2);
expect(_CACHE.size).toBeGreaterThan(0);
expect(createPacResolverMock).toHaveBeenCalledTimes(1);
});
test('returns empty array when resolver returns non-string', async () => {
mockAxiosSuccess('script');
setupPacMocks(async () => null);
const { getPacResolver } = require('./pac-resolver');
const wrapper = await getPacResolver({ pacSource: 'http://example.com/proxy.pac' });
expect(await wrapper.resolve('http://example.com/')).toEqual([]);
});
test('rejects when axios throws a network error', async () => {
mockAxiosNetworkError('ECONNREFUSED');
jest.doMock('pac-resolver', () => ({ createPacResolver: jest.fn() }));
jest.doMock('quickjs-emscripten', () => ({ getQuickJS: jest.fn(async () => ({})) }));
const { getPacResolver } = require('./pac-resolver');
await expect(getPacResolver({ pacSource: 'http://unreachable/proxy.pac' })).rejects.toThrow('ECONNREFUSED');
});
test('rejects with readable message when PAC server returns non-2xx', async () => {
mockAxiosHttpError(404);
jest.doMock('pac-resolver', () => ({ createPacResolver: jest.fn() }));
jest.doMock('quickjs-emscripten', () => ({ getQuickJS: jest.fn(async () => ({})) }));
const { getPacResolver } = require('./pac-resolver');
await expect(getPacResolver({ pacSource: 'http://example.com/missing.pac' })).rejects.toThrow('Failed to fetch PAC (404)');
});
test('re-downloads PAC after cache TTL expires', async () => {
const axiosGetMock = jest.fn().mockResolvedValue({ data: 'script' });
jest.doMock('axios', () => ({ get: axiosGetMock }));
const { createPacResolverMock } = setupPacMocks(async () => 'DIRECT');
const { getPacResolver } = require('./pac-resolver');
const pacSource = 'http://example.com/proxy.pac';
const ttlMs = 100;
const w1 = await getPacResolver({ pacSource, opts: { cacheTtlMs: ttlMs } });
expect(axiosGetMock).toHaveBeenCalledTimes(1);
const realNow = Date.now;
Date.now = () => realNow() + ttlMs + 1;
try {
const w2 = await getPacResolver({ pacSource, opts: { cacheTtlMs: ttlMs } });
expect(axiosGetMock).toHaveBeenCalledTimes(2);
expect(w2).not.toBe(w1);
} finally {
Date.now = realNow;
}
});
test('resolve propagates error from a malformed PAC script', async () => {
mockAxiosSuccess('not valid JS {{{{');
setupPacMocks(async () => { throw new Error('invalid PAC script'); });
const { getPacResolver } = require('./pac-resolver');
const wrapper = await getPacResolver({ pacSource: 'http://example.com/bad.pac' });
await expect(wrapper.resolve('http://example.com/')).rejects.toThrow('invalid PAC script');
});
/** file:// PAC tests */
test('reads PAC from filesystem for file:// URL and does not call axios', async () => {
const pacScript = 'function FindProxyForURL(url, host) { return "PROXY p.example:8080"; }';
const expectedPath = '/Users/test/proxy.pac';
mockFsReadSuccess(pacScript);
const { createPacResolverMock } = setupPacMocks(async () => 'PROXY p.example:8080');
const axiosGetMock = jest.fn();
jest.doMock('axios', () => ({ get: axiosGetMock }));
jest.doMock('url', () => ({ fileURLToPath: jest.fn(() => expectedPath) }));
const { getPacResolver } = require('./pac-resolver');
const { readFile } = require('fs/promises');
const pacSource = 'file:///Users/test/proxy.pac';
const wrapper = await getPacResolver({ pacSource });
expect(readFile).toHaveBeenCalledWith(expectedPath, 'utf8');
expect(axiosGetMock).not.toHaveBeenCalled();
expect(createPacResolverMock).toHaveBeenCalledWith(expect.any(Object), pacScript);
const directives = await wrapper.resolve('http://foo.example/');
expect(directives).toEqual(['PROXY p.example:8080']);
});
test('resolves Windows file:// URL to correct OS path', async () => {
const pacScript = 'function FindProxyForURL(url, host) { return "DIRECT"; }';
mockFsReadSuccess(pacScript);
setupPacMocks(async () => 'DIRECT');
jest.doMock('url', () => ({
fileURLToPath: jest.fn(() => 'C:\\Users\\test\\proxy.pac')
}));
const { getPacResolver } = require('./pac-resolver');
const { readFile } = require('fs/promises');
const { fileURLToPath } = require('url');
await getPacResolver({ pacSource: 'file:///C:/Users/test/proxy.pac' });
expect(fileURLToPath).toHaveBeenCalledWith('file:///C:/Users/test/proxy.pac');
expect(readFile).toHaveBeenCalledWith('C:\\Users\\test\\proxy.pac', 'utf8');
});
test('rejects when file:// PAC file does not exist', async () => {
const err = Object.assign(new Error('no such file or directory'), { code: 'ENOENT' });
mockFsReadError(err);
jest.doMock('pac-resolver', () => ({ createPacResolver: jest.fn() }));
jest.doMock('quickjs-emscripten', () => ({ getQuickJS: jest.fn(async () => ({})) }));
const { getPacResolver } = require('./pac-resolver');
await expect(getPacResolver({ pacSource: 'file:///nonexistent/proxy.pac' })).rejects.toThrow('no such file or directory');
});
test('caches resolver for file:// URL and reads file only once', async () => {
mockFsReadSuccess('script');
const { createPacResolverMock } = setupPacMocks(async () => 'DIRECT');
const { getPacResolver } = require('./pac-resolver');
const { readFile } = require('fs/promises');
const pacSource = 'file:///Users/test/proxy.pac';
const w1 = await getPacResolver({ pacSource });
const w2 = await getPacResolver({ pacSource });
expect(w1).toBe(w2);
expect(readFile).toHaveBeenCalledTimes(1);
expect(createPacResolverMock).toHaveBeenCalledTimes(1);
});
test('does not create https.Agent for file:// URL', async () => {
mockFsReadSuccess('script');
setupPacMocks(async () => 'DIRECT');
const mockAgentConstructor = jest.fn();
jest.doMock('https', () => ({ Agent: mockAgentConstructor }));
const { getPacResolver } = require('./pac-resolver');
await getPacResolver({ pacSource: 'file:///Users/test/proxy.pac' });
expect(mockAgentConstructor).not.toHaveBeenCalled();
});
test('clearPacCache clears entries by prefix and entirely', async () => {
mockAxiosSuccess('script');
setupPacMocks(async () => 'DIRECT');
const { getPacResolver, _CACHE, clearPacCache } = require('./pac-resolver');
await getPacResolver({ pacSource: 'http://one/pac' });
await getPacResolver({ pacSource: 'http://two/pac' });
expect(_CACHE.size).toBeGreaterThanOrEqual(2);
clearPacCache('url:http://one');
for (const key of Array.from(_CACHE.keys()) as string[]) {
expect(key.startsWith('url:http://one')).toBe(false);
}
clearPacCache();
expect(_CACHE.size).toBe(0);
});
});

View File

@@ -1,118 +0,0 @@
import axios from 'axios';
import crypto from 'node:crypto';
import { readFile } from 'fs/promises';
import https, { type AgentOptions } from 'https';
import { fileURLToPath } from 'url';
import { createPacResolver } from 'pac-resolver';
import { getQuickJS } from 'quickjs-emscripten';
const CACHE = new Map<string, { wrapper: Promise<PacWrapper>; ts: number }>();
type TlsOptions = {
ca?: string | string[];
rejectUnauthorized?: boolean;
minVersion?: string;
};
export type PacWrapper = {
resolve: (url: string) => Promise<string[]>;
};
async function downloadPac(pacSource: string, tlsOptions: TlsOptions, timeoutMs: number): Promise<string> {
if (pacSource.startsWith('file://')) {
return readFile(fileURLToPath(pacSource), 'utf8');
}
const config: Record<string, any> = {
timeout: timeoutMs,
proxy: false,
responseType: 'text',
maxRedirects: 3
};
if (pacSource.startsWith('https://')) {
const agentOpts: AgentOptions = {
ca: tlsOptions.ca,
rejectUnauthorized: tlsOptions.rejectUnauthorized,
minVersion: tlsOptions.minVersion as AgentOptions['minVersion']
};
config.httpsAgent = new https.Agent(agentOpts);
}
try {
const response = await axios.get(pacSource, config);
return response.data;
} catch (err: any) {
if (err.response) throw new Error(`Failed to fetch PAC (${err.response.status})`);
throw err;
}
}
export type GetPacResolverParams = {
pacSource: string;
httpsAgentRequestFields?: TlsOptions;
opts?: { cacheTtlMs?: number; timeoutMs?: number };
};
export async function getPacResolver({ pacSource, httpsAgentRequestFields = {}, opts = {} }: GetPacResolverParams): Promise<PacWrapper> {
if (!pacSource) throw new Error('pacSource must be provided');
const cacheTtlMs = opts.cacheTtlMs ?? 5 * 60 * 1000;
let key: string;
if (pacSource.startsWith('https://')) {
const caRaw = httpsAgentRequestFields.ca;
const caHash = caRaw
? crypto.createHash('sha256').update(Array.isArray(caRaw) ? caRaw.join('|') : caRaw).digest('hex').slice(0, 16)
: '';
key = `url:${pacSource}|ca:${caHash}|ru:${httpsAgentRequestFields.rejectUnauthorized ?? ''}|mv:${httpsAgentRequestFields.minVersion ?? ''}`;
} else {
// file:// and http:// — no TLS options involved in fetching
key = `url:${pacSource}`;
}
const now = Date.now();
const cached = CACHE.get(key);
if (cached && now - cached.ts < cacheTtlMs) return cached.wrapper;
const wrapperPromise: Promise<PacWrapper> = (async () => {
const script = await downloadPac(pacSource, httpsAgentRequestFields, opts.timeoutMs ?? 5000);
// pac-resolver v7 uses QuickJS WASM sandbox — not affected by CVE GHSA-9j49-mfvp-vmhm (<v5)
const qjs = await getQuickJS();
const resolverFn = createPacResolver(qjs, script);
return {
resolve: async (url: string) => {
let host: string;
try {
host = new URL(url).hostname;
} catch {
return [];
}
const out = await resolverFn(url, host);
if (!out || typeof out !== 'string') return [];
return out.split(';').map((s) => s.trim()).filter(Boolean);
}
};
})();
CACHE.set(key, { wrapper: wrapperPromise, ts: now });
try {
return await wrapperPromise;
} catch (err) {
CACHE.delete(key);
throw err;
}
}
export function clearPacCache(keyPrefix?: string): void {
if (!keyPrefix) {
CACHE.clear();
return;
}
for (const key of Array.from(CACHE.keys())) {
if (key.startsWith(keyPrefix)) CACHE.delete(key);
}
}
export const _CACHE = CACHE;

View File

@@ -200,63 +200,6 @@ describe('transformProxyConfig', () => {
expect(result).toEqual(newConfig);
});
// Backward compat: old manual users have no source field — pass through unchanged
test('should not modify new format without source field (backward compat: treated as manual)', () => {
const newConfig = {
inherit: false,
config: {
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: { username: 'user', password: 'pass' },
bypassProxy: ''
}
};
const result = transformProxyConfig(newConfig);
expect(result).toEqual(newConfig);
expect((result as any).source).toBeUndefined();
});
test('should not modify new format with source: manual', () => {
const newConfig = {
inherit: false,
source: 'manual',
pac: { source: '' },
config: {
protocol: 'http',
hostname: 'proxy.example.com',
port: 8080,
auth: { username: 'user', password: 'pass' },
bypassProxy: ''
}
};
const result = transformProxyConfig(newConfig);
expect(result).toEqual(newConfig);
});
test('should not modify new format with source: pac', () => {
const newConfig = {
inherit: false,
source: 'pac',
pac: { source: 'http://internal/proxy.pac' },
config: {
protocol: 'http',
hostname: '',
port: null,
auth: { username: '', password: '' },
bypassProxy: ''
}
};
const result = transformProxyConfig(newConfig);
expect(result).toEqual(newConfig);
});
});
describe('Edge Cases', () => {

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: consumer_secret_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: HMAC-SHA1
signatureEncoding: HMAC-SHA1
version: "1.0"
placement: header
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: wrong_secret
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: HMAC-SHA1
signatureEncoding: HMAC-SHA1
version: "1.0"
placement: header
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: consumer_secret_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: HMAC-SHA1
signatureEncoding: HMAC-SHA1
version: "1.0"
placement: body
includeBodyHash: false

View File

@@ -15,7 +15,7 @@ http:
consumerSecret: consumer_secret_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: HMAC-SHA1
signatureEncoding: HMAC-SHA1
version: "1.0"
placement: body
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: consumer_secret_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: HMAC-SHA1
signatureEncoding: HMAC-SHA1
version: "1.0"
placement: header
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: consumer_secret_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: HMAC-SHA1
signatureEncoding: HMAC-SHA1
version: "1.0"
placement: query
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: consumer_secret_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: HMAC-SHA256
signatureEncoding: HMAC-SHA256
version: "1.0"
placement: header
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: wrong_secret
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: HMAC-SHA256
signatureEncoding: HMAC-SHA256
version: "1.0"
placement: header
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: consumer_secret_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: HMAC-SHA256
signatureEncoding: HMAC-SHA256
version: "1.0"
placement: body
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: consumer_secret_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: HMAC-SHA512
signatureEncoding: HMAC-SHA512
version: "1.0"
placement: header
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: wrong_secret
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: HMAC-SHA512
signatureEncoding: HMAC-SHA512
version: "1.0"
placement: header
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: consumer_secret_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: PLAINTEXT
signatureEncoding: PLAINTEXT
version: '1.0'
placement: header
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: wrong_secret
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: PLAINTEXT
signatureEncoding: PLAINTEXT
version: "1.0"
placement: header
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: consumer_secret_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: PLAINTEXT
signatureEncoding: PLAINTEXT
version: "1.0"
placement: body
includeBodyHash: false

View File

@@ -12,7 +12,7 @@ http:
consumerSecret: consumer_secret_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: PLAINTEXT
signatureEncoding: PLAINTEXT
version: "1.0"
placement: query
includeBodyHash: false

View File

@@ -11,7 +11,7 @@ http:
consumerKey: consumer_key_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: RSA-SHA1
signatureEncoding: RSA-SHA1
privateKey:
type: text
value: |

View File

@@ -13,7 +13,7 @@ http:
consumerKey: consumer_key_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: RSA-SHA1
signatureEncoding: RSA-SHA1
privateKey:
type: text
value: |

View File

@@ -16,7 +16,7 @@ http:
consumerKey: consumer_key_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: RSA-SHA1
signatureEncoding: RSA-SHA1
privateKey:
type: text
value: |

View File

@@ -11,7 +11,7 @@ http:
consumerKey: consumer_key_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: RSA-SHA1
signatureEncoding: RSA-SHA1
privateKey:
type: file
value: test-private-key.pem

View File

@@ -11,7 +11,7 @@ http:
consumerKey: consumer_key_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: RSA-SHA1
signatureEncoding: RSA-SHA1
privateKey:
type: text
value: |

View File

@@ -11,7 +11,7 @@ http:
consumerKey: consumer_key_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: RSA-SHA1
signatureEncoding: RSA-SHA1
privateKey:
type: text
value: "{{private-key}}"

View File

@@ -11,7 +11,7 @@ http:
consumerKey: consumer_key_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: RSA-SHA256
signatureEncoding: RSA-SHA256
privateKey:
type: text
value: |

View File

@@ -11,7 +11,7 @@ http:
consumerKey: consumer_key_1
accessToken: access_token_1
accessTokenSecret: token_secret_1
signatureMethod: RSA-SHA512
signatureEncoding: RSA-SHA512
privateKey:
type: text
value: |

View File

@@ -26,7 +26,6 @@ test.describe('OAuth 1.0 Authentication', () => {
});
test('Request auth UI', async ({ page, createTmpDir }) => {
test.setTimeout(60_000);
// Setup
await createCollection(page, 'oauth1-test', await createTmpDir());
await createRequest(page, 'oauth1-request', 'oauth1-test', { url: 'https://example.com/api' });
@@ -138,7 +137,6 @@ test.describe('OAuth 1.0 Authentication', () => {
});
test('Collection settings auth', async ({ page }) => {
test.setTimeout(60_000);
const collectionRow = page.getByTestId('collections').locator('#sidebar-collection-name').filter({ hasText: 'oauth1-test' });
await collectionRow.click();
await page.locator('.tab.auth').click();

View File

@@ -19,13 +19,13 @@ test.describe('Create collection', () => {
// Set the URL
await page.locator('#request-url .CodeMirror').click();
await page.locator('#request-url').locator('textarea').fill('http://localhost:8081');
await page.locator('#request-actions').getByTitle('Save Request').click();
await page.locator('#send-request').getByTitle('Save Request').click();
// Send a request
await page.locator('#request-url .CodeMirror').click();
await page.locator('#request-url').locator('textarea').fill('/ping');
await page.locator('#request-actions').getByTitle('Save Request').click();
await page.getByTestId('send-arrow-icon').click();
await page.locator('#send-request').getByTitle('Save Request').click();
await page.locator('#send-request').getByRole('img').nth(2).click();
// Verify the response
await expect(page.getByRole('main')).toContainText('200 OK');

View File

@@ -22,7 +22,7 @@ test.describe('Multiline Variables - Read Environment Test', () => {
await expect(page.locator('.current-environment').filter({ hasText: /Test/ })).toBeVisible();
// send request
const sendButton = page.getByTestId('send-arrow-icon');
const sendButton = page.locator('#send-request').getByRole('img').nth(2);
await expect(sendButton).toBeVisible();
await sendButton.click();
await expect(page.locator('.response-status-code.text-ok')).toBeVisible();

View File

@@ -1,6 +0,0 @@
{
"version": "1",
"name": "pac-proxy-test",
"type": "collection",
"ignore": []
}

View File

@@ -1,21 +0,0 @@
meta {
name: direct
type: http
seq: 2
}
get {
url: http://localhost:19000/direct
body: none
auth: none
}
assert {
res.status: eq 200
}
tests {
test("request bypassed proxy (PAC returned DIRECT)", function() {
expect(res.headers['x-proxied']).to.be.undefined;
});
}

View File

@@ -1,21 +0,0 @@
meta {
name: proxied
type: http
seq: 1
}
get {
url: http://localhost:19000/proxied
body: none
auth: none
}
assert {
res.status: eq 200
}
tests {
test("request was routed through PAC proxy", function() {
expect(res.headers['x-proxied']).to.equal('test-proxy');
});
}

View File

@@ -1,6 +0,0 @@
function FindProxyForURL(url, host) {
if (url.indexOf('/proxied') !== -1) {
return 'PROXY localhost:18888';
}
return 'DIRECT';
}

View File

@@ -1,17 +0,0 @@
{
"maximized": false,
"lastOpenedCollections": ["{{projectRoot}}/tests/proxy/pac/fixtures/collection"],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true,
"hasSeenWelcomeModal": true
},
"proxy": {
"source": "pac",
"pac": {
"source": "{{pacUrl}}"
},
"config": {}
}
}
}

View File

@@ -1,68 +0,0 @@
import * as path from 'path';
import { pathToFileURL } from 'url';
import { test } from '../../../playwright';
import { setSandboxMode, runCollection, validateRunnerResults } from '../../utils/page';
import { startServers, stopServers, PAC_PORT, type TestServers } from './server';
test.describe('PAC Proxy', () => {
let servers: TestServers;
test.beforeAll(async () => {
servers = await startServers();
});
test.afterAll(async () => {
if (servers) {
await stopServers(servers);
}
});
/**
* Verifies end-to-end PAC proxy resolution:
*
* - The PAC file routes /proxied paths to the local test proxy (port 18888).
* - The local test proxy injects `x-proxied: test-proxy` into every response.
* - /direct paths are returned DIRECT — no proxy header added.
*
* Both assertions live inside the collection's `tests {}` blocks, so
* validateRunnerResults confirms the full flow passed.
*/
test('routes requests per PAC directive (PROXY and DIRECT) via HTTP URL', async ({ launchElectronApp }) => {
const pacUrl = `http://localhost:${PAC_PORT}/test.pac`;
const initUserDataPath = path.join(__dirname, 'init-user-data');
const app = await launchElectronApp({ initUserDataPath, templateVars: { pacUrl } });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await setSandboxMode(page, 'pac-proxy-test', 'developer');
await runCollection(page, 'pac-proxy-test');
await validateRunnerResults(page, {
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});
});
test('routes requests via file:// PAC URL', async ({ launchElectronApp }) => {
// Compute the file:// URL at runtime so it is correct on every OS:
// Mac/Linux → file:///abs/path/to/test.pac
// Windows → file:///C:/abs/path/to/test.pac
const pacUrl = pathToFileURL(path.join(__dirname, 'fixtures', 'pac-files', 'test.pac')).href;
const initUserDataPath = path.join(__dirname, 'init-user-data');
const app = await launchElectronApp({ initUserDataPath, templateVars: { pacUrl } });
const page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
await setSandboxMode(page, 'pac-proxy-test', 'developer');
await runCollection(page, 'pac-proxy-test');
await validateRunnerResults(page, {
totalRequests: 2,
passed: 2,
failed: 0,
skipped: 0
});
});
});

View File

@@ -1,113 +0,0 @@
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
export const PAC_PORT = 18080;
export const PROXY_PORT = 18888;
export const TARGET_PORT = 19000;
export interface TestServers {
pacServer: http.Server;
proxyServer: http.Server;
targetServer: http.Server;
}
/** Serves .pac files from the pac-files/ directory. */
function createPacServer(): Promise<http.Server> {
const pacDir = path.join(__dirname, 'pac-files');
const server = http.createServer((req, res) => {
const filename = (req.url ?? '/').replace(/^\//, '') || 'test.pac';
const filepath = path.join(pacDir, filename);
fs.readFile(filepath, 'utf8', (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, { 'Content-Type': 'application/x-ns-proxy-autoconfig' });
res.end(data);
});
});
return listen(server, PAC_PORT);
}
/**
* Plain HTTP proxy. Forwards requests and injects `x-proxied: test-proxy`
* into every response so tests can confirm traffic went through it.
*/
function createProxyServer(): Promise<http.Server> {
const server = http.createServer((clientReq, clientRes) => {
let targetUrl: URL;
try {
targetUrl = new URL(clientReq.url!);
} catch {
clientRes.writeHead(400);
clientRes.end('Bad request URL');
return;
}
const options: http.RequestOptions = {
hostname: targetUrl.hostname,
port: targetUrl.port || 80,
path: targetUrl.pathname + targetUrl.search,
method: clientReq.method,
headers: { ...clientReq.headers, host: targetUrl.host }
};
delete (options.headers as Record<string, string>)['proxy-connection'];
const proxyReq = http.request(options, (proxyRes) => {
const headers = { ...proxyRes.headers, 'x-proxied': 'test-proxy' };
clientRes.writeHead(proxyRes.statusCode!, headers);
proxyRes.pipe(clientRes);
});
proxyReq.on('error', (err) => {
if (!clientRes.headersSent) {
clientRes.writeHead(502);
}
clientRes.end(`Proxy error: ${err.message}`);
});
clientReq.pipe(proxyReq);
});
return listen(server, PROXY_PORT);
}
/** Simple JSON echo server — the requests' target. */
function createTargetServer(): Promise<http.Server> {
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, path: req.url }));
});
return listen(server, TARGET_PORT);
}
function listen(server: http.Server, port: number): Promise<http.Server> {
return new Promise((resolve, reject) => {
server.on('error', reject);
server.listen(port, '127.0.0.1', () => resolve(server));
});
}
function close(server: http.Server): Promise<void> {
return new Promise((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}
export async function startServers(): Promise<TestServers> {
const [pacServer, proxyServer, targetServer] = await Promise.all([
createPacServer(),
createProxyServer(),
createTargetServer()
]);
return { pacServer, proxyServer, targetServer };
}
export async function stopServers(servers: TestServers): Promise<void> {
await Promise.all([
close(servers.pacServer),
close(servers.proxyServer),
close(servers.targetServer)
]);
}

View File

@@ -1,7 +0,0 @@
function FindProxyForURL(url, host) {
// Route requests whose path starts with /proxied through the local test proxy
if (url.indexOf("/proxied") > -1) {
return "PROXY 127.0.0.1:18888";
}
return "DIRECT";
}

View File

@@ -1,79 +0,0 @@
import { test, expect } from '../../../playwright';
import {
closeAllCollections,
createCollection,
createRequest,
sendRequest
} from '../../utils/page/actions';
/**
* Select a tab in the response pane, handling the overflow dropdown (>>) if the tab is hidden.
*/
const selectResponsePaneTab = async (page, tabName: string) => {
await test.step(`Select response pane tab "${tabName}"`, async () => {
const responsePaneTabs = page.locator('.response-pane .tabs');
const visibleTab = responsePaneTabs.getByRole('tab', { name: tabName });
if (await visibleTab.isVisible()) {
await visibleTab.click();
return;
}
// Tab is hidden in the overflow dropdown (>> button)
const overflowButton = responsePaneTabs.locator('.more-tabs');
if (await overflowButton.isVisible()) {
await overflowButton.click();
const dropdownItem = page.locator('.tippy-box .dropdown-item').filter({ hasText: tabName });
await dropdownItem.click();
}
});
};
test.describe('Timeline URL Update', () => {
test.afterAll(async ({ page }) => {
await closeAllCollections(page);
});
test('should show correct URL in timeline after changing request URL between sends', async ({ page, createTmpDir }) => {
const collectionName = 'timeline-url-test';
const firstUrl = 'http://localhost:8081/ping';
const secondUrl = 'http://localhost:8081/headers';
await test.step('Create collection and request', async () => {
await createCollection(page, collectionName, await createTmpDir(collectionName));
await createRequest(page, 'url-change-test', collectionName, { url: firstUrl });
});
await test.step('Send first request', async () => {
await sendRequest(page, 200);
});
await test.step('Change URL and send second request', async () => {
// Click into the URL field, select all, then type the new URL
// (fillRequestUrl appends in CodeMirror, so we clear manually)
const urlEditor = page.locator('#request-url .CodeMirror');
await urlEditor.click();
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
await page.keyboard.press(`${modifier}+a`);
await page.keyboard.type(secondUrl);
await page.waitForTimeout(200);
await sendRequest(page, 200);
});
await test.step('Open Timeline tab and verify URLs', async () => {
await selectResponsePaneTab(page, 'Timeline');
// Get all timeline entries
const timelineItems = page.locator('.timeline-item');
await expect(timelineItems).toHaveCount(2, { timeout: 5000 });
// Most recent entry (first in list) should show the second URL
const firstEntry = timelineItems.nth(0);
await expect(firstEntry).toContainText('/headers');
// Older entry (second in list) should show the first URL
const secondEntry = timelineItems.nth(1);
await expect(secondEntry).toContainText('/ping');
});
});
});

View File

@@ -77,7 +77,7 @@ test.describe.serial('Create and Delete Response Examples', () => {
});
await test.step('Test form reset', async () => {
await page.getByTestId('send-arrow-icon').click();
await page.locator('#send-request').getByRole('img').nth(2).click();
await clickResponseAction(page, 'response-bookmark-btn');
await page.getByTestId('create-example-name-input').fill('Test Name');

View File

@@ -141,7 +141,7 @@ const createUntitledRequest = async (
if (url) {
await page.locator('#request-url .CodeMirror').click();
await page.locator('#request-url textarea').fill(url);
await page.locator('#request-actions').getByTitle('Save Request').click();
await page.locator('#send-request').getByTitle('Save Request').click();
await page.waitForTimeout(200);
}

View File

@@ -83,7 +83,7 @@ export const buildCommonLocators = (page: Page) => ({
newRequestUrl: () => page.locator('#new-request-url .CodeMirror'),
requestNameInput: () => page.getByPlaceholder('Request Name'),
requestTestId: () => page.getByTestId('request-name'),
generateCodeButton: () => page.locator('#request-actions .infotip').first(),
generateCodeButton: () => page.locator('#send-request .infotip').first(),
bodyModeSelector: () => page.getByTestId('request-body-mode-selector'),
bodyEditor: () => page.getByTestId('request-body-editor')
},