Merge remote-tracking branch 'origin/main' into oauth2_additional_params

This commit is contained in:
lohit-bruno
2025-08-13 21:16:16 +05:30
135 changed files with 6301 additions and 1396 deletions

2
.github/CODEOWNERS vendored
View File

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

View File

@@ -30,6 +30,7 @@ jobs:
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-filestore
- name: Lint Check
run: npm run lint
@@ -80,6 +81,7 @@ jobs:
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-filestore
- name: Run tests
run: |
@@ -125,6 +127,7 @@ jobs:
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
npm run build:bruno-filestore
- name: Run Playwright tests
run: |

302
package-lock.json generated
View File

@@ -18,7 +18,8 @@
"packages/bruno-tests",
"packages/bruno-toml",
"packages/bruno-graphql-docs",
"packages/bruno-requests"
"packages/bruno-requests",
"packages/bruno-filestore"
],
"devDependencies": {
"@faker-js/faker": "^7.6.0",
@@ -3650,9 +3651,9 @@
}
},
"node_modules/@electron/get/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8725,6 +8726,10 @@
"integrity": "sha512-khvEnRF6/UVDw4df06j+6lFWGNDYWlcWnxfmEgU2o/CdsGY291NC1Cexz99ud7sbGBQP2d8JUXZe4zXPkGNJpQ==",
"license": "MIT"
},
"node_modules/@usebruno/filestore": {
"resolved": "packages/bruno-filestore",
"link": true
},
"node_modules/@usebruno/graphql-docs": {
"resolved": "packages/bruno-graphql-docs",
"link": true
@@ -11519,6 +11524,33 @@
"node": ">=6"
}
},
"node_modules/clone-regexp": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-3.0.0.tgz",
"integrity": "sha512-ujdnoq2Kxb8s3ItNBtnYeXdm07FcU0u8ARAT1lQ2YdMwQC+cdiXX8KoqMVuglztILivceTtp4ivqGSmEmhBUJw==",
"dev": true,
"dependencies": {
"is-regexp": "^3.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clone-regexp/node_modules/is-regexp": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz",
"integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clone-response": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
@@ -11951,6 +11983,18 @@
"node": ">= 0.6"
}
},
"node_modules/convert-hrtime": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz",
"integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -13423,15 +13467,15 @@
}
},
"node_modules/electron": {
"version": "33.2.1",
"resolved": "https://registry.npmjs.org/electron/-/electron-33.2.1.tgz",
"integrity": "sha512-SG/nmSsK9Qg1p6wAW+ZfqU+AV8cmXMTIklUL18NnOKfZLlum4ZsDoVdmmmlL39ZmeCaq27dr7CgslRPahfoVJg==",
"version": "37.2.6",
"resolved": "https://registry.npmjs.org/electron/-/electron-37.2.6.tgz",
"integrity": "sha512-Ns6xyxE+hIK5UlujtRlw7w4e2Ju/ImCWXf1Q/PoOhc0N3/6SN6YW7+ujCarsHbxWnolbW+1RlkHtdklUJpjbPA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^20.9.0",
"@types/node": "^22.7.7",
"extract-zip": "^2.0.1"
},
"bin": {
@@ -13656,23 +13700,6 @@
"integrity": "sha512-R1oD5gMBPS7PVU8gJwH6CtT0e6VSoD0+SzSnYpNm+dBkcijgA+K7VAMHDfnRq/lkKPZArpzplTW6jfiMYosdzw==",
"license": "MIT"
},
"node_modules/electron/node_modules/@types/node": {
"version": "20.17.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.10.tgz",
"integrity": "sha512-/jrvh5h6NXhEauFFexRin69nA0uHJ5gwk4iDivp/DeoEua3uwCUto6PC86IpRITBOs4+6i2I56K5x5b6WYGXHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/electron/node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true,
"license": "MIT"
},
"node_modules/elliptic": {
"version": "6.6.1",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz",
@@ -14849,9 +14876,9 @@
}
},
"node_modules/extract-zip/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -15556,6 +15583,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/function-timeout": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-0.1.1.tgz",
"integrity": "sha512-0NVVC0TaP7dSTvn1yMiy6d6Q8gifzbvQafO46RtLG/kHJUBNd+pVRGOBoK44wNBvtSPUJRfdVvkFdD3p0xvyZg==",
"dev": true,
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/functions-have-names": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
@@ -15819,9 +15858,9 @@
}
},
"node_modules/global-agent/node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
"dev": true,
"license": "ISC",
"optional": true,
@@ -26252,9 +26291,9 @@
}
},
"node_modules/sumchecker/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -26276,6 +26315,23 @@
"dev": true,
"license": "MIT"
},
"node_modules/super-regex": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/super-regex/-/super-regex-0.2.0.tgz",
"integrity": "sha512-WZzIx3rC1CvbMDloLsVw0lkZVKJWbrkJ0k1ghKFmcnPrW1+jWbgTkTEWVtD9lMdmI4jZEz40+naBxl1dCUhXXw==",
"dev": true,
"dependencies": {
"clone-regexp": "^3.0.0",
"function-timeout": "^0.1.0",
"time-span": "^5.1.0"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -26808,6 +26864,21 @@
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
"license": "MIT"
},
"node_modules/time-span": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz",
"integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==",
"dev": true,
"dependencies": {
"convert-hrtime": "^5.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/timers-browserify": {
"version": "2.0.12",
"resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz",
@@ -26905,6 +26976,7 @@
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
@@ -26920,6 +26992,7 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
@@ -29922,6 +29995,7 @@
"@aws-sdk/credential-providers": "3.750.0",
"@usebruno/common": "0.1.0",
"@usebruno/converters": "^0.1.0",
"@usebruno/filestore": "^0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
"@usebruno/requests": "^0.1.0",
@@ -29941,7 +30015,6 @@
"lodash": "^4.17.21",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^4.1.3",
"xmlbuilder": "^15.1.1",
"yargs": "^17.6.2"
},
@@ -31015,6 +31088,7 @@
"@rollup/plugin-typescript": "^12.1.2",
"@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
"is-ip": "^5.0.1",
"moment": "^2.29.4",
"rollup": "3.29.5",
"rollup-plugin-dts": "^5.0.0",
@@ -31535,6 +31609,34 @@
"node": ">=4"
}
},
"packages/bruno-common/node_modules/ip-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz",
"integrity": "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==",
"dev": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/bruno-common/node_modules/is-ip": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/is-ip/-/is-ip-5.0.1.tgz",
"integrity": "sha512-FCsGHdlrOnZQcp0+XT5a+pYowf33itBalCl+7ovNXC/7o5BhIpG14M3OrpPPdBSIQJCm+0M5+9mO7S9VVTTCFw==",
"dev": true,
"dependencies": {
"ip-regex": "^5.0.0",
"super-regex": "^0.2.0"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/bruno-common/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -31576,7 +31678,7 @@
"@web/rollup-plugin-copy": "^0.5.1",
"babel-jest": "^29.7.0",
"rimraf": "^5.0.7",
"rollup": "3.2.5",
"rollup": "3.29.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
@@ -31664,23 +31766,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/bruno-converters/node_modules/rollup": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.2.5.tgz",
"integrity": "sha512-/Ha7HhVVofduy+RKWOQJrxe4Qb3xyZo+chcpYiD8SoQa4AG7llhupUtyfKSSrdBM2mWJjhM8wZwmbY23NmlIYw==",
"dev": true,
"license": "MIT",
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=14.18.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"packages/bruno-electron": {
"name": "bruno",
"version": "2.0.0",
@@ -31688,6 +31773,7 @@
"@aws-sdk/credential-providers": "3.750.0",
"@usebruno/common": "0.1.0",
"@usebruno/converters": "^0.1.0",
"@usebruno/filestore": "^0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
"@usebruno/node-machine-id": "^2.0.0",
@@ -31720,12 +31806,11 @@
"nanoid": "3.3.8",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^4.1.3",
"uuid": "^9.0.0",
"yup": "^0.32.11"
},
"devDependencies": {
"electron": "33.2.1",
"electron": "~37.2.6",
"electron-builder": "25.1.8",
"electron-devtools-installer": "^4.0.0"
},
@@ -32803,6 +32888,113 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"packages/bruno-filestore": {
"name": "@usebruno/filestore",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@usebruno/lang": "0.12.0",
"lodash": "^4.17.21"
},
"devDependencies": {
"@babel/preset-env": "^7.22.0",
"@babel/preset-typescript": "^7.22.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"@types/jest": "^29.5.11",
"@types/lodash": "^4.14.191",
"@types/node": "^24.1.0",
"babel-jest": "^29.7.0",
"jest": "^29.2.0",
"rimraf": "^3.0.2",
"rollup": "3.29.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
}
},
"packages/bruno-filestore/node_modules/@types/node": {
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.8.0"
}
},
"packages/bruno-filestore/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"packages/bruno-filestore/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/bruno-filestore/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"packages/bruno-filestore/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"packages/bruno-filestore/node_modules/undici-types": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"dev": true,
"license": "MIT"
},
"packages/bruno-graphql-docs": {
"name": "@usebruno/graphql-docs",
"version": "0.1.0",

View File

@@ -14,7 +14,8 @@
"packages/bruno-tests",
"packages/bruno-toml",
"packages/bruno-graphql-docs",
"packages/bruno-requests"
"packages/bruno-requests",
"packages/bruno-filestore"
],
"homepage": "https://usebruno.com",
"devDependencies": {
@@ -40,6 +41,7 @@
"setup": "node ./scripts/setup.js",
"watch:converters": "npm run watch --workspace=packages/bruno-converters",
"dev": "concurrently --kill-others \"npm run dev:web\" \"npm run dev:electron\"",
"watch": "npm run dev:watch",
"dev:watch": "node ./scripts/dev-hot-reload.js",
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
@@ -48,6 +50,7 @@
"dev:electron:debug": "npm run debug --workspace=packages/bruno-electron",
"build:bruno-common": "npm run build --workspace=packages/bruno-common",
"build:bruno-requests": "npm run build --workspace=packages/bruno-requests",
"build:bruno-filestore": "npm run build --workspace=packages/bruno-filestore",
"build:bruno-converters": "npm run build --workspace=packages/bruno-converters",
"build:bruno-query": "npm run build --workspace=packages/bruno-query",
"build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs",

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const AwsV4Auth = ({ collection }) => {
const { storedTheme } = useTheme();
const awsv4Auth = get(collection, 'root.request.auth.awsv4', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(awsv4Auth?.secretAccessKey);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
@@ -131,7 +135,7 @@ const AwsV4Auth = ({ collection }) => {
</div>
<label className="block font-medium mb-2">Secret Access Key</label>
<div className="single-line-editor-wrapper mb-2">
<div className="single-line-editor-wrapper mb-2 flex items-center">
<SingleLineEditor
value={awsv4Auth.secretAccessKey || ''}
theme={storedTheme}
@@ -140,6 +144,7 @@ const AwsV4Auth = ({ collection }) => {
collection={collection}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="awsv4-secret-access-key" warningMessage={warningMessage} />}
</div>
<label className="block font-medium mb-2">Session Token</label>

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const BasicAuth = ({ collection }) => {
const { storedTheme } = useTheme();
const basicAuth = get(collection, 'root.request.auth.basic', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(basicAuth?.password);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
@@ -55,7 +59,7 @@ const BasicAuth = ({ collection }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={basicAuth.password || ''}
theme={storedTheme}
@@ -64,6 +68,7 @@ const BasicAuth = ({ collection }) => {
collection={collection}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="basic-password" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const BearerAuth = ({ collection }) => {
const { storedTheme } = useTheme();
const bearerToken = get(collection, 'root.request.auth.bearer.token', '');
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(bearerToken);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
@@ -30,7 +34,7 @@ const BearerAuth = ({ collection }) => {
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Token</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={bearerToken}
theme={storedTheme}
@@ -39,6 +43,7 @@ const BearerAuth = ({ collection }) => {
collection={collection}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="bearer-token" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const DigestAuth = ({ collection }) => {
const { storedTheme } = useTheme();
const digestAuth = get(collection, 'root.request.auth.digest', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(digestAuth?.password);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
@@ -55,7 +59,7 @@ const DigestAuth = ({ collection }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={digestAuth.password || ''}
theme={storedTheme}
@@ -64,6 +68,7 @@ const DigestAuth = ({ collection }) => {
collection={collection}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="digest-password" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -18,6 +20,8 @@ const NTLMAuth = ({ collection }) => {
const { storedTheme } = useTheme();
const ntlmAuth = get(collection, 'root.request.auth.ntlm', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(ntlmAuth?.password);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
@@ -82,7 +86,7 @@ const NTLMAuth = ({ collection }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={ntlmAuth.password || ''}
theme={storedTheme}
@@ -91,6 +95,7 @@ const NTLMAuth = ({ collection }) => {
collection={collection}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="ntlm-password" warningMessage={warningMessage} />}
</div>
<label className="block font-medium mb-2">Domain</label>

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const WsseAuth = ({ collection }) => {
const { storedTheme } = useTheme();
const wsseAuth = get(collection, 'root.request.auth.wsse', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(wsseAuth?.password);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
@@ -55,14 +59,16 @@ const WsseAuth = ({ collection }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={wsseAuth.password || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handlePasswordChange(val)}
collection={collection}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="wsse-password" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -46,7 +46,7 @@ const Docs = ({ collection }) => {
}
return (
<StyledWrapper className="mt-1 h-full w-full relative flex flex-col">
<StyledWrapper className="h-full w-full relative flex flex-col">
<div className='flex flex-row w-full justify-between items-center mb-4'>
<div className='text-lg font-medium flex items-center gap-2'>
<IconFileText size={20} strokeWidth={1.5} />

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
@@ -7,19 +7,30 @@ import { useTheme } from 'providers/Theme';
import {
addCollectionHeader,
updateCollectionHeader,
deleteCollectionHeader
deleteCollectionHeader,
setCollectionHeaders
} from 'providers/ReduxStore/slices/collections';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = get(collection, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setCollectionHeaders({ collectionUid: collection.uid, headers: newHeaders }));
};
const addHeader = () => {
dispatch(
@@ -63,6 +74,22 @@ const Headers = ({ collection }) => {
);
};
if (isBulkEditMode) {
return (
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 text-muted">
Add request headers that will be sent with every request in this collection.
</div>
<BulkEditor
params={headers}
onChange={handleBulkHeadersChange}
onToggle={toggleBulkEditMode}
onSave={handleSave}
/>
</StyledWrapper>
);
}
return (
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 text-muted">
@@ -141,9 +168,14 @@ const Headers = ({ collection }) => {
: null}
</tbody>
</table>
<button className="btn-add-header text-link pr-2 py-3 mt-2 select-none" onClick={addHeader}>
+ Add Header
</button>
<div className="flex justify-between mt-2">
<button className="btn-add-header text-link pr-2 py-3 select-none" onClick={addHeader}>
+ Add Header
</button>
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>

View File

@@ -132,7 +132,7 @@ const CollectionSettings = ({ collection }) => {
};
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-scroll">
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-hidden">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
Overview
@@ -169,7 +169,7 @@ const CollectionSettings = ({ collection }) => {
{clientCertConfig.length > 0 && <StatusDot />}
</div>
</div>
<section className="mt-4 h-full overflow-scroll">{getTabPanel(tab)}</section>
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</StyledWrapper>
);
};

View File

@@ -0,0 +1,11 @@
const sensitiveFields = [
'request.auth.oauth2.clientSecret',
'request.auth.basic.password',
'request.auth.digest.password',
'request.auth.wsse.password',
'request.auth.ntlm.password',
'request.auth.awsv4.secretAccessKey',
'request.auth.bearer.token'
];
export { sensitiveFields };

View File

@@ -1,5 +1,6 @@
import React, { useRef, useEffect } from 'react';
import React, { useRef, useEffect, useMemo } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { get } from 'lodash';
import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
@@ -13,7 +14,9 @@ import { variableNameRegex } from 'utils/common/regex';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'utils/collections';
import { sensitiveFields } from './constants';
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => {
const dispatch = useDispatch();
@@ -26,6 +29,50 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
const nonSecretSensitiveVarUsageMap = useMemo(() => {
const result = {};
if (!collection || !environment?.variables) {
return result;
}
const nonSecretVars = environment.variables.filter((v) => v.enabled && !v.secret && v.name);
if (!nonSecretVars.length) {
return result;
}
const varNames = new Set(nonSecretVars.map((v) => v.name));
const checkSensitiveField = (obj, fieldPath) => {
const value = get(obj, fieldPath);
if (typeof value === 'string') {
varNames.forEach((varName) => {
if (new RegExp(`\{\{\s*${varName}\s*\}\}`).test(value)) {
result[varName] = true;
}
});
}
};
const getObjectToProcess = (item) => {
if (isItemARequest(item)) {
return item.draft || item;
}
return item.root;
};
const collectionObj = getObjectToProcess(collection);
sensitiveFields.forEach((fieldPath) => {
checkSensitiveField(collectionObj, fieldPath);
});
const items = flattenItems(collection.items || []);
items.forEach((item) => {
const objToProcess = getObjectToProcess(item);
sensitiveFields.forEach((fieldPath) => {
checkSensitiveField(objToProcess, fieldPath);
});
});
return result;
}, [collection, environment]);
const formik = useFormik({
enableReinitialize: true,
initialValues: environment.variables || [],
@@ -61,6 +108,8 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
}
});
const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name];
// Effect to track modifications.
React.useEffect(() => {
setIsModified(formik.dirty);
@@ -163,7 +212,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
<ErrorMessage name={`${index}.name`} />
</div>
</td>
<td className="flex flex-row flex-nowrap">
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
theme={storedTheme}
@@ -174,6 +223,12 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
/>
</div>
{!variable.secret && hasSensitiveUsage(variable.name) && (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
)}
</td>
<td className="text-center">
<input

View File

@@ -1,21 +1,31 @@
import React from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addFolderHeader, updateFolderHeader, deleteFolderHeader } from 'providers/ReduxStore/slices/collections';
import { addFolderHeader, updateFolderHeader, deleteFolderHeader, setFolderHeaders } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = get(folder, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setFolderHeaders({ collectionUid: collection.uid, folderUid: folder.uid, headers: newHeaders }));
};
const addHeader = () => {
dispatch(
@@ -62,6 +72,22 @@ const Headers = ({ collection, folder }) => {
);
};
if (isBulkEditMode) {
return (
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">
Request headers that will be sent with every request inside this folder.
</div>
<BulkEditor
params={headers}
onChange={handleBulkHeadersChange}
onToggle={toggleBulkEditMode}
onSave={handleSave}
/>
</StyledWrapper>
);
}
return (
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">
@@ -141,9 +167,14 @@ const Headers = ({ collection, folder }) => {
: null}
</tbody>
</table>
<button className="btn-add-header text-link pr-2 py-3 mt-2 select-none" onClick={addHeader}>
+ Add Header
</button>
<div className="flex justify-between mt-2">
<button className="btn-add-header text-link pr-2 py-3 select-none" onClick={addHeader}>
+ Add Header
</button>
<button className="text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>

View File

@@ -74,7 +74,7 @@ const FolderSettings = ({ collection, folder }) => {
};
return (
<StyledWrapper className="flex flex-col h-full overflow-scroll">
<StyledWrapper className="flex flex-col h-full overflow-auto">
<div className="flex flex-col h-full relative px-4 py-4">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
@@ -101,7 +101,7 @@ const FolderSettings = ({ collection, folder }) => {
Docs
</div>
</div>
<section className={`flex mt-4 h-full overflow-scroll`}>{getTabPanel(tab)}</section>
<section className={`flex mt-4 h-full overflow-auto`}>{getTabPanel(tab)}</section>
</div>
</StyledWrapper>
);

View File

@@ -6,13 +6,16 @@ import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { update } from 'lodash';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const awsv4Auth = get(request, 'auth.awsv4', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(awsv4Auth?.secretAccessKey);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -144,7 +147,7 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
</div>
<label className="block font-medium mb-2">Secret Access Key</label>
<div className="single-line-editor-wrapper mb-2">
<div className="single-line-editor-wrapper mb-2 flex items-center">
<SingleLineEditor
value={awsv4Auth.secretAccessKey || ''}
theme={storedTheme}
@@ -155,6 +158,8 @@ const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="awsv4-secret-access-key" warningMessage={warningMessage} />}
</div>
<label className="block font-medium mb-2">Session Token</label>

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
const { storedTheme } = useTheme();
const basicAuth = get(request, 'auth.basic', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(basicAuth?.password);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -63,7 +67,7 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={basicAuth.password || ''}
theme={storedTheme}
@@ -74,6 +78,7 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="basic-password" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -13,6 +15,8 @@ const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
// Use the request prop directly like OAuth2ClientCredentials does
const bearerToken = get(request, 'auth.bearer.token', '');
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(bearerToken);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -36,7 +40,7 @@ const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Token</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={bearerToken}
theme={storedTheme}
@@ -47,6 +51,7 @@ const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="bearer-token" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -11,6 +13,8 @@ const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
const { storedTheme } = useTheme();
const digestAuth = get(request, 'auth.digest', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(digestAuth?.password);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -62,7 +66,7 @@ const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={digestAuth.password || ''}
theme={storedTheme}
@@ -73,6 +77,7 @@ const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="digest-password" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
const { storedTheme } = useTheme();
const ntlmAuth = get(request, 'auth.ntlm', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(ntlmAuth?.password);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -80,7 +84,7 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={ntlmAuth.password || ''}
theme={storedTheme}
@@ -91,6 +95,7 @@ const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="ntlm-password" warningMessage={warningMessage} />}
</div>
<label className="block font-medium mb-2">Domain</label>

View File

@@ -1,4 +1,5 @@
import React, { useRef, forwardRef } from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -10,13 +11,14 @@ import { inputsConfig } from './inputsConfig';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const { isSensitive } = useDetectSensitiveField(collection);
const oAuth = get(request, 'auth.oauth2', {});
const {
callbackUrl,
@@ -133,12 +135,15 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
const value = oAuth[key] || '';
const { showWarning, warningMessage } = isSensitive(value);
return (
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className="single-line-editor-wrapper flex-1">
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={oAuth[key] || ''}
value={value}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
@@ -147,6 +152,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
item={item}
isSecret={isSecret}
/>
{isSecret && showWarning && <SensitiveFieldWarning fieldName={key} warningMessage={warningMessage} />}
</div>
</div>
);

View File

@@ -1,4 +1,5 @@
import React, { useRef, forwardRef } from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -10,13 +11,14 @@ import Dropdown from 'components/Dropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const { isSensitive } = useDetectSensitiveField(collection);
const oAuth = get(request, 'auth.oauth2', {});
const {
@@ -99,12 +101,15 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
const value = oAuth[key] || '';
const { showWarning, warningMessage } = isSensitive(value);
return (
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className="single-line-editor-wrapper flex-1">
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={oAuth[key] || ''}
value={value}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
@@ -113,6 +118,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
item={item}
isSecret={isSecret}
/>
{isSecret && showWarning && <SensitiveFieldWarning fieldName={key} warningMessage={warningMessage} />}
</div>
</div>
);

View File

@@ -1,4 +1,5 @@
import React, { useRef, forwardRef } from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -10,14 +11,15 @@ import Dropdown from 'components/Dropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index';
const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = get(request, 'auth.oauth2', {});
const { isSensitive } = useDetectSensitiveField(collection);
const {
accessTokenUrl,
@@ -102,12 +104,15 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
const value = oAuth[key] || '';
const { showWarning, warningMessage } = isSensitive(value);
return (
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className="single-line-editor-wrapper flex-1">
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={oAuth[key] || ''}
value={value}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
@@ -116,6 +121,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
item={item}
isSecret={isSecret}
/>
{isSecret && showWarning && <SensitiveFieldWarning fieldName={key} warningMessage={warningMessage} />}
</div>
</div>
);

View File

@@ -1,4 +1,6 @@
import React from 'react';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -12,6 +14,8 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
const { storedTheme } = useTheme();
const wsseAuth = get(request, 'auth.wsse', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(wsseAuth?.password);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -63,7 +67,7 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor
value={wsseAuth.password || ''}
theme={storedTheme}
@@ -74,6 +78,7 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="wsse-password" message={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -109,7 +109,7 @@ const Auth = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full mt-1 overflow-y-scroll">
<StyledWrapper className="w-full mt-1 overflow-auto">
<div className="flex flex-grow justify-start items-center">
<AuthMode item={item} collection={collection} />
</div>

View File

@@ -176,7 +176,7 @@ const ResponsePane = ({ item, collection }) => {
) : null}
</div>
<section
className={`flex flex-col min-h-0 relative px-4 auto overflow-scroll`}
className={`flex flex-col min-h-0 relative px-4 auto overflow-auto`}
style={{
flex: '1 1 0',
height: hasScriptError && showScriptErrorCard ? 'auto' : '100%'

View File

@@ -96,7 +96,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
}
return (
<StyledWrapper className="flex flex-col h-full relative overflow-scroll">
<StyledWrapper className="flex flex-col h-full relative overflow-auto">
<div className="flex items-center tabs overflow-visible" role="tablist">
<div className={getTabClassname('response')} role="tab" onClick={() => selectTab('response')}>
Response
@@ -128,7 +128,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
<ResponseSize size={size} />
</div>
</div>
<section className="flex flex-col flex-grow overflow-scroll">
<section className="flex flex-col flex-grow overflow-auto">
{hasScriptError && showScriptErrorCard && (
<ScriptError
item={item}

View File

@@ -0,0 +1,231 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
background-color: ${props => props.theme.sidebar.bg};
height: 100%;
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid ${props => props.theme.sidebar.dragbar};
margin-bottom: 0.5rem;
.counter {
font-size: 0.875rem;
font-weight: 500;
}
.actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.btn-select-all,
.btn-reset {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: ${props => props.theme.textLink};
background: none;
border: none;
padding: 0.25rem 0.5rem;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
.request-list {
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: ${props => props.theme.console.scrollbarThumb};
border-radius: 3px;
}
.loading-message,
.empty-message {
padding: 0.75rem;
color: ${props => props.theme.colors.text.muted};
font-size: 0.875rem;
}
.requests-container {
padding: 0.5rem;
position: relative;
}
}
.request-item {
display: flex;
align-items: center;
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 0.25rem;
position: relative;
height: 2.5rem;
border: 1px solid transparent;
background-color: ${props => props.theme.sidebar.bg};
transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
&.is-selected {
background-color: ${props => props.theme.requestTabs.active.bg};
}
&.is-dragging {
opacity: 0.5;
background-color: ${props => props.theme.sidebar.bg};
border: 1px dashed ${props => props.theme.sidebar.dragbar};
transform: scale(0.98);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.12);
z-index: 5;
}
&::before,
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: ${props => props.theme.dragAndDrop?.border || props.theme.textLink};
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
&::before {
top: -1px;
}
&::after {
bottom: -1px;
}
&.drop-target-above {
&::before {
opacity: 1;
height: 2px;
background: ${props => props.theme.dragAndDrop?.border || props.theme.textLink};
}
}
&.drop-target-below {
&::after {
opacity: 1;
height: 2px;
background: ${props => props.theme.dragAndDrop?.border || props.theme.textLink};
}
}
.drag-handle {
cursor: grab;
margin-right: 0.25rem;
color: ${props => props.theme.sidebar.muted};
display: flex;
align-items: center;
transition: color 0.15s ease;
&:hover {
color: ${props => props.theme.text};
}
&:active {
cursor: grabbing;
color: ${props => props.theme.textLink};
}
}
.checkbox-container {
cursor: pointer;
margin-right: 0.5rem;
.checkbox {
width: 1rem;
height: 1rem;
border: 1px solid ${props => props.theme.sidebar.dragbar};
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s ease;
&:hover {
border-color: ${props => props.theme.textLink};
}
}
}
.method {
font-family: monospace;
font-size: 0.75rem;
font-weight: 500;
margin-right: 0.5rem;
min-width: 3rem;
color: ${props => props.theme.sidebar.muted}; // Default color for unknown methods
&.method-get {
color: ${props => props.theme.request.methods.get};
}
&.method-post {
color: ${props => props.theme.request.methods.post};
}
&.method-put {
color: ${props => props.theme.request.methods.put};
}
&.method-delete {
color: ${props => props.theme.request.methods.delete};
}
&.method-patch {
color: ${props => props.theme.request.methods.patch};
}
&.method-options {
color: ${props => props.theme.request.methods.options};
}
&.method-head {
color: ${props => props.theme.request.methods.head};
}
}
.request-name {
flex: 1;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.folder-path {
margin-left: 0.5rem;
font-size: 0.75rem;
color: ${props => props.theme.sidebar.muted};
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,327 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { getEmptyImage } from 'react-dnd-html5-backend';
import { IconGripVertical, IconCheck, IconAdjustmentsAlt } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { updateRunnerConfiguration } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { isItemARequest } from 'utils/collections';
import path from 'utils/common/path';
import { cloneDeep, get } from 'lodash';
const ItemTypes = {
REQUEST_ITEM: 'request-item'
};
const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) => {
const ref = useRef(null);
const [dropType, setDropType] = useState(null);
const determineDropType = (monitor) => {
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!hoverBoundingRect || !clientOffset) return null;
const clientY = clientOffset.y - hoverBoundingRect.top;
const middleY = hoverBoundingRect.height / 2;
return clientY < middleY ? 'above' : 'below';
};
const [{ isDragging }, drag, preview] = useDrag({
type: ItemTypes.REQUEST_ITEM,
item: { uid: item.uid, name: item.name, request: item.request, index },
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
options: {
dropEffect: "move"
},
end: (draggedItem, monitor) => {
if (monitor.didDrop()) {
onDrop();
}
},
});
const [{ isOver, canDrop }, drop] = useDrop({
accept: ItemTypes.REQUEST_ITEM,
hover: (draggedItem, monitor) => {
if (draggedItem.uid === item.uid) {
setDropType(null);
return;
}
const dropType = determineDropType(monitor);
setDropType(dropType);
},
drop: (draggedItem, monitor) => {
if (draggedItem.uid === item.uid) return;
const dropType = determineDropType(monitor);
let targetIndex = index;
if (dropType === 'below') {
targetIndex = index + 1;
}
if (draggedItem.index < targetIndex) {
targetIndex = targetIndex - 1;
}
moveItem(draggedItem.uid, targetIndex);
setDropType(null);
return { item: draggedItem };
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop()
}),
});
useEffect(() => {
preview(getEmptyImage(), { captureDraggingState: true });
}, []);
// Clear drop type when not hovering
useEffect(() => {
if (!isOver) {
setDropType(null);
}
}, [isOver]);
drag(drop(ref));
const itemClasses = [
'request-item',
isDragging ? 'is-dragging' : '',
isSelected ? 'is-selected' : '',
isOver && canDrop && dropType === 'above' ? 'drop-target-above' : '',
isOver && canDrop && dropType === 'below' ? 'drop-target-below' : ''
].filter(Boolean).join(' ');
return (
<div ref={ref} className={itemClasses}>
<div className="drag-handle">
<IconGripVertical size={16} strokeWidth={1.5} />
</div>
<div className="checkbox-container" onClick={() => onSelect(item)}>
<div className="checkbox">
{isSelected && <IconCheck size={12} />}
</div>
</div>
<div className={`method method-${item.request?.method.toLowerCase()}`}>
{item.request?.method.toUpperCase()}
</div>
<div className="request-name">
<span>{item.name}</span>
{item.folderPath && (
<span className="folder-path">{item.folderPath}</span>
)}
</div>
</div>
);
};
const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems }) => {
const dispatch = useDispatch();
const [flattenedRequests, setFlattenedRequests] = useState([]);
const [originalRequests, setOriginalRequests] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const flattenRequests = useCallback((collection) => {
const result = [];
const processItems = (items) => {
if (!items?.length) return;
items.forEach(item => {
if (isItemARequest(item) && !item.partial) {
const relativePath = path.relative(collection.pathname, path.dirname(item.pathname));
const folderPath = relativePath !== '.' ? relativePath : '';
result.push({
...item,
folderPath: folderPath.replace(/\\/g, '/')
});
}
if (item.items?.length) {
processItems(item.items);
}
});
};
processItems(collection.items);
return result;
}, []);
useEffect(() => {
setIsLoading(true);
try {
const structureCopy = cloneDeep(collection);
const requests = flattenRequests(structureCopy);
const savedConfiguration = get(collection, 'runnerConfiguration', null);
if (savedConfiguration?.requestItemsOrder?.length > 0) {
const orderedRequests = [];
const requestMap = new Map(requests.map(req => [req.uid, req]));
savedConfiguration.requestItemsOrder.forEach(uid => {
const request = requestMap.get(uid);
if (request) {
orderedRequests.push(request);
requestMap.delete(uid);
}
});
requestMap.forEach(request => {
orderedRequests.push(request);
});
setFlattenedRequests(orderedRequests);
} else {
setFlattenedRequests(requests);
}
setOriginalRequests(cloneDeep(requests));
} catch (error) {
console.error("Error loading collection structure:", error);
} finally {
setIsLoading(false);
}
}, [collection, flattenRequests]);
const moveItem = useCallback((draggedItemUid, hoverIndex) => {
setFlattenedRequests((prevRequests) => {
const dragIndex = prevRequests.findIndex(item => item.uid === draggedItemUid);
if (dragIndex === -1 || dragIndex === hoverIndex) {
return prevRequests;
}
const updatedRequests = [...prevRequests];
const [draggedItem] = updatedRequests.splice(dragIndex, 1);
updatedRequests.splice(hoverIndex, 0, draggedItem);
return updatedRequests;
});
}, []);
const handleDrop = useCallback(() => {
const selectedUids = new Set(selectedItems);
setFlattenedRequests(currentRequests => {
const newOrderedSelectedUids = currentRequests
.filter(item => selectedUids.has(item.uid))
.map(item => item.uid);
const allRequestUidsOrder = currentRequests.map(item => item.uid);
setSelectedItems(newOrderedSelectedUids);
dispatch(updateRunnerConfiguration(collection.uid, newOrderedSelectedUids, allRequestUidsOrder));
return currentRequests;
});
}, [selectedItems, collection.uid, dispatch, setSelectedItems]);
const handleRequestSelect = useCallback((item) => {
try {
if (selectedItems.includes(item.uid)) {
const newSelectedUids = selectedItems.filter(uid => uid !== item.uid);
setSelectedItems(newSelectedUids);
const allRequestUidsOrder = flattenedRequests.map(item => item.uid);
dispatch(updateRunnerConfiguration(collection.uid, newSelectedUids, allRequestUidsOrder));
} else {
const newSelectedUids = [...selectedItems, item.uid];
const orderedSelectedUids = flattenedRequests
.filter(req => newSelectedUids.includes(req.uid))
.map(req => req.uid);
setSelectedItems(orderedSelectedUids);
const allRequestUidsOrder = flattenedRequests.map(item => item.uid);
dispatch(updateRunnerConfiguration(collection.uid, orderedSelectedUids, allRequestUidsOrder));
}
} catch (error) {
console.error("Error selecting item:", error);
}
}, [selectedItems, setSelectedItems, flattenedRequests, dispatch, collection.uid]);
const handleSelectAll = useCallback(() => {
try {
const allRequestUidsOrder = flattenedRequests.map(item => item.uid);
if (selectedItems.length === flattenedRequests.length) {
setSelectedItems([]);
dispatch(updateRunnerConfiguration(collection.uid, [], allRequestUidsOrder));
} else {
setSelectedItems(allRequestUidsOrder);
dispatch(updateRunnerConfiguration(collection.uid, allRequestUidsOrder, allRequestUidsOrder));
}
} catch (error) {
console.error("Error selecting/deselecting all items:", error);
}
}, [flattenedRequests, selectedItems, setSelectedItems, dispatch, collection.uid]);
const handleReset = useCallback(() => {
try {
setFlattenedRequests(cloneDeep(originalRequests));
setSelectedItems([]);
dispatch(updateRunnerConfiguration(collection.uid, [], []));
} catch (error) {
console.error("Error resetting configuration:", error);
}
}, [originalRequests, setSelectedItems, collection.uid, dispatch]);
return (
<StyledWrapper>
<div className="header">
<div className="counter">
{selectedItems.length} of {flattenedRequests.length} selected
</div>
<div className="actions">
<button className="btn-select-all" onClick={handleSelectAll}>
{selectedItems.length === flattenedRequests.length ? "Deselect All" : "Select All"}
</button>
<button className="btn-reset" onClick={handleReset} title="Reset selection and order">
<IconAdjustmentsAlt size={16} strokeWidth={1.5} />
Reset
</button>
</div>
</div>
<div className="request-list">
{isLoading ? (
<div className="loading-message">Loading requests...</div>
) : flattenedRequests.length === 0 ? (
<div className="empty-message">No requests found in this collection</div>
) : (
<div className="requests-container">
{flattenedRequests.map((item, idx) => {
const isSelected = selectedItems.includes(item.uid);
return (
<RequestItem
key={item.uid}
item={item}
index={idx}
isSelected={isSelected}
onSelect={() => handleRequestSelect(item)}
moveItem={moveItem}
onDrop={handleDrop}
/>
);
})}
</div>
)}
</div>
</StyledWrapper>
);
};
export default RunConfigurationPanel;

View File

@@ -89,13 +89,15 @@ const RunnerTags = ({ collectionUid, className = '' }) => {
return (
<div className={`mt-6 flex flex-col ${className}`}>
<div className="flex gap-2">
<label className="block font-medium">Filter requests with tags</label>
<input
className="cursor-pointer"
type="checkbox"
id="filter-tags"
type="radio"
name="filterMode"
checked={tagsEnabled}
onChange={() => setTagsEnabled(!tagsEnabled)}
/>
<label htmlFor="filter-tags" className="block font-medium">Filter requests with tags</label>
</div>
{tagsEnabled && (
<div className="flex flex-row mt-4 gap-4 w-full">

View File

@@ -2,15 +2,17 @@ import React, { useState, useRef, useEffect } from 'react';
import path from 'utils/common/path';
import { useDispatch } from 'react-redux';
import { get, cloneDeep } from 'lodash';
import { runCollectionFolder, cancelRunnerExecution } from 'providers/ReduxStore/slices/collections/actions';
import { runCollectionFolder, cancelRunnerExecution, mountCollection, updateRunnerConfiguration } from 'providers/ReduxStore/slices/collections/actions';
import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections';
import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun } from '@tabler/icons';
import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun, IconLoader2 } from '@tabler/icons';
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
import RunnerTags from './RunnerTags/index';
import RunConfigurationPanel from './RunConfigurationPanel';
import { getRequestItemsForCollectionRun } from 'utils/collections/index';
import { updateRunnerTagsDetails } from 'providers/ReduxStore/slices/collections/index';
const getDisplayName = (fullPath, pathname, name = '') => {
let relativePath = path.relative(fullPath, pathname);
@@ -25,25 +27,27 @@ const getTestStatus = (results) => {
};
const allTestsPassed = (item) => {
return item.status !== 'error' &&
item.testStatus === 'pass' &&
item.assertionStatus === 'pass' &&
item.preRequestTestStatus === 'pass' &&
item.postResponseTestStatus === 'pass';
return item.status !== 'error' &&
item.testStatus === 'pass' &&
item.assertionStatus === 'pass' &&
item.preRequestTestStatus === 'pass' &&
item.postResponseTestStatus === 'pass';
};
const anyTestFailed = (item) => {
return item.status === 'error' ||
item.testStatus === 'fail' ||
item.assertionStatus === 'fail' ||
item.preRequestTestStatus === 'fail' ||
item.postResponseTestStatus === 'fail';
return item.status === 'error' ||
item.testStatus === 'fail' ||
item.assertionStatus === 'fail' ||
item.preRequestTestStatus === 'fail' ||
item.postResponseTestStatus === 'fail';
};
export default function RunnerResults({ collection }) {
const dispatch = useDispatch();
const [selectedItem, setSelectedItem] = useState(null);
const [delay, setDelay] = useState(null);
const [selectedRequestItems, setSelectedRequestItems] = useState([]);
const [configureMode, setConfigureMode] = useState(false);
// ref for the runner output body
const runnerBodyRef = useRef();
@@ -62,6 +66,22 @@ export default function RunnerResults({ collection }) {
autoScrollRunnerBody();
}, [collection, setSelectedItem]);
useEffect(() => {
const runnerInfo = get(collection, 'runnerResult.info', {});
if (runnerInfo.status === 'running') {
setConfigureMode(false);
}
}, [collection.runnerResult]);
useEffect(() => {
const savedConfiguration = get(collection, 'runnerConfiguration', null);
if (savedConfiguration && configureMode) {
if (savedConfiguration.selectedRequestItems) {
setSelectedRequestItems(savedConfiguration.selectedRequestItems);
}
}
}, [collection.runnerConfiguration, configureMode]);
const collectionCopy = cloneDeep(collection);
const runnerInfo = get(collection, 'runnerResult.info', {});
@@ -103,18 +123,39 @@ export default function RunnerResults({ collection }) {
})
.filter(Boolean);
const ensureCollectionIsMounted = () => {
if(collection.mountStatus === 'mounted'){
return;
}
dispatch(mountCollection({
collectionUid: collection.uid,
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
};
const runCollection = () => {
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags));
if (configureMode && selectedRequestItems.length > 0) {
dispatch(updateRunnerConfiguration(collection.uid, selectedRequestItems, selectedRequestItems));
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags, selectedRequestItems));
} else {
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags));
}
};
const runAgain = () => {
ensureCollectionIsMounted();
// Get the saved configuration to determine what to run
const savedConfiguration = get(collection, 'runnerConfiguration', null);
const savedSelectedItems = savedConfiguration?.selectedRequestItems || [];
dispatch(
runCollectionFolder(
collection.uid,
runnerInfo.folderUid,
runnerInfo.isRecursive,
true,
Number(delay),
tagsEnabled && tags
tagsEnabled && tags,
savedSelectedItems
)
);
};
@@ -125,12 +166,25 @@ export default function RunnerResults({ collection }) {
collectionUid: collection.uid
})
);
setSelectedRequestItems([]);
setConfigureMode(false);
};
const cancelExecution = () => {
dispatch(cancelRunnerExecution(runnerInfo.cancelTokenUid));
};
const toggleConfigureMode = () => {
dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tagsEnabled: false }));
setConfigureMode(!configureMode);
};
useEffect(() => {
if(tagsEnabled) {
setConfigureMode(false);
}
}, [tagsEnabled]);
const totalRequestsInCollection = getTotalRequestCountInCollection(collectionCopy);
const passedRequests = items.filter(allTestsPassed);
const failedRequests = items.filter(anyTestFailed);
@@ -142,59 +196,104 @@ export default function RunnerResults({ collection }) {
if (!items || !items.length) {
return (
<StyledWrapper className="px-4 pb-4">
<div className="font-medium mt-6 title flex items-center">
Runner
<IconRun size={20} strokeWidth={1.5} className="ml-2" />
</div>
<div className="mt-6">
You have <span className="font-medium">{totalRequestsInCollection}</span> requests in this collection.
</div>
{isCollectionLoading ? <div className='my-1 danger'>Requests in this collection are still loading.</div> : null}
<div className="mt-6">
<label>Delay (in ms)</label>
<input
type="number"
className="block textbox mt-2 py-5"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={delay}
onChange={(e) => setDelay(e.target.value)}
/>
</div>
<StyledWrapper className="pl-4 overflow-hidden h-full">
<div className="flex overflow-hidden max-h-full h-full">
<div className={`${configureMode ? 'w-1/2 pr-4' : 'w-full'}`}>
<div className="font-medium mt-6 title flex items-center">
Runner
<IconRun size={20} strokeWidth={1.5} className="ml-2" />
</div>
<div className="mt-6">
You have <span className="font-medium">{totalRequestsInCollection}</span> requests in this collection.
{isCollectionLoading && (
<span className="ml-2 text-sm text-gray-500">
(Loading...)
</span>
)}
</div>
{isCollectionLoading ? <div className='my-1 danger'>Requests in this collection are still loading.</div> : null}
<div className="mt-6">
<label>Delay (in ms)</label>
<input
type="number"
className="block textbox mt-2 py-5"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={delay}
onChange={(e) => setDelay(e.target.value)}
/>
</div>
{/* Tags for the collection run */}
<RunnerTags collectionUid={collection.uid} className='mb-6' />
{/* Tags for the collection run */}
<RunnerTags collectionUid={collection.uid} className='mb-6' />
<button type="submit" className="submit btn btn-sm btn-secondary mt-6" disabled={shouldDisableCollectionRun} onClick={runCollection}>
Run Collection
</button>
{/* Configure requests option */}
<div className="flex flex-col border-b pb-6 mb-6 border-gray-200 dark:border-gray-700">
<div className="flex gap-2">
<input
className="cursor-pointer"
id="filter-config"
type="radio"
name="filterMode"
checked={configureMode}
onChange={toggleConfigureMode}
/>
<label htmlFor="filter-config" className="block font-medium">Configure requests to run</label>
</div>
</div>
<button className="submit btn btn-sm btn-close mt-6 ml-3" onClick={resetRunner}>
Reset
</button>
<div className='flex flex-row gap-2'>
<button
type="submit"
className="submit btn btn-sm btn-secondary"
disabled={shouldDisableCollectionRun || (configureMode && selectedRequestItems.length === 0) || isCollectionLoading}
onClick={runCollection}
>
{configureMode && selectedRequestItems.length > 0
? `Run ${selectedRequestItems.length} Selected Request${selectedRequestItems.length > 1 ? 's' : ''}`
: "Run Collection"
}
</button>
<button className="submit btn btn-sm btn-close" onClick={resetRunner}>
Reset
</button>
</div>
</div>
{configureMode && (
<div className="w-1/2 border-l border-gray-200 dark:border-gray-700">
<RunConfigurationPanel
collection={collection}
selectedItems={selectedRequestItems}
setSelectedItems={setSelectedRequestItems}
/>
</div>
)}
</div>
</StyledWrapper>
);
}
return (
<StyledWrapper className="px-4 pb-4 flex flex-grow flex-col relative overflow-scroll">
<div className="flex flex-row">
<div className="font-medium my-6 title flex items-center">
<StyledWrapper className="px-4 pb-4 flex flex-grow flex-col relative overflow-auto">
<div className="flex items-center my-6 flex-row">
<div className="font-medium title flex items-center">
Runner
<IconRun size={20} strokeWidth={1.5} className="ml-2" />
</div>
{runnerInfo.status !== 'ended' && runnerInfo.cancelTokenUid && (
<button className="btn ml-6 my-4 btn-sm btn-danger" onClick={cancelExecution}>
<button className="btn btn-sm btn-danger" onClick={cancelExecution}>
Cancel Execution
</button>
)}
</div>
<div className="flex flex-row gap-4 h-[calc(100%_-_4.375rem)]">
<div className="flex gap-4 h-[calc(100vh_-_10rem)] overflow-hidden">
<div
className="flex flex-col flex-1 overflow-y-auto w-full"
className={`flex flex-col overflow-y-auto ${selectedItem || (configureMode && !selectedItem && !runnerInfo.status === 'running') ? 'w-1/2' : 'w-full'}`}
ref={runnerBodyRef}
>
<div className="pb-2 font-medium test-summary">
@@ -214,57 +313,59 @@ export default function RunnerResults({ collection }) {
</div>
</div>
)}
{runnerInfo?.statusText ?
{runnerInfo?.statusText ?
<div className="pb-2 font-medium danger">
{runnerInfo?.statusText}
</div>
: null}
{items.map((item) => {
return (
<div key={item.uid}>
<div className="item-path mt-2">
<div className="flex items-center">
<span>
{allTestsPassed(item) ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{item.status === 'skipped' ?
<IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
:null}
{anyTestFailed(item) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
:null}
</span>
<span
className={`mr-1 ml-2 ${item.status == 'skipped' ? 'skipped-request' : anyTestFailed(item) ? 'danger' : ''}`}
>
{item.displayName}
</span>
{item.status !== 'error' && item.status !== 'skipped' && item.status !== 'completed' ? (
<IconRefresh className="animate-spin ml-1" size={18} strokeWidth={1.5} />
) : item.responseReceived?.status ? (
<span className="text-xs link cursor-pointer" onClick={() => setSelectedItem(item)}>
<span className="mr-1">{item.responseReceived?.status}</span>
-&nbsp;
<span>{item.responseReceived?.statusText}</span>
</span>
) : (
<span className="danger text-xs cursor-pointer" onClick={() => setSelectedItem(item)}>
(request failed)
</span>
)}
</div>
{tagsEnabled && areTagsAdded && item?.tags?.length > 0 && (
<div className="pl-7 text-xs text-gray-500">
Tags: {item.tags.filter(t => tags.include.includes(t)).join(', ')}
</div>
)}
{item.status == 'error' ? <div className="error-message pl-8 pt-2 text-xs">{item.error}</div> : null}
: null}
<ul className="pl-8">
{item.preRequestTestResults
? item.preRequestTestResults.map((result) => (
{/* Items list */}
<div className="overflow-y-auto flex-1">
{items.map((item) => {
return (
<div key={item.uid}>
<div className="item-path mt-2">
<div className="flex items-center">
<span>
{allTestsPassed(item) ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{item.status === 'skipped' ?
<IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
: null}
{anyTestFailed(item) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
: null}
</span>
<span
className={`mr-1 ml-2 ${item.status == 'skipped' ? 'skipped-request' : anyTestFailed(item) ? 'danger' : ''}`}
>
{item.displayName}
</span>
{item.status !== 'error' && item.status !== 'skipped' && item.status !== 'completed' ? (
<IconRefresh className="animate-spin ml-1" size={18} strokeWidth={1.5} />
) : item.responseReceived?.status ? (
<span className="text-xs link cursor-pointer" onClick={() => setSelectedItem(item)}>
<span className="mr-1">{item.responseReceived?.status}</span>
-&nbsp;
<span>{item.responseReceived?.statusText}</span>
</span>
) : (
<span className="danger text-xs cursor-pointer" onClick={() => setSelectedItem(item)}>
(request failed)
</span>
)}
</div>
{tagsEnabled && areTagsAdded && item?.tags?.length > 0 && (
<div className="pl-7 text-xs text-gray-500">
Tags: {item.tags.filter(t => tags.include.includes(t)).join(', ')}
</div>
)}
{item.status == 'error' ? <div className="error-message pl-8 pt-2 text-xs">{item.error}</div> : null}
<ul className="pl-8">
{item.preRequestTestResults
? item.preRequestTestResults.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -282,9 +383,9 @@ export default function RunnerResults({ collection }) {
)}
</li>
))
: null}
{item.postResponseTestResults
? item.postResponseTestResults.map((result) => (
: null}
{item.postResponseTestResults
? item.postResponseTestResults.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -302,9 +403,9 @@ export default function RunnerResults({ collection }) {
)}
</li>
))
: null}
{item.testResults
? item.testResults.map((result) => (
: null}
{item.testResults
? item.testResults.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -322,30 +423,32 @@ export default function RunnerResults({ collection }) {
)}
</li>
))
: null}
{item.assertionResults?.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
<IconCheck size={18} strokeWidth={2} className="mr-2" />
{result.lhsExpr}: {result.rhsExpr}
</span>
) : (
<>
<span className="test-failure flex items-center">
<IconX size={18} strokeWidth={2} className="mr-2" />
: null}
{item.assertionResults?.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
<IconCheck size={18} strokeWidth={2} className="mr-2" />
{result.lhsExpr}: {result.rhsExpr}
</span>
<span className="error-message pl-8 text-xs">{result.error}</span>
</>
)}
</li>
))}
</ul>
) : (
<>
<span className="test-failure flex items-center">
<IconX size={18} strokeWidth={2} className="mr-2" />
{result.lhsExpr}: {result.rhsExpr}
</span>
<span className="error-message pl-8 text-xs">{result.error}</span>
</>
)}
</li>
))}
</ul>
</div>
</div>
</div>
);
})}
);
})}
</div>
{runnerInfo.status === 'ended' ? (
<div className="mt-2 mb-4">
<button type="submit" className="submit btn btn-sm btn-secondary mt-6" onClick={runAgain}>
@@ -366,15 +469,15 @@ export default function RunnerResults({ collection }) {
<div className="flex items-center mb-4 font-medium">
<span className="mr-2">{selectedItem.displayName}</span>
<span>
{allTestsPassed(selectedItem) ?
{allTestsPassed(selectedItem) ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{anyTestFailed(selectedItem) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
: null}
: null}
{anyTestFailed(selectedItem) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
: null}
{selectedItem.status === 'skipped' ?
<IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
: null}
: null}
</span>
</div>
<ResponsePane item={selectedItem} collection={collection} />

View File

@@ -0,0 +1,10 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.tooltip-mod {
font-size: 11px !important;
width: 150px !important;
}
`;
export default Wrapper;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
import StyledWrapper from './StyledWrapper';
const SensitiveFieldWarning = ({ fieldName, warningMessage }) => {
const tooltipId = `sensitive-field-warning-${fieldName}`;
return (
<StyledWrapper>
<span className="ml-2 flex items-center">
<IconAlertTriangle id={tooltipId} className="text-amber-600 cursor-pointer" size={20} />
<Tooltip
anchorId={tooltipId}
className="tooltip-mod max-w-lg"
content={
<div>
<p>
<span>{warningMessage}</span>
</p>
</div>
}
/>
</span>
</StyledWrapper>
);
};
export default SensitiveFieldWarning;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import Modal from 'components/Modal';
import { IconDownload } from '@tabler/icons';
import { IconDownload, IconLoader2 } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import Bruno from 'components/Bruno';
import exportBrunoCollection from 'utils/collections/export';
@@ -8,10 +8,12 @@ import exportPostmanCollection from 'utils/exporters/postman-collection';
import { cloneDeep } from 'lodash';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
import { useSelector } from 'react-redux';
import { findCollectionByUid } from 'utils/collections/index';
import { findCollectionByUid, areItemsLoading } from 'utils/collections/index';
const ShareCollection = ({ onClose, collectionUid }) => {
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const isCollectionLoading = areItemsLoading(collection);
const handleExportBrunoCollection = () => {
const collectionCopy = cloneDeep(collection);
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
@@ -35,23 +37,49 @@ const ShareCollection = ({ onClose, collectionUid }) => {
>
<StyledWrapper className="flex flex-col h-full w-[500px]">
<div className="space-y-2">
<div className="flex border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-500/10 items-center p-3 rounded-lg transition-colors cursor-pointer" onClick={handleExportBrunoCollection}>
<div
className={`flex border border-gray-200 dark:border-gray-600 items-center p-3 rounded-lg transition-colors ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-100 dark:hover:bg-gray-500/10 cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportBrunoCollection}
>
<div className="mr-3 p-1 rounded-full">
<Bruno width={28} />
{isCollectionLoading ? (
<IconLoader2 size={28} className="animate-spin" />
) : (
<Bruno width={28} />
)}
</div>
<div className="flex-1">
<div className="font-medium">Bruno Collection</div>
<div className="text-xs">Export in Bruno format</div>
<div className="text-xs">
{isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'}
</div>
</div>
</div>
<div className="flex border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-500/10 items-center p-3 rounded-lg transition-colors cursor-pointer" onClick={handleExportPostmanCollection}>
<div
className={`flex border border-gray-200 dark:border-gray-600 items-center p-3 rounded-lg transition-colors ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-100 dark:hover:bg-gray-500/10 cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportPostmanCollection}
>
<div className="mr-3 p-1 rounded-full">
<IconDownload size={28} strokeWidth={1} className="" />
{isCollectionLoading ? (
<IconLoader2 size={28} className="animate-spin" />
) : (
<IconDownload size={28} strokeWidth={1} className="" />
)}
</div>
<div className="flex-1">
<div className="font-medium">Postman Collection</div>
<div className="text-xs">Export in Postman format</div>
<div className="text-xs">
{isCollectionLoading ? 'Loading collection...' : 'Export in Postman format'}
</div>
</div>
</div>
</div>

View File

@@ -11,7 +11,7 @@ import {
import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { getAllVariables, getGlobalEnvironmentVariables } from 'utils/collections/index';
import { resolveInheritedAuth } from './utils/auth-utils';
const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
@@ -37,12 +37,11 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
const requestUrl =
get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
const variables = getAllVariables(collection, item);
const interpolatedUrl = interpolateUrl({
url: requestUrl,
globalEnvironmentVariables,
envVars,
runtimeVariables: collection.runtimeVariables,
processEnvVars: collection.processEnvVariables
variables
});
// interpolate the path params

View File

@@ -69,24 +69,3 @@ export const interpolateBody = (body, variables = {}) => {
return interpolatedBody;
};
export const createVariablesObject = ({
globalEnvironmentVariables = {},
collectionVars = {},
allVariables = {},
collection = {},
runtimeVariables = {},
processEnvVars = {}
}) => {
return {
...globalEnvironmentVariables,
...allVariables,
...collectionVars,
...runtimeVariables,
process: {
env: {
...processEnvVars
}
}
};
};

View File

@@ -1,7 +1,7 @@
import { buildHarRequest } from 'utils/codegenerator/har';
import { getAuthHeaders } from 'utils/codegenerator/auth';
import { getAllVariables, getTreePathFromCollectionToItem } from 'utils/collections/index';
import { interpolateHeaders, interpolateBody, createVariablesObject } from './interpolation';
import { interpolateHeaders, interpolateBody } from './interpolation';
// Merge headers from collection, folders, and request
const mergeHeaders = (collection, request, requestTreePath) => {
@@ -46,17 +46,7 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
// Get HTTPSnippet dynamically so mocks can be applied in tests
const { HTTPSnippet } = require('httpsnippet');
const allVariables = getAllVariables(collection, item);
// Create variables object for interpolation
const variables = createVariablesObject({
globalEnvironmentVariables: collection.globalEnvironmentVariables || {},
collectionVars: collection.collectionVars || {},
allVariables,
collection,
runtimeVariables: collection.runtimeVariables || {},
processEnvVars: collection.processEnvVariables || {}
});
const variables = getAllVariables(collection, item);
const request = item.request;

View File

@@ -46,7 +46,10 @@ jest.mock('utils/codegenerator/auth', () => ({
}));
jest.mock('utils/collections/index', () => ({
getAllVariables: jest.fn(() => ({
getAllVariables: jest.fn((collection) => ({
...collection?.globalEnvironmentVariables,
...collection?.runtimeVariables,
...collection?.processEnvVariables,
baseUrl: 'https://api.example.com',
apiKey: 'secret-key-123',
userId: '12345'

View File

@@ -61,13 +61,14 @@ const Collection = ({ collection, searchText }) => {
};
const ensureCollectionIsMounted = () => {
if (collection.mountStatus === 'unmounted') {
dispatch(mountCollection({
collectionUid: collection.uid,
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
if(collection.mountStatus === 'mounted'){
return;
}
dispatch(mountCollection({
collectionUid: collection.uid,
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
}
const hasSearchText = searchText && searchText?.trim()?.length;
@@ -269,6 +270,7 @@ const Collection = ({ collection, searchText }) => {
className="dropdown-item"
onClick={(e) => {
menuDropdownTippyRef.current.hide();
ensureCollectionIsMounted();
handleRun();
}}
>
@@ -287,6 +289,7 @@ const Collection = ({ collection, searchText }) => {
className="dropdown-item"
onClick={(e) => {
menuDropdownTippyRef.current.hide();
ensureCollectionIsMounted();
setShowShareCollectionModal(true);
}}
>

View File

@@ -5,12 +5,12 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 22px;
padding: 0 1rem;
height: 1.5rem;
background: ${(props) => props.theme.sidebar.bg};
border-top: 1px solid ${(props) => props.theme.statusBar.border};
color: ${(props) => props.theme.sidebar.color};
font-size: 12px;
color: ${(props) => props.theme.statusBar.color};
font-size: 0.75rem;
user-select: none;
position: relative;
}
@@ -32,9 +32,7 @@ const StyledWrapper = styled.div`
align-items: center;
justify-content: center;
padding: 0 4px;
color: ${(props) => props.theme.sidebar.color};
cursor: pointer;
opacity: 0.7;
position: relative;
outline: none;
}
@@ -43,13 +41,11 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
gap: 0.25rem;
position: relative;
}
.console-label {
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
@@ -66,17 +62,13 @@ const StyledWrapper = styled.div`
width: 1px;
height: 16px;
background: ${(props) => props.theme.sidebar.dragbar};
margin: 0 8px;
opacity: 0.3;
opacity: 0.4;
}
.status-bar-version {
display: flex;
align-items: center;
padding: 2px 6px;
font-size: 10px;
color: ${(props) => props.theme.sidebar.muted};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
`;

View File

@@ -71,18 +71,6 @@ const StatusBar = () => {
</button>
</ToolHint>
<ToolHint text="Cookies" toolhintId="Cookies" place="top" offset={10}>
<button
className="status-bar-button"
data-trigger="cookies"
onClick={() => setCookiesOpen(true)}
tabIndex={0}
aria-label="Open Cookies Settings"
>
<IconCookie size={16} strokeWidth={1.5} aria-hidden="true" />
</button>
</ToolHint>
<ToolHint text="Notifications" toolhintId="Notifications" place="top" offset={10}>
<div className="status-bar-button">
<Notifications />
@@ -92,7 +80,20 @@ const StatusBar = () => {
</div>
<div className="status-bar-section">
<div className="status-bar-group">
<div className="flex items-center gap-3">
<button
className="status-bar-button"
data-trigger="cookies"
onClick={() => setCookiesOpen(true)}
tabIndex={0}
aria-label="Open Cookies"
>
<div className="console-button-content">
<IconCookie size={16} strokeWidth={1.5} aria-hidden="true" />
<span className="console-label">Cookies</span>
</div>
</button>
<button
className={`status-bar-button ${errorCount > 0 ? 'has-errors' : ''}`}
data-trigger="dev-tools"
@@ -100,13 +101,13 @@ const StatusBar = () => {
tabIndex={0}
aria-label={`Open Dev Tools${errorCount > 0 ? ` (${errorCount} errors)` : ''}`}
>
<div className="console-button-content">
<IconTool size={16} strokeWidth={1.5} aria-hidden="true" />
<span className="console-label">Dev Tools</span>
{errorCount > 0 && (
<span className="error-count-inline">{errorCount}</span>
)}
</div>
<div className="console-button-content">
<IconTool size={16} strokeWidth={1.5} aria-hidden="true" />
<span className="console-label">Dev Tools</span>
{errorCount > 0 && (
<span className="error-count-inline">{errorCount}</span>
)}
</div>
</button>
<div className="status-bar-divider"></div>

View File

@@ -6,7 +6,7 @@ const StyledWrapper = styled.div`
display: grid;
overflow-y: hidden;
overflow-x: auto;
padding: 0 1px;
padding: 0 1.5px;
// for icon hover
position: inherit;

View File

@@ -89,7 +89,7 @@ const VariablesEditor = ({ collection }) => {
const reactInspectorTheme = storedTheme === 'light' ? 'chromeLight' : 'chromeDark';
return (
<StyledWrapper className="px-4 py-4 overflow-scroll">
<StyledWrapper className="px-4 py-4 overflow-auto">
<RuntimeVariables collection={collection} theme={reactInspectorTheme} />
<EnvVariables collection={collection} theme={reactInspectorTheme} />

View File

@@ -0,0 +1,67 @@
import { useMemo } from 'react';
const VARIABLE_NAME_REGEX = /\{\{([^}]+)\}\}/g;
const ENV_VAR_REFERENCE_REGEX = /^\s*\{\{.*\}\}\s*$/;
export const useDetectSensitiveField = (collection) => {
const envVars = useMemo(() => {
if (!collection) {
return [];
}
const activeEnv = collection?.environments?.find((env) => env.uid === collection.activeEnvironmentUid);
if (!activeEnv || !Array.isArray(activeEnv.variables)) {
return [];
}
return activeEnv.variables;
}, [collection]);
// Checks if the value is a single environment variable reference (e.g., {{API_KEY}})
const isEnvVarReference = (value) => {
return typeof value === 'string' && ENV_VAR_REFERENCE_REGEX.test(value);
};
// Extracts all variable names from a string (e.g., "Bearer {{TOKEN}}-{{SUFFIX}}" → ["TOKEN", "SUFFIX"])
const extractVarNames = (value) => {
if (!value || typeof value !== 'string') {
return [];
}
const matches = [];
let match;
while ((match = VARIABLE_NAME_REGEX.exec(value)) !== null) {
matches.push(match[1].trim());
}
return matches;
};
// Checks if a variable is present and not marked as secret in the environment
const isVarNotSecret = (varName, envVars = []) => {
const found = envVars.find((v) => v.name === varName);
return found && !found.secret;
};
const isSensitive = (value) => {
if (value && !isEnvVarReference(value)) {
return {
showWarning: true,
warningMessage: 'Store sensitive info as a secret variable or in a .env file'
};
}
if (value && typeof value === 'string') {
const varNames = extractVarNames(value);
if (varNames.some((varName) => isVarNotSecret(varName, envVars))) {
return {
showWarning: true,
warningMessage: 'Mark the environment variable as secret for better security.'
};
}
}
// No warning needed
return { showWarning: false };
};
return {
isSensitive
};
};

View File

@@ -24,7 +24,7 @@ import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { isElectron } from 'utils/common/platform';
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
import { collectionAddOauth2CredentialsByUrl } from 'providers/ReduxStore/slices/collections/index';
import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
import { addLog } from 'providers/ReduxStore/slices/logs';
const useIpcEvents = () => {
@@ -179,6 +179,10 @@ const useIpcEvents = () => {
dispatch(collectionAddOauth2CredentialsByUrl(payload));
});
const removeCollectionLoadingStateListener = ipcRenderer.on('main:collection-loading-state-updated', (val) => {
dispatch(updateCollectionLoadingState(val));
});
return () => {
removeCollectionTreeUpdateListener();
removeOpenCollectionListener();
@@ -199,6 +203,7 @@ const useIpcEvents = () => {
removeGlobalEnvironmentsUpdatesListener();
removeSnapshotHydrationListener();
removeCollectionOauth2CredentialsUpdatesListener();
removeCollectionLoadingStateListener();
};
}, [isElectron]);
};

View File

@@ -27,22 +27,25 @@ taskMiddleware.startListening({
each(openRequestTasks, (task) => {
if (collectionUid === task.collectionUid) {
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const item = findItemInCollectionByPathname(collection, task.itemPathname);
if (item) {
listenerApi.dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item)
})
);
listenerApi.dispatch(hideHomePage());
listenerApi.dispatch(
removeTaskFromQueue({
taskUid: task.uid
})
);
if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) {
const item = findItemInCollectionByPathname(collection, task.itemPathname);
if (item) {
listenerApi.dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item)
})
);
listenerApi.dispatch(hideHomePage());
}
}
listenerApi.dispatch(
removeTaskFromQueue({
taskUid: task.uid
})
);
}
});
}

View File

@@ -11,7 +11,7 @@ const initialState = {
isEnvironmentSettingsModalOpen: false,
preferences: {
request: {
sslVerification: false,
sslVerification: true,
customCaCertificate: {
enabled: false,
filePath: null

View File

@@ -38,7 +38,8 @@ import {
setCollectionSecurityConfig,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl,
initRunRequestEvent
initRunRequestEvent,
updateRunnerConfiguration as _updateRunnerConfiguration
} from './index';
import { each } from 'lodash';
@@ -316,9 +317,9 @@ export const cancelRunnerExecution = (cancelTokenUid) => (dispatch) => {
cancelNetworkRequest(cancelTokenUid).catch((err) => console.log(err));
};
export const runCollectionFolder = (collectionUid, folderUid, recursive, delay, tags) => (dispatch, getState) => {
export const runCollectionFolder = (collectionUid, folderUid, recursive, delay, tags, selectedRequestUids) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
@@ -346,6 +347,26 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay,
})
);
// to only include those requests in the specified order while preserving folder data
if (selectedRequestUids && selectedRequestUids.length > 0) {
const newItems = [];
selectedRequestUids.forEach((uid, index) => {
const requestItem = findItemInCollection(collectionCopy, uid);
if (requestItem) {
const clonedRequest = cloneDeep(requestItem);
clonedRequest.seq = index + 1;
newItems.push(clonedRequest);
}
});
if (folder) {
folder.items = newItems;
} else {
collectionCopy.items = newItems;
}
}
const { ipcRenderer } = window;
ipcRenderer
.invoke(
@@ -1064,7 +1085,7 @@ export const removeCollection = (collectionUid) => (dispatch, getState) => {
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:remove-collection', collection.pathname)
.invoke('renderer:remove-collection', collection.pathname, collectionUid)
.then(() => {
dispatch(closeAllCollectionTabs({ collectionUid }));
})
@@ -1373,3 +1394,11 @@ export const mountCollection = ({ collectionUid, collectionPathname, brunoConfig
ipcRenderer.invoke('renderer:show-in-folder', collectionPath).then(resolve).catch(reject);
});
};
export const updateRunnerConfiguration = (collectionUid, selectedRequestItems, requestItemsOrder) => (dispatch) => {
dispatch(_updateRunnerConfiguration({
collectionUid,
selectedRequestItems,
requestItemsOrder
}));
};

View File

@@ -67,6 +67,12 @@ export const collectionsSlice = createSlice({
}
}
},
updateCollectionLoadingState: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
collection.isLoading = action.payload.isLoading;
}
},
setCollectionSecurityConfig: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
@@ -867,6 +873,43 @@ export const collectionsSlice = createSlice({
enabled: enabled
}));
},
setCollectionHeaders: (state, action) => {
const { collectionUid, headers } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) {
return;
}
collection.root.request.headers = map(headers, ({name = '', value = '', enabled = true}) => ({
uid: uuid(),
name: name,
value: value,
description: '',
enabled: enabled
}));
},
setFolderHeaders: (state, action) => {
const { collectionUid, folderUid, headers } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) {
return;
}
const folder = findItemInCollection(collection, folderUid);
if (!folder || !isItemAFolder(folder)) {
return;
}
folder.root.request.headers = map(headers, ({name = '', value = '', enabled = true}) => ({
uid: uuid(),
name: name,
value: value,
description: '',
enabled: enabled
}));
},
addFormUrlEncodedParam: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -2232,6 +2275,7 @@ export const collectionsSlice = createSlice({
collection.runnerResult = null;
collection.runnerTags = { include: [], exclude: [] }
collection.runnerTagsEnabled = false;
collection.runnerConfiguration = null;
}
},
updateRunnerTagsDetails: (state, action) => {
@@ -2246,6 +2290,16 @@ export const collectionsSlice = createSlice({
}
}
},
updateRunnerConfiguration: (state, action) => {
const { collectionUid, selectedRequestItems, requestItemsOrder } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
collection.runnerConfiguration = {
selectedRequestItems: selectedRequestItems || [],
requestItemsOrder: requestItemsOrder || []
};
}
},
updateRequestDocs: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -2413,6 +2467,7 @@ export const collectionsSlice = createSlice({
export const {
createCollection,
updateCollectionMountStatus,
updateCollectionLoadingState,
setCollectionSecurityConfig,
brunoConfigUpdateEvent,
renameCollection,
@@ -2454,6 +2509,8 @@ export const {
deleteRequestHeader,
moveRequestHeader,
setRequestHeaders,
setCollectionHeaders,
setFolderHeaders,
addFormUrlEncodedParam,
updateFormUrlEncodedParam,
deleteFormUrlEncodedParam,
@@ -2516,6 +2573,7 @@ export const {
runFolderEvent,
resetCollectionRunner,
updateRunnerTagsDetails,
updateRunnerConfiguration,
updateRequestDocs,
updateFolderDocs,
moveCollection,

View File

@@ -299,6 +299,7 @@ const darkTheme = {
statusBar: {
border: '#323233',
color: 'rgb(169, 169, 169)'
},
console: {
bg: '#1e1e1e',

View File

@@ -300,6 +300,7 @@ const lightTheme = {
statusBar: {
border: '#E9E9E9',
color: 'rgb(100, 100, 100)'
},
console: {
bg: '#f8f9fa',

View File

@@ -37,6 +37,7 @@ const STATIC_API_HINTS = {
'res.headers',
'res.body',
'res.responseTime',
'res.url',
'res.getStatus()',
'res.getStatusText()',
'res.getHeader(name)',
@@ -48,6 +49,7 @@ const STATIC_API_HINTS = {
'res.getSize().header',
'res.getSize().body',
'res.getSize().total',
'res.getUrl()'
],
bru: [
'bru',
@@ -78,7 +80,17 @@ const STATIC_API_HINTS = {
'bru.runner.setNextRequest(requestName)',
'bru.runner.skipRequest()',
'bru.runner.stopExecution()',
'bru.interpolate(str)'
'bru.interpolate(str)',
'bru.cookies',
'bru.cookies.jar()',
'bru.cookies.jar().getCookie(url, name, callback)',
'bru.cookies.jar().getCookies(url, callback)',
'bru.cookies.jar().setCookie(url, name, value, callback)',
'bru.cookies.jar().setCookie(url, cookieObject, callback)',
'bru.cookies.jar().setCookies(url, cookiesArray, callback)',
'bru.cookies.jar().clear(callback)',
'bru.cookies.jar().deleteCookies(url, callback)',
'bru.cookies.jar().deleteCookie(url, name, callback)',
]
};

View File

@@ -137,6 +137,10 @@ export const findEnvironmentInCollectionByName = (collection, name) => {
};
export const areItemsLoading = (folder) => {
if (!folder || folder.isLoading) {
return true;
}
let flattenedItems = flattenItems(folder.items);
return flattenedItems?.reduce((isLoading, i) => {
if (i?.loading) {

View File

@@ -69,21 +69,12 @@ export const isValidUrl = (url) => {
}
};
export const interpolateUrl = ({ url, globalEnvironmentVariables = {}, envVars, runtimeVariables, processEnvVars }) => {
export const interpolateUrl = ({ url, variables }) => {
if (!url || !url.length || typeof url !== 'string') {
return;
}
return interpolate(url, {
...globalEnvironmentVariables,
...envVars,
...runtimeVariables,
process: {
env: {
...processEnvVars
}
}
});
return interpolate(url, variables);
};
export const interpolateUrlPathParams = (url, params) => {

View File

@@ -77,11 +77,7 @@ describe('Url Utils - interpolateUrl, interpolateUrlPathParams', () => {
const url = '{{host}}/api/:id/path?foo={{foo}}&bar={{bar}}&baz={{process.env.baz}}';
const expectedUrl = 'https://example.com/api/:id/path?foo=foo_value&bar=bar_value&baz=baz_value';
const envVars = { host: 'https://example.com', foo: 'foo_value' };
const runtimeVariables = { bar: 'bar_value' };
const processEnvVars = { baz: 'baz_value' };
const result = interpolateUrl({ url, envVars, runtimeVariables, processEnvVars });
const result = interpolateUrl({ url, variables: { host: 'https://example.com', foo: 'foo_value', bar: 'bar_value', 'process.env.baz': 'baz_value' } });
expect(result).toEqual(expectedUrl);
});
@@ -101,11 +97,7 @@ describe('Url Utils - interpolateUrl, interpolateUrlPathParams', () => {
const params = [{ name: 'id', type: 'path', enabled: true, value: '123' }];
const expectedUrl = 'https://example.com/api/123/path?foo=foo_value&bar=bar_value&baz=baz_value';
const envVars = { host: 'https://example.com', foo: 'foo_value' };
const runtimeVariables = { bar: 'bar_value' };
const processEnvVars = { baz: 'baz_value' };
const intermediateResult = interpolateUrl({ url, envVars, runtimeVariables, processEnvVars });
const intermediateResult = interpolateUrl({ url, variables: { host: 'https://example.com', foo: 'foo_value', bar: 'bar_value', 'process.env.baz': 'baz_value' } });
const result = interpolateUrlPathParams(intermediateResult, params);
expect(result).toEqual(expectedUrl);

View File

@@ -48,11 +48,12 @@
"dependencies": {
"@aws-sdk/credential-providers": "3.750.0",
"@usebruno/common": "0.1.0",
"@usebruno/converters": "^0.1.0",
"@usebruno/filestore": "^0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
"@usebruno/vm2": "^3.9.13",
"@usebruno/requests": "^0.1.0",
"@usebruno/converters": "^0.1.0",
"@usebruno/vm2": "^3.9.13",
"aws4-axios": "^3.3.0",
"axios": "^1.8.3",
"axios-ntlm": "^1.4.2",
@@ -68,7 +69,6 @@
"lodash": "^4.17.21",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^4.1.3",
"xmlbuilder": "^15.1.1",
"yargs": "^17.6.2"
}

View File

@@ -5,15 +5,16 @@ const { forOwn, cloneDeep } = require('lodash');
const { getRunnerSummary } = require('@usebruno/common/runner');
const { exists, isFile, isDirectory } = require('../utils/filesystem');
const { runSingleRequest } = require('../runner/run-single-request');
const { bruToEnvJson, getEnvVars } = require('../utils/bru');
const { getEnvVars } = require('../utils/bru');
const { isRequestTagsIncluded } = require("@usebruno/common")
const makeJUnitOutput = require('../reporters/junit');
const makeHtmlOutput = require('../reporters/html');
const { rpad } = require('../utils/common');
const { bruToJson, getOptions, collectionBruToJson } = require('../utils/bru');
const { dotenvToJson } = require('@usebruno/lang');
const { getOptions } = require('../utils/bru');
const { parseDotEnv, parseEnvironment } = require('@usebruno/filestore');
const constants = require('../constants');
const { findItemInCollection, getAllRequestsInFolder, createCollectionJsonFromPathname, getCallStack } = require('../utils/collection');
const { findItemInCollection, createCollectionJsonFromPathname, getCallStack } = require('../utils/collection');
const { hasExecutableTestInScript } = require('../utils/request');
const command = 'run [paths...]';
const desc = 'Run one or more requests/folders';
@@ -346,7 +347,7 @@ const handler = async function (argv) {
}
const envBruContent = fs.readFileSync(envFilePath, 'utf8').replace(/\r\n/g, '\n');
const envJson = bruToEnvJson(envBruContent);
const envJson = parseEnvironment(envBruContent);
envVars = getEnvVars(envJson);
envVars.__name__ = envFile ? path.basename(envFilePath, '.bru') : env;
}
@@ -439,7 +440,7 @@ const handler = async function (argv) {
};
if (dotEnvExists) {
const content = fs.readFileSync(dotEnvPath, 'utf8');
const jsonData = dotenvToJson(content);
const jsonData = parseDotEnv(content);
forOwn(jsonData, (value, key) => {
processEnvVars[key] = value;
@@ -467,10 +468,17 @@ const handler = async function (argv) {
requestItems = getCallStack(resolvedPaths, collection, { recursive });
if (testsOnly) {
requestItems = requestItems.filter((iter) => {
const requestHasTests = iter.request?.tests;
const requestHasActiveAsserts = iter.request?.assertions.some((x) => x.enabled) || false;
return requestHasTests || requestHasActiveAsserts;
requestItems = requestItems.filter((item) => {
const requestHasTests = hasExecutableTestInScript(item.request?.tests);
const requestHasActiveAsserts = item.request?.assertions.some((x) => x.enabled) || false;
const preRequestScript = item.request?.script?.req;
const requestHasPreRequestTests = hasExecutableTestInScript(preRequestScript);
const postResponseScript = item.request?.script?.res;
const requestHasPostResponseTests = hasExecutableTestInScript(postResponseScript);
return requestHasTests || requestHasActiveAsserts || requestHasPreRequestTests || requestHasPostResponseTests;
});
}

View File

@@ -20,7 +20,7 @@ const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-he
const { shouldUseProxy, PatchedHttpsProxyAgent, getSystemProxyEnvVariables } = require('../utils/proxy-util');
const path = require('path');
const { parseDataFromResponse } = require('../utils/common');
const { getCookieStringForUrl, saveCookies, shouldUseCookies } = require('../utils/cookies');
const { getCookieStringForUrl, saveCookies } = require('../utils/cookies');
const { createFormData } = require('../utils/form-data');
const { getOAuth2Token } = require('./oauth2');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
@@ -287,6 +287,10 @@ const runSingleRequest = async function (
https_proxy,
Object.keys(httpsAgentRequestFields).length > 0 ? { ...httpsAgentRequestFields } : undefined
);
} else {
request.httpsAgent = new https.Agent({
...httpsAgentRequestFields
});
}
} catch (error) {
throw new Error('Invalid system https_proxy');
@@ -459,6 +463,7 @@ const runSingleRequest = async function (
statusText: null,
headers: null,
data: null,
url: null,
responseTime: 0
},
error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!',
@@ -598,6 +603,7 @@ const runSingleRequest = async function (
statusText: response.statusText,
headers: response.headers,
data: response.data,
url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null,
responseTime
},
error: null,
@@ -626,6 +632,7 @@ const runSingleRequest = async function (
statusText: null,
headers: null,
data: null,
url: null,
responseTime: 0
},
status: 'error',

View File

@@ -1,9 +1,12 @@
const _ = require('lodash');
const { bruToEnvJsonV2, bruToJsonV2, collectionBruToJson: _collectionBruToJson } = require('@usebruno/lang');
const {
parseRequest: _parseRequest,
parseCollection: _parseCollection
} = require('@usebruno/filestore');
const collectionBruToJson = (bru) => {
try {
const json = _collectionBruToJson(bru);
const json = _parseCollection(bru);
const transformedJson = {
request: {
@@ -46,7 +49,7 @@ const collectionBruToJson = (bru) => {
*/
const bruToJson = (bru) => {
try {
const json = bruToJsonV2(bru);
const json = _parseRequest(bru);
let requestType = _.get(json, 'meta.type');
if (requestType === 'http') {
@@ -88,14 +91,6 @@ const bruToJson = (bru) => {
}
};
const bruToEnvJson = (bru) => {
try {
return bruToEnvJsonV2(bru);
} catch (err) {
return Promise.reject(err);
}
};
const getEnvVars = (environment = {}) => {
const variables = environment.variables;
if (!variables || !variables.length) {
@@ -119,7 +114,6 @@ const getOptions = () => {
module.exports = {
bruToJson,
bruToEnvJson,
getEnvVars,
getOptions,
collectionBruToJson

View File

@@ -2,9 +2,8 @@ const { get, each, find, compact } = require('lodash');
const os = require('os');
const fs = require('fs');
const path = require('path');
const { jsonToBruV2, envJsonToBruV2, jsonToCollectionBru } = require('@usebruno/lang');
const { sanitizeName } = require('./filesystem');
const { bruToJson, collectionBruToJson } = require('./bru');
const { parseRequest, parseCollection, parseFolder, stringifyCollection, stringifyFolder, stringifyEnvironment, stringifyRequest } = require('@usebruno/filestore');
const constants = require('../constants');
const chalk = require('chalk');
@@ -46,7 +45,7 @@ const createCollectionJsonFromPathname = (collectionPath) => {
// get the request item
const bruContent = fs.readFileSync(filePath, 'utf8');
const requestItem = bruToJson(bruContent);
const requestItem = parseRequest(bruContent);
currentDirItems.push({
name: file,
pathname: filePath,
@@ -97,7 +96,7 @@ const getCollectionRoot = (dir) => {
}
const content = fs.readFileSync(collectionRootPath, 'utf8');
return collectionBruToJson(content);
return parseCollection(content);
};
const getFolderRoot = (dir) => {
@@ -108,7 +107,7 @@ const getFolderRoot = (dir) => {
}
const content = fs.readFileSync(folderRootPath, 'utf8');
return collectionBruToJson(content);
return parseFolder(content);
};
const mergeHeaders = (collection, request, requestTreePath) => {
@@ -417,7 +416,7 @@ const createCollectionFromBrunoObject = async (collection, dirPath) => {
// Create collection.bru if root exists
if (collection.root) {
const collectionContent = await jsonToCollectionBru(collection.root);
const collectionContent = await stringifyCollection(collection.root);
fs.writeFileSync(path.join(dirPath, 'collection.bru'), collectionContent);
}
@@ -427,7 +426,7 @@ const createCollectionFromBrunoObject = async (collection, dirPath) => {
fs.mkdirSync(envDirPath, { recursive: true });
for (const env of collection.environments) {
const content = await envJsonToBruV2(env);
const content = await stringifyEnvironment(env);
const filename = sanitizeName(`${env.name}.bru`);
fs.writeFileSync(path.join(envDirPath, filename), content);
}
@@ -459,10 +458,7 @@ const processCollectionItems = async (items = [], currentPath) => {
if (item.seq) {
item.root.meta.seq = item.seq;
}
const folderContent = await jsonToCollectionBru(
item.root,
true
);
const folderContent = await stringifyFolder(item.root);
safeWriteFileSync(folderBruFilePath, folderContent);
}
@@ -506,7 +502,7 @@ const processCollectionItems = async (items = [], currentPath) => {
};
// Convert to BRU format and write to file
const content = await jsonToBruV2(bruJson);
const content = await stringifyRequest(bruJson);
safeWriteFileSync(path.join(currentPath, sanitizedFilename), content);
}
}

View File

@@ -1,103 +1 @@
const { Cookie, CookieJar } = require('tough-cookie');
const each = require('lodash/each');
const { isPotentiallyTrustworthyOrigin } = require('@usebruno/requests').utils;
const cookieJar = new CookieJar();
const addCookieToJar = (setCookieHeader, requestUrl) => {
const cookie = Cookie.parse(setCookieHeader, { loose: true });
cookieJar.setCookieSync(cookie, requestUrl, {
ignoreError: true // silently ignore things like parse errors and invalid domains
});
};
const getCookiesForUrl = (url) => {
return cookieJar.getCookiesSync(url, {
secure: isPotentiallyTrustworthyOrigin(url)
});
};
const getCookieStringForUrl = (url) => {
const cookies = getCookiesForUrl(url);
if (!Array.isArray(cookies) || !cookies.length) {
return '';
}
const validCookies = cookies.filter((cookie) => !cookie.expires || cookie.expires > Date.now());
return validCookies.map((cookie) => cookie.cookieString()).join('; ');
};
const getDomainsWithCookies = () => {
return new Promise((resolve, reject) => {
const domainCookieMap = {};
cookieJar.store.getAllCookies((err, cookies) => {
if (err) {
return reject(err);
}
cookies.forEach((cookie) => {
if (!domainCookieMap[cookie.domain]) {
domainCookieMap[cookie.domain] = [cookie];
} else {
domainCookieMap[cookie.domain].push(cookie);
}
});
const domains = Object.keys(domainCookieMap);
const domainsWithCookies = [];
each(domains, (domain) => {
const cookies = domainCookieMap[domain];
const validCookies = cookies.filter((cookie) => !cookie.expires || cookie.expires > Date.now());
if (validCookies.length) {
domainsWithCookies.push({
domain,
cookies: validCookies,
cookieString: validCookies.map((cookie) => cookie.cookieString()).join('; ')
});
}
});
resolve(domainsWithCookies);
});
});
};
const deleteCookiesForDomain = (domain) => {
return new Promise((resolve, reject) => {
cookieJar.store.removeCookies(domain, null, (err) => {
if (err) {
return reject(err);
}
return resolve();
});
});
};
const saveCookies = (url, headers) => {
let setCookieHeaders = [];
if (headers['set-cookie']) {
setCookieHeaders = Array.isArray(headers['set-cookie'])
? headers['set-cookie']
: [headers['set-cookie']];
for (let setCookieHeader of setCookieHeaders) {
if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
addCookieToJar(setCookieHeader, url);
}
}
}
}
module.exports = {
addCookieToJar,
getCookiesForUrl,
getCookieStringForUrl,
getDomainsWithCookies,
deleteCookiesForDomain,
saveCookies
};
module.exports = require('@usebruno/common').cookies;

View File

@@ -0,0 +1,44 @@
// Check for meaningful test() calls (not commented out or in strings)
const hasExecutableTestInScript = (script) => {
if (!script) return false;
// Remove single-line comments (// ...) and multi-line comments (/* ... */)
let cleanScript = script
.replace(/\/\/.*$/gm, '') // Remove line comments
.replace(/\/\*[\s\S]*?\*\//g, ''); // Remove block comments
// Remove string literals to avoid matching test() inside strings
cleanScript = cleanScript
.replace(/"(?:[^"\\]|\\.)*"/g, '""') // Remove double-quoted strings
.replace(/'(?:[^'\\]|\\.)*'/g, "''") // Remove single-quoted strings
.replace(/`(?:[^`\\]|\\.)*`/g, '``'); // Remove template literals
// Look for standalone test() calls (not object method calls like obj.test())
// Find all test( occurrences and check they're not preceded by dots
let hasValidTest = false;
let searchFrom = 0;
while (true) {
const index = cleanScript.indexOf('test', searchFrom);
if (index === -1) break;
// Check if this looks like test( with optional whitespace
const afterTest = cleanScript.substring(index + 4);
if (/^\s*\(/.test(afterTest)) {
// Found test( - check if it's not preceded by a dot
if (index === 0 || cleanScript[index - 1] !== '.') {
hasValidTest = true;
break;
}
}
searchFrom = index + 1;
}
return hasValidTest;
};
module.exports = {
hasExecutableTestInScript
};

View File

@@ -0,0 +1,309 @@
const { describe, it, expect } = require('@jest/globals');
const { hasExecutableTestInScript } = require('../../src/utils/request');
describe('hasExecutableTestInScript', () => {
describe('should return true for valid test() calls', () => {
it('should detect basic test calls', () => {
const script = `
test("should work", function() {
expect(true).to.be.true;
});
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should detect indented test calls', () => {
const script = `
if (true) {
test("indented test", function() {
expect(1).to.equal(1);
});
}
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should detect test calls with extra whitespace', () => {
const script = `test ("with spaces", function() { });`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should detect test calls after assignments', () => {
const script = `
const result = test("assignment test", function() {
expect("hello").to.be.a("string");
});
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should detect test calls in conditionals', () => {
const script = `
if (condition) {
test("conditional test", function() {
expect(true).to.be.true;
});
}
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should detect test calls in arrays', () => {
const script = `
const tests = [
test("array test", function() {
expect(Array.isArray([])).to.be.true;
})
];
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should detect test calls in ternary operators', () => {
const script = `
const result = condition ? test("ternary test", function() {
expect(true).to.be.true;
}) : null;
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should detect test calls after semicolons', () => {
const script = `
const data = res.data; test("after semicolon", function() {
expect(data).to.be.an("object");
});
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should detect test calls in object values', () => {
const script = `
const config = {
validation: test("object value test", function() {
expect(true).to.be.true;
})
};
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should detect multiple test calls', () => {
const script = `
test("first test", function() {
expect(1).to.equal(1);
});
test("second test", function() {
expect(2).to.equal(2);
});
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should detect test calls at start of script', () => {
const script = `test("at start", function() { expect(true).to.be.true; });`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
});
describe('should return false for invalid test() calls', () => {
it('should ignore commented out test calls with //', () => {
const script = `
// test("commented test", function() {
// expect(true).to.be.true;
// });
console.log("no real tests here");
`;
expect(hasExecutableTestInScript(script)).toBe(false);
});
it('should ignore commented out test calls with /* */', () => {
const script = `
/* test("block commented test", function() {
expect(true).to.be.true;
}); */
console.log("no real tests here");
`;
expect(hasExecutableTestInScript(script)).toBe(false);
});
it('should ignore test() in double-quoted strings', () => {
const script = `
console.log("This contains test() but should not match");
console.log("Remember to test() your API");
`;
expect(hasExecutableTestInScript(script)).toBe(false);
});
it('should ignore test() in single-quoted strings', () => {
const script = `
console.log('Single quote test() should not match');
const message = 'Use test() for validation';
`;
expect(hasExecutableTestInScript(script)).toBe(false);
});
it('should ignore test() in template literals', () => {
const script = `
console.log(\`Template literal test() should not match\`);
const message = \`Remember to test() your code\`;
`;
expect(hasExecutableTestInScript(script)).toBe(false);
});
it('should ignore object method calls', () => {
const script = `
const obj = { test: function() { return "not a real test"; } };
obj.test("This is a method call");
`;
expect(hasExecutableTestInScript(script)).toBe(false);
});
it('should ignore this.test() calls', () => {
const script = `
this.test("Another method call");
this.test();
`;
expect(hasExecutableTestInScript(script)).toBe(false);
});
it('should ignore complex object chain calls', () => {
const script = `
api.client.test("Should not match");
user.test.endpoint("Chained method");
window.test("Should not match");
`;
expect(hasExecutableTestInScript(script)).toBe(false);
});
it('should ignore object methods in variables', () => {
const script = `
const validator = {
test: function(value) { return value > 0; }
};
validator.test(42);
const tester = { test: () => "mock" };
tester.test("method call");
`;
expect(hasExecutableTestInScript(script)).toBe(false);
});
it('should return false for empty scripts', () => {
expect(hasExecutableTestInScript('')).toBe(false);
expect(hasExecutableTestInScript(null)).toBe(false);
expect(hasExecutableTestInScript(undefined)).toBe(false);
});
it('should return false for scripts with no test calls', () => {
const script = `
bru.setVar("userId", "12345");
console.log("Setting up request");
const data = res.data;
bru.setVar("responseData", data);
`;
expect(hasExecutableTestInScript(script)).toBe(false);
});
it('should return false when test is part of other words', () => {
const script = `
const testing = "value";
const protest = "demo";
const fastest = "speed";
console.log("contest results");
`;
expect(hasExecutableTestInScript(script)).toBe(false);
});
});
describe('should handle mixed scenarios correctly', () => {
it('should return true when valid test exists among invalid ones', () => {
const script = `
// test("commented out");
console.log("test() in string");
obj.test("method call");
test("real test", function() {
expect(true).to.be.true;
});
api.client.test("another method");
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should return false when only invalid tests exist', () => {
const script = `
// test("commented out test", function() {
// expect(true).to.be.true;
// });
console.log("test() inside string");
console.log('test() in single quotes');
console.log(\`test() in template\`);
const obj = { test: () => "mock" };
obj.test("method call");
this.test("another method");
api.client.test("chained method");
bru.setVar("test", "variable name");
`;
expect(hasExecutableTestInScript(script)).toBe(false);
});
it('should handle complex nested quotes correctly', () => {
const script = `
console.log("String with 'nested quotes' and test() call");
console.log('String with "nested quotes" and test() call');
test("real test with \\"escaped quotes\\"", function() {
expect(true).to.be.true;
});
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should handle multi-line comments correctly', () => {
const script = `
/*
* This is a multi-line comment with
* test("commented test", function() {
* expect(true).to.be.true;
* });
*/
test("real test", function() {
expect(true).to.be.true;
});
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
it('should handle inline comments correctly', () => {
const script = `
const data = res.data; // test("inline comment")
test("real test", function() { // this is a real test
expect(data).to.be.an("object");
});
`;
expect(hasExecutableTestInScript(script)).toBe(true);
});
});
describe('edge cases', () => {
it('should handle test calls immediately after dots (edge case)', () => {
const script = `
// This should not match because it's after a dot
console.test("should not match");
// But this should match because there's a space
console. test("should match due to space");
`;
// Note: Our current implementation would consider the second one valid
// because there's a space between the dot and test
expect(hasExecutableTestInScript(script)).toBe(true);
});
});
});

View File

@@ -3,7 +3,7 @@ module.exports = {
'^.+\\.(ts|js)$': 'babel-jest',
},
transformIgnorePatterns: [
'/node_modules/(?!(lodash-es)/)',
'/node_modules/(?!(lodash-es|is-ip|ip-regex|super-regex|function-timeout|time-span|convert-hrtime|clone-regexp|is-regexp)/)'
],
testEnvironment: 'node'
};

View File

@@ -46,6 +46,7 @@
"@rollup/plugin-typescript": "^12.1.2",
"@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
"is-ip": "^5.0.1",
"moment": "^2.29.4",
"rollup": "3.29.5",
"rollup-plugin-dts": "^5.0.0",

View File

@@ -0,0 +1,500 @@
import { Cookie, CookieJar } from 'tough-cookie';
import each from 'lodash/each';
import moment from 'moment';
import { isPotentiallyTrustworthyOrigin } from '../utils';
const cookieJar = new CookieJar();
const addCookieToJar = (setCookieHeader: string, requestUrl: string): void => {
const cookie = Cookie.parse(setCookieHeader, { loose: true });
if (!cookie) return;
cookieJar.setCookieSync(cookie, requestUrl, {
ignoreError: true
});
};
const getCookiesForUrl = (url: string) => {
return cookieJar.getCookiesSync(url, {
secure: isPotentiallyTrustworthyOrigin(url)
});
};
const getCookieStringForUrl = (url: string): string => {
const cookies = getCookiesForUrl(url);
if (!Array.isArray(cookies) || !cookies.length) return '';
const validCookies = cookies.filter((cookie: any) => !cookie.expires || (cookie.expires as any) > Date.now());
return validCookies.map((cookie) => cookie.cookieString()).join('; ');
};
const getDomainsWithCookies = (): Promise<Array<{ domain: string; cookies: Cookie[]; cookieString: string }>> => {
return new Promise((resolve, reject) => {
const domainCookieMap: Record<string, Cookie[]> = {};
(cookieJar as any).store.getAllCookies((err: Error, cookies: Cookie[]) => {
if (err) return reject(err);
cookies.forEach((cookie) => {
// Handle null domain by skipping the cookie
if (!cookie.domain) return;
if (!domainCookieMap[cookie.domain]) {
domainCookieMap[cookie.domain] = [cookie];
} else {
domainCookieMap[cookie.domain].push(cookie);
}
});
const domains = Object.keys(domainCookieMap);
const domainsWithCookies: Array<{ domain: string; cookies: Cookie[]; cookieString: string }> = [];
each(domains, (domain) => {
const cookiesForDomain = domainCookieMap[domain];
const validCookies = cookiesForDomain.filter((cookie: any) => !cookie.expires || (cookie.expires as any) > Date.now());
if (validCookies.length) {
domainsWithCookies.push({
domain,
cookies: validCookies,
cookieString: validCookies.map((cookie) => cookie.cookieString()).join('; ')
});
}
});
resolve(domainsWithCookies);
});
});
};
const deleteCookie = (domain: string, path: string, cookieKey: string): Promise<void> => {
return new Promise((resolve, reject) => {
(cookieJar as any).store.removeCookie(domain, path, cookieKey, (err: Error) => {
if (err) return reject(err);
resolve();
});
});
};
const deleteCookiesForDomain = (domain: string): Promise<void> => {
return new Promise((resolve, reject) => {
(cookieJar as any).store.removeCookies(domain, null, (err: Error) => {
if (err) return reject(err);
resolve();
});
});
};
const updateCookieObj = (cookieObj: any, oldCookie: Cookie) => {
return {
...cookieObj,
path: oldCookie.path,
key: oldCookie.key,
domain: oldCookie.domain,
expires: cookieObj?.expires && moment(cookieObj.expires).isValid() ? new Date(cookieObj.expires) : Infinity,
creation: oldCookie?.creation && moment(oldCookie.creation).isValid() ? new Date(oldCookie.creation) : new Date(),
lastAccessed:
oldCookie?.lastAccessed && moment(oldCookie.lastAccessed).isValid()
? new Date(oldCookie.lastAccessed)
: new Date()
} as any;
};
const createCookieObj = (cookieObj: any) => {
return {
...cookieObj,
path: cookieObj.path,
expires: cookieObj?.expires && moment(cookieObj.expires).isValid() ? new Date(cookieObj.expires) : Infinity,
creation: cookieObj?.creation && moment(cookieObj.creation).isValid() ? new Date(cookieObj.creation) : new Date(),
lastAccessed:
cookieObj?.lastAccessed && moment(cookieObj.lastAccessed).isValid()
? new Date(cookieObj.lastAccessed)
: new Date()
} as any;
};
const addCookieForDomain = (domain: string, cookieObj: any): Promise<void> => {
return new Promise((resolve, reject) => {
try {
const cookie = new Cookie(createCookieObj(cookieObj));
(cookieJar as any).store.putCookie(cookie, (err: Error) => {
if (err) return reject(err);
resolve();
});
} catch (err) {
reject(err);
}
});
};
const modifyCookieForDomain = (domain: string, oldCookieObj: any, cookieObj: any): Promise<void> => {
return new Promise((resolve, reject) => {
try {
const oldCookie = new Cookie(createCookieObj(oldCookieObj));
const newCookie = new Cookie(updateCookieObj(cookieObj, oldCookie));
(cookieJar as any).store.updateCookie(oldCookie, newCookie, (removeErr: Error) => {
if (removeErr) return reject(removeErr);
resolve();
});
} catch (err) {
reject(err);
}
});
};
const parseCookieString = (cookieStr: string): any | null => {
try {
const cookie = Cookie.parse(cookieStr);
if (!cookie) return null;
return {
...cookie,
expires: cookie.expires === 'Infinity' || (cookie.expires as any) === Infinity ? null : cookie.expires
};
} catch (err) {
throw err;
}
};
const createCookieString = (cookieObj: any): string => {
const cookie = new Cookie(createCookieObj(cookieObj));
let cookieString = cookie.toString(); // tough-cookie omits domain
// Manually append domain if cookie is hostOnly but we still want Domain flag
if (cookieObj.hostOnly && !cookieString.includes('Domain=')) {
cookieString += `; Domain=${cookieObj.domain}`;
}
return cookieString;
}
const saveCookies = (url: string, headers: any) => {
if (headers['set-cookie']) {
let setCookieHeaders = Array.isArray(headers['set-cookie'])
? headers['set-cookie']
: [headers['set-cookie']];
for (let setCookieHeader of setCookieHeaders) {
if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
addCookieToJar(setCookieHeader, url);
}
}
}
};
const cookieJarWrapper = () => {
return {
// Get the full cookie object for the given URL & name.
getCookie: function (
url: string,
cookieName: string,
callback?: (err: Error | null | undefined, cookie?: Cookie | null) => void
) {
if (!url || !cookieName) {
const error = new Error('URL and cookie name are required');
if (callback) return callback(error);
return Promise.reject(error);
}
if (callback) {
// Callback mode
return cookieJar.getCookies(url, (err: Error | null, cookies: Cookie[]) => {
if (err) return callback(err);
const cookie = cookies.find((c) => c.key === cookieName);
callback(null, cookie || null);
});
}
// Promise mode
return new Promise<Cookie | null>((resolve, reject) => {
cookieJar.getCookies(url, (err: Error | null, cookies: Cookie[]) => {
if (err) return reject(err);
const cookie = cookies.find((c) => c.key === cookieName);
resolve(cookie || null);
});
});
},
// Get all cookies that would be sent to the given URL.
getCookies: function (url: string, callback?: (err: Error | null | undefined, cookies?: Cookie[]) => void) {
if (!url) {
const error = new Error('URL is required');
if (callback) return callback(error);
return Promise.reject(error);
}
if (callback) {
// Callback mode
return cookieJar.getCookies(url, callback);
}
// Promise mode
return new Promise<Cookie[]>((resolve, reject) => {
cookieJar.getCookies(url, (err: Error | null, cookies: Cookie[]) => {
if (err) return reject(err);
resolve(cookies);
});
});
},
setCookie: function (
url: string,
nameOrCookieObj: string | Record<string, any>,
valueOrCallback?: string | ((err?: Error | undefined) => void),
maybeCallback?: (err?: Error | undefined) => void
) {
// Determine the callback
let callback: ((err?: Error | undefined) => void) | undefined;
if (typeof maybeCallback === 'function') {
callback = maybeCallback;
} else if (typeof valueOrCallback === 'function') {
callback = valueOrCallback as (err?: Error | undefined) => void;
}
const executeSetCookie = () => {
if (!url) throw new Error('URL is required');
// CASE 1: name/value pair provided
if (typeof nameOrCookieObj === 'string') {
const cookieName = nameOrCookieObj;
const cookieValue = typeof valueOrCallback === 'string' ? valueOrCallback : '';
if (!cookieName) throw new Error('Cookie name is required');
const cookie = new Cookie({
key: cookieName,
value: cookieValue,
domain: new URL(url).hostname,
});
cookieJar.setCookieSync(cookie, url, { ignoreError: true });
return;
}
// CASE 2: cookie object provided
if (typeof nameOrCookieObj === 'object' && nameOrCookieObj !== null) {
const obj = { ...(nameOrCookieObj as any) } as any;
if (!obj.key && obj.name) obj.key = obj.name;
if (!obj.key) throw new Error('cookieObject.key (name) is required');
const base = {
domain: new URL(url).hostname,
...obj,
} as any;
const processedCookie = createCookieObj(base);
const cookie = new Cookie(processedCookie);
cookieJar.setCookieSync(cookie, url, { ignoreError: true });
return;
}
// If we reach here, arguments were invalid
throw new Error('Invalid arguments passed to setCookie');
};
if (callback) {
// Callback mode
try {
executeSetCookie();
callback(undefined);
} catch (err) {
callback(err as Error);
}
return;
}
// Promise mode
return new Promise<void>((resolve, reject) => {
try {
executeSetCookie();
resolve();
} catch (err) {
reject(err);
}
});
},
setCookies: function (
url: string,
cookiesArray: any[],
callback?: (err?: Error | undefined) => void
) {
const executeSetCookies = () => {
if (!url) throw new Error('URL is required');
if (!Array.isArray(cookiesArray)) {
throw new Error('setCookies expects an array of cookie objects');
}
for (const cookieObject of cookiesArray) {
const obj = { ...(cookieObject as any) } as any;
if (!obj.key && obj.name) obj.key = obj.name;
if (!obj.key) throw new Error('cookieObject.key (name) is required');
const base = {
domain: new URL(url).hostname,
...obj
} as any;
const processedCookie = createCookieObj(base);
const cookie = new Cookie(processedCookie);
cookieJar.setCookieSync(cookie, url, { ignoreError: true });
}
};
if (callback) {
// Callback mode
try {
executeSetCookies();
callback(undefined);
} catch (err) {
callback(err as Error);
}
return;
}
// Promise mode
return new Promise<void>((resolve, reject) => {
try {
executeSetCookies();
resolve();
} catch (err) {
reject(err);
}
});
},
clear: function (callback?: (err?: Error | undefined) => void) {
if (callback) {
// Callback mode
return (cookieJar as any).store.removeAllCookies(callback);
}
// Promise mode
return new Promise<void>((resolve, reject) => {
(cookieJar as any).store.removeAllCookies((err?: Error) => {
if (err) reject(err);
else resolve();
});
});
},
deleteCookies: function (url: string, callback?: (err?: Error | undefined) => void) {
if (!url) {
const error = new Error('URL is required');
if (callback) return callback(error);
return Promise.reject(error);
}
if (callback) {
// Callback mode
return cookieJar.getCookies(url, (err: Error | null, cookies: Cookie[]) => {
if (err) return callback(err);
if (!cookies || !cookies.length) return callback(undefined);
let pending = cookies.length;
const done = (removeErr?: Error) => {
if (removeErr) return callback(removeErr);
if (--pending === 0) {
callback(undefined);
}
};
cookies.forEach((cookie) => {
(cookieJar as any).store.removeCookie(cookie.domain, cookie.path, cookie.key, done);
});
});
}
// Promise mode
return new Promise<void>((resolve, reject) => {
cookieJar.getCookies(url, (err: Error | null, cookies: Cookie[]) => {
if (err) return reject(err);
if (!cookies || !cookies.length) return resolve();
let pending = cookies.length;
const done = (removeErr?: Error) => {
if (removeErr) return reject(removeErr);
if (--pending === 0) {
resolve();
}
};
cookies.forEach((cookie) => {
(cookieJar as any).store.removeCookie(cookie.domain, cookie.path, cookie.key, done);
});
});
});
},
deleteCookie: function (url: string, cookieName: string, callback?: (err?: Error | undefined) => void) {
if (!url || !cookieName) {
const error = new Error('URL and cookie name are required');
if (callback) return callback(error);
return Promise.reject(error);
}
const executeDelete = (callback: (err?: Error) => void) => {
cookieJar.getCookies(url, (err: Error | null, cookies: Cookie[]) => {
if (err) return callback(err);
// Filter cookies matching key
const matchingCookies = (cookies || []).filter((c) => c.key === cookieName);
if (!matchingCookies.length) return callback(undefined);
const urlPath = new URL(url).pathname || '/';
// Prioritise a cookie whose path exactly matches the URL path
let cookieToDelete = matchingCookies.find((c) => c.path === urlPath);
// If not found, fall back to the first matching cookie (most specific path first)
if (!cookieToDelete) {
// tough-cookie sorts cookies by path length desc, preserve that order
cookieToDelete = matchingCookies[0];
}
(cookieJar as any).store.removeCookie(
cookieToDelete.domain,
cookieToDelete.path,
cookieToDelete.key,
callback
);
});
};
if (callback) {
// Callback mode
return executeDelete(callback);
}
// Promise mode
return new Promise<void>((resolve, reject) => {
executeDelete((err?: Error) => {
if (err) reject(err);
else resolve();
});
});
}
} as const;
};
const cookiesModule = {
cookieJar,
addCookieToJar,
getCookiesForUrl,
getCookieStringForUrl,
getDomainsWithCookies,
deleteCookie,
deleteCookiesForDomain,
addCookieForDomain,
modifyCookieForDomain,
parseCookieString,
createCookieString,
updateCookieObj,
createCookieObj,
jar: cookieJarWrapper,
saveCookies
};
export default cookiesModule;

View File

@@ -1,5 +1,6 @@
export { mockDataFunctions } from './utils/faker-functions';
export { default as interpolate } from './interpolate';
export { default as isRequestTagsIncluded } from './tags';
export { default as cookies } from './cookies';
export * as utils from './utils';

View File

@@ -1,5 +1,9 @@
export {
encodeUrl,
parseQueryParams,
buildQueryString
buildQueryString,
} from './url';
export {
isPotentiallyTrustworthyOrigin
} from './url/validation';

View File

@@ -1,4 +1,4 @@
const { isPotentiallyTrustworthyOrigin } = require('./cookie-utils');
import { isPotentiallyTrustworthyOrigin } from './validation';
describe('isPotentiallyTrustworthyOrigin', () => {
describe('secure schemes', () => {
@@ -130,4 +130,4 @@ describe('isPotentiallyTrustworthyOrigin', () => {
expect(isPotentiallyTrustworthyOrigin('wss://localhost')).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,67 @@
import { isIPv4, isIPv6, isIP } from 'is-ip';
const hostNoBrackets = (host: string): string => {
if (host.length >= 2 && host.startsWith('[') && host.endsWith(']')) {
return host.substring(1, host.length - 1);
}
return host;
};
const isLoopbackV4 = (address: string): boolean => {
const octets = address.split('.');
if (octets.length !== 4 || parseInt(octets[0], 10) !== 127) {
return false;
}
return octets.every((octet) => {
const n = parseInt(octet, 10);
return !Number.isNaN(n) && n >= 0 && n <= 255;
});
};
const isLoopbackV6 = (address: string): boolean => address === '::1';
const isIpLoopback = (address: string): boolean => {
if (isIPv4(address)) {
return isLoopbackV4(address);
}
if (isIPv6(address)) {
return isLoopbackV6(address);
}
return false;
};
const isNormalizedLocalhostTLD = (host: string): boolean => host.toLowerCase().endsWith('.localhost');
const isLocalHostname = (host: string): boolean => {
return host.toLowerCase() === 'localhost' || isNormalizedLocalhostTLD(host);
};
/**
* Mirrors Chrome / Secure Contexts spec for "potentially trustworthy origins".
*/
const isPotentiallyTrustworthyOrigin = (urlString: string): boolean => {
let url: URL;
try {
url = new URL(urlString);
} catch {
return false; // invalid URL or opaque origin
}
const scheme = url.protocol.replace(':', '').toLowerCase();
const hostname = hostNoBrackets(url.hostname).replace(/\.+$/, '');
// Secure schemes
if (scheme === 'https' || scheme === 'wss' || scheme === 'file') {
return true;
}
// IP literals
if (isIP(hostname)) {
return isIpLoopback(hostname);
}
// localhost / *.localhost
return isLocalHostname(hostname);
};
export { isPotentiallyTrustworthyOrigin };

View File

@@ -0,0 +1,228 @@
const cookiesModule = require('../../src/cookies/index.ts').default;
describe('Bruno Cookie Jar Wrapper - API Examples', () => {
let jar;
const testUrl = 'https://api.example.com';
beforeEach(() => {
jar = cookiesModule.jar();
// Clear all cookies before each test
jar.clear();
});
describe('Basic Cookie Operations', () => {
test('setCookie and getCookie - name/value pair', async () => {
const cookieName = 'authToken';
const cookieValue = 'jwt123';
// Set a cookie
await jar.setCookie(testUrl, cookieName, cookieValue);
// Get the cookie back
const cookie = await jar.getCookie(testUrl, cookieName);
expect(cookie.key).toBe(cookieName);
expect(cookie.value).toBe(cookieValue);
expect(cookie.domain).toBe('api.example.com');
});
test('setCookie with cookie object', async () => {
const cookieObj = {
key: 'sessionId',
value: 'abc123',
path: '/api',
httpOnly: true,
secure: true
};
await jar.setCookie(testUrl, cookieObj);
const cookie = await jar.getCookie(testUrl + '/api', 'sessionId');
expect(cookie.key).toBe('sessionId');
expect(cookie.value).toBe('abc123');
expect(cookie.path).toBe('/api');
expect(cookie.httpOnly).toBe(true);
expect(cookie.secure).toBe(true);
});
test('getCookie returns null for non-existent cookie', async () => {
const cookie = await jar.getCookie(testUrl, 'nonexistent');
expect(cookie).toBeNull();
});
});
describe('Multiple Cookie Operations', () => {
test('setCookies with array of cookie objects', async () => {
const cookies = [
{ key: 'cookie1', value: 'value1' },
{ key: 'cookie2', value: 'value2' },
{ key: 'cookie3', value: 'value3', httpOnly: true }
];
await jar.setCookies(testUrl, cookies);
// Verify all cookies were set
const retrievedCookies = await jar.getCookies(testUrl);
expect(retrievedCookies).toHaveLength(3);
const cookieNames = retrievedCookies.map(c => c.key);
expect(cookieNames).toContain('cookie1');
expect(cookieNames).toContain('cookie2');
expect(cookieNames).toContain('cookie3');
});
test('getCookies returns all cookies for URL', async () => {
// Set multiple cookies
await jar.setCookie(testUrl, 'auth', 'token123');
await jar.setCookie(testUrl, 'session', 'sess456');
await jar.setCookie(testUrl, 'prefs', 'theme=dark');
const cookies = await jar.getCookies(testUrl);
expect(cookies).toHaveLength(3);
const cookieMap = cookies.reduce((map, cookie) => {
map[cookie.key] = cookie.value;
return map;
}, {});
expect(cookieMap.auth).toBe('token123');
expect(cookieMap.session).toBe('sess456');
expect(cookieMap.prefs).toBe('theme=dark');
});
});
describe('Cookie Deletion', () => {
test('deleteCookie removes specific cookie', async () => {
// Set two cookies
await jar.setCookie(testUrl, 'keep', 'keepValue');
await jar.setCookie(testUrl, 'remove', 'removeValue');
// Delete one cookie
await jar.deleteCookie(testUrl, 'remove');
// Verify only one cookie remains
const cookies = await jar.getCookies(testUrl);
expect(cookies).toHaveLength(1);
expect(cookies[0].key).toBe('keep');
expect(cookies[0].value).toBe('keepValue');
});
test('deleteCookies removes all cookies for URL', async () => {
// Set multiple cookies
await jar.setCookie(testUrl, 'cookie1', 'value1');
await jar.setCookie(testUrl, 'cookie2', 'value2');
// Delete all cookies for the URL
await jar.deleteCookies(testUrl);
// Verify no cookies remain
const cookies = await jar.getCookies(testUrl);
expect(cookies).toHaveLength(0);
});
test('clear removes all cookies from jar', async () => {
// Set cookies for multiple URLs
await jar.setCookie('https://site1.com', 'cookie1', 'value1');
await jar.setCookie('https://site2.com', 'cookie2', 'value2');
// Clear entire jar
await jar.clear();
// Verify no cookies remain for any URL
const cookies1 = await jar.getCookies('https://site1.com');
const cookies2 = await jar.getCookies('https://site2.com');
expect(cookies1).toHaveLength(0);
expect(cookies2).toHaveLength(0);
});
});
describe('Error Handling', () => {
test('setCookie handles missing URL', async () => {
await expect(jar.setCookie('', 'name', 'value')).rejects.toThrow('URL is required');
});
test('getCookie handles missing URL', async () => {
await expect(jar.getCookie('', 'name')).rejects.toThrow('URL and cookie name are required');
});
test('setCookies handles invalid input', async () => {
await expect(jar.setCookies(testUrl, 'not-an-array')).rejects.toThrow('expects an array');
});
test('setCookie handles missing cookie name in object', async () => {
await expect(jar.setCookie(testUrl, { value: 'test' })).rejects.toThrow('key (name) is required');
});
});
describe('Real-world Usage Examples', () => {
test('Authentication workflow example', async () => {
const apiUrl = 'https://api.example.com';
const authToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
// Simulate login - set auth cookie
await jar.setCookie(apiUrl, 'authToken', authToken);
// Later in the session - retrieve auth token
const cookie = await jar.getCookie(apiUrl, 'authToken');
expect(cookie.value).toBe(authToken);
// Simulate logout - remove auth cookie
await jar.deleteCookie(apiUrl, 'authToken');
// Verify cookie is gone
const deletedCookie = await jar.getCookie(apiUrl, 'authToken');
expect(deletedCookie).toBeNull();
});
test('Session management with multiple cookies', async () => {
const sessionUrl = 'https://app.example.com';
// Set session cookies
const sessionCookies = [
{ key: 'sessionId', value: 'sess_123', httpOnly: true },
{ key: 'csrfToken', value: 'csrf_456' },
{ key: 'userPrefs', value: JSON.stringify({ theme: 'dark', lang: 'en' }) }
];
await jar.setCookies(sessionUrl, sessionCookies);
// Retrieve all session cookies
const cookies = await jar.getCookies(sessionUrl);
expect(cookies).toHaveLength(3);
// Find specific cookies
const sessionCookie = cookies.find(c => c.key === 'sessionId');
const csrfCookie = cookies.find(c => c.key === 'csrfToken');
const prefsCookie = cookies.find(c => c.key === 'userPrefs');
expect(sessionCookie.value).toBe('sess_123');
expect(sessionCookie.httpOnly).toBe(true);
expect(csrfCookie.value).toBe('csrf_456');
const prefs = JSON.parse(prefsCookie.value);
expect(prefs.theme).toBe('dark');
expect(prefs.lang).toBe('en');
});
test('Cookie path handling', async () => {
const baseUrl = 'https://example.com';
// Set cookies with different paths
await jar.setCookie(baseUrl, { key: 'global', value: 'global_val', path: '/' });
await jar.setCookie(baseUrl, { key: 'api', value: 'api_val', path: '/api' });
await jar.setCookie(baseUrl, { key: 'admin', value: 'admin_val', path: '/admin' });
const rootCookies = await jar.getCookies(baseUrl + '/');
const globalCookie = rootCookies.find(c => c.key === 'global');
expect(globalCookie).toBeTruthy();
expect(globalCookie.value).toBe('global_val');
const apiCookies = await jar.getCookies(baseUrl + '/api/users');
expect(apiCookies.length).toBeGreaterThanOrEqual(2);
const apiCookieNames = apiCookies.map(c => c.key);
expect(apiCookieNames).toContain('global');
expect(apiCookieNames).toContain('api');
});
});
});

View File

@@ -35,13 +35,13 @@
"@web/rollup-plugin-copy": "^0.5.1",
"babel-jest": "^29.7.0",
"rimraf": "^5.0.7",
"rollup": "3.2.5",
"rollup": "3.29.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
},
"overrides": {
"rollup": "3.2.5"
"rollup": "3.29.5"
}
}

View File

@@ -48,6 +48,13 @@ const replacements = {
'pm\\.execution\\.skipRequest': 'bru.runner.skipRequest',
'pm\\.execution\\.setNextRequest\\(null\\)': 'bru.runner.stopExecution()',
'pm\\.execution\\.setNextRequest\\(\'null\'\\)': 'bru.runner.stopExecution()',
// Cookie jar translations
'pm\\.cookies\\.jar\\(\\)': 'bru.cookies.jar()',
'pm\\.cookies\\.jar\\(\\)\\.get\\(': 'bru.cookies.jar().getCookie(',
'pm\\.cookies\\.jar\\(\\)\\.set\\(': 'bru.cookies.jar().setCookie(',
'pm\\.cookies\\.jar\\(\\)\\.unset\\(': 'bru.cookies.jar().deleteCookie(',
'pm\\.cookies\\.jar\\(\\)\\.clear\\(': 'bru.cookies.jar().deleteCookies(',
'pm\\.cookies\\.jar\\(\\)\\.getAll\\(': 'bru.cookies.jar().getCookies(',
};
const extendedReplacements = Object.keys(replacements).reduce((acc, key) => {

View File

@@ -14,6 +14,11 @@ function getMemberExpressionString(node) {
return node.name;
}
if (node.type === 'CallExpression') {
const calleeStr = getMemberExpressionString(node.callee);
return `${calleeStr}()`;
}
// Handle member expressions
if (node.type === 'MemberExpression') {
const objectStr = getMemberExpressionString(node.object);
@@ -89,6 +94,13 @@ const simpleTranslations = {
'pm.response.size().body': 'res.getSize().body',
'pm.response.size().header': 'res.getSize().header',
'pm.response.size().total': 'res.getSize().total',
'pm.cookies.jar': 'bru.cookies.jar',
'pm.cookies.jar().get': 'bru.cookies.jar().getCookie',
'pm.cookies.jar().getAll': 'bru.cookies.jar().getCookies',
'pm.cookies.jar().set': 'bru.cookies.jar().setCookie',
'pm.cookies.jar().unset': 'bru.cookies.jar().deleteCookie',
'pm.cookies.jar().clear': 'bru.cookies.jar().deleteCookies',
// Execution control
'pm.execution.skipRequest': 'bru.runner.skipRequest',
@@ -332,6 +344,9 @@ function translateCode(code) {
// Preprocess the code to resolve all aliases
preprocessAliases(ast);
// Handle cookie jar variable assignments and method renaming
processCookieJarVariables(ast);
// Process all transformations in a single pass
processTransformations(ast, transformedNodes);
@@ -610,6 +625,59 @@ function removeResolvedDeclarations(ast, symbolTable) {
return changesMade;
}
/**
* Process cookie jar variable assignments and rename methods on those variables
* @param {Object} ast - jscodeshift AST
*/
function processCookieJarVariables(ast) {
// Map of Postman cookie jar method names to Bruno equivalents
const cookieMethodMapping = {
'get': 'getCookie',
'getAll': 'getCookies',
'set': 'setCookie',
'unset': 'deleteCookie',
'clear': 'deleteCookies'
};
// Track variables that are assigned to cookie jar instances
const cookieJarVariables = new Set();
// First pass: Find all variables assigned to cookie jar instances
ast.find(j.VariableDeclarator).forEach(path => {
if (path.value.init && path.value.init.type === 'CallExpression') {
const initCall = path.value.init;
// Check if this is a cookie jar assignment
if (initCall.callee.type === 'MemberExpression') {
const calleeStr = getMemberExpressionString(initCall.callee);
if (calleeStr === 'pm.cookies.jar' || calleeStr === 'bru.cookies.jar') {
if (path.value.id.type === 'Identifier') {
cookieJarVariables.add(path.value.id.name);
}
}
}
}
});
// Second pass: Rename method calls on cookie jar variables
ast.find(j.CallExpression).forEach(path => {
if (path.value.callee.type === 'MemberExpression' &&
path.value.callee.object.type === 'Identifier' &&
path.value.callee.property.type === 'Identifier') {
const varName = path.value.callee.object.name;
const methodName = path.value.callee.property.name;
// If this is a method call on a cookie jar variable
if (cookieJarVariables.has(varName) && cookieMethodMapping[methodName]) {
const newMethodName = cookieMethodMapping[methodName];
path.value.callee.property.name = newMethodName;
}
}
});
}
/**
* Handle Postman's tests["..."] = ... syntax
* @param {Object} ast - jscodeshift AST

View File

@@ -16,8 +16,8 @@ describe('postmanTranslations - comment handling', () => {
});
test('should comment non-translated pm commands', () => {
const inputScript = "pm.test('random test', () => pm.cookies.get('cookieName'));";
const expectedOutput = "// test('random test', () => pm.cookies.get('cookieName'));";
const inputScript = "pm.test('random test', () => pm.globals.clear());";
const expectedOutput = "// test('random test', () => pm.globals.clear());";
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});

View File

@@ -0,0 +1,319 @@
const { default: postmanTranslation } = require("../../../src/postman/postman-translations");
describe('postmanTranslations - cookie API conversions', () => {
test('should convert pm.cookies.jar().get to bru.cookies.jar().getCookie', () => {
const inputScript = `pm.cookies.jar().get('https://example.com', 'sessionId', (err, cookie) => {
console.log(cookie);
});`;
const expectedOutput = `bru.cookies.jar().getCookie('https://example.com', 'sessionId', (err, cookie) => {
console.log(cookie);
});`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should convert pm.cookies.jar().getAll to bru.cookies.jar().getCookies', () => {
const inputScript = `pm.cookies.jar().getAll('https://example.com', (err, cookies) => {
console.log(cookies);
});`;
const expectedOutput = `bru.cookies.jar().getCookies('https://example.com', (err, cookies) => {
console.log(cookies);
});`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should convert pm.cookies.jar().set to bru.cookies.jar().setCookie', () => {
const inputScript = `pm.cookies.jar().set('https://example.com', 'sessionId', 'abc123', (err) => {
if (err) console.error(err);
});`;
const expectedOutput = `bru.cookies.jar().setCookie('https://example.com', 'sessionId', 'abc123', (err) => {
if (err) console.error(err);
});`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should convert pm.cookies.jar().unset to bru.cookies.jar().deleteCookie', () => {
const inputScript = `pm.cookies.jar().unset('https://example.com', 'sessionId', (err) => {
if (err) console.error(err);
});`;
const expectedOutput = `bru.cookies.jar().deleteCookie('https://example.com', 'sessionId', (err) => {
if (err) console.error(err);
});`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should convert pm.cookies.jar().clear to bru.cookies.jar().deleteCookies (behavior difference)', () => {
const inputScript = `pm.cookies.jar().clear('https://example.com', (err) => {
if (err) console.error(err);
});`;
const expectedOutput = `bru.cookies.jar().deleteCookies('https://example.com', (err) => {
if (err) console.error(err);
});`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should handle multiple cookie operations in one script', () => {
const inputScript = `
pm.cookies.jar().set('https://api.example.com', 'auth', 'token123');
const cookie = pm.cookies.jar().get('https://api.example.com', 'auth');
pm.cookies.jar().getAll('https://api.example.com', (err, cookies) => {
console.log('All cookies:', cookies);
});
pm.cookies.jar().unset('https://api.example.com', 'temp');
pm.cookies.jar().clear('https://api.example.com');
`;
const expectedOutput = `
bru.cookies.jar().setCookie('https://api.example.com', 'auth', 'token123');
const cookie = bru.cookies.jar().getCookie('https://api.example.com', 'auth');
bru.cookies.jar().getCookies('https://api.example.com', (err, cookies) => {
console.log('All cookies:', cookies);
});
bru.cookies.jar().deleteCookie('https://api.example.com', 'temp');
bru.cookies.jar().deleteCookies('https://api.example.com');
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should convert variable assignment and method calls on cookie jar variables', () => {
const inputScript = `
const jar = pm.cookies.jar();
jar.set('https://example.com', 'user', 'john');
const userCookie = jar.get('https://example.com', 'user');
`;
const expectedOutput = `
const jar = bru.cookies.jar();
jar.setCookie('https://example.com', 'user', 'john');
const userCookie = jar.getCookie('https://example.com', 'user');
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should convert jar.get to jar.getCookie with callback', () => {
const inputScript = `
const jar = pm.cookies.jar();
jar.get('https://api.example.com', 'authToken', (error, cookie) => {
if (error) {
console.error('Error getting cookie:', error);
} else {
console.log('Retrieved cookie:', cookie);
}
});
`;
const expectedOutput = `
const jar = bru.cookies.jar();
jar.getCookie('https://api.example.com', 'authToken', (error, cookie) => {
if (error) {
console.error('Error getting cookie:', error);
} else {
console.log('Retrieved cookie:', cookie);
}
});
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should convert jar.getAll to jar.getCookies with callback', () => {
const inputScript = `
const jar = pm.cookies.jar();
jar.getAll('https://api.example.com', (error, cookies) => {
if (error) {
console.error('Error getting cookies:', error);
} else {
console.log('All cookies:', cookies);
}
});
`;
const expectedOutput = `
const jar = bru.cookies.jar();
jar.getCookies('https://api.example.com', (error, cookies) => {
if (error) {
console.error('Error getting cookies:', error);
} else {
console.log('All cookies:', cookies);
}
});
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should convert jar.set to jar.setCookie with cookie object', () => {
const inputScript = `
const jar = pm.cookies.jar();
jar.set('https://api.example.com', {
key: 'sessionId',
value: 'abc123',
path: '/api',
httpOnly: true,
secure: true
}, (error) => {
if (error) console.error(error);
});
`;
const expectedOutput = `
const jar = bru.cookies.jar();
jar.setCookie('https://api.example.com', {
key: 'sessionId',
value: 'abc123',
path: '/api',
httpOnly: true,
secure: true
}, (error) => {
if (error) console.error(error);
});
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should convert jar.unset to jar.deleteCookie', () => {
const inputScript = `
const jar = pm.cookies.jar();
jar.unset('https://api.example.com', 'tempCookie', (error) => {
if (error) {
console.error('Failed to delete cookie:', error);
} else {
console.log('Cookie deleted successfully');
}
});
`;
const expectedOutput = `
const jar = bru.cookies.jar();
jar.deleteCookie('https://api.example.com', 'tempCookie', (error) => {
if (error) {
console.error('Failed to delete cookie:', error);
} else {
console.log('Cookie deleted successfully');
}
});
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should convert jar.clear to jar.deleteCookies', () => {
const inputScript = `
const jar = pm.cookies.jar();
jar.clear('https://api.example.com', (error) => {
if (error) {
console.error('Failed to clear cookies:', error);
} else {
console.log('All cookies cleared for domain');
}
});
`;
const expectedOutput = `
const jar = bru.cookies.jar();
jar.deleteCookies('https://api.example.com', (error) => {
if (error) {
console.error('Failed to clear cookies:', error);
} else {
console.log('All cookies cleared for domain');
}
});
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should handle complex cookie workflow with jar variable', () => {
const inputScript = `
const cookieJar = pm.cookies.jar();
// Set multiple cookies
cookieJar.set('https://example.com', 'auth', 'token123');
cookieJar.set('https://example.com', {
key: 'preferences',
value: JSON.stringify({theme: 'dark'}),
path: '/'
});
// Get specific cookie
cookieJar.get('https://example.com', 'auth', (err, authCookie) => {
console.log('Auth cookie:', authCookie);
});
// Get all cookies
cookieJar.getAll('https://example.com', (err, allCookies) => {
console.log('Total cookies:', allCookies.length);
});
// Clean up
cookieJar.unset('https://example.com', 'temp');
cookieJar.clear('https://example.com');
`;
const expectedOutput = `
const cookieJar = bru.cookies.jar();
// Set multiple cookies
cookieJar.setCookie('https://example.com', 'auth', 'token123');
cookieJar.setCookie('https://example.com', {
key: 'preferences',
value: JSON.stringify({theme: 'dark'}),
path: '/'
});
// Get specific cookie
cookieJar.getCookie('https://example.com', 'auth', (err, authCookie) => {
console.log('Auth cookie:', authCookie);
});
// Get all cookies
cookieJar.getCookies('https://example.com', (err, allCookies) => {
console.log('Total cookies:', allCookies.length);
});
// Clean up
cookieJar.deleteCookie('https://example.com', 'temp');
cookieJar.deleteCookies('https://example.com');
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
test('should handle mixed jar variable and direct calls', () => {
const inputScript = `
const jar = pm.cookies.jar();
jar.get('https://api.com', 'session');
pm.cookies.jar().set('https://other.com', 'temp', 'value');
jar.getAll('https://api.com', (err, cookies) => {
console.log(cookies);
});
`;
const expectedOutput = `
const jar = bru.cookies.jar();
jar.getCookie('https://api.com', 'session');
bru.cookies.jar().setCookie('https://other.com', 'temp', 'value');
jar.getCookies('https://api.com', (err, cookies) => {
console.log(cookies);
});
`;
expect(postmanTranslation(inputScript)).toBe(expectedOutput);
});
});

View File

@@ -32,12 +32,13 @@
"@aws-sdk/credential-providers": "3.750.0",
"@usebruno/common": "0.1.0",
"@usebruno/converters": "^0.1.0",
"@usebruno/filestore": "^0.1.0",
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
"@usebruno/node-machine-id": "^2.0.0",
"@usebruno/requests": "^0.1.0",
"@usebruno/schema": "0.7.0",
"@usebruno/vm2": "^3.9.13",
"@usebruno/requests": "^0.1.0",
"about-window": "^1.15.2",
"aws4-axios": "^3.3.0",
"axios": "^1.8.3",
@@ -64,7 +65,6 @@
"nanoid": "3.3.8",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^4.1.3",
"uuid": "^9.0.0",
"yup": "^0.32.11"
},
@@ -72,7 +72,7 @@
"dmg-license": "^1.0.11"
},
"devDependencies": {
"electron": "33.2.1",
"electron": "~37.2.6",
"electron-builder": "25.1.8",
"electron-devtools-installer": "^4.0.0"
}

View File

@@ -3,12 +3,18 @@ const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
const { hasBruExtension, isWSLPath, normalizeAndResolvePath, sizeInMB } = require('../utils/filesystem');
const { bruToEnvJson, bruToJson, bruToJsonViaWorker, collectionBruToJson } = require('../bru');
const { dotenvToJson } = require('@usebruno/lang');
const {
parseEnvironment,
parseRequest,
parseRequestViaWorker,
parseCollection,
parseFolder
} = require('@usebruno/filestore');
const { parseDotEnv } = require('@usebruno/filestore');
const { uuid } = require('../utils/common');
const { getRequestUid } = require('../cache/requestUids');
const { decryptString } = require('../utils/encryption');
const { decryptStringSafe } = require('../utils/encryption');
const { setDotEnvVars } = require('../store/process-env');
const { setBrunoConfig } = require('../store/bruno-config');
const EnvironmentSecretsStore = require('../store/env-secrets');
@@ -80,7 +86,7 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath)
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = await bruToEnvJson(bruContent);
file.data = await parseEnvironment(bruContent);
file.data.name = basename.substring(0, basename.length - 4);
file.data.uid = getRequestUid(pathname);
@@ -92,14 +98,15 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath)
_.each(envSecrets, (secret) => {
const variable = _.find(file.data.variables, (v) => v.name === secret.name);
if (variable && secret.value) {
variable.value = decryptString(secret.value);
const decryptionResult = decryptStringSafe(secret.value);
variable.value = decryptionResult.value;
}
});
}
win.webContents.send('main:collection-tree-updated', 'addEnvironmentFile', file);
} catch (err) {
console.error(err);
console.error('Error processing environment file: ', err);
}
};
@@ -115,7 +122,7 @@ const changeEnvironmentFile = async (win, pathname, collectionUid, collectionPat
};
const bruContent = fs.readFileSync(pathname, 'utf8');
file.data = await bruToEnvJson(bruContent);
file.data = await parseEnvironment(bruContent);
file.data.name = basename.substring(0, basename.length - 4);
file.data.uid = getRequestUid(pathname);
_.each(_.get(file, 'data.variables', []), (variable) => (variable.uid = uuid()));
@@ -126,7 +133,8 @@ const changeEnvironmentFile = async (win, pathname, collectionUid, collectionPat
_.each(envSecrets, (secret) => {
const variable = _.find(file.data.variables, (v) => v.name === secret.name);
if (variable && secret.value) {
variable.value = decryptString(secret.value);
const decryptionResult = decryptStringSafe(secret.value);
variable.value = decryptionResult.value;
}
});
}
@@ -160,7 +168,7 @@ const unlinkEnvironmentFile = async (win, pathname, collectionUid) => {
}
};
const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread) => {
const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread, watcher) => {
console.log(`watcher add: ${pathname}`);
if (isBrunoConfigFile(pathname, collectionPath)) {
@@ -177,7 +185,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
if (isDotEnvFile(pathname, collectionPath)) {
try {
const content = fs.readFileSync(pathname, 'utf8');
const jsonData = dotenvToJson(content);
const jsonData = parseDotEnv(content);
setDotEnvVars(collectionUid, jsonData);
const payload = {
@@ -209,7 +217,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = await collectionBruToJson(bruContent);
file.data = await parseCollection(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
@@ -233,7 +241,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = await collectionBruToJson(bruContent);
file.data = await parseCollection(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
@@ -245,6 +253,8 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
}
if (hasBruExtension(pathname)) {
watcher.addFileToProcessing(collectionUid, pathname);
const file = {
meta: {
collectionUid,
@@ -258,14 +268,17 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
// If worker thread is not used, we can directly parse the file
if (!useWorkerThread) {
try {
file.data = await bruToJson(bruContent);
file.data = await parseRequest(bruContent);
file.partial = false;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
} catch (error) {
console.error(error);
} finally {
watcher.markFileAsProcessed(win, collectionUid, pathname);
}
return;
}
@@ -278,7 +291,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
type: 'http-request'
};
const metaJson = await bruToJson(parseBruFileMeta(bruContent), true);
const metaJson = parseBruFileMeta(bruContent);
file.data = metaJson;
file.partial = true;
file.loading = false;
@@ -295,7 +308,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
win.webContents.send('main:collection-tree-updated', 'addFile', file);
// This is to update the file info in the UI
file.data = await bruToJsonViaWorker(bruContent);
file.data = await parseRequestViaWorker(bruContent);
file.partial = false;
file.loading = false;
hydrateRequestWithUuid(file.data, pathname);
@@ -314,6 +327,8 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
} finally {
watcher.markFileAsProcessed(win, collectionUid, pathname);
}
}
};
@@ -331,7 +346,7 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
if (fs.existsSync(folderBruFilePath)) {
let folderBruFileContent = fs.readFileSync(folderBruFilePath, 'utf8');
let folderBruData = await collectionBruToJson(folderBruFileContent);
let folderBruData = await parseFolder(folderBruFileContent);
name = folderBruData?.meta?.name || name;
seq = folderBruData?.meta?.seq;
}
@@ -370,7 +385,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
if (isDotEnvFile(pathname, collectionPath)) {
try {
const content = fs.readFileSync(pathname, 'utf8');
const jsonData = dotenvToJson(content);
const jsonData = parseDotEnv(content);
setDotEnvVars(collectionUid, jsonData);
const payload = {
@@ -402,7 +417,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = await collectionBruToJson(bruContent);
file.data = await parseCollection(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'change', file);
return;
@@ -425,7 +440,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = await collectionBruToJson(bruContent);
file.data = await parseCollection(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'change', file);
@@ -447,7 +462,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
};
const bru = fs.readFileSync(pathname, 'utf8');
file.data = await bruToJson(bru);
file.data = await parseRequest(bru);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'change', file);
@@ -490,7 +505,7 @@ const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
if (fs.existsSync(folderBruFilePath)) {
let folderBruFileContent = fs.readFileSync(folderBruFilePath, 'utf8');
let folderBruData = await collectionBruToJson(folderBruFileContent);
let folderBruData = await parseFolder(folderBruFileContent);
name = folderBruData?.meta?.name || name;
}
@@ -504,7 +519,10 @@ const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory);
};
const onWatcherSetupComplete = (win, watchPath) => {
const onWatcherSetupComplete = (win, watchPath, collectionUid, watcher) => {
// Mark discovery as complete
watcher.completeCollectionDiscovery(win, collectionUid);
const UiStateSnapshotStore = new UiStateSnapshot();
const collectionsSnapshotState = UiStateSnapshotStore.getCollections();
const collectionSnapshotState = collectionsSnapshotState?.find(c => c?.pathname == watchPath);
@@ -514,6 +532,75 @@ const onWatcherSetupComplete = (win, watchPath) => {
class CollectionWatcher {
constructor() {
this.watchers = {};
this.loadingStates = {};
}
// Initialize loading state tracking for a collection
initializeLoadingState(collectionUid) {
if (!this.loadingStates[collectionUid]) {
this.loadingStates[collectionUid] = {
isDiscovering: false, // Initial discovery phase
isProcessing: false, // Processing discovered files
pendingFiles: new Set(), // Files that need processing
};
}
}
startCollectionDiscovery(win, collectionUid) {
this.initializeLoadingState(collectionUid);
const state = this.loadingStates[collectionUid];
state.isDiscovering = true;
state.pendingFiles.clear();
win.webContents.send('main:collection-loading-state-updated', {
collectionUid,
isLoading: true
});
}
addFileToProcessing(collectionUid, filepath) {
this.initializeLoadingState(collectionUid);
const state = this.loadingStates[collectionUid];
state.pendingFiles.add(filepath);
}
markFileAsProcessed(win, collectionUid, filepath) {
if (!this.loadingStates[collectionUid]) return;
const state = this.loadingStates[collectionUid];
state.pendingFiles.delete(filepath);
// If discovery is complete and no pending files, mark as not loading
if (!state.isDiscovering && state.pendingFiles.size === 0 && state.isProcessing) {
state.isProcessing = false;
win.webContents.send('main:collection-loading-state-updated', {
collectionUid,
isLoading: false
});
}
}
completeCollectionDiscovery(win, collectionUid) {
if (!this.loadingStates[collectionUid]) return;
const state = this.loadingStates[collectionUid];
state.isDiscovering = false;
// If there are pending files, start processing phase
if (state.pendingFiles.size > 0) {
state.isProcessing = true;
} else {
// No pending files, collection is fully loaded
win.webContents.send('main:collection-loading-state-updated', {
collectionUid,
isLoading: false
});
}
}
cleanupLoadingState(collectionUid) {
delete this.loadingStates[collectionUid];
}
addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false, useWorkerThread) {
@@ -521,6 +608,10 @@ class CollectionWatcher {
this.watchers[watchPath].close();
}
this.initializeLoadingState(collectionUid);
this.startCollectionDiscovery(win, collectionUid);
const ignores = brunoConfig?.ignore || [];
setTimeout(() => {
const watcher = chokidar.watch(watchPath, {
@@ -546,8 +637,8 @@ class CollectionWatcher {
let startedNewWatcher = false;
watcher
.on('ready', () => onWatcherSetupComplete(win, watchPath))
.on('add', (pathname) => add(win, pathname, collectionUid, watchPath, useWorkerThread))
.on('ready', () => onWatcherSetupComplete(win, watchPath, collectionUid, this))
.on('add', (pathname) => add(win, pathname, collectionUid, watchPath, useWorkerThread, this))
.on('addDir', (pathname) => addDirectory(win, pathname, collectionUid, watchPath))
.on('change', (pathname) => change(win, pathname, collectionUid, watchPath))
.on('unlink', (pathname) => unlink(win, pathname, collectionUid, watchPath))
@@ -582,11 +673,15 @@ class CollectionWatcher {
return this.watchers[watchPath];
}
removeWatcher(watchPath, win) {
removeWatcher(watchPath, win, collectionUid) {
if (this.watchers[watchPath]) {
this.watchers[watchPath].close();
this.watchers[watchPath] = null;
}
if (collectionUid) {
this.cleanupLoadingState(collectionUid);
}
}
getWatcherByItemPath(itemPath) {

View File

@@ -1,279 +0,0 @@
const _ = require('lodash');
const {
bruToJsonV2,
jsonToBruV2,
bruToEnvJsonV2,
envJsonToBruV2,
collectionBruToJson: _collectionBruToJson,
jsonToCollectionBru: _jsonToCollectionBru
} = require('@usebruno/lang');
const BruParserWorker = require('./workers');
const bruParserWorker = new BruParserWorker();
const collectionBruToJson = async (data, parsed = false) => {
try {
const json = parsed ? data : _collectionBruToJson(data);
const transformedJson = {
request: {
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
script: _.get(json, 'script', {}),
vars: _.get(json, 'vars', {}),
tests: _.get(json, 'tests', '')
},
settings: _.get(json, 'settings', {}),
docs: _.get(json, 'docs', '')
};
// add meta if it exists
// this is only for folder bru file
// in the future, all of this will be replaced by standard bru lang
const sequence = _.get(json, 'meta.seq');
if (json?.meta) {
transformedJson.meta = {
name: json.meta.name,
};
if (sequence) {
transformedJson.meta.seq = Number(sequence);
}
}
return transformedJson;
} catch (error) {
return Promise.reject(error);
}
};
const jsonToCollectionBru = async (json, isFolder) => {
try {
const collectionBruJson = {
headers: _.get(json, 'request.headers', []),
script: {
req: _.get(json, 'request.script.req', ''),
res: _.get(json, 'request.script.res', '')
},
vars: {
req: _.get(json, 'request.vars.req', []),
res: _.get(json, 'request.vars.res', [])
},
tests: _.get(json, 'request.tests', ''),
auth: _.get(json, 'request.auth', {}),
docs: _.get(json, 'docs', '')
};
// add meta if it exists
// this is only for folder bru file
// in the future, all of this will be replaced by standard bru lang
const sequence = _.get(json, 'meta.seq');
if (json?.meta) {
collectionBruJson.meta = {
name: json.meta.name,
};
if (sequence) {
collectionBruJson.meta.seq = Number(sequence);
}
}
return _jsonToCollectionBru(collectionBruJson);
} catch (error) {
return Promise.reject(error);
}
};
const bruToEnvJson = async (bru) => {
try {
const json = bruToEnvJsonV2(bru);
// the app env format requires each variable to have a type
// this need to be evaluated and safely removed
// i don't see it being used in schema validation
if (json && json.variables && json.variables.length) {
_.each(json.variables, (v) => (v.type = 'text'));
}
return json;
} catch (error) {
return Promise.reject(error);
}
};
const envJsonToBru = async (json) => {
try {
const bru = envJsonToBruV2(json);
return bru;
} catch (error) {
return Promise.reject(error);
}
};
/**
* The transformer function for converting a BRU file to JSON.
*
* We map the json response from the bru lang and transform it into the DSL
* format that the app uses
*
* @param {string} data The BRU file content.
* @returns {object} The JSON representation of the BRU file.
*/
const bruToJson = (data, parsed = false) => {
try {
const json = parsed ? data : bruToJsonV2(data);
let requestType = _.get(json, 'meta.type');
if (requestType === 'http') {
requestType = 'http-request';
} else if (requestType === 'graphql') {
requestType = 'graphql-request';
} else {
requestType = 'http-request';
}
const sequence = _.get(json, 'meta.seq');
const transformedJson = {
type: requestType,
name: _.get(json, 'meta.name'),
seq: !_.isNaN(sequence) ? Number(sequence) : 1,
settings: _.get(json, 'settings', {}),
tags: _.get(json, 'meta.tags', []),
request: {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
params: _.get(json, 'params', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
body: _.get(json, 'body', {}),
script: _.get(json, 'script', {}),
vars: _.get(json, 'vars', {}),
assertions: _.get(json, 'assertions', []),
tests: _.get(json, 'tests', ''),
docs: _.get(json, 'docs', '')
}
};
transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none');
transformedJson.request.body.mode = _.get(json, 'http.body', 'none');
return transformedJson;
} catch (e) {
return Promise.reject(e);
}
};
const bruToJsonViaWorker = async (data) => {
try {
const json = await bruParserWorker?.bruToJson(data);
return bruToJson(json, true);
} catch (e) {
return Promise.reject(e);
}
};
/**
* The transformer function for converting a JSON to BRU file.
*
* We map the json response from the app and transform it into the DSL
* format that the bru lang understands
*
* @param {object} json The JSON representation of the BRU file.
* @returns {string} The BRU file content.
*/
const jsonToBru = async (json) => {
let type = _.get(json, 'type');
if (type === 'http-request') {
type = 'http';
} else if (type === 'graphql-request') {
type = 'graphql';
} else {
type = 'http';
}
const sequence = _.get(json, 'seq');
const bruJson = {
meta: {
name: _.get(json, 'name'),
type: type,
seq: !_.isNaN(sequence) ? Number(sequence) : 1,
tags: _.get(json, 'tags', []),
},
http: {
method: _.lowerCase(_.get(json, 'request.method')),
url: _.get(json, 'request.url'),
auth: _.get(json, 'request.auth.mode', 'none'),
body: _.get(json, 'request.body.mode', 'none')
},
params: _.get(json, 'request.params', []),
headers: _.get(json, 'request.headers', []),
auth: _.get(json, 'request.auth', {}),
body: _.get(json, 'request.body', {}),
script: _.get(json, 'request.script', {}),
vars: {
req: _.get(json, 'request.vars.req', []),
res: _.get(json, 'request.vars.res', [])
},
assertions: _.get(json, 'request.assertions', []),
tests: _.get(json, 'request.tests', ''),
settings: _.get(json, 'settings', {}),
docs: _.get(json, 'request.docs', '')
};
const bru = jsonToBruV2(bruJson);
return bru;
};
const jsonToBruViaWorker = async (json) => {
let type = _.get(json, 'type');
if (type === 'http-request') {
type = 'http';
} else if (type === 'graphql-request') {
type = 'graphql';
} else {
type = 'http';
}
const sequence = _.get(json, 'seq');
const bruJson = {
meta: {
name: _.get(json, 'name'),
type: type,
seq: !_.isNaN(sequence) ? Number(sequence) : 1,
tags: _.get(json, 'tags', [])
},
http: {
method: _.lowerCase(_.get(json, 'request.method')),
url: _.get(json, 'request.url'),
auth: _.get(json, 'request.auth.mode', 'none'),
body: _.get(json, 'request.body.mode', 'none')
},
params: _.get(json, 'request.params', []),
headers: _.get(json, 'request.headers', []),
auth: _.get(json, 'request.auth', {}),
body: _.get(json, 'request.body', {}),
script: _.get(json, 'request.script', {}),
vars: {
req: _.get(json, 'request.vars.req', []),
res: _.get(json, 'request.vars.res', [])
},
assertions: _.get(json, 'request.assertions', []),
tests: _.get(json, 'request.tests', ''),
settings: _.get(json, 'settings', {}),
docs: _.get(json, 'request.docs', '')
};
const bru = await bruParserWorker?.jsonToBru(bruJson)
return bru;
};
module.exports = {
bruToJson,
bruToJsonViaWorker,
jsonToBru,
bruToEnvJson,
envJsonToBru,
collectionBruToJson,
jsonToCollectionBru,
jsonToBruViaWorker
};

View File

@@ -1,64 +0,0 @@
const { sizeInMB } = require("../../utils/filesystem");
const WorkerQueue = require("../../workers");
const path = require("path");
const getSize = (data) => {
return sizeInMB(typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : Buffer.byteLength(JSON.stringify(data), 'utf8'));
}
/**
* Lanes are used to determine which worker queue to use based on the size of the data.
*
* The first lane is for smaller files (<0.1MB), the second lane is for larger files (>=0.1MB).
* This helps with parsing performance.
*/
const LANES = [{
maxSize: 0.005
},{
maxSize: 0.1
},{
maxSize: 1
},{
maxSize: 10
},{
maxSize: 100
}];
class BruParserWorker {
constructor() {
this.workerQueues = LANES?.map(lane => ({
maxSize: lane?.maxSize,
workerQueue: new WorkerQueue()
}));
}
getWorkerQueue(size) {
// Find the first queue that can handle the given size
// or fallback to the last queue for largest files
const queueForSize = this.workerQueues.find((queue) =>
queue.maxSize >= size
);
return queueForSize?.workerQueue ?? this.workerQueues.at(-1).workerQueue;
}
async enqueueTask({data, scriptFile }) {
const size = getSize(data);
const workerQueue = this.getWorkerQueue(size);
return workerQueue.enqueue({
data,
priority: size,
scriptPath: path.join(__dirname, `./scripts/${scriptFile}.js`)
});
}
async bruToJson(data) {
return this.enqueueTask({ data, scriptFile: `bru-to-json` });
}
async jsonToBru(data) {
return this.enqueueTask({ data, scriptFile: `json-to-bru` });
}
}
module.exports = BruParserWorker;

View File

@@ -1,16 +0,0 @@
const { parentPort } = require('worker_threads');
const {
bruToJsonV2,
} = require('@usebruno/lang');
parentPort.on('message', (workerData) => {
try {
const bru = workerData;
const json = bruToJsonV2(bru);
parentPort.postMessage(json);
}
catch(error) {
console.error(error);
parentPort.postMessage({ error: error?.message });
}
});

View File

@@ -1,16 +0,0 @@
const { parentPort } = require('worker_threads');
const {
jsonToBruV2,
} = require('@usebruno/lang');
parentPort.on('message', (workerData) => {
try {
const json = workerData;
const bru = jsonToBruV2(json);
parentPort.postMessage(bru);
}
catch(error) {
console.error(error);
parentPort.postMessage({ error: error?.message });
}
});

View File

@@ -5,7 +5,18 @@ const fsExtra = require('fs-extra');
const os = require('os');
const path = require('path');
const { ipcMain, shell, dialog, app } = require('electron');
const { envJsonToBru, bruToJson, jsonToBru, jsonToBruViaWorker, collectionBruToJson, jsonToCollectionBru, bruToJsonViaWorker } = require('../bru');
const {
parseRequest,
stringifyRequest,
parseRequestViaWorker,
stringifyRequestViaWorker,
parseCollection,
stringifyCollection,
parseFolder,
stringifyFolder,
parseEnvironment,
stringifyEnvironment
} = require('@usebruno/filestore');
const brunoConverters = require('@usebruno/converters');
const { postmanToBruno } = brunoConverters;
@@ -225,10 +236,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
};
}
const content = await jsonToCollectionBru(
folderRoot,
true // isFolder
);
const content = await stringifyFolder(folderRoot);
await writeFile(folderBruFilePath, content);
} catch (error) {
return Promise.reject(error);
@@ -238,7 +246,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
try {
const collectionBruFilePath = path.join(collectionPathname, 'collection.bru');
const content = await jsonToCollectionBru(collectionRoot);
const content = await stringifyCollection(collectionRoot);
await writeFile(collectionBruFilePath, content);
} catch (error) {
return Promise.reject(error);
@@ -256,7 +264,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`${request.filename}.bru is not a valid filename`);
}
validatePathIsInsideCollection(pathname, lastOpenedCollections);
const content = await jsonToBruViaWorker(request);
const content = await stringifyRequestViaWorker(request);
await writeFile(pathname, content);
} catch (error) {
return Promise.reject(error);
@@ -270,7 +278,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`path: ${pathname} does not exist`);
}
const content = await jsonToBruViaWorker(request);
const content = await stringifyRequestViaWorker(request);
await writeFile(pathname, content);
} catch (error) {
return Promise.reject(error);
@@ -288,7 +296,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`path: ${pathname} does not exist`);
}
const content = await jsonToBruViaWorker(request);
const content = await stringifyRequestViaWorker(request);
await writeFile(pathname, content);
}
} catch (error) {
@@ -318,7 +326,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
environmentSecretsStore.storeEnvSecrets(collectionPathname, environment);
}
const content = await envJsonToBru(environment);
const content = await stringifyEnvironment(environment);
await writeFile(envFilePath, content);
} catch (error) {
@@ -343,7 +351,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
environmentSecretsStore.storeEnvSecrets(collectionPathname, environment);
}
const content = await envJsonToBru(environment);
const content = await stringifyEnvironment(environment);
await writeFile(envFilePath, content);
} catch (error) {
return Promise.reject(error);
@@ -402,7 +410,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
let folderBruFileJsonContent;
if (fs.existsSync(folderBruFilePath)) {
const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8');
folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent);
folderBruFileJsonContent = await parseFolder(oldFolderBruFileContent);
folderBruFileJsonContent.meta.name = newName;
} else {
folderBruFileJsonContent = {
@@ -412,7 +420,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
};
}
const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true);
const folderBruFileContent = await stringifyFolder(folderBruFileJsonContent);
await writeFile(folderBruFilePath, folderBruFileContent);
return;
@@ -424,9 +432,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
const data = fs.readFileSync(itemPath, 'utf8');
const jsonData = await bruToJson(data);
const jsonData = parseRequest(data);
jsonData.name = newName;
const content = await jsonToBru(jsonData);
const content = stringifyRequest(jsonData);
await writeFile(itemPath, content);
} catch (error) {
return Promise.reject(error);
@@ -452,7 +460,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
let folderBruFileJsonContent;
if (fs.existsSync(folderBruFilePath)) {
const oldFolderBruFileContent = await fs.promises.readFile(folderBruFilePath, 'utf8');
folderBruFileJsonContent = await collectionBruToJson(oldFolderBruFileContent);
folderBruFileJsonContent = await parseFolder(oldFolderBruFileContent);
folderBruFileJsonContent.meta.name = newName;
} else {
folderBruFileJsonContent = {
@@ -462,7 +470,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
};
}
const folderBruFileContent = await jsonToCollectionBru(folderBruFileJsonContent, true);
const folderBruFileContent = await stringifyFolder(folderBruFileJsonContent);
await writeFile(folderBruFilePath, folderBruFileContent);
const bruFilesAtSource = await searchForBruFiles(oldPath);
@@ -503,11 +511,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// update name in file and save new copy, then delete old copy
const data = await fs.promises.readFile(oldPath, 'utf8'); // Use async read
const jsonData = await bruToJsonViaWorker(data);
const jsonData = parseRequest(data);
jsonData.name = newName;
moveRequestUid(oldPath, newPath);
const content = await jsonToBruViaWorker(jsonData);
const content = stringifyRequest(jsonData);
await fs.promises.unlink(oldPath);
await writeFile(newPath, content);
@@ -538,7 +546,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (!fs.existsSync(pathname)) {
fs.mkdirSync(pathname);
const folderBruFilePath = path.join(pathname, 'folder.bru');
const content = await jsonToCollectionBru(folderBruJsonData, true); // isFolder flag
const content = await stringifyFolder(folderBruJsonData);
await writeFile(folderBruFilePath, content);
} else {
return Promise.reject(new Error('The directory already exists'));
@@ -585,10 +593,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
ipcMain.handle('renderer:remove-collection', async (event, collectionPath) => {
ipcMain.handle('renderer:remove-collection', async (event, collectionPath, collectionUid) => {
if (watcher && mainWindow) {
console.log(`watcher stopWatching: ${collectionPath}`);
watcher.removeWatcher(collectionPath, mainWindow);
watcher.removeWatcher(collectionPath, mainWindow, collectionUid);
lastOpenedCollections.remove(collectionPath);
}
});
@@ -611,7 +619,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`);
const content = await jsonToBruViaWorker(item);
const content = await stringifyRequestViaWorker(item);
const filePath = path.join(currentPath, sanitizedFilename);
safeWriteFileSync(filePath, content);
}
@@ -623,10 +631,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (item?.root?.meta?.name) {
const folderBruFilePath = path.join(folderPath, 'folder.bru');
item.root.meta.seq = item.seq;
const folderContent = await jsonToCollectionBru(
item.root,
true // isFolder
);
const folderContent = await stringifyFolder(item.root);
safeWriteFileSync(folderBruFilePath, folderContent);
}
@@ -650,7 +655,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
environments.forEach(async (env) => {
const content = await envJsonToBru(env);
const content = await stringifyEnvironment(env);
let sanitizedEnvFilename = sanitizeName(`${env.name}.bru`);
const filePath = path.join(envDirPath, sanitizedEnvFilename);
safeWriteFileSync(filePath, content);
@@ -681,7 +686,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// Write the Bruno configuration to a file
await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
const collectionContent = await jsonToCollectionBru(collection.root);
const collectionContent = await stringifyCollection(collection.root);
await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
const { size, filesCount } = await getCollectionStats(collectionPath);
@@ -711,7 +716,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const parseCollectionItems = (items = [], currentPath) => {
items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
const content = await jsonToBruViaWorker(item);
const content = await stringifyRequestViaWorker(item);
const filePath = path.join(currentPath, item.filename);
safeWriteFileSync(filePath, content);
}
@@ -721,7 +726,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// If folder has a root element, then I should write its folder.bru file
if (item.root) {
const folderContent = await jsonToCollectionBru(item.root, true);
const folderContent = await stringifyFolder(item.root);
folderContent.name = item.name;
if (folderContent) {
const bruFolderPath = path.join(folderPath, `folder.bru`);
@@ -740,7 +745,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// If initial folder has a root element, then I should write its folder.bru file
if (itemFolder.root) {
const folderContent = await jsonToCollectionBru(itemFolder.root, true);
const folderContent = await stringifyFolder(itemFolder.root);
if (folderContent) {
const bruFolderPath = path.join(collectionPath, `folder.bru`);
safeWriteFileSync(bruFolderPath, folderContent);
@@ -767,7 +772,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
};
if (fs.existsSync(folderRootPath)) {
const bru = fs.readFileSync(folderRootPath, 'utf8');
folderBruJsonData = await collectionBruToJson(bru);
folderBruJsonData = await parseCollection(bru);
if (!folderBruJsonData?.meta) {
folderBruJsonData.meta = {
name: path.basename(item.pathname),
@@ -779,12 +784,12 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
folderBruJsonData.meta.seq = item.seq;
}
const content = await jsonToCollectionBru(folderBruJsonData);
const content = await stringifyFolder(folderBruJsonData);
await writeFile(folderRootPath, content);
} else {
if (fs.existsSync(item.pathname)) {
const itemToSave = transformRequestToSaveToFilesystem(item);
const content = await jsonToBruViaWorker(itemToSave);
const content = await stringifyRequestViaWorker(itemToSave);
await writeFile(item.pathname, content);
}
}
@@ -1065,14 +1070,14 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
const metaJson = await bruToJson(parseBruFileMeta(bruContent), true);
const metaJson = parseBruFileMeta(bruContent);
file.data = metaJson;
file.loading = true;
file.partial = true;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
file.data = await bruToJsonViaWorker(bruContent);
file.data = await parseRequestViaWorker(bruContent);
file.partial = false;
file.loading = true;
file.size = sizeInMB(fileStats?.size);
@@ -1089,7 +1094,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
const metaJson = await bruToJson(parseBruFileMeta(bruContent), true);
const metaJson = parseRequest(parseBruFileMeta(bruContent));
file.data = metaJson;
file.partial = true;
file.loading = false;
@@ -1140,14 +1145,14 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
const metaJson = await bruToJson(parseBruFileMeta(bruContent), true);
const metaJson = parseRequest(parseBruFileMeta(bruContent));
file.data = metaJson;
file.loading = true;
file.partial = true;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
file.data = bruToJson(bruContent);
file.data = parseRequest(bruContent);
file.partial = false;
file.loading = true;
file.size = sizeInMB(fileStats?.size);
@@ -1164,7 +1169,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
const metaJson = await bruToJson(parseBruFileMeta(bruContent), true);
const metaJson = parseRequest(parseBruFileMeta(bruContent));
file.data = metaJson;
file.partial = true;
file.loading = false;

View File

@@ -472,6 +472,9 @@ const registerNetworkIpc = (mainWindow) => {
});
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
const domainsWithCookies = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
}
// interpolate variables inside request
@@ -584,6 +587,9 @@ const registerNetworkIpc = (mainWindow) => {
});
collection.globalEnvironmentVariables = scriptResult.globalEnvironmentVariables;
const domainsWithCookiesPost = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesPost)));
}
return scriptResult;
};
@@ -891,6 +897,9 @@ const registerNetworkIpc = (mainWindow) => {
scriptType: 'test',
error: testError
});
const domainsWithCookiesTest = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest)));
}
return {
@@ -901,6 +910,7 @@ const registerNetworkIpc = (mainWindow) => {
dataBuffer: response.dataBuffer.toString('base64'),
size: Buffer.byteLength(response.dataBuffer),
duration: responseTime ?? 0,
url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null,
timeline: response.timeline
};
} catch (error) {
@@ -1093,6 +1103,9 @@ const registerNetworkIpc = (mainWindow) => {
error: preRequestError
});
const domainsWithCookiesPreRequest = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesPreRequest)));
if (preRequestError) {
throw preRequestError;
}
@@ -1208,13 +1221,14 @@ const registerNetworkIpc = (mainWindow) => {
data: response.data,
responseTime: response.responseTime,
timeline: response.timeline,
url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null
},
...eventData
});
} catch (error) {
// Skip further processing if request was cancelled
if (axios.isCancel(error)) {
throw Promise.reject(error);
throw error;
}
if (error?.response) {
@@ -1248,7 +1262,7 @@ const registerNetworkIpc = (mainWindow) => {
await executeRequestOnFailHandler(request, error);
// if it's not a network error, don't continue
throw Promise.reject(error);
throw error;
}
}
@@ -1280,6 +1294,9 @@ const registerNetworkIpc = (mainWindow) => {
error: postResponseError
});
const domainsWithCookiesPostResponse = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesPostResponse)));
if (postResponseScriptResult?.nextRequestName !== undefined) {
nextRequestName = postResponseScriptResult.nextRequestName;
}
@@ -1384,6 +1401,9 @@ const registerNetworkIpc = (mainWindow) => {
scriptType: 'test',
error: testError
});
const domainsWithCookiesTest = await getDomainsWithCookies();
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookiesTest)));
}
} catch (error) {
mainWindow.webContents.send('main:run-folder-event', {

View File

@@ -15,11 +15,17 @@ const registerPreferencesIpc = (mainWindow, watcher, lastOpenedCollections) => {
const { http_proxy, https_proxy, no_proxy } = systemProxyVars || {};
mainWindow.webContents.send('main:load-system-proxy-env', { http_proxy, https_proxy, no_proxy });
// load global environments
const globalEnvironments = globalEnvironmentsStore.getGlobalEnvironments();
let activeGlobalEnvironmentUid = globalEnvironmentsStore.getActiveGlobalEnvironmentUid();
activeGlobalEnvironmentUid = globalEnvironments?.find(env => env?.uid == activeGlobalEnvironmentUid) ? activeGlobalEnvironmentUid : null;
mainWindow.webContents.send('main:load-global-environments', { globalEnvironments, activeGlobalEnvironmentUid });
try {
// load global environments
const globalEnvironments = globalEnvironmentsStore.getGlobalEnvironments();
let activeGlobalEnvironmentUid = globalEnvironmentsStore.getActiveGlobalEnvironmentUid();
activeGlobalEnvironmentUid = globalEnvironments?.find(env => env?.uid == activeGlobalEnvironmentUid) ? activeGlobalEnvironmentUid : null;
mainWindow.webContents.send('main:load-global-environments', { globalEnvironments, activeGlobalEnvironmentUid });
}
catch(error) {
console.error("Error occured while fetching global environements!");
console.error(error);
}
// reload last opened collections
const lastOpened = lastOpenedCollections.getAll();

View File

@@ -1,6 +1,6 @@
const _ = require('lodash');
const Store = require('electron-store');
const { encryptString } = require('../utils/encryption');
const { encryptStringSafe } = require('../utils/encryption');
/**
* Sample secrets store file
@@ -33,7 +33,7 @@ class EnvironmentSecretsStore {
if (v.secret) {
envVars.push({
name: v.name,
value: encryptString(v.value)
value: encryptStringSafe(v.value).value
});
}
});

View File

@@ -1,6 +1,6 @@
const _ = require('lodash');
const Store = require('electron-store');
const { encryptString, decryptString } = require('../utils/encryption');
const { encryptStringSafe, decryptStringSafe } = require('../utils/encryption');
class GlobalEnvironmentsStore {
constructor() {
@@ -14,7 +14,7 @@ class GlobalEnvironmentsStore {
return globalEnvironments?.map(env => {
const variables = env.variables?.map(v => ({
...v,
value: v?.secret ? encryptString(v.value) : v?.value
value: v?.secret ? encryptStringSafe(v.value).value : v?.value
})) || [];
return {
@@ -28,7 +28,7 @@ class GlobalEnvironmentsStore {
return globalEnvironments?.map(env => {
const variables = env.variables?.map(v => ({
...v,
value: v?.secret ? decryptString(v.value) : v?.value
value: v?.secret ? decryptStringSafe(v.value).value : v?.value
})) || [];
return {

View File

@@ -1,7 +1,7 @@
const _ = require('lodash');
const Store = require('electron-store');
const { uuid, safeStringifyJSON, safeParseJSON } = require('../utils/common');
const { encryptString, decryptString } = require('../utils/encryption');
const { encryptStringSafe, decryptStringSafe } = require('../utils/encryption');
/**
* Sample secrets store file
@@ -119,7 +119,8 @@ class Oauth2Store {
let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
let credentials = oauth2DataForCollection?.credentials?.find(c => (c?.url == url) && (c?.credentialsId == credentialsId));
if (!credentials?.data) return null;
let decryptedCredentialsData = safeParseJSON(decryptString(credentials?.data));
const decryptionResult = decryptStringSafe(credentials?.data);
const decryptedCredentialsData = safeParseJSON(decryptionResult.value);
return decryptedCredentialsData;
} catch (err) {
console.log('error retrieving oauth2 credentials from cache', err);
@@ -128,7 +129,8 @@ class Oauth2Store {
updateCredentialsForCollection({ collectionUid, url, credentialsId, credentials = {} }) {
try {
let encryptedCredentialsData = encryptString(safeStringifyJSON(credentials));
const encryptionResult = encryptStringSafe(safeStringifyJSON(credentials));
const encryptedCredentialsData = encryptionResult.value;
let oauth2DataForCollection = this.getOauth2DataOfCollection({ collectionUid, url });
let filteredCredentials = oauth2DataForCollection?.credentials?.filter(c => (c?.url !== url) || (c?.credentialsId !== credentialsId));
if (!filteredCredentials) filteredCredentials = [];

View File

@@ -10,7 +10,7 @@ const { get, merge } = require('lodash');
const defaultPreferences = {
request: {
sslVerification: false,
sslVerification: true,
customCaCertificate: {
enabled: false,
filePath: null
@@ -27,7 +27,7 @@ const defaultPreferences = {
codeFontSize: 14
},
proxy: {
mode: 'system',
mode: 'off',
protocol: 'http',
hostname: '',
port: null,
@@ -132,7 +132,7 @@ const savePreferences = async (newPreferences) => {
const preferencesUtil = {
shouldVerifyTls: () => {
return get(getPreferences(), 'request.sslVerification', false);
return get(getPreferences(), 'request.sslVerification', true);
},
shouldUseCustomCaCertificate: () => {
return get(getPreferences(), 'request.customCaCertificate.enabled', false);

View File

@@ -237,12 +237,47 @@ const parseBruFileMeta = (data) => {
metaJson[key] = isNaN(value) ? value : Number(value);
}
});
return { meta: metaJson };
// Transform to the format expected by bruno-app
let requestType = metaJson.type;
if (requestType === 'http') {
requestType = 'http-request';
} else if (requestType === 'graphql') {
requestType = 'graphql-request';
} else {
requestType = 'http-request';
}
const sequence = metaJson.seq;
const transformedJson = {
type: requestType,
name: metaJson.name,
seq: !isNaN(sequence) ? Number(sequence) : 1,
settings: {},
tags: metaJson.tags || [],
request: {
method: '',
url: '',
params: [],
headers: [],
auth: { mode: 'none' },
body: { mode: 'none' },
script: {},
vars: {},
assertions: [],
tests: '',
docs: ''
}
};
return transformedJson;
} else {
console.log('No "meta" block found in the file.');
return null;
}
} catch (err) {
console.error('Error reading file:', err);
return null;
}
}

View File

@@ -1,197 +1 @@
const { Cookie, CookieJar } = require('tough-cookie');
const each = require('lodash/each');
const moment = require('moment');
const { isPotentiallyTrustworthyOrigin } = require('@usebruno/requests').utils;
const cookieJar = new CookieJar();
const addCookieToJar = (setCookieHeader, requestUrl) => {
const cookie = Cookie.parse(setCookieHeader, { loose: true });
cookieJar.setCookieSync(cookie, requestUrl, {
ignoreError: true // silently ignore things like parse errors and invalid domains
});
};
const getCookiesForUrl = (url) => {
return cookieJar.getCookiesSync(url, {
secure: isPotentiallyTrustworthyOrigin(url)
});
};
const getCookieStringForUrl = (url) => {
const cookies = getCookiesForUrl(url);
if (!Array.isArray(cookies) || !cookies.length) {
return '';
}
const validCookies = cookies.filter((cookie) => !cookie.expires || cookie.expires > Date.now());
return validCookies.map((cookie) => cookie.cookieString()).join('; ');
};
const getDomainsWithCookies = () => {
return new Promise((resolve, reject) => {
const domainCookieMap = {};
cookieJar.store.getAllCookies((err, cookies) => {
if (err) {
return reject(err);
}
cookies.forEach((cookie) => {
if (!domainCookieMap[cookie.domain]) {
domainCookieMap[cookie.domain] = [cookie];
} else {
domainCookieMap[cookie.domain].push(cookie);
}
});
const domains = Object.keys(domainCookieMap);
const domainsWithCookies = [];
each(domains, (domain) => {
const cookies = domainCookieMap[domain];
const validCookies = cookies.filter((cookie) => !cookie.expires || cookie.expires > Date.now());
if (validCookies.length) {
domainsWithCookies.push({
domain,
cookies: validCookies,
cookieString: validCookies.map((cookie) => cookie.cookieString()).join('; ')
});
}
});
resolve(domainsWithCookies);
});
});
};
const deleteCookie = (domain, path, cookieKey) => {
return new Promise((resolve, reject) => {
cookieJar.store.removeCookie(domain, path, cookieKey, (err) => {
if (err) {
return reject(err);
}
return resolve();
});
});
};
const deleteCookiesForDomain = (domain) => {
return new Promise((resolve, reject) => {
cookieJar.store.removeCookies(domain, null, (err) => {
if (err) {
return reject(err);
}
return resolve();
});
});
};
const updateCookieObj = (cookieObj, oldCookie) => {
return {
...cookieObj,
// Preserve immutable properties from old cookie
path: oldCookie.path,
key: oldCookie.key,
domain: oldCookie.domain,
// Handle other mutable properties
expires: cookieObj?.expires && moment(cookieObj.expires).isValid() ? new Date(cookieObj.expires) : Infinity,
creation: oldCookie?.creation && moment(oldCookie.creation).isValid() ? new Date(oldCookie.creation) : new Date(),
lastAccessed:
oldCookie?.lastAccessed && moment(oldCookie.lastAccessed).isValid()
? new Date(oldCookie.lastAccessed)
: new Date()
};
};
const createCookieObj = (cookieObj) => {
return {
...cookieObj,
path: cookieObj.path || '/',
expires: cookieObj?.expires && moment(cookieObj.expires).isValid() ? new Date(cookieObj.expires) : Infinity,
creation: cookieObj?.creation && moment(cookieObj.creation).isValid() ? new Date(cookieObj.creation) : new Date(),
lastAccessed:
cookieObj?.lastAccessed && moment(cookieObj.lastAccessed).isValid()
? new Date(cookieObj.lastAccessed)
: new Date()
};
};
const addCookieForDomain = (domain, cookieObj) => {
return new Promise((resolve, reject) => {
try {
const cookie = new Cookie(createCookieObj(cookieObj));
cookieJar.store.putCookie(cookie, (err) => {
if (err) {
return reject(err);
}
return resolve();
});
} catch (err) {
reject(err);
}
});
};
const modifyCookieForDomain = (domain, oldCookieObj, cookieObj) => {
return new Promise((resolve, reject) => {
try {
const oldCookie = new Cookie(createCookieObj(oldCookieObj));
const newCookie = new Cookie(updateCookieObj(cookieObj, oldCookie));
cookieJar.store.updateCookie(oldCookie, newCookie, (removeErr) => {
if (removeErr) {
return reject(removeErr);
}
return resolve();
});
} catch (err) {
reject(err);
}
});
};
const parseCookieString = (cookieStr) => {
try {
const cookie = Cookie.parse(cookieStr);
if (!cookie) return null;
return {
...cookie,
expires: cookie.expires === Infinity ? null : cookie.expires
};
} catch (err) {
throw new Error(err);
}
};
const createCookieString = (cookieObj) => {
const cookie = new Cookie(createCookieObj(cookieObj));
// cookie.toString() omits the domain
let cookieString = cookie.toString();
// Manually append domain and hostOnly if they exist
if (cookieObj.hostOnly && !cookieString.includes('Domain=')) {
cookieString += `; Domain=${cookieObj.domain}`;
}
return cookieString;
};
module.exports = {
addCookieToJar,
getCookiesForUrl,
getCookieStringForUrl,
getDomainsWithCookies,
deleteCookie,
deleteCookiesForDomain,
addCookieForDomain,
modifyCookieForDomain,
parseCookieString,
createCookieString,
updateCookieObj,
createCookieObj
};
module.exports = require('@usebruno/common').cookies;

View File

@@ -54,11 +54,16 @@ function aes256Decrypt(data) {
return decrypted;
} catch (err) {
// If decryption fails, fall back to old key derivation
const { key: oldKey, iv: oldIv } = deriveKeyAndIv(rawKey, 32, 16);
const decipher = crypto.createDecipheriv('aes-256-cbc', oldKey, oldIv);
let decrypted = decipher.update(data, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
try {
const { key: oldKey, iv: oldIv } = deriveKeyAndIv(rawKey, 32, 16);
const decipher = crypto.createDecipheriv('aes-256-cbc', oldKey, oldIv);
const decrypted = decipher.update(data, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (fallbackErr) {
console.error('AES256 decryption failed with both methods:', err, fallbackErr);
throw new Error('AES256 decryption failed: ' + fallbackErr.message);
}
}
}
@@ -73,16 +78,21 @@ function safeStorageEncrypt(str) {
return encryptedString;
}
function safeStorageDecrypt(str) {
// Convert the hexadecimal string to a buffer
const encryptedStringBuffer = Buffer.from(str, 'hex');
try {
// Convert the hexadecimal string to a buffer
const encryptedStringBuffer = Buffer.from(str, 'hex');
// Decrypt the buffer
const decryptedStringBuffer = safeStorage.decryptString(encryptedStringBuffer);
// Decrypt the buffer
const decryptedStringBuffer = safeStorage.decryptString(encryptedStringBuffer);
// Convert the decrypted buffer to a string
const decryptedString = decryptedStringBuffer.toString();
// Convert the decrypted buffer to a string
const decryptedString = decryptedStringBuffer.toString();
return decryptedString;
return decryptedString;
} catch (err) {
console.error('SafeStorage decryption failed:', err);
throw new Error('SafeStorage decryption failed: ' + err.message);
}
}
function encryptString(str) {
@@ -142,7 +152,29 @@ function decryptString(str) {
}
}
function decryptStringSafe(str) {
try {
const result = decryptString(str);
return { success: true, value: result };
} catch (err) {
console.error('Decryption failed:', err.message);
return { success: false, error: err.message, value: '' };
}
}
function encryptStringSafe(str) {
try {
const result = encryptString(str);
return { success: true, value: result };
} catch (err) {
console.error('Encryption failed:', err.message);
return { success: false, error: err.message, value: '' };
}
}
module.exports = {
encryptString,
decryptString
encryptStringSafe,
decryptString,
decryptStringSafe
};

View File

@@ -368,6 +368,9 @@ function setupProxyAgents({
{ proxy: https_proxy,...tlsOptions },
timeline
);
} else {
const TimelineHttpsAgent = createTimelineAgentClass(https.Agent);
requestConfig.httpsAgent = new TimelineHttpsAgent(tlsOptions, timeline);
}
} catch (error) {
throw new Error('Invalid system https_proxy');

View File

@@ -1,68 +0,0 @@
const { Worker } = require('worker_threads');
class WorkerQueue {
constructor() {
this.queue = [];
this.isProcessing = false;
this.workers = {};
}
async getWorkerForScriptPath(scriptPath) {
if (!this.workers) this.workers = {};
let worker = this.workers[scriptPath];
if (!worker || worker.threadId === -1) {
this.workers[scriptPath] = worker = new Worker(scriptPath);
}
return worker;
}
async enqueue(task) {
const { priority, scriptPath, data } = task;
return new Promise((resolve, reject) => {
this.queue.push({ priority, scriptPath, data, resolve, reject });
this.queue?.sort((taskX, taskY) => taskX?.priority - taskY?.priority);
this.processQueue();
});
}
async processQueue() {
if (this.isProcessing || this.queue.length === 0){
return;
}
this.isProcessing = true;
const { scriptPath, data, resolve, reject } = this.queue.shift();
try {
const result = await this.runWorker({ scriptPath, data });
resolve(result);
} catch (error) {
reject(error);
} finally {
this.isProcessing = false;
this.processQueue();
}
}
async runWorker({ scriptPath, data }) {
return new Promise(async (resolve, reject) => {
let worker = await this.getWorkerForScriptPath(scriptPath);
worker.postMessage(data);
worker.on('message', (data) => {
if (data?.error) {
reject(new Error(data?.error));
}
resolve(data);
});
worker.on('error', (error) => {
reject(error);
});
worker.on('exit', (code) => {
reject(new Error(`stopped with ${code} exit code`));
});
});
}
}
module.exports = WorkerQueue;

View File

@@ -11,22 +11,35 @@ describe('parseBruFileMeta', () => {
const result = parseBruFileMeta(data);
expect(result).toEqual({
meta: {
name: '0.2_mb',
type: 'http',
seq: 1,
},
type: 'http-request',
name: '0.2_mb',
seq: 1,
settings: {},
tags: [],
request: {
method: '',
url: '',
params: [],
headers: [],
auth: { mode: 'none' },
body: { mode: 'none' },
script: {},
vars: {},
assertions: [],
tests: '',
docs: ''
}
});
});
test('returns undefined for missing meta block', () => {
test('returns null for missing meta block', () => {
const data = `someOtherBlock {
key: value
}`;
const result = parseBruFileMeta(data);
expect(result).toBeUndefined();
expect(result).toBeNull();
});
test('handles empty meta block gracefully', () => {
@@ -34,7 +47,26 @@ describe('parseBruFileMeta', () => {
const result = parseBruFileMeta(data);
expect(result).toEqual({ meta: {} });
expect(result).toEqual({
type: 'http-request',
name: undefined,
seq: 1,
settings: {},
tags: [],
request: {
method: '',
url: '',
params: [],
headers: [],
auth: { mode: 'none' },
body: { mode: 'none' },
script: {},
vars: {},
assertions: [],
tests: '',
docs: ''
}
});
});
test('ignores invalid lines in meta block', () => {
@@ -47,10 +79,24 @@ describe('parseBruFileMeta', () => {
const result = parseBruFileMeta(data);
expect(result).toEqual({
meta: {
name: '0.2_mb',
seq: 1,
},
type: 'http-request',
name: '0.2_mb',
seq: 1,
settings: {},
tags: [],
request: {
method: '',
url: '',
params: [],
headers: [],
auth: { mode: 'none' },
body: { mode: 'none' },
script: {},
vars: {},
assertions: [],
tests: '',
docs: ''
}
});
});
@@ -59,7 +105,7 @@ describe('parseBruFileMeta', () => {
const result = parseBruFileMeta(data);
expect(result).toBeUndefined();
expect(result).toBeNull();
});
test('handles missing colon gracefully', () => {
@@ -71,9 +117,24 @@ describe('parseBruFileMeta', () => {
const result = parseBruFileMeta(data);
expect(result).toEqual({
meta: {
seq: 1,
},
type: 'http-request',
name: undefined,
seq: 1,
settings: {},
tags: [],
request: {
method: '',
url: '',
params: [],
headers: [],
auth: { mode: 'none' },
body: { mode: 'none' },
script: {},
vars: {},
assertions: [],
tests: '',
docs: ''
}
});
});
@@ -82,16 +143,30 @@ describe('parseBruFileMeta', () => {
numValue: 1234
floatValue: 12.34
strValue: some_text
seq: 5
}`;
const result = parseBruFileMeta(data);
expect(result).toEqual({
meta: {
numValue: 1234,
floatValue: 12.34,
strValue: 'some_text',
},
type: 'http-request',
name: undefined,
seq: 5,
settings: {},
tags: [],
request: {
method: '',
url: '',
params: [],
headers: [],
auth: { mode: 'none' },
body: { mode: 'none' },
script: {},
vars: {},
assertions: [],
tests: '',
docs: ''
}
});
});
@@ -104,7 +179,7 @@ describe('parseBruFileMeta', () => {
const result = parseBruFileMeta(data);
expect(result).toBeUndefined();
expect(result).toBeNull();
});
test('handles syntax error in meta block 2', () => {
@@ -116,6 +191,98 @@ describe('parseBruFileMeta', () => {
const result = parseBruFileMeta(data);
expect(result).toBeUndefined();
expect(result).toBeNull();
});
});
test('handles graphql type correctly', () => {
const data = `meta {
name: graphql_query
type: graphql
seq: 2
}`;
const result = parseBruFileMeta(data);
expect(result).toEqual({
type: 'graphql-request',
name: 'graphql_query',
seq: 2,
settings: {},
tags: [],
request: {
method: '',
url: '',
params: [],
headers: [],
auth: { mode: 'none' },
body: { mode: 'none' },
script: {},
vars: {},
assertions: [],
tests: '',
docs: ''
}
});
});
test('handles unknown type correctly', () => {
const data = `meta {
name: unknown_request
type: unknown
seq: 3
}`;
const result = parseBruFileMeta(data);
expect(result).toEqual({
type: 'http-request',
name: 'unknown_request',
seq: 3,
settings: {},
tags: [],
request: {
method: '',
url: '',
params: [],
headers: [],
auth: { mode: 'none' },
body: { mode: 'none' },
script: {},
vars: {},
assertions: [],
tests: '',
docs: ''
}
});
});
test('handles missing seq gracefully', () => {
const data = `meta {
name: no_seq_request
type: http
}`;
const result = parseBruFileMeta(data);
expect(result).toEqual({
type: 'http-request',
name: 'no_seq_request',
seq: 1, // Default fallback
settings: {},
tags: [],
request: {
method: '',
url: '',
params: [],
headers: [],
auth: { mode: 'none' },
body: { mode: 'none' },
script: {},
vars: {},
assertions: [],
tests: '',
docs: ''
}
});
});
});

View File

@@ -1,4 +1,4 @@
const { encryptString, decryptString } = require('../../src/utils/encryption');
const { encryptString, decryptString, encryptStringSafe, decryptStringSafe } = require('../../src/utils/encryption');
// We can only unit test aes 256 fallback as safeStorage is only available
// in the main process
@@ -45,3 +45,69 @@ describe('Encryption and Decryption Tests', () => {
expect(() => decryptString(invalidAlgo)).toThrow('Decrypt failed: Invalid algo');
});
});
describe('Safe Encryption and Decryption Tests', () => {
it('should encrypt and decrypt successfully using encryptStringSafe and decryptStringSafe', () => {
const plaintext = 'bruno is awesome';
const encryptionResult = encryptStringSafe(plaintext);
const decryptionResult = decryptStringSafe(encryptionResult.value);
expect(encryptionResult.success).toBe(true);
expect(decryptionResult.success).toBe(true);
expect(decryptionResult.value).toBe(plaintext);
});
it('should handle empty strings in encryptStringSafe', () => {
const result = encryptStringSafe('');
expect(result.success).toBe(true);
expect(result.value).toBe('');
});
it('should handle empty strings in decryptStringSafe', () => {
const result = decryptStringSafe('');
expect(result.success).toBe(true);
expect(result.value).toBe('');
});
it('should handle invalid string format in decryptStringSafe', () => {
const result = decryptStringSafe('garbage');
expect(result.success).toBe(false);
expect(result.error).toBe('Decrypt failed: unrecognized string format');
expect(result.value).toBe('');
});
it('should handle invalid algorithm in decryptStringSafe', () => {
const invalidAlgo = '$99:abcdefg';
const result = decryptStringSafe(invalidAlgo);
expect(result.success).toBe(false);
expect(result.error).toBe('Decrypt failed: Invalid algo');
expect(result.value).toBe('');
});
it('should handle malformed encrypted string in decryptStringSafe', () => {
const malformedString = '$01:invalid-hex-string';
const result = decryptStringSafe(malformedString);
expect(result.success).toBe(false);
expect(result.error).toContain('AES256 decryption failed');
expect(result.value).toBe('');
});
it('should handle special characters in encryptStringSafe and decryptStringSafe', () => {
const specialText = 'bruno@#$%^&*()_+-=[]{}|;:,.<>?';
const encryptionResult = encryptStringSafe(specialText);
const decryptionResult = decryptStringSafe(encryptionResult.value);
expect(encryptionResult.success).toBe(true);
expect(decryptionResult.success).toBe(true);
expect(decryptionResult.value).toBe(specialText);
});
it('decrypt-safe should not throw error for invalid inputs', () => {
expect(() => decryptStringSafe(null)).not.toThrow();
expect(() => decryptStringSafe(undefined)).not.toThrow();
expect(() => decryptStringSafe('garbage')).not.toThrow();
expect(() => decryptStringSafe(123456789)).not.toThrow();
expect(() => decryptStringSafe('aes256:')).not.toThrow();
expect(() => decryptStringSafe('aes256:invalid_base64')).not.toThrow();
});
});

5
packages/bruno-filestore/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.DS_Store
*.log
dist
coverage

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2022 Anoop M D, Anusree P S and Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Some files were not shown because too many files have changed in this diff Show More