mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 01:18:32 +00:00
Compare commits
12 Commits
exp/postma
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34460d5bcf | ||
|
|
422a43ce56 | ||
|
|
2720ac20b4 | ||
|
|
2e1c8b3382 | ||
|
|
95fccbeb8d | ||
|
|
e964bdc7fe | ||
|
|
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -84,8 +84,8 @@ export default class CodeEditor extends React.Component {
|
||||
this.searchBarRef.current?.focus();
|
||||
});
|
||||
},
|
||||
'Cmd-H': 'replace',
|
||||
'Ctrl-H': 'replace',
|
||||
'Cmd-H': this.props.readOnly ? false : 'replace',
|
||||
'Ctrl-H': this.props.readOnly ? false : 'replace',
|
||||
'Tab': function (cm) {
|
||||
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
|
||||
? cm.execCommand('indentMore')
|
||||
|
||||
@@ -39,7 +39,6 @@ const StyledWrapper = styled.div`
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background: ${(props) => props.theme.input.border};
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.tables-container {
|
||||
@@ -75,7 +74,7 @@ const StyledWrapper = styled.div`
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.table.thead.color} !important;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
background: ${(props) => props.theme.table.striped};
|
||||
user-select: none;
|
||||
|
||||
td {
|
||||
@@ -100,9 +99,8 @@ const StyledWrapper = styled.div`
|
||||
tr {
|
||||
transition: background 0.1s ease;
|
||||
height: 30px;
|
||||
|
||||
td {
|
||||
padding: 0 10px !important;
|
||||
padding: 0px 10px !important;
|
||||
border: none !important;
|
||||
vertical-align: middle;
|
||||
background: transparent;
|
||||
@@ -111,7 +109,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
tr:hover:not(.row-editing) td {
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -120,7 +118,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
tr.section-heading-row td {
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
padding: 6px 10px !important;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -131,8 +129,28 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
tr.section-last-row td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
tr.section-spacer-row {
|
||||
height: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
tr.section-spacer-row td {
|
||||
padding: 0 !important;
|
||||
height: 8px;
|
||||
line-height: 8px;
|
||||
font-size: 0;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
|
||||
}
|
||||
|
||||
tr.section-spacer-row:hover td {
|
||||
background: transparent !important;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.keybinding-row {
|
||||
@@ -180,7 +198,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.shortcut-input--editing {
|
||||
outline: 1px solid #E4AE49;
|
||||
outline: 1px solid ${(props) => props.theme.status.warning.border};
|
||||
border-radius: 4px;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
@@ -189,7 +207,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.shortcut-input--error.shortcut-input--editing {
|
||||
outline: 1px solid #CE4F3B;
|
||||
outline: 1px solid ${(props) => props.theme.status.danger.border};
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -220,39 +238,41 @@ const StyledWrapper = styled.div`
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 22px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
background: ${(props) => props.theme.background.base};
|
||||
color: ${(props) => props.theme.table.input.color};
|
||||
font-size: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
tbody tr.row-success td {
|
||||
background: #2E8A540F;
|
||||
tbody tr.row-success td,
|
||||
tbody tr.row-success:hover td {
|
||||
background: ${(props) => props.theme.status.success.background} !important;
|
||||
}
|
||||
|
||||
tbody tr.row-error td {
|
||||
background: #D32F2F0F;
|
||||
tbody tr.row-error td,
|
||||
tbody tr.row-error:hover td {
|
||||
background: ${(props) => props.theme.status.danger.background} !important;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
color: #2E8A54;
|
||||
color: ${(props) => props.theme.status.success.text};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: #CE4F3B;
|
||||
color: ${(props) => props.theme.status.danger.text};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-error-icon {
|
||||
color: #CE4F3B;
|
||||
color: ${(props) => props.theme.status.danger.text};
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
@@ -294,6 +314,11 @@ const StyledWrapper = styled.div`
|
||||
border-radius: 6px;
|
||||
padding: 0px 6px;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import React, { useMemo, useRef, useState, useEffect, Fragment } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
@@ -10,6 +10,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { KEY_BINDING_SECTIONS } from 'providers/Hotkeys/keyMappings.js';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import ToggleSwitch from 'components/ToggleSwitch/index';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const SEP = '+bind+';
|
||||
const getOS = () => (isMacOS() ? 'mac' : 'windows');
|
||||
@@ -82,10 +83,10 @@ const renderDisplayValue = (displayValue, os) => {
|
||||
return (
|
||||
<span className="shortcut-pills">
|
||||
{parsed.map((keysArr, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Fragment key={index}>
|
||||
{index > 0 && <span className="shortcut-separator"> - </span>}
|
||||
{renderKeycaps(keysArr, os)}
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
@@ -218,23 +219,21 @@ const RESERVED_BY_OS = {
|
||||
comboSignature(['f12']) // Dashboard (older macOS)
|
||||
]),
|
||||
windows: new Set([
|
||||
// System-level shortcuts (intercepted by Windows before reaching the app)
|
||||
comboSignature(['alt', 'tab']),
|
||||
comboSignature(['alt', 'shift', 'tab']),
|
||||
comboSignature(['alt', 'f4']),
|
||||
comboSignature(['f1']), // Windows Help
|
||||
comboSignature(['alt', 'esc']),
|
||||
comboSignature(['alt', 'space']),
|
||||
comboSignature(['ctrl', 'alt', 'delete']),
|
||||
comboSignature(['command', 'l']),
|
||||
comboSignature(['command', 'd']),
|
||||
comboSignature(['command', 'e']),
|
||||
comboSignature(['command', 'r']),
|
||||
comboSignature(['command', 'i']),
|
||||
comboSignature(['command', 's']),
|
||||
comboSignature(['command', 'a']),
|
||||
comboSignature(['command', 'x']),
|
||||
comboSignature(['command', 'm']),
|
||||
comboSignature(['command', 'tab']),
|
||||
comboSignature(['ctrl', 'shift', 'esc']),
|
||||
// Function keys
|
||||
comboSignature(['f1']), // Windows Help
|
||||
comboSignature(['f11']), // Fullscreen toggle
|
||||
comboSignature(['f12']), // DevTools
|
||||
// Undo/Redo - standard text editing shortcuts that browsers handle natively
|
||||
comboSignature(['ctrl', 'z']),
|
||||
comboSignature(['ctrl', 'y']),
|
||||
comboSignature(['ctrl', 'shift', 'z']),
|
||||
// Toggle Developer Tools
|
||||
comboSignature(['ctrl', 'shift', 'i'])
|
||||
@@ -493,7 +492,7 @@ const Keybindings = () => {
|
||||
if (buildUsedSignatures(action).has(sig)) {
|
||||
return {
|
||||
code: ERROR.DUPLICATE,
|
||||
message: 'That shortcut is already in use.'
|
||||
message: 'This shortcut is already in use.'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -562,9 +561,24 @@ const Keybindings = () => {
|
||||
return next;
|
||||
});
|
||||
|
||||
persistToPreferences(action, def);
|
||||
// Remove the entry from user preferences entirely so falls back to default.
|
||||
// This also keeps `hasCustomizedKeybindings` accurate.
|
||||
const nextKeyBindings = { ...(preferences?.keyBindings || {}) };
|
||||
delete nextKeyBindings[action];
|
||||
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
keyBindings: nextKeyBindings
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
const hasCustomizedKeybindings = useMemo(() => {
|
||||
const userKeyBindings = preferences?.keyBindings || {};
|
||||
return Object.keys(userKeyBindings).length > 0;
|
||||
}, [preferences?.keyBindings]);
|
||||
|
||||
const resetAllKeybindings = () => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
@@ -572,6 +586,7 @@ const Keybindings = () => {
|
||||
};
|
||||
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
toast.success('All shortcuts have been reset to default');
|
||||
};
|
||||
|
||||
const startEditing = (action) => {
|
||||
@@ -799,6 +814,7 @@ const Keybindings = () => {
|
||||
onClick={resetAllKeybindings}
|
||||
className="reset-btn"
|
||||
data-testid="reset-all-keybindings-btn"
|
||||
disabled={!hasCustomizedKeybindings}
|
||||
>
|
||||
Reset Default
|
||||
</button>
|
||||
@@ -817,7 +833,7 @@ const Keybindings = () => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupedKeyMappings.map((section, sectionIndex) => (
|
||||
<React.Fragment key={section.heading}>
|
||||
<Fragment key={section.heading}>
|
||||
<tr className="section-heading-row">
|
||||
<td colSpan={2}>{section.heading}</td>
|
||||
</tr>
|
||||
@@ -946,7 +962,12 @@ const Keybindings = () => {
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
{sectionIndex < groupedKeyMappings.length - 1 && (
|
||||
<tr className="section-spacer-row" aria-hidden="true">
|
||||
<td colSpan={2}> </td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -94,6 +94,7 @@ const ArgValueInput = ({ value, onChange, field }) => {
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="Enter value"
|
||||
className="mousetrap"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -139,7 +140,7 @@ const InputObjectFields = ({ namedType, parentKey, fieldPath, indent, argValues,
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isEnabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -230,12 +231,6 @@ const FieldNode = ({
|
||||
role="treeitem"
|
||||
aria-expanded={isExpanded}
|
||||
onClick={handleExpand}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleExpand(e);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="field-indent" style={{ width: indent }} />
|
||||
@@ -248,7 +243,7 @@ const FieldNode = ({
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isChecked}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -268,12 +263,6 @@ const FieldNode = ({
|
||||
role="treeitem"
|
||||
aria-expanded={canExpand ? isExpanded : undefined}
|
||||
onClick={handleExpand}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleExpand(e);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="field-indent" style={{ width: indent }} />
|
||||
@@ -288,7 +277,7 @@ const FieldNode = ({
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isChecked}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -315,7 +304,7 @@ const FieldNode = ({
|
||||
<span className="input-object-chevron-spacer" />
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isArgEnabled}
|
||||
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -369,7 +358,7 @@ const FieldNode = ({
|
||||
<span className="input-object-chevron-spacer" />
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isArgEnabled}
|
||||
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -419,12 +408,6 @@ const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent
|
||||
className="arg-row"
|
||||
style={{ paddingLeft: sectionIndent + 8 }}
|
||||
onClick={toggleExpand}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleExpand(e);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={isExpanded}
|
||||
@@ -438,7 +421,7 @@ const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isArgEnabled}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -486,12 +469,6 @@ const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onT
|
||||
className="arg-row"
|
||||
style={{ paddingLeft: sectionIndent + 8 }}
|
||||
onClick={toggleExpand}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleExpand(e);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-expanded={isExpanded}
|
||||
@@ -505,7 +482,7 @@ const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onT
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="field-checkbox"
|
||||
className="field-checkbox mousetrap"
|
||||
checked={isArgEnabled}
|
||||
onChange={handleCheck}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -211,7 +211,12 @@ const StyledWrapper = styled.div`
|
||||
padding: 3px 8px;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: ${(props) => props.theme.background.surface0};
|
||||
}
|
||||
|
||||
.input-object-chevron {
|
||||
width: 14px;
|
||||
|
||||
@@ -175,6 +175,7 @@ const QueryBuilder = ({ schema, onQueryChange, editorValue, onVariablesChange, v
|
||||
type="text"
|
||||
placeholder="Search operations..."
|
||||
value={searchText}
|
||||
className="mousetrap"
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -137,6 +137,12 @@ export default class QueryEditor extends React.Component {
|
||||
this.addOverlay();
|
||||
|
||||
setupLinkAware(editor);
|
||||
|
||||
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
|
||||
const cmInput = editor.getInputField();
|
||||
if (cmInput) {
|
||||
cmInput.classList.add('mousetrap');
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -225,7 +225,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
if (tab.type === 'environment-settings') {
|
||||
if (collection?.environmentsDraft) {
|
||||
const { environmentUid, variables } = collection.environmentsDraft;
|
||||
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
|
||||
if (environmentUid?.startsWith('dotenv:')) {
|
||||
window.dispatchEvent(new Event('dotenv-save'));
|
||||
} else {
|
||||
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
|
||||
}
|
||||
}
|
||||
} else if (tab.type === 'global-environment-settings') {
|
||||
if (globalEnvironmentDraft) {
|
||||
|
||||
@@ -19,6 +19,8 @@ const QueryResponse = ({
|
||||
const previewFormatOptions = useResponsePreviewFormatOptions(dataBuffer, headers);
|
||||
const [selectedFormat, setSelectedFormat] = useState('raw');
|
||||
const [selectedTab, setSelectedTab] = useState('editor');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [filterExpanded, setFilterExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFormat !== null && initialTab !== null) {
|
||||
@@ -56,6 +58,10 @@ const QueryResponse = ({
|
||||
error={error}
|
||||
selectedFormat={selectedFormat}
|
||||
selectedTab={selectedTab}
|
||||
filter={filter}
|
||||
filterExpanded={filterExpanded}
|
||||
onFilterChange={setFilter}
|
||||
onFilterExpandChange={setFilterExpanded}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -316,18 +316,6 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
|
||||
<form
|
||||
className="bruno-form"
|
||||
onSubmit={formik.handleSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.defaultPrevented) {
|
||||
const isTextInput
|
||||
= ['input', 'textarea'].includes(e.target.tagName.toLowerCase())
|
||||
|| e.target.isContentEditable;
|
||||
|
||||
if (!isTextInput) {
|
||||
e.preventDefault();
|
||||
formik.handleSubmit();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="requestName" className="block font-medium">
|
||||
@@ -523,6 +511,7 @@ const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
|
||||
className="flex px-2 items-center flex-grow input-container h-full min-w-0"
|
||||
>
|
||||
<SingleLineEditor
|
||||
onRun={() => formik.handleSubmit()}
|
||||
onPaste={handlePaste}
|
||||
placeholder="Request URL"
|
||||
value={formik.values.requestUrl || ''}
|
||||
|
||||
@@ -35,7 +35,8 @@ taskMiddleware.startListening({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(item),
|
||||
preview: task?.preview ?? true
|
||||
preview: task?.preview ?? true,
|
||||
...(item.isTransient ? { isTransient: true } : {})
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1077,11 +1079,12 @@ export const collectionsSlice = createSlice({
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
const existingOtherParams = item.draft.request.params?.filter((p) => p.type !== 'query') || [];
|
||||
const newQueryParams = map(params, ({ uid, name = '', value = '', description = '', type = 'query', enabled = true }) => ({
|
||||
const newQueryParams = map(params, ({ uid, name = '', value = '', description = '', annotations = null, type = 'query', enabled = true }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
annotations,
|
||||
type,
|
||||
enabled
|
||||
}));
|
||||
@@ -1323,11 +1326,12 @@ export const collectionsSlice = createSlice({
|
||||
if (!item.draft) {
|
||||
item.draft = cloneDeep(item);
|
||||
}
|
||||
item.draft.request.headers = map(action.payload.headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
|
||||
item.draft.request.headers = map(action.payload.headers, ({ uid, name = '', value = '', description = '', annotations = null, enabled = true }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
annotations,
|
||||
enabled
|
||||
}));
|
||||
},
|
||||
@@ -1351,11 +1355,12 @@ export const collectionsSlice = createSlice({
|
||||
collection.draft.root.request = {};
|
||||
}
|
||||
|
||||
collection.draft.root.request.headers = map(headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
|
||||
collection.draft.root.request.headers = map(headers, ({ uid, name = '', value = '', description = '', annotations = null, enabled = true }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
annotations,
|
||||
enabled
|
||||
}));
|
||||
},
|
||||
@@ -1378,11 +1383,12 @@ export const collectionsSlice = createSlice({
|
||||
if (!folder.draft.request) {
|
||||
folder.draft.request = {};
|
||||
}
|
||||
folder.draft.request.headers = map(headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
|
||||
folder.draft.request.headers = map(headers, ({ uid, name = '', value = '', description = '', annotations = null, enabled = true }) => ({
|
||||
uid: uid || uuid(),
|
||||
name,
|
||||
value,
|
||||
description,
|
||||
annotations,
|
||||
enabled
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ export const tabsSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
addTab: (state, action) => {
|
||||
const { uid, collectionUid, type, requestPaneTab, preview, exampleUid, itemUid } = action.payload;
|
||||
const { uid, collectionUid, type, requestPaneTab, preview, exampleUid, itemUid, isTransient } = action.payload;
|
||||
|
||||
const nonReplaceableTabTypes = [
|
||||
'variables',
|
||||
@@ -75,7 +75,8 @@ export const tabsSlice = createSlice({
|
||||
: !nonReplaceableTabTypes.includes(type),
|
||||
...(uid ? { folderUid: uid } : {}),
|
||||
...(exampleUid ? { exampleUid } : {}),
|
||||
...(itemUid ? { itemUid } : {})
|
||||
...(itemUid ? { itemUid } : {}),
|
||||
...(isTransient ? { isTransient: true } : {})
|
||||
};
|
||||
|
||||
state.activeTabUid = uid;
|
||||
@@ -103,7 +104,8 @@ export const tabsSlice = createSlice({
|
||||
? preview
|
||||
: !nonReplaceableTabTypes.includes(type),
|
||||
...(exampleUid ? { exampleUid } : {}),
|
||||
...(itemUid ? { itemUid } : {})
|
||||
...(itemUid ? { itemUid } : {}),
|
||||
...(isTransient ? { isTransient: true } : {})
|
||||
});
|
||||
state.activeTabUid = uid;
|
||||
},
|
||||
@@ -270,8 +272,9 @@ export const tabsSlice = createSlice({
|
||||
const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments'];
|
||||
|
||||
// Push closed tabs onto the recently closed stack (LIFO)
|
||||
// Exclude transient requests — they have no persisted file and can't be reopened
|
||||
const closedTabs = state.tabs.filter((t) =>
|
||||
tabUids.includes(t.uid) && !nonClosableTypes.includes(t.type)
|
||||
tabUids.includes(t.uid) && !nonClosableTypes.includes(t.type) && !t.isTransient
|
||||
);
|
||||
if (closedTabs.length > 0) {
|
||||
state.recentlyClosedTabs.push(...closedTabs);
|
||||
|
||||
@@ -181,6 +181,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
description: header.description,
|
||||
annotations: header.annotations,
|
||||
enabled: header.enabled
|
||||
};
|
||||
});
|
||||
@@ -193,6 +194,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
|
||||
name: param.name,
|
||||
value: param.value,
|
||||
description: param.description,
|
||||
annotations: param.annotations,
|
||||
type: param.type,
|
||||
enabled: param.enabled
|
||||
};
|
||||
@@ -745,6 +747,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
|
||||
name: param.name,
|
||||
value: param.value,
|
||||
description: param.description,
|
||||
annotations: param.annotations,
|
||||
type: param.type,
|
||||
enabled: param.enabled
|
||||
});
|
||||
@@ -757,6 +760,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
description: header.description,
|
||||
annotations: header.annotations,
|
||||
enabled: header.enabled
|
||||
});
|
||||
});
|
||||
@@ -813,6 +817,7 @@ export const transformCollectionRootToSave = (collection) => {
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
description: header.description,
|
||||
annotations: header.annotations,
|
||||
enabled: header.enabled
|
||||
});
|
||||
});
|
||||
@@ -843,6 +848,7 @@ export const transformFolderRootToSave = (folder) => {
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
description: header.description,
|
||||
annotations: header.annotations,
|
||||
enabled: header.enabled
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { describe, it, expect } = require('@jest/globals');
|
||||
import { mergeHeaders } from './index';
|
||||
import { mergeHeaders, transformRequestToSaveToFilesystem } from './index';
|
||||
|
||||
describe('mergeHeaders', () => {
|
||||
it('should include headers from collection, folder and request (with correct precedence)', () => {
|
||||
@@ -35,3 +35,54 @@ describe('mergeHeaders', () => {
|
||||
expect(names).toEqual(expect.arrayContaining(['X-Collection', 'X-Folder', 'X-Request']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformRequestToSaveToFilesystem', () => {
|
||||
it('preserves header and param annotations', () => {
|
||||
const item = {
|
||||
uid: 'requestuid123456789012',
|
||||
type: 'http-request',
|
||||
name: 'Annotated Request',
|
||||
seq: 1,
|
||||
settings: {},
|
||||
tags: [],
|
||||
examples: [],
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: 'https://example.com',
|
||||
params: [
|
||||
{
|
||||
uid: 'paramuid1234567890123',
|
||||
name: 'q',
|
||||
value: '1',
|
||||
description: '',
|
||||
annotations: [{ name: 'param-note', value: 'keep me' }],
|
||||
type: 'query',
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
headers: [
|
||||
{
|
||||
uid: 'headeruid123456789012',
|
||||
name: 'X-Test',
|
||||
value: '1',
|
||||
description: '',
|
||||
annotations: [{ name: 'header-note', value: 'keep me' }],
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
auth: { mode: 'none' },
|
||||
body: { mode: 'none' },
|
||||
script: { req: '', res: '' },
|
||||
vars: { req: [], res: [] },
|
||||
assertions: [],
|
||||
tests: '',
|
||||
docs: ''
|
||||
}
|
||||
};
|
||||
|
||||
const transformed = transformRequestToSaveToFilesystem(item);
|
||||
|
||||
expect(transformed.request.params[0].annotations).toEqual([{ name: 'param-note', value: 'keep me' }]);
|
||||
expect(transformed.request.headers[0].annotations).toEqual([{ name: 'header-note', value: 'keep me' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -322,6 +322,18 @@ const transformSwaggerRequestItem = (request, usedNames = new Set(), options = {
|
||||
requestBodySchema,
|
||||
requestBodyContentType
|
||||
}));
|
||||
} else if (response.description) {
|
||||
// description only (e.g., 204 No Content) — create example without body
|
||||
examples.push(createBrunoExample({
|
||||
brunoRequestItem,
|
||||
exampleValue: '',
|
||||
exampleName: `${statusCode} Response`,
|
||||
exampleDescription: response.description,
|
||||
statusCode,
|
||||
contentType: null,
|
||||
requestBodySchema,
|
||||
requestBodyContentType
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 || '' },
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('swagger2-to-bruno response examples', () => {
|
||||
expect(req.examples[0].response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should not generate examples when responses have no schema or examples', () => {
|
||||
it('should generate examples from description only responses', () => {
|
||||
const spec = {
|
||||
swagger: '2.0',
|
||||
info: { title: 'No Schema API', version: '1.0' },
|
||||
@@ -229,8 +229,20 @@ describe('swagger2-to-bruno response examples', () => {
|
||||
const collection = swagger2ToBruno(spec);
|
||||
const req = collection.items.find((i) => i.name === 'Delete data');
|
||||
|
||||
// No schema or examples → no examples array
|
||||
expect(req.examples).toBeUndefined();
|
||||
expect(req.examples).toBeDefined();
|
||||
expect(req.examples.length).toBe(2);
|
||||
|
||||
const noContentExample = req.examples.find((e) => e.response.status === 204);
|
||||
expect(noContentExample).toBeDefined();
|
||||
expect(noContentExample.name).toBe('204 Response');
|
||||
expect(noContentExample.description).toBe('No Content');
|
||||
expect(noContentExample.response.body.content).toBe('');
|
||||
expect(noContentExample.response.headers).toEqual([]);
|
||||
|
||||
const notFoundExample = req.examples.find((e) => e.response.status === 404);
|
||||
expect(notFoundExample).toBeDefined();
|
||||
expect(notFoundExample.name).toBe('404 Response');
|
||||
expect(notFoundExample.description).toBe('Not Found');
|
||||
});
|
||||
|
||||
it('should set correct statusText in response examples', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -645,6 +645,7 @@ const transformRequestToSaveToFilesystem = (item) => {
|
||||
name: param.name,
|
||||
value: param.value,
|
||||
description: param.description,
|
||||
annotations: param.annotations,
|
||||
type: param.type,
|
||||
enabled: param.enabled
|
||||
});
|
||||
@@ -657,6 +658,7 @@ const transformRequestToSaveToFilesystem = (item) => {
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
description: header.description,
|
||||
annotations: header.annotations,
|
||||
enabled: header.enabled
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('transformRequestToSaveToFilesystem', () => {
|
||||
name: 'param1',
|
||||
value: 'value1',
|
||||
description: 'Test parameter',
|
||||
annotations: [{ name: 'note', value: 'param annotation' }],
|
||||
type: 'text',
|
||||
enabled: true
|
||||
}
|
||||
@@ -30,6 +31,7 @@ describe('transformRequestToSaveToFilesystem', () => {
|
||||
name: 'Content-Type',
|
||||
value: 'application/json',
|
||||
description: 'Request content type',
|
||||
annotations: [{ name: 'note', value: 'header annotation' }],
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
@@ -101,6 +103,7 @@ describe('transformRequestToSaveToFilesystem', () => {
|
||||
name: 'param1',
|
||||
value: 'value1',
|
||||
description: 'Test parameter',
|
||||
annotations: [{ name: 'note', value: 'param annotation' }],
|
||||
type: 'text',
|
||||
enabled: true
|
||||
});
|
||||
@@ -112,6 +115,7 @@ describe('transformRequestToSaveToFilesystem', () => {
|
||||
name: 'Content-Type',
|
||||
value: 'application/json',
|
||||
description: 'Request content type',
|
||||
annotations: [{ name: 'note', value: 'header annotation' }],
|
||||
enabled: true
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
@@ -585,10 +585,11 @@ ${indentString(body.sparql)}
|
||||
const selected = item.selected ? '' : '~';
|
||||
const contentType
|
||||
= item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
|
||||
const annotPrefix = serializeAnnotations(item.annotations);
|
||||
const filePath = item.filePath || '';
|
||||
const value = `@file(${filePath})`;
|
||||
const itemName = 'file';
|
||||
return `${selected}${itemName}: ${value}${contentType}`;
|
||||
return `${annotPrefix}${selected}${itemName}: ${value}${contentType}`;
|
||||
})
|
||||
.join('\n')
|
||||
)}`;
|
||||
|
||||
@@ -320,6 +320,35 @@ headers {
|
||||
expect(parsed.headers[0].annotations).toEqual([{ name: 'description', value: '{{baseUrl}}/path' }]);
|
||||
});
|
||||
|
||||
it('serializeAnnotations — annotation on params:path', () => {
|
||||
const json = {
|
||||
params: [{ name: 'userId', value: '123', enabled: true, type: 'path', annotations: [{ name: 'description', value: 'user id' }] }]
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('params:path {');
|
||||
expect(bru).toContain('@description(\'user id\')\n userId: 123');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — annotation on metadata', () => {
|
||||
const json = {
|
||||
metadata: [{ name: 'trace-id', value: 'abc123', enabled: true, annotations: [{ name: 'description', value: 'trace id' }] }]
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('metadata {');
|
||||
expect(bru).toContain('@description(\'trace id\')\n trace-id: abc123');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — annotation on body:form-urlencoded', () => {
|
||||
const json = {
|
||||
body: {
|
||||
formUrlEncoded: [{ name: 'username', value: 'alice', enabled: true, annotations: [{ name: 'description', value: 'username field' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('body:form-urlencoded {');
|
||||
expect(bru).toContain('@description(\'username field\')\n username: alice');
|
||||
});
|
||||
|
||||
it('annotation on params:query block', () => {
|
||||
const input = `
|
||||
params:query {
|
||||
@@ -333,6 +362,45 @@ params:query {
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on params:path block', () => {
|
||||
const input = `
|
||||
params:path {
|
||||
@description('user id')
|
||||
userId: 123
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.params).toEqual([
|
||||
{ name: 'userId', value: '123', enabled: true, type: 'path', annotations: [{ name: 'description', value: 'user id' }] }
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on metadata block', () => {
|
||||
const input = `
|
||||
metadata {
|
||||
@description('trace id')
|
||||
trace-id: abc123
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.metadata).toEqual([
|
||||
{ name: 'trace-id', value: 'abc123', enabled: true, annotations: [{ name: 'description', value: 'trace id' }] }
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on body:form-urlencoded block', () => {
|
||||
const input = `
|
||||
body:form-urlencoded {
|
||||
@description('username field')
|
||||
username: alice
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.body.formUrlEncoded).toEqual([
|
||||
{ name: 'username', value: 'alice', enabled: true, annotations: [{ name: 'description', value: 'username field' }] }
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on vars:pre-request block', () => {
|
||||
const input = `
|
||||
vars:pre-request {
|
||||
@@ -352,6 +420,225 @@ vars:pre-request {
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on vars:post-response block', () => {
|
||||
const input = `
|
||||
vars:post-response {
|
||||
@description('auth token')
|
||||
token: abc123
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.vars.res).toEqual([
|
||||
{
|
||||
name: 'token',
|
||||
value: 'abc123',
|
||||
enabled: true,
|
||||
local: false,
|
||||
annotations: [{ name: 'description', value: 'auth token' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on local vars:pre-request pair', () => {
|
||||
const input = `
|
||||
vars:pre-request {
|
||||
@description('local base url')
|
||||
@BASE_URL: http://localhost
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.vars.req).toEqual([
|
||||
{
|
||||
name: 'BASE_URL',
|
||||
value: 'http://localhost',
|
||||
enabled: true,
|
||||
local: true,
|
||||
annotations: [{ name: 'description', value: 'local base url' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on local vars:post-response pair', () => {
|
||||
const input = `
|
||||
vars:post-response {
|
||||
@description('local token')
|
||||
@token: abc123
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.vars.res).toEqual([
|
||||
{
|
||||
name: 'token',
|
||||
value: 'abc123',
|
||||
enabled: true,
|
||||
local: true,
|
||||
annotations: [{ name: 'description', value: 'local token' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on body:multipart-form text field', () => {
|
||||
const input = `
|
||||
body:multipart-form {
|
||||
@description('plain field')
|
||||
field: value @contentType(text/plain)
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.body.multipartForm).toEqual([
|
||||
{
|
||||
name: 'field',
|
||||
value: 'value',
|
||||
enabled: true,
|
||||
type: 'text',
|
||||
contentType: 'text/plain',
|
||||
annotations: [{ name: 'description', value: 'plain field' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on body:multipart-form file field', () => {
|
||||
const input = `
|
||||
body:multipart-form {
|
||||
@description('upload image')
|
||||
upload: @file(/tmp/a.png|/tmp/b.png) @contentType(image/png)
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.body.multipartForm).toEqual([
|
||||
{
|
||||
name: 'upload',
|
||||
value: ['/tmp/a.png', '/tmp/b.png'],
|
||||
enabled: true,
|
||||
type: 'file',
|
||||
contentType: 'image/png',
|
||||
annotations: [{ name: 'description', value: 'upload image' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('annotation on body:file', () => {
|
||||
const input = `
|
||||
body:file {
|
||||
@description('upload doc')
|
||||
file: @file(/tmp/readme.pdf) @contentType(application/pdf)
|
||||
}
|
||||
`;
|
||||
const output = parser(input);
|
||||
expect(output.body.file).toEqual([
|
||||
{
|
||||
filePath: '/tmp/readme.pdf',
|
||||
selected: true,
|
||||
contentType: 'application/pdf',
|
||||
annotations: [{ name: 'description', value: 'upload doc' }]
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializeAnnotations — multipart text field with contentType', () => {
|
||||
const json = {
|
||||
body: {
|
||||
multipartForm: [
|
||||
{
|
||||
name: 'field',
|
||||
value: 'value',
|
||||
enabled: true,
|
||||
type: 'text',
|
||||
contentType: 'text/plain',
|
||||
annotations: [{ name: 'description', value: 'plain field' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('@description(\'plain field\')\n field: value @contentType(text/plain)');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — multipart file field with contentType', () => {
|
||||
const json = {
|
||||
body: {
|
||||
multipartForm: [
|
||||
{
|
||||
name: 'upload',
|
||||
value: ['/tmp/a.png', '/tmp/b.png'],
|
||||
enabled: true,
|
||||
type: 'file',
|
||||
contentType: 'image/png',
|
||||
annotations: [{ name: 'description', value: 'upload image' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('@description(\'upload image\')\n upload: @file(/tmp/a.png|/tmp/b.png) @contentType(image/png)');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — annotation on vars:post-response', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
res: [{ name: 'token', value: 'abc123', enabled: true, local: false, annotations: [{ name: 'description', value: 'auth token' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('vars:post-response {');
|
||||
expect(bru).toContain('@description(\'auth token\')\n token: abc123');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — annotation on local vars:pre-request', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
req: [{ name: 'BASE_URL', value: 'http://localhost', enabled: true, local: true, annotations: [{ name: 'description', value: 'local base url' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('vars:pre-request {');
|
||||
expect(bru).toContain('@description(\'local base url\')\n @BASE_URL: http://localhost');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — annotation on disabled local vars:post-response', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
res: [{ name: 'token', value: 'abc123', enabled: false, local: true, annotations: [{ name: 'description', value: 'local token' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('vars:post-response {');
|
||||
expect(bru).toContain('@description(\'local token\')\n ~@token: abc123');
|
||||
});
|
||||
|
||||
it('serializeAnnotations — body:file with annotations', () => {
|
||||
const json = {
|
||||
body: {
|
||||
file: [{ filePath: '/tmp/readme.pdf', selected: true, contentType: 'application/pdf', annotations: [{ name: 'description', value: 'upload doc' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
expect(bru).toContain('body:file {');
|
||||
expect(bru).toContain('@description(\'upload doc\')\n file: @file(/tmp/readme.pdf) @contentType(application/pdf)');
|
||||
const parsed = parser(bru);
|
||||
expect(parsed.body.file).toEqual(json.body.file);
|
||||
});
|
||||
|
||||
it('roundtrip — multipart annotation survives json→bru→json', () => {
|
||||
const json = {
|
||||
body: {
|
||||
multipartForm: [
|
||||
{
|
||||
name: 'upload',
|
||||
value: ['/tmp/a.png'],
|
||||
enabled: true,
|
||||
type: 'file',
|
||||
contentType: 'image/png',
|
||||
annotations: [{ name: 'description', value: 'upload image' }]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
const bru = jsonToBru(json);
|
||||
const parsed = parser(bru);
|
||||
expect(parsed.body.multipartForm).toEqual(json.body.multipartForm);
|
||||
});
|
||||
|
||||
it('roundtrip: bru → json → bru → json equal', () => {
|
||||
const input = `get {
|
||||
url: https://example.com
|
||||
@@ -792,6 +1079,39 @@ describe('collection pair annotations', () => {
|
||||
expect(bru).toContain('@description(\'base url\')\n BASE_URL: http://localhost');
|
||||
});
|
||||
|
||||
it('serializeAnnotations in jsonToCollectionBru — vars:post-response with annotation', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
res: [{ name: 'token', value: 'abc123', enabled: true, local: false, annotations: [{ name: 'description', value: 'auth token' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToCollectionBru(json);
|
||||
expect(bru).toContain('vars:post-response {');
|
||||
expect(bru).toContain('@description(\'auth token\')\n token: abc123');
|
||||
});
|
||||
|
||||
it('serializeAnnotations in jsonToCollectionBru — local vars:pre-request with annotation', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
req: [{ name: 'BASE_URL', value: 'http://localhost', enabled: true, local: true, annotations: [{ name: 'description', value: 'local base url' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToCollectionBru(json);
|
||||
expect(bru).toContain('vars:pre-request {');
|
||||
expect(bru).toContain('@description(\'local base url\')\n @BASE_URL: http://localhost');
|
||||
});
|
||||
|
||||
it('serializeAnnotations in jsonToCollectionBru — disabled local vars:post-response with annotation', () => {
|
||||
const json = {
|
||||
vars: {
|
||||
res: [{ name: 'token', value: 'abc123', enabled: false, local: true, annotations: [{ name: 'description', value: 'local token' }] }]
|
||||
}
|
||||
};
|
||||
const bru = jsonToCollectionBru(json);
|
||||
expect(bru).toContain('vars:post-response {');
|
||||
expect(bru).toContain('@description(\'local token\')\n ~@token: abc123');
|
||||
});
|
||||
|
||||
it('parseAndSerialise - bru sourced roundtrip check - collection headers', () => {
|
||||
const input = `headers {
|
||||
@description('content type')
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UID } from '../common';
|
||||
import type { UID, Annotation } from '../common';
|
||||
|
||||
export interface EnvironmentVariable {
|
||||
uid: UID;
|
||||
@@ -7,6 +7,7 @@ export interface EnvironmentVariable {
|
||||
type: 'text';
|
||||
enabled?: boolean;
|
||||
secret?: boolean;
|
||||
annotations?: Annotation[] | null;
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
|
||||
7
packages/bruno-schema-types/src/common/annotation.ts
Normal file
7
packages/bruno-schema-types/src/common/annotation.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Annotation applied to pairs (headers, vars, params, etc.)
|
||||
*/
|
||||
export interface Annotation {
|
||||
name: string;
|
||||
value?: string | null;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Annotation } from './annotation';
|
||||
import type { UID } from './uid';
|
||||
|
||||
export interface FileEntry {
|
||||
@@ -5,6 +6,7 @@ export interface FileEntry {
|
||||
filePath?: string | null;
|
||||
contentType?: string | null;
|
||||
selected: boolean;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export type FileList = FileEntry[];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type { UID } from './uid';
|
||||
export type { KeyValue } from './key-value';
|
||||
export type { Variable, Variables } from './variables';
|
||||
export type { Annotation } from './annotation';
|
||||
export type { MultipartFormEntry, MultipartForm } from './multipart-form';
|
||||
export type { FileEntry, FileList } from './file';
|
||||
export type { GraphqlBody } from './graphql';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Annotation } from './annotation';
|
||||
import type { UID } from './uid';
|
||||
|
||||
/**
|
||||
@@ -9,4 +10,5 @@ export interface KeyValue {
|
||||
value?: string | null;
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
annotations?: Annotation[] | null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Annotation } from './annotation';
|
||||
import type { UID } from './uid';
|
||||
|
||||
export interface MultipartFormEntry {
|
||||
@@ -8,6 +9,7 @@ export interface MultipartFormEntry {
|
||||
description?: string | null;
|
||||
contentType?: string | null;
|
||||
enabled?: boolean;
|
||||
annotations?: Annotation[];
|
||||
}
|
||||
|
||||
export type MultipartForm = MultipartFormEntry[];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Annotation } from './annotation';
|
||||
import type { UID } from './uid';
|
||||
|
||||
/**
|
||||
@@ -10,6 +11,7 @@ export interface Variable {
|
||||
description?: string | null;
|
||||
enabled?: boolean;
|
||||
local?: boolean;
|
||||
annotations?: Annotation[] | null;
|
||||
}
|
||||
|
||||
export type Variables = Variable[] | null;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const { itemSchema, environmentSchema, collectionSchema } = require('./index');
|
||||
|
||||
describe('annotation acceptance', () => {
|
||||
test('itemSchema accepts annotations on headers and params', async () => {
|
||||
const item = {
|
||||
uid: 'aaaaaaaaaaaaaaaaaaaaa',
|
||||
type: 'http-request',
|
||||
name: 'Req',
|
||||
request: {
|
||||
url: 'https://example.com',
|
||||
method: 'GET',
|
||||
headers: [
|
||||
{ uid: 'bbbbbbbbbbbbbbbbbbbbb', name: 'X-Test', value: '1', annotations: [{ name: 'note', value: 'header note' }] }
|
||||
],
|
||||
params: [
|
||||
{ uid: 'ccccccccccccccccccccc', name: 'q', value: '1', type: 'query', annotations: [{ name: 'hint' }] }
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await expect(itemSchema.validate(item)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('environmentSchema accepts annotations on variables', async () => {
|
||||
const env = {
|
||||
uid: 'ddddddddddddddddddddd',
|
||||
name: 'Env',
|
||||
variables: [
|
||||
{ uid: 'eeeeeeeeeeeeeeeeeeeee', name: 'API_KEY', value: 'abc', annotations: [{ name: 'secret', value: null }], type: 'text', enabled: true, secret: false }
|
||||
]
|
||||
};
|
||||
|
||||
await expect(environmentSchema.validate(env)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
test('collectionSchema accepts annotations in item vars and items', async () => {
|
||||
const coll = {
|
||||
version: '1',
|
||||
uid: 'fffffffffffffffffffff',
|
||||
name: 'Coll',
|
||||
items: [
|
||||
{
|
||||
uid: 'ggggggggggggggggggggg',
|
||||
type: 'http-request',
|
||||
name: 'Req2',
|
||||
request: { url: '/path', method: 'POST', headers: [], params: [], vars: { req: [{ uid: 'hhhhhhhhhhhhhhhhhhhhh', name: 'base', value: 'https://example.com', annotations: [{ name: 'base-note' }] }] } }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await expect(collectionSchema.validate(coll)).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,22 @@
|
||||
const Yup = require('yup');
|
||||
const { uidSchema } = require('../common');
|
||||
|
||||
const annotationSchema = Yup.object({
|
||||
name: Yup.string().min(1).required('annotation name is required'),
|
||||
value: Yup.string().nullable()
|
||||
}).noUnknown(true)
|
||||
.strict();
|
||||
|
||||
const environmentVariablesSchema = Yup.object({
|
||||
uid: uidSchema,
|
||||
name: Yup.string().nullable(),
|
||||
// Allow mixed types (string, number, boolean, object) to support setting non-string values via scripts.
|
||||
value: Yup.mixed().nullable(),
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
annotationSchema
|
||||
)
|
||||
.nullable(),
|
||||
type: Yup.string().oneOf(['text']).required('type is required'),
|
||||
enabled: Yup.boolean().defined(),
|
||||
secret: Yup.boolean()
|
||||
@@ -29,6 +40,11 @@ const keyValueSchema = Yup.object({
|
||||
name: Yup.string().nullable(),
|
||||
value: Yup.string().nullable(),
|
||||
description: Yup.string().nullable(),
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
annotationSchema
|
||||
)
|
||||
.nullable(),
|
||||
enabled: Yup.boolean()
|
||||
})
|
||||
.noUnknown(true)
|
||||
@@ -79,6 +95,12 @@ const varsSchema = Yup.object({
|
||||
name: Yup.string().nullable(),
|
||||
value: Yup.string().nullable(),
|
||||
description: Yup.string().nullable(),
|
||||
// Optional annotations on variables
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
annotationSchema
|
||||
)
|
||||
.nullable(),
|
||||
enabled: Yup.boolean(),
|
||||
|
||||
// todo
|
||||
@@ -109,6 +131,17 @@ const multipartFormSchema = Yup.object({
|
||||
then: Yup.array().of(Yup.string().nullable()).nullable(),
|
||||
otherwise: Yup.string().nullable()
|
||||
}),
|
||||
// Optional annotations on multipart entries
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
Yup.object({
|
||||
name: Yup.string().min(1).required('annotation name is required'),
|
||||
value: Yup.string().nullable()
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict()
|
||||
)
|
||||
.nullable(),
|
||||
description: Yup.string().nullable(),
|
||||
contentType: Yup.string().nullable(),
|
||||
enabled: Yup.boolean()
|
||||
@@ -126,6 +159,16 @@ const fileSchema = Yup.object({
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
|
||||
// Add annotations to file entries (when parsed from body:file blocks they can have @contentType only currently,
|
||||
// but adding annotations ensures roundtrip validation doesn't fail if annotations are present in future)
|
||||
const fileSchemaWithAnnotations = fileSchema.shape({
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
annotationSchema
|
||||
)
|
||||
.nullable()
|
||||
});
|
||||
|
||||
const requestBodySchema = Yup.object({
|
||||
mode: Yup.string()
|
||||
.oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql', 'file'])
|
||||
@@ -137,7 +180,7 @@ const requestBodySchema = Yup.object({
|
||||
formUrlEncoded: Yup.array().of(keyValueSchema).nullable(),
|
||||
multipartForm: Yup.array().of(multipartFormSchema).nullable(),
|
||||
graphql: graphqlBodySchema.nullable(),
|
||||
file: Yup.array().of(fileSchema).nullable()
|
||||
file: Yup.array().of(fileSchemaWithAnnotations).nullable()
|
||||
})
|
||||
.noUnknown(true)
|
||||
.strict();
|
||||
@@ -378,6 +421,12 @@ const requestParamsSchema = Yup.object({
|
||||
name: Yup.string().nullable(),
|
||||
value: Yup.string().nullable(),
|
||||
description: Yup.string().nullable(),
|
||||
// Optional annotations on params
|
||||
annotations: Yup.array()
|
||||
.of(
|
||||
annotationSchema
|
||||
)
|
||||
.nullable(),
|
||||
type: Yup.string().oneOf(['query', 'path']).required('type is required'),
|
||||
enabled: Yup.boolean()
|
||||
})
|
||||
@@ -649,5 +698,6 @@ module.exports = {
|
||||
itemSchema,
|
||||
environmentSchema,
|
||||
environmentsSchema,
|
||||
collectionSchema
|
||||
collectionSchema,
|
||||
annotationSchema
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user