Compare commits

...

12 Commits

Author SHA1 Message Date
shubh-bruno
34460d5bcf fix: shortcut in query builder (#7812)
* fix: enter shortcut for query builder

* chore: remove comments

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-22 22:24:14 +05:30
shubh-bruno
422a43ce56 fix: shortcut for query builder (#7805)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-22 22:24:06 +05:30
shubh-bruno
2720ac20b4 fix: qol fixes for keybindings (#7709)
* fix: keybindings issues

* chore: let SingleLineEditor handle it's own handleSubmit

* fix: resolve issues

* fix: disable reset default if none are changed

* fix: exlude transient request from reopen last closed tabs

* fix: updated all hardcoded colors to respective theme colors

* chore: pick color from theme

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-04-22 22:23:27 +05:30
gopu-bruno
2e1c8b3382 fix: generate examples for description only responses in swagger 2.0 converter (#7717) 2026-04-22 22:23:10 +05:30
Sid
95fccbeb8d fix: avoid round trip loss of annotation data (#7730)
* fix: avoid round trip loss of annotation data

* feat: update types for file , multipart and tests for the same

* chore: optional

* chore: fix body:file annotation

* chore: remove log
2026-04-22 22:22:58 +05:30
shubh-bruno
e964bdc7fe fix: response filter in runner (#7747)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-22 22:22:50 +05:30
lohit
cd06f28430 fix: rename signatureEncoding to signatureMethod for OAuth1 in opencollection types (#7724)
Align with @opencollection/types 0.9.1 which renamed the OAuth1 field from
signatureEncoding to signatureMethod. Update converters, filestore, and all
YML test fixtures. Increase OAuth1 UI test timeouts from 30s to 60s.
2026-04-09 23:36:39 +05:30
Sid
3b502fd63d give sid-bruno some nice privileges (#7702) 2026-04-08 11:36:18 +05:30
naman-bruno
d4cd34fc50 fix: fix scroll in querybar component (#7700) 2026-04-07 19:25:51 +05:30
Pragadesh-45
58942b383d Feat: Support PAC file upload (#7651)
* Add proxy .pac file resolver

chore(dependencies): update package-lock.json with new dependencies and version upgrades

- Added new dependencies: ajv, git-url-parse, @opencollection/types, and storybook packages.
- Updated existing dependencies to their latest versions, including eslint and babel packages.
- Removed deprecated entries and cleaned up the package-lock structure for better maintainability.

* tests

* wip

* wip

* wip

* wip

* feat: file upload .pac

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* feat: Refactor proxy settings to use a new structure for PAC configuration. Introduced 'source' field to determine proxy type (manual or PAC) and updated related validation and state management. Removed deprecated 'pacUrl' field in favor of 'pac.source'. Updated preferences schema and test data accordingly.

* fix: Update proxy settings to correctly reference 'source' field for PAC configuration. Adjusted state management and validation logic to align with new structure. Enhanced tests for backward compatibility and new format handling.

* feat: Enhance proxy configuration by adding 'proxyModeReason' to provide context for proxy settings. Updated related functions to accommodate the new parameter and improved logging for proxy mode changes.

* wip

* refactor: Update proxy settings to remove 'inherit' field and replace it with 'source' for better clarity. Adjusted validation schema, default preferences, and migration logic to align with the new structure. Enhanced tests to ensure compatibility with the updated proxy configuration.

* wip

* wip

* wip

* wip

* wip

* chore: consistent path check

* chore: consistency

* tests(pac): fix unit params

---------

Co-authored-by: Gianluca D'Abrosca <gianluca.dabrosca.1999@gmail.com>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-04-07 17:00:32 +05:30
gopu-bruno
476d30a49e feat: replace send button with Send/Cancel buttons on request url (#7675)
* feat: replace request send icon with Send/Cancel buttons

---------

Co-authored-by: naman-bruno <naman@usebruno.com>
2026-04-07 13:42:09 +05:30
Pooja
4d6032ba0d fix: timeline url race condition (#7154)
* fix: timeline url race condition

* add: requestSent in catch block

* add: requestSent in catch
2026-04-06 17:09:37 +05:30
105 changed files with 2535 additions and 521 deletions

2
.github/CODEOWNERS vendored
View File

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

View File

@@ -324,7 +324,7 @@ test('should create and execute HTTP request', async ({ page, createTmpDir }) =>
await page.getByRole('button', { name: 'Create' }).click();
// Execute request
await page.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
View File

@@ -30,7 +30,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.9.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",

View File

@@ -23,7 +23,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.9.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",

View File

@@ -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')

View File

@@ -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 {

View File

@@ -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}>&nbsp;</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>

View File

@@ -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};

View File

@@ -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>
);

View File

@@ -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()}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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>
);
};

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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 || ''}

View File

@@ -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 } : {})
})
);
}

View File

@@ -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
})
);
});

View File

@@ -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
}));
},

View File

@@ -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);

View File

@@ -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
});
});

View File

@@ -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' }]);
});
});

View File

@@ -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));

View File

@@ -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": {

View File

@@ -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'
}),
];

View File

@@ -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",

View File

@@ -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
}));
}
});

View File

@@ -292,7 +292,7 @@ export const fromOpenCollectionAuth = (auth: Auth | undefined): BrunoAuth => {
accessTokenSecret: oauth1Auth.accessTokenSecret || null,
callbackUrl: oauth1Auth.callbackUrl || null,
verifier: oauth1Auth.verifier || null,
signatureMethod: (oauth1Auth.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 || '' },

View File

@@ -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', () => {

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 };
};
/**

View File

@@ -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) {

View File

@@ -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 });
});
});
});

View File

@@ -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
};
}
};

View File

@@ -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);
}
}

View File

@@ -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
});
});

View File

@@ -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) {

View File

@@ -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
});
});

View 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 }));
});
});

View File

@@ -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();
});
});
});

View File

@@ -129,7 +129,7 @@ const buildOAuth1Auth = (config?: BrunoAuth['oauth1']): AuthOAuth1 => {
if (isString(config.accessTokenSecret)) auth.accessTokenSecret = config.accessTokenSecret;
if (isString(config.callbackUrl)) auth.callbackUrl = config.callbackUrl;
if (isString(config.verifier)) auth.verifier = config.verifier;
if (isString(config.signatureMethod)) auth.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,

View File

@@ -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')
)}`;

View File

@@ -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')

View File

@@ -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",

View File

@@ -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)
}
];

View File

@@ -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';

View File

@@ -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,

View File

@@ -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
};

View 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);
});
});

View 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;

View File

@@ -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', () => {

View File

@@ -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 {

View File

@@ -0,0 +1,7 @@
/**
* Annotation applied to pairs (headers, vars, params, etc.)
*/
export interface Annotation {
name: string;
value?: string | null;
}

View File

@@ -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[];

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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
};

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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: |

View File

@@ -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: |

View File

@@ -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: |

View File

@@ -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

View File

@@ -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: |

View File

@@ -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}}"

View File

@@ -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: |

View File

@@ -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: |

View File

@@ -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();

View File

@@ -19,13 +19,13 @@ test.describe('Create collection', () => {
// Set the URL
await page.locator('#request-url .CodeMirror').click();
await page.locator('#request-url').locator('textarea').fill('http://localhost:8081');
await page.locator('#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');

View File

@@ -22,7 +22,7 @@ test.describe('Multiline Variables - Read Environment Test', () => {
await expect(page.locator('.current-environment').filter({ hasText: /Test/ })).toBeVisible();
// send request
const sendButton = page.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();

View File

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

View 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;
});
}

View 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');
});
}

View File

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

View 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": {}
}
}
}

View 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
});
});
});

View 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