mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
7 Commits
exp/postma
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f37eb2d1f | ||
|
|
cd06f28430 | ||
|
|
3b502fd63d | ||
|
|
d4cd34fc50 | ||
|
|
58942b383d | ||
|
|
476d30a49e | ||
|
|
4d6032ba0d |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno
|
||||
|
||||
2
.github/workflows/flaky-test-detector.yml
vendored
2
.github/workflows/flaky-test-detector.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Post PR comment
|
||||
if: hashFiles('pr-comment.md') != ''
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -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.locator('#send-request').getByRole('img').nth(2).click();
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
|
||||
// Verify response
|
||||
await expect(page.getByRole('main')).toContainText('200 OK');
|
||||
|
||||
130
package-lock.json
generated
130
package-lock.json
generated
@@ -30,7 +30,7 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "0.9.0",
|
||||
"@opencollection/types": "0.9.1",
|
||||
"@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.0",
|
||||
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.9.0.tgz",
|
||||
"integrity": "sha512-2p9Pb1cSpUBvtsnvsHtqxbzmJtUvkfE7r2R/BVWiVG0CRohvuhyClcgb061aa/95TEo0cXdXKLXmtZSGWvf1NA==",
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.9.1.tgz",
|
||||
"integrity": "sha512-kYJvPSvR9XohCo7qACiCQEbWlvj4KgxM8igrTEhudIxTO1QAy8BBOEUeHLqYeSFz1MSSW1CuWkMJOyw/egr7Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -17654,6 +17654,32 @@
|
||||
"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",
|
||||
@@ -18560,7 +18586,6 @@
|
||||
"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",
|
||||
@@ -18582,7 +18607,6 @@
|
||||
"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"
|
||||
@@ -18965,7 +18989,6 @@
|
||||
"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"
|
||||
@@ -24678,6 +24701,15 @@
|
||||
"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",
|
||||
@@ -25190,6 +25222,19 @@
|
||||
"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",
|
||||
@@ -34950,7 +34995,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@opencollection/types": "0.9.0",
|
||||
"@opencollection/types": "0.9.1",
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^23.0.2",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
@@ -35969,6 +36014,8 @@
|
||||
"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",
|
||||
@@ -36010,6 +36057,48 @@
|
||||
"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",
|
||||
@@ -36033,6 +36122,31 @@
|
||||
"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",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@eslint/compat": "^1.3.2",
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@opencollection/types": "0.9.0",
|
||||
"@opencollection/types": "0.9.1",
|
||||
"@playwright/test": "^1.51.1",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
|
||||
|
||||
@@ -5,7 +5,7 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
|
||||
|
||||
.settings-label {
|
||||
width: 100px;
|
||||
}
|
||||
@@ -26,6 +26,57 @@ 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};
|
||||
|
||||
@@ -17,7 +17,22 @@ const ProxySettings = ({ close }) => {
|
||||
|
||||
const proxySchema = Yup.object({
|
||||
disabled: Yup.boolean().optional(),
|
||||
inherit: Yup.boolean().required(),
|
||||
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(),
|
||||
config: Yup.object({
|
||||
protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']),
|
||||
hostname: Yup.string().max(1024),
|
||||
@@ -39,7 +54,10 @@ const ProxySettings = ({ close }) => {
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
disabled: preferences.proxy.disabled || false,
|
||||
inherit: preferences.proxy.inherit || false,
|
||||
source: preferences.proxy.source || 'manual',
|
||||
pac: {
|
||||
source: preferences.proxy.pac?.source || ''
|
||||
},
|
||||
config: {
|
||||
protocol: preferences.proxy.config?.protocol || 'http',
|
||||
hostname: preferences.proxy.config?.hostname || '',
|
||||
@@ -86,15 +104,26 @@ 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]);
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave, proxyMode]);
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
@@ -110,10 +139,10 @@ const ProxySettings = ({ close }) => {
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="off"
|
||||
checked={formik.values.disabled === true}
|
||||
checked={proxyMode === 'off'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('off');
|
||||
formik.setFieldValue('disabled', true);
|
||||
formik.setFieldValue('inherit', false);
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
@@ -123,11 +152,12 @@ const ProxySettings = ({ close }) => {
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="on"
|
||||
checked={formik.values.disabled === false && formik.values.inherit === false}
|
||||
value="manual"
|
||||
checked={proxyMode === 'manual'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('manual');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('inherit', false);
|
||||
formik.setFieldValue('source', 'manual');
|
||||
}}
|
||||
className="mr-1 cursor-pointer"
|
||||
/>
|
||||
@@ -137,24 +167,40 @@ const ProxySettings = ({ close }) => {
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
value="system"
|
||||
checked={formik.values.disabled === false && formik.values.inherit === true}
|
||||
value="inherit"
|
||||
checked={proxyMode === 'inherit'}
|
||||
onChange={(e) => {
|
||||
setProxyMode('inherit');
|
||||
formik.setFieldValue('disabled', false);
|
||||
formik.setFieldValue('inherit', true);
|
||||
formik.setFieldValue('source', 'inherit');
|
||||
}}
|
||||
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>
|
||||
{formik.values.disabled === false && formik.values.inherit === true ? (
|
||||
{proxyMode === 'inherit' ? (
|
||||
<div className="mb-3 pt-1 text-muted system-proxy-settings">
|
||||
<SystemProxy />
|
||||
</div>
|
||||
) : null}
|
||||
{formik.values.disabled === false && formik.values.inherit === false ? (
|
||||
{proxyMode === 'manual' ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center">
|
||||
<label className="settings-label" htmlFor="protocol">
|
||||
@@ -335,6 +381,79 @@ 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>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,13 @@ 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;
|
||||
@@ -49,6 +53,7 @@ const Wrapper = styled.div`
|
||||
.shortcut {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -16,8 +16,9 @@ import { saveRequest, cancelRequest } from 'providers/ReduxStore/slices/collecti
|
||||
import { getRequestFromCurlCommand } from 'utils/curl';
|
||||
import HttpMethodSelector from './HttpMethodSelector';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
|
||||
import { IconDeviceFloppy, IconCode } 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';
|
||||
@@ -384,76 +385,67 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
};
|
||||
return (
|
||||
<StyledWrapper className="flex items-center w-full">
|
||||
<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 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>
|
||||
<div
|
||||
title="Save Request"
|
||||
className="infotip"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!hasChanges) return;
|
||||
onSave();
|
||||
}}
|
||||
id="request-url"
|
||||
className="h-full w-full flex flex-row items-center input-container overflow-hidden"
|
||||
>
|
||||
<IconDeviceFloppy
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={20}
|
||||
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
<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}
|
||||
/>
|
||||
<span className="infotiptext text-xs">
|
||||
Save <span className="shortcut">({saveShortcut})</span>
|
||||
</span>
|
||||
<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>
|
||||
</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}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
@@ -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,6 +99,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
|
||||
import { IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons';
|
||||
import SendButton from 'components/RequestPane/SendButton';
|
||||
import classnames from 'classnames';
|
||||
import SingleLineEditor from 'components/SingleLineEditor/index';
|
||||
import { requestUrlChanged } from 'providers/ReduxStore/slices/collections';
|
||||
@@ -123,7 +124,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center h-full">
|
||||
<div className="flex items-center input-container flex-1 w-full h-full relative">
|
||||
<div className="flex items-center input-container flex-1 min-w-0 h-full relative">
|
||||
<div className="flex items-center justify-center px-[10px]">
|
||||
<span className="text-xs font-medium method-ws">WS</span>
|
||||
</div>
|
||||
@@ -187,15 +188,14 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -591,10 +591,11 @@ 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 = {
|
||||
...response,
|
||||
timeline: response.timeline?.map((entry) => ({
|
||||
...responseData,
|
||||
timeline: responseData.timeline?.map((entry) => ({
|
||||
...entry,
|
||||
timestamp: entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp
|
||||
}))
|
||||
@@ -604,18 +605,23 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
|
||||
responseReceived({
|
||||
itemUid,
|
||||
collectionUid,
|
||||
response: serializedResponse
|
||||
response: serializedResponse,
|
||||
requestSent
|
||||
})
|
||||
);
|
||||
})
|
||||
.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
|
||||
response: null,
|
||||
requestSent
|
||||
})
|
||||
);
|
||||
return;
|
||||
@@ -633,7 +639,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
|
||||
responseReceived({
|
||||
itemUid,
|
||||
collectionUid,
|
||||
response: errorResponse
|
||||
response: errorResponse,
|
||||
requestSent
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -538,10 +538,12 @@ 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 = item?.requestSent?.timestamp instanceof Date
|
||||
? item.requestSent.timestamp.getTime()
|
||||
: item?.requestSent?.timestamp || Date.now();
|
||||
const timestamp = timelineRequest?.timestamp instanceof Date
|
||||
? timelineRequest.timestamp.getTime()
|
||||
: timelineRequest?.timestamp || Date.now();
|
||||
|
||||
// Append the new timeline entry with numeric timestamp
|
||||
collection.timeline.push({
|
||||
@@ -552,7 +554,7 @@ export const collectionsSlice = createSlice({
|
||||
requestUid: item.requestUid,
|
||||
timestamp: timestamp,
|
||||
data: {
|
||||
request: item.requestSent || item.request,
|
||||
request: timelineRequest,
|
||||
response: action.payload.response,
|
||||
timestamp: timestamp
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
|
||||
statusText: response.statusText,
|
||||
duration: response.duration,
|
||||
timeline: response.timeline,
|
||||
stream: response.stream
|
||||
stream: response.stream,
|
||||
requestSent: response.requestSent
|
||||
});
|
||||
})
|
||||
.catch((err) => reject(err));
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"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",
|
||||
@@ -43,6 +44,7 @@
|
||||
"@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",
|
||||
@@ -52,7 +54,6 @@
|
||||
"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": {
|
||||
|
||||
@@ -7,10 +7,11 @@ const peerDepsExternal = require('rollup-plugin-peer-deps-external');
|
||||
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
function createBuildConfig({ inputDir, input, cjsOutput, esmOutput }) {
|
||||
function createBuildConfig({ inputDir, input, cjsOutput, esmOutput, dtsOutput, external = [] }) {
|
||||
return [
|
||||
{
|
||||
input,
|
||||
external,
|
||||
output: [
|
||||
{
|
||||
file: cjsOutput,
|
||||
@@ -36,30 +37,38 @@ function createBuildConfig({ inputDir, input, cjsOutput, esmOutput }) {
|
||||
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
|
||||
esmOutput: packageJson.module,
|
||||
dtsOutput: packageJson.types
|
||||
}),
|
||||
// reports/html
|
||||
...createBuildConfig({
|
||||
inputDir: 'src/runner/**/*',
|
||||
input: 'src/runner/index.ts',
|
||||
cjsOutput: 'dist/runner/cjs/index.js',
|
||||
esmOutput: 'dist/runner/esm/index.js'
|
||||
esmOutput: 'dist/runner/esm/index.js',
|
||||
dtsOutput: 'dist/runner/index.d.ts'
|
||||
}),
|
||||
...createBuildConfig({
|
||||
inputDir: 'src/utils/**/*',
|
||||
input: 'src/utils/index.ts',
|
||||
cjsOutput: 'dist/utils/cjs/index.js',
|
||||
esmOutput: 'dist/utils/esm/index.js'
|
||||
})
|
||||
esmOutput: 'dist/utils/esm/index.js',
|
||||
dtsOutput: 'dist/utils/index.d.ts'
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@opencollection/types": "0.9.0",
|
||||
"@opencollection/types": "0.9.1",
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@rollup/plugin-commonjs": "^23.0.2",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
|
||||
@@ -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.signatureEncoding as BrunoAuthOauth1['signatureMethod']) || 'HMAC-SHA1',
|
||||
signatureMethod: (oauth1Auth.signatureMethod 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 || '',
|
||||
signatureEncoding: auth.oauth1?.signatureMethod || 'HMAC-SHA1',
|
||||
signatureMethod: 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 || '' },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { ipcMain } = require('electron');
|
||||
const { ipcMain, dialog } = require('electron');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
const {
|
||||
browseDirectory,
|
||||
@@ -27,6 +28,15 @@ 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);
|
||||
|
||||
@@ -73,6 +73,7 @@ const checkConnection = (host, port) =>
|
||||
*/
|
||||
function makeAxiosInstance({
|
||||
proxyMode = 'off',
|
||||
proxyModeReason = '',
|
||||
proxyConfig = {},
|
||||
requestMaxRedirects = 5,
|
||||
httpsAgentRequestFields = {},
|
||||
@@ -202,19 +203,17 @@ function makeAxiosInstance({
|
||||
};
|
||||
|
||||
try {
|
||||
// Now call setupProxyAgents and pass the timeline
|
||||
setupProxyAgents({
|
||||
// Now call setupProxyAgents and pass the timeline (async - may perform PAC resolution)
|
||||
await setupProxyAgents({
|
||||
requestConfig: config,
|
||||
proxyMode: proxyMode, // 'on', 'off', or 'system', depending on your settings
|
||||
proxyConfig: proxyConfig,
|
||||
proxyMode,
|
||||
proxyModeReason,
|
||||
proxyConfig,
|
||||
httpsAgentRequestFields: agentOptions,
|
||||
interpolationOptions: interpolationOptions, // Provide your interpolation options
|
||||
interpolationOptions,
|
||||
timeline
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.timeline) {
|
||||
timeline = err.timeline;
|
||||
}
|
||||
timeline.push({
|
||||
timestamp: new Date(),
|
||||
type: 'error',
|
||||
@@ -270,7 +269,7 @@ function makeAxiosInstance({
|
||||
response.timeline = timeline;
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
async (error) => {
|
||||
const config = error.config;
|
||||
const timeline = config?.metadata?.timeline || [];
|
||||
timeline?.push({
|
||||
@@ -417,9 +416,10 @@ function makeAxiosInstance({
|
||||
}
|
||||
|
||||
try {
|
||||
setupProxyAgents({
|
||||
await setupProxyAgents({
|
||||
requestConfig,
|
||||
proxyMode,
|
||||
proxyModeReason,
|
||||
proxyConfig,
|
||||
httpsAgentRequestFields,
|
||||
interpolationOptions,
|
||||
|
||||
@@ -121,6 +121,7 @@ const getCertsAndProxyConfig = async ({
|
||||
*/
|
||||
let proxyMode = 'off';
|
||||
let proxyConfig = {};
|
||||
let proxyModeReason = '';
|
||||
|
||||
const collectionProxyConfig = get(brunoConfig, 'proxy', {});
|
||||
const collectionProxyDisabled = get(collectionProxyConfig, 'disabled', false);
|
||||
@@ -135,24 +136,32 @@ const getCertsAndProxyConfig = async ({
|
||||
// Inherit from global preferences
|
||||
const globalProxy = preferencesUtil.getGlobalProxyConfig();
|
||||
const globalDisabled = get(globalProxy, 'disabled', false);
|
||||
const globalInherit = get(globalProxy, 'inherit', false);
|
||||
const globalProxyConfigData = get(globalProxy, 'config', globalProxy);
|
||||
const globalProxySource = get(globalProxy, 'source', 'manual');
|
||||
const globalProxyConfigData = get(globalProxy, 'config', {});
|
||||
|
||||
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' };
|
||||
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';
|
||||
}
|
||||
// else: global proxy is disabled, proxyMode stays 'off'
|
||||
} else {
|
||||
proxyModeReason = 'Collection-level proxy is disabled';
|
||||
}
|
||||
// else: collection proxy is disabled, proxyMode stays 'off'
|
||||
|
||||
return { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions };
|
||||
return { proxyMode, proxyModeReason, proxyConfig, httpsAgentRequestFields, interpolationOptions };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -29,7 +30,37 @@ let grpcClient;
|
||||
* @param {Object} interpolationOptions - Variable interpolation options
|
||||
* @returns {{ proxyUrl: string | null }}
|
||||
*/
|
||||
const resolveGrpcProxyConfig = (proxyMode, proxyConfig, requestUrl, interpolationOptions) => {
|
||||
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 };
|
||||
}
|
||||
|
||||
if (proxyMode === 'on') {
|
||||
const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', ''));
|
||||
if (!shouldProxy) return { proxyUrl: null };
|
||||
@@ -170,7 +201,7 @@ const registerGrpcEventHandlers = (window) => {
|
||||
const pfx = httpsAgentRequestFields.pfx;
|
||||
|
||||
// Resolve proxy configuration for gRPC
|
||||
const grpcProxyConfig = resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions);
|
||||
const grpcProxyConfig = await resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions);
|
||||
|
||||
const requestSent = {
|
||||
type: 'request',
|
||||
@@ -330,7 +361,7 @@ const registerGrpcEventHandlers = (window) => {
|
||||
const pfx = httpsAgentRequestFields.pfx;
|
||||
|
||||
// Resolve proxy configuration for gRPC
|
||||
const grpcProxyConfig = resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions);
|
||||
const grpcProxyConfig = await resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions);
|
||||
|
||||
// Send OAuth credentials update if available
|
||||
if (preparedRequest?.oauth2Credentials) {
|
||||
|
||||
@@ -8,139 +8,139 @@ const emptyInterpolationOptions = {};
|
||||
|
||||
describe('resolveGrpcProxyConfig', () => {
|
||||
describe('proxyMode "off"', () => {
|
||||
it('should return null proxyUrl', () => {
|
||||
expect(resolveGrpcProxyConfig('off', {}, 'grpc://localhost:50051', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
it('should return null proxyUrl', async () => {
|
||||
await expect(resolveGrpcProxyConfig('off', {}, 'grpc://localhost:50051', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxyMode "on"', () => {
|
||||
it('should return proxy URL without auth', () => {
|
||||
it('should return proxy URL without auth', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: '8080',
|
||||
auth: { disabled: true }
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: 'http://proxy.example.com:8080' });
|
||||
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: 'http://proxy.example.com:8080' });
|
||||
});
|
||||
|
||||
it('should return proxy URL with auth when auth is enabled', () => {
|
||||
it('should return proxy URL with auth when auth is enabled', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: '8080',
|
||||
auth: { disabled: false, username: 'user', password: 'pass' }
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: 'http://user:pass@proxy.example.com:8080' });
|
||||
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: 'http://user:pass@proxy.example.com:8080' });
|
||||
});
|
||||
|
||||
it('should URL-encode special characters in credentials', () => {
|
||||
it('should URL-encode special characters in credentials', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: '8080',
|
||||
auth: { disabled: false, username: 'user@domain', password: 'p@ss:word' }
|
||||
};
|
||||
const result = resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions);
|
||||
const result = await 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', () => {
|
||||
it('should reject SOCKS proxy protocols', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'socks5',
|
||||
hostname: 'proxy.example.com',
|
||||
port: '1080'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
|
||||
it('should reject HTTPS proxy protocol', () => {
|
||||
it('should reject HTTPS proxy protocol', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'https',
|
||||
hostname: 'proxy.example.com',
|
||||
port: '8080'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
|
||||
it('should return null when request URL is in bypassProxy list', () => {
|
||||
it('should return null when request URL is in bypassProxy list', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: '8080',
|
||||
bypassProxy: 'localhost,api.example.com'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
|
||||
it('should omit port when not provided', () => {
|
||||
it('should omit port when not provided', async () => {
|
||||
const proxyConfig = {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
auth: { disabled: true }
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: 'http://proxy.example.com' });
|
||||
await expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: 'http://proxy.example.com' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxyMode "system"', () => {
|
||||
it('should use https_proxy when available', () => {
|
||||
it('should use https_proxy when available', async () => {
|
||||
const proxyConfig = {
|
||||
https_proxy: 'http://system-proxy.example.com:3128',
|
||||
http_proxy: 'http://fallback-proxy.example.com:3128'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: 'http://system-proxy.example.com:3128' });
|
||||
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: 'http://system-proxy.example.com:3128' });
|
||||
});
|
||||
|
||||
it('should fall back to http_proxy when https_proxy is not set', () => {
|
||||
it('should fall back to http_proxy when https_proxy is not set', async () => {
|
||||
const proxyConfig = {
|
||||
http_proxy: 'http://fallback-proxy.example.com:3128'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: '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' });
|
||||
});
|
||||
|
||||
it('should return null when no system proxy is configured', () => {
|
||||
expect(resolveGrpcProxyConfig('system', {}, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
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 reject non-HTTP system proxy protocols', () => {
|
||||
it('should reject non-HTTP system proxy protocols', async () => {
|
||||
const proxyConfig = {
|
||||
https_proxy: 'socks5://system-proxy.example.com:1080'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
|
||||
it('should return null when request URL matches no_proxy', () => {
|
||||
it('should return null when request URL matches no_proxy', async () => {
|
||||
const proxyConfig = {
|
||||
https_proxy: 'http://system-proxy.example.com:3128',
|
||||
no_proxy: 'api.example.com'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
await expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
|
||||
it('should return null for invalid system proxy URL', () => {
|
||||
it('should return null for invalid system proxy URL', async () => {
|
||||
const proxyConfig = {
|
||||
https_proxy: 'not-a-valid-url'
|
||||
};
|
||||
expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.toEqual({ proxyUrl: null });
|
||||
await expect(resolveGrpcProxyConfig('system', proxyConfig, '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 });
|
||||
it('should return null when proxyConfig is null', async () => {
|
||||
await expect(resolveGrpcProxyConfig('system', null, 'grpc://api.example.com:443', emptyInterpolationOptions))
|
||||
.resolves.toEqual({ proxyUrl: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,9 +144,10 @@ const configureRequest = async (
|
||||
request.maxRedirects = 0;
|
||||
|
||||
const { promptVariables = {} } = collection;
|
||||
let { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
|
||||
let { proxyMode, proxyModeReason, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
|
||||
let axiosInstance = makeAxiosInstance({
|
||||
proxyMode,
|
||||
proxyModeReason,
|
||||
proxyConfig,
|
||||
requestMaxRedirects,
|
||||
httpsAgentRequestFields,
|
||||
@@ -774,6 +775,7 @@ 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);
|
||||
@@ -864,7 +866,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
}
|
||||
});
|
||||
|
||||
let requestSent = {
|
||||
requestSent = {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers: headersSent,
|
||||
@@ -1141,7 +1143,8 @@ 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
|
||||
timeline: response.timeline,
|
||||
requestSent
|
||||
};
|
||||
} catch (error) {
|
||||
deleteCancelToken(cancelTokenUid);
|
||||
@@ -1151,7 +1154,8 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
return {
|
||||
status: error?.status,
|
||||
error: error?.message || ERROR_OCCURRED_WHILE_EXECUTING_REQUEST,
|
||||
timeline: error?.timeline
|
||||
timeline: error?.timeline,
|
||||
requestSent
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -30,7 +30,8 @@ const defaultPreferences = {
|
||||
codeFontSize: 13
|
||||
},
|
||||
proxy: {
|
||||
inherit: true,
|
||||
source: 'inherit',
|
||||
pac: { source: '' },
|
||||
config: {
|
||||
protocol: 'http',
|
||||
hostname: '',
|
||||
@@ -93,7 +94,10 @@ const preferencesSchema = Yup.object().shape({
|
||||
}),
|
||||
proxy: Yup.object({
|
||||
disabled: Yup.boolean().optional(),
|
||||
inherit: Yup.boolean().required(),
|
||||
source: Yup.string().oneOf(['manual', 'pac', 'inherit']).required(),
|
||||
pac: Yup.object({
|
||||
source: Yup.string().optional().max(2048).nullable()
|
||||
}).optional(),
|
||||
config: Yup.object({
|
||||
protocol: Yup.string().oneOf(['http', 'https', 'socks4', 'socks5']),
|
||||
hostname: Yup.string().max(1024),
|
||||
@@ -150,7 +154,7 @@ class PreferencesStore {
|
||||
// New users (empty preferences) will get defaultPreferences.proxy via merge
|
||||
if (Object.keys(preferences).length > 0 && !preferences.proxy) {
|
||||
preferences.proxy = {
|
||||
inherit: false,
|
||||
source: 'manual',
|
||||
disabled: true,
|
||||
config: {
|
||||
protocol: 'http',
|
||||
@@ -173,7 +177,8 @@ class PreferencesStore {
|
||||
|
||||
if (hasOldFormat) {
|
||||
let newProxy = {
|
||||
inherit: true,
|
||||
source: 'inherit',
|
||||
pac: { source: '' },
|
||||
config: {
|
||||
protocol: proxy.protocol || 'http',
|
||||
hostname: proxy.hostname || '',
|
||||
@@ -188,19 +193,17 @@ 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.disabled = false;
|
||||
newProxy.inherit = false;
|
||||
newProxy.source = 'manual';
|
||||
} else if (proxy.mode === 'system') {
|
||||
newProxy.disabled = false;
|
||||
newProxy.inherit = true;
|
||||
newProxy.source = 'inherit';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +211,6 @@ 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) {
|
||||
@@ -220,6 +222,18 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -103,14 +104,23 @@ class PatchedHttpsProxyAgent extends HttpsProxyAgent {
|
||||
}
|
||||
}
|
||||
|
||||
function setupProxyAgents({
|
||||
async 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;
|
||||
@@ -153,14 +163,6 @@ 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';
|
||||
@@ -218,6 +220,38 @@ 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) {
|
||||
|
||||
148
packages/bruno-electron/test/proxy-util.test.js
Normal file
148
packages/bruno-electron/test/proxy-util.test.js
Normal file
@@ -0,0 +1,148 @@
|
||||
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 }));
|
||||
});
|
||||
});
|
||||
@@ -15,44 +15,35 @@ 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 inherit: true for new users (empty preferences)', () => {
|
||||
// New user - no preferences.json exists, store returns empty object
|
||||
it('should default to source: inherit for new users (empty preferences)', () => {
|
||||
mockStoreData['preferences'] = {};
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
// New users get the default proxy settings with inherit: true
|
||||
expect(preferences.proxy.inherit).toBe(true);
|
||||
expect(preferences.proxy.source).toBe('inherit');
|
||||
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 disabled: true, inherit: false for existing users without proxy settings', () => {
|
||||
// Existing user - has preferences but no proxy property
|
||||
it('should default to source: manual, disabled: true for existing users without proxy settings', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
request: {
|
||||
sslVerification: true
|
||||
},
|
||||
font: {
|
||||
codeFont: 'default',
|
||||
codeFontSize: 13
|
||||
}
|
||||
request: { sslVerification: true },
|
||||
font: { codeFont: 'default', codeFontSize: 13 }
|
||||
};
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
// Existing users without proxy get disabled proxy by default
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.disabled).toBe(true);
|
||||
expect(preferences.proxy.inherit).toBe(false);
|
||||
expect(preferences.proxy.config).toBeDefined();
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
expect(preferences.proxy.config.protocol).toBe('http');
|
||||
expect(preferences.proxy.config.hostname).toBe('');
|
||||
expect(preferences.proxy.config.port).toBeNull();
|
||||
@@ -62,279 +53,295 @@ describe('Proxy Preferences Migration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('New Format (no migration needed)', () => {
|
||||
it('should handle new format with inherit: false', () => {
|
||||
const newFormatProxy = {
|
||||
describe('v3 Format (no migration needed)', () => {
|
||||
it('should handle source: manual', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
inherit: false,
|
||||
source: 'manual',
|
||||
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();
|
||||
|
||||
// Verify key fields are preserved from stored preferences
|
||||
expect(preferences.proxy.inherit).toBe(false);
|
||||
expect(preferences.proxy.config.protocol).toBe('http');
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
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 new format with inherit: true', () => {
|
||||
const newFormatProxy = {
|
||||
it('should handle source: inherit', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
inherit: true,
|
||||
config: {
|
||||
protocol: 'http',
|
||||
hostname: '',
|
||||
port: null,
|
||||
auth: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
bypassProxy: ''
|
||||
}
|
||||
source: 'inherit',
|
||||
config: { protocol: 'http', hostname: '', port: null, auth: { username: '', password: '' }, bypassProxy: '' }
|
||||
}
|
||||
};
|
||||
|
||||
mockStoreData['preferences'] = newFormatProxy;
|
||||
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.inherit).toBe(true);
|
||||
expect(preferences.proxy.config).toBeDefined();
|
||||
expect(preferences.proxy.source).toBe('pac');
|
||||
expect(preferences.proxy.pac.source).toBe('http://internal/proxy.pac');
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle new format with disabled: true', () => {
|
||||
const newFormatProxy = {
|
||||
it('should handle disabled: true with source: manual', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
disabled: true,
|
||||
inherit: false,
|
||||
config: {
|
||||
protocol: 'http',
|
||||
hostname: '',
|
||||
port: null,
|
||||
auth: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
bypassProxy: ''
|
||||
}
|
||||
source: 'manual',
|
||||
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();
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle new format with auth.disabled: true', () => {
|
||||
const newFormatProxy = {
|
||||
it('should handle auth.disabled: true', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
inherit: false,
|
||||
source: 'manual',
|
||||
config: {
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: 8080,
|
||||
auth: {
|
||||
disabled: true,
|
||||
username: 'user',
|
||||
password: 'pass'
|
||||
},
|
||||
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('Old Format 1: enabled (boolean)', () => {
|
||||
it('should migrate enabled: true to disabled: false, inherit: false', () => {
|
||||
const oldFormatProxy = {
|
||||
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'] = {
|
||||
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();
|
||||
|
||||
// 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.source).toBe('manual');
|
||||
expect(preferences.proxy.disabled).toBeUndefined();
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
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 to disabled: true, inherit: false', () => {
|
||||
const oldFormatProxy = {
|
||||
it('should migrate enabled: false → source: manual, disabled: true', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
proxy: {
|
||||
enabled: false,
|
||||
protocol: 'http',
|
||||
hostname: 'proxy.example.com',
|
||||
port: 8080,
|
||||
auth: {
|
||||
enabled: false,
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
hostname: '',
|
||||
port: null,
|
||||
auth: { enabled: false, username: '', password: '' },
|
||||
bypassProxy: ''
|
||||
}
|
||||
};
|
||||
|
||||
mockStoreData['preferences'] = oldFormatProxy;
|
||||
|
||||
const preferences = getPreferences();
|
||||
|
||||
// After migration, enabled: false becomes disabled: true, inherit: false
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.disabled).toBe(true);
|
||||
expect(preferences.proxy.inherit).toBe(false);
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should migrate auth.enabled: false to auth.disabled: true', () => {
|
||||
const oldFormatProxy = {
|
||||
it('should migrate auth.enabled: false → auth.disabled: true', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
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('Old Format 2: mode (string)', () => {
|
||||
it('should migrate mode: "off" to disabled: true, inherit: false', () => {
|
||||
const oldFormatProxy = {
|
||||
describe('v1 → v3 Migration (mode string)', () => {
|
||||
it('should migrate mode: off → source: manual, disabled: true', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
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();
|
||||
|
||||
// disabled: true is preserved from migration
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.disabled).toBe(true);
|
||||
expect(preferences.proxy.inherit).toBe(false);
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should migrate mode: "on" to disabled: false, inherit: false', () => {
|
||||
const oldFormatProxy = {
|
||||
it('should migrate mode: on → source: manual', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
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.disabled).toBeUndefined(); // disabled: false is omitted
|
||||
expect(preferences.proxy.inherit).toBe(false);
|
||||
// Values are preserved from stored preferences
|
||||
expect(preferences.proxy.source).toBe('manual');
|
||||
expect(preferences.proxy.disabled).toBeUndefined();
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
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" to disabled: false, inherit: true', () => {
|
||||
const oldFormatProxy = {
|
||||
it('should migrate mode: system → source: inherit', () => {
|
||||
mockStoreData['preferences'] = {
|
||||
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.disabled).toBeUndefined(); // disabled: false is omitted
|
||||
expect(preferences.proxy.inherit).toBe(true);
|
||||
expect(preferences.proxy.source).toBe('inherit');
|
||||
expect(preferences.proxy.disabled).toBeUndefined();
|
||||
expect(preferences.proxy.inherit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.signatureEncoding = config.signatureMethod;
|
||||
if (isString(config.signatureMethod)) auth.signatureMethod = 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.signatureEncoding as BrunoAuthOauth1['signatureMethod']) || 'HMAC-SHA1',
|
||||
signatureMethod: (auth.signatureMethod 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,
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
"@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",
|
||||
|
||||
@@ -39,6 +39,6 @@ module.exports = [
|
||||
typescript({ tsconfig: './tsconfig.json' }),
|
||||
terser()
|
||||
],
|
||||
external: (id) => isBuiltin(id) || ['axios', 'qs', 'ws', 'debug', 'shell-env'].includes(id)
|
||||
external: (id) => isBuiltin(id) || ['axios', 'qs', 'ws', 'debug', 'shell-env', 'pac-resolver', 'quickjs-emscripten'].includes(id)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -10,6 +10,8 @@ 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';
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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> = {
|
||||
@@ -23,7 +24,7 @@ const DEFAULT_PORTS: Record<string, number> = {
|
||||
wss: 443
|
||||
};
|
||||
|
||||
type ProxyMode = 'on' | 'off' | 'system';
|
||||
type ProxyMode = 'on' | 'off' | 'system' | 'pac';
|
||||
|
||||
type ProxyAuth = {
|
||||
enabled: boolean;
|
||||
@@ -39,6 +40,9 @@ type ProxyConfig = {
|
||||
auth?: ProxyAuth;
|
||||
bypassProxy?: string;
|
||||
mode?: ProxyMode;
|
||||
pac?: {
|
||||
source: string;
|
||||
};
|
||||
};
|
||||
|
||||
type SystemProxyConfig = {
|
||||
@@ -309,7 +313,7 @@ const getCertsAndProxyConfig = ({
|
||||
/**
|
||||
* Proxy configuration
|
||||
*
|
||||
* Preferences proxyMode has three possible values: on, off, system
|
||||
* Preferences proxyMode has four possible values: on, off, system, pac
|
||||
* Collection proxyMode has three possible values: true, false, global
|
||||
*
|
||||
* When collection proxyMode is true, it overrides the app-level proxy settings
|
||||
@@ -337,18 +341,22 @@ const getCertsAndProxyConfig = ({
|
||||
// Inherit from app-level proxy settings
|
||||
if (appLevelProxyConfig) {
|
||||
const globalDisabled = get(appLevelProxyConfig, 'disabled', false);
|
||||
const globalInherit = get(appLevelProxyConfig, 'inherit', false);
|
||||
const globalProxyConfigData = get(appLevelProxyConfig, 'config', appLevelProxyConfig);
|
||||
const globalProxySource = get(appLevelProxyConfig, 'source', 'inherit');
|
||||
const globalProxyConfigData = get(appLevelProxyConfig, 'config', {});
|
||||
|
||||
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';
|
||||
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';
|
||||
}
|
||||
}
|
||||
// else: app-level proxy is disabled, proxyMode stays 'off'
|
||||
@@ -374,7 +382,7 @@ function extractHostname(url: string | undefined): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function createAgents({
|
||||
async function createAgents({
|
||||
requestUrl,
|
||||
proxyMode,
|
||||
proxyConfig,
|
||||
@@ -383,7 +391,7 @@ function createAgents({
|
||||
httpsAgentRequestFields,
|
||||
timeline,
|
||||
disableCache = true
|
||||
}: CreateAgentsParams): AgentResult {
|
||||
}: CreateAgentsParams): Promise<AgentResult> {
|
||||
// Ensure TLS options are properly set
|
||||
const tlsOptions: TlsOptions = {
|
||||
...httpsAgentRequestFields,
|
||||
@@ -447,6 +455,40 @@ 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');
|
||||
@@ -514,7 +556,7 @@ const getHttpHttpsAgents = async ({
|
||||
httpsAgentRequestFields.rejectUnauthorized = false;
|
||||
}
|
||||
|
||||
const { httpAgent, httpsAgent } = createAgents({
|
||||
const { httpAgent, httpsAgent } = await createAgents({
|
||||
requestUrl,
|
||||
proxyMode,
|
||||
proxyConfig,
|
||||
|
||||
@@ -188,6 +188,7 @@ function createVaultClient(config: VaultConfig = {}): VaultClient {
|
||||
method: method as any,
|
||||
url: uri,
|
||||
headers,
|
||||
proxy: false,
|
||||
validateStatus: () => true // Don't throw on non-2xx status
|
||||
};
|
||||
|
||||
|
||||
281
packages/bruno-requests/src/utils/pac-resolver.spec.ts
Normal file
281
packages/bruno-requests/src/utils/pac-resolver.spec.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
118
packages/bruno-requests/src/utils/pac-resolver.ts
Normal file
118
packages/bruno-requests/src/utils/pac-resolver.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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;
|
||||
@@ -200,6 +200,63 @@ 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', () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: consumer_secret_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: HMAC-SHA1
|
||||
signatureMethod: HMAC-SHA1
|
||||
version: "1.0"
|
||||
placement: header
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: wrong_secret
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: HMAC-SHA1
|
||||
signatureMethod: HMAC-SHA1
|
||||
version: "1.0"
|
||||
placement: header
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: consumer_secret_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: HMAC-SHA1
|
||||
signatureMethod: HMAC-SHA1
|
||||
version: "1.0"
|
||||
placement: body
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -15,7 +15,7 @@ http:
|
||||
consumerSecret: consumer_secret_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: HMAC-SHA1
|
||||
signatureMethod: HMAC-SHA1
|
||||
version: "1.0"
|
||||
placement: body
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: consumer_secret_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: HMAC-SHA1
|
||||
signatureMethod: HMAC-SHA1
|
||||
version: "1.0"
|
||||
placement: header
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: consumer_secret_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: HMAC-SHA1
|
||||
signatureMethod: HMAC-SHA1
|
||||
version: "1.0"
|
||||
placement: query
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: consumer_secret_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: HMAC-SHA256
|
||||
signatureMethod: HMAC-SHA256
|
||||
version: "1.0"
|
||||
placement: header
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: wrong_secret
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: HMAC-SHA256
|
||||
signatureMethod: HMAC-SHA256
|
||||
version: "1.0"
|
||||
placement: header
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: consumer_secret_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: HMAC-SHA256
|
||||
signatureMethod: HMAC-SHA256
|
||||
version: "1.0"
|
||||
placement: body
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: consumer_secret_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: HMAC-SHA512
|
||||
signatureMethod: HMAC-SHA512
|
||||
version: "1.0"
|
||||
placement: header
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: wrong_secret
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: HMAC-SHA512
|
||||
signatureMethod: HMAC-SHA512
|
||||
version: "1.0"
|
||||
placement: header
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: consumer_secret_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: PLAINTEXT
|
||||
signatureMethod: PLAINTEXT
|
||||
version: '1.0'
|
||||
placement: header
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: wrong_secret
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: PLAINTEXT
|
||||
signatureMethod: PLAINTEXT
|
||||
version: "1.0"
|
||||
placement: header
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: consumer_secret_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: PLAINTEXT
|
||||
signatureMethod: PLAINTEXT
|
||||
version: "1.0"
|
||||
placement: body
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -12,7 +12,7 @@ http:
|
||||
consumerSecret: consumer_secret_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: PLAINTEXT
|
||||
signatureMethod: PLAINTEXT
|
||||
version: "1.0"
|
||||
placement: query
|
||||
includeBodyHash: false
|
||||
|
||||
@@ -11,7 +11,7 @@ http:
|
||||
consumerKey: consumer_key_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: RSA-SHA1
|
||||
signatureMethod: RSA-SHA1
|
||||
privateKey:
|
||||
type: text
|
||||
value: |
|
||||
|
||||
@@ -13,7 +13,7 @@ http:
|
||||
consumerKey: consumer_key_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: RSA-SHA1
|
||||
signatureMethod: RSA-SHA1
|
||||
privateKey:
|
||||
type: text
|
||||
value: |
|
||||
|
||||
@@ -16,7 +16,7 @@ http:
|
||||
consumerKey: consumer_key_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: RSA-SHA1
|
||||
signatureMethod: RSA-SHA1
|
||||
privateKey:
|
||||
type: text
|
||||
value: |
|
||||
|
||||
@@ -11,7 +11,7 @@ http:
|
||||
consumerKey: consumer_key_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: RSA-SHA1
|
||||
signatureMethod: RSA-SHA1
|
||||
privateKey:
|
||||
type: file
|
||||
value: test-private-key.pem
|
||||
|
||||
@@ -11,7 +11,7 @@ http:
|
||||
consumerKey: consumer_key_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: RSA-SHA1
|
||||
signatureMethod: RSA-SHA1
|
||||
privateKey:
|
||||
type: text
|
||||
value: |
|
||||
|
||||
@@ -11,7 +11,7 @@ http:
|
||||
consumerKey: consumer_key_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: RSA-SHA1
|
||||
signatureMethod: RSA-SHA1
|
||||
privateKey:
|
||||
type: text
|
||||
value: "{{private-key}}"
|
||||
|
||||
@@ -11,7 +11,7 @@ http:
|
||||
consumerKey: consumer_key_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: RSA-SHA256
|
||||
signatureMethod: RSA-SHA256
|
||||
privateKey:
|
||||
type: text
|
||||
value: |
|
||||
|
||||
@@ -11,7 +11,7 @@ http:
|
||||
consumerKey: consumer_key_1
|
||||
accessToken: access_token_1
|
||||
accessTokenSecret: token_secret_1
|
||||
signatureEncoding: RSA-SHA512
|
||||
signatureMethod: RSA-SHA512
|
||||
privateKey:
|
||||
type: text
|
||||
value: |
|
||||
|
||||
@@ -26,6 +26,7 @@ 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' });
|
||||
@@ -137,6 +138,7 @@ 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();
|
||||
|
||||
@@ -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('#send-request').getByTitle('Save Request').click();
|
||||
await page.locator('#request-actions').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('#send-request').getByTitle('Save Request').click();
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
await page.locator('#request-actions').getByTitle('Save Request').click();
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
|
||||
// Verify the response
|
||||
await expect(page.getByRole('main')).toContainText('200 OK');
|
||||
|
||||
@@ -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.locator('#send-request').getByRole('img').nth(2);
|
||||
const sendButton = page.getByTestId('send-arrow-icon');
|
||||
await expect(sendButton).toBeVisible();
|
||||
await sendButton.click();
|
||||
await expect(page.locator('.response-status-code.text-ok')).toBeVisible();
|
||||
|
||||
6
tests/proxy/pac/fixtures/collection/bruno.json
Normal file
6
tests/proxy/pac/fixtures/collection/bruno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "pac-proxy-test",
|
||||
"type": "collection",
|
||||
"ignore": []
|
||||
}
|
||||
21
tests/proxy/pac/fixtures/collection/direct.bru
Normal file
21
tests/proxy/pac/fixtures/collection/direct.bru
Normal file
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
21
tests/proxy/pac/fixtures/collection/proxied.bru
Normal file
21
tests/proxy/pac/fixtures/collection/proxied.bru
Normal file
@@ -0,0 +1,21 @@
|
||||
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');
|
||||
});
|
||||
}
|
||||
6
tests/proxy/pac/fixtures/pac-files/test.pac
Normal file
6
tests/proxy/pac/fixtures/pac-files/test.pac
Normal file
@@ -0,0 +1,6 @@
|
||||
function FindProxyForURL(url, host) {
|
||||
if (url.indexOf('/proxied') !== -1) {
|
||||
return 'PROXY localhost:18888';
|
||||
}
|
||||
return 'DIRECT';
|
||||
}
|
||||
17
tests/proxy/pac/init-user-data/preferences.json
Normal file
17
tests/proxy/pac/init-user-data/preferences.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"maximized": false,
|
||||
"lastOpenedCollections": ["{{projectRoot}}/tests/proxy/pac/fixtures/collection"],
|
||||
"preferences": {
|
||||
"onboarding": {
|
||||
"hasLaunchedBefore": true,
|
||||
"hasSeenWelcomeModal": true
|
||||
},
|
||||
"proxy": {
|
||||
"source": "pac",
|
||||
"pac": {
|
||||
"source": "{{pacUrl}}"
|
||||
},
|
||||
"config": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
tests/proxy/pac/pac-proxy.spec.ts
Normal file
68
tests/proxy/pac/pac-proxy.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
113
tests/proxy/pac/server/index.ts
Normal file
113
tests/proxy/pac/server/index.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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)
|
||||
]);
|
||||
}
|
||||
7
tests/proxy/pac/server/pac-files/test.pac
Normal file
7
tests/proxy/pac/server/pac-files/test.pac
Normal file
@@ -0,0 +1,7 @@
|
||||
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";
|
||||
}
|
||||
79
tests/request/timeline/timeline-url-update.spec.ts
Normal file
79
tests/request/timeline/timeline-url-update.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,7 @@ test.describe.serial('Create and Delete Response Examples', () => {
|
||||
});
|
||||
|
||||
await test.step('Test form reset', async () => {
|
||||
await page.locator('#send-request').getByRole('img').nth(2).click();
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await clickResponseAction(page, 'response-bookmark-btn');
|
||||
|
||||
await page.getByTestId('create-example-name-input').fill('Test Name');
|
||||
|
||||
@@ -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('#send-request').getByTitle('Save Request').click();
|
||||
await page.locator('#request-actions').getByTitle('Save Request').click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
|
||||
@@ -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('#send-request .infotip').first(),
|
||||
generateCodeButton: () => page.locator('#request-actions .infotip').first(),
|
||||
bodyModeSelector: () => page.getByTestId('request-body-mode-selector'),
|
||||
bodyEditor: () => page.getByTestId('request-body-editor')
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user