Compare commits

..

68 Commits

Author SHA1 Message Date
Anoop M D
aebc8241cc Merge pull request #4923 from maintainer-bruno/fix/e2etest-dependencies
fix(workflow): ensure E2E test collection dependencies are installed …
2025-06-17 14:46:55 +05:30
Maintainer Bruno
0eda1b761d fix(workflow): ensure E2E test collection dependencies are installed in GitHub Actions 2025-06-17 13:40:06 +05:30
lohit
a05f7cb686 Merge pull request #4918 from lohxt1/bru_send_request_fixes
bru.sendRequest translation fixes
2025-06-17 00:26:39 +05:30
lohit
745a71700c add await keyword to the translated bru.sendRequest function calls (#4906)
* add await keyword for the bru.sendRequest postman translations

---------

Co-authored-by: lohit <lohit@usebruno.com>
2025-06-16 22:50:45 +05:30
Anoop M D
ac9c190b41 Merge pull request #4914 from naman-bruno/bugfix/timeline-scroll
fix: timeline scroll
2025-06-16 22:48:44 +05:30
Pragadesh-45
1a1a230a1e Merge pull request #4901 from Pragadesh-45/feat/support-multiple-run-cli-v1
Co-authored-by: William Quintal <william95quintalwilliam@outlook.com>
Feat: Enhance run command to accept multiple inputs for requests and folders in Bruno CLI (Improves: #2956) (Fixes: #2955)
2025-06-16 22:27:34 +05:30
Anoop M D
b2e02b7762 Merge pull request #4908 from Pragadesh-45/feature/support-json-env-files
feat(cli): add support for environment file input in run command
2025-06-16 22:19:27 +05:30
naman-bruno
9cbfeccbed fix: timeline-scroll 2025-06-16 21:53:38 +05:30
Pragadesh-45
4725300c41 feat(cli): add support for environment file input in run command 2025-06-16 19:34:56 +05:45
naman-bruno
f2aedf780d Fix: showing test script errors (#4902)
* fix: catch errors in tests
2025-06-14 22:20:24 +05:30
lohit
f03047a2f9 feat: bru.sendRequest api (#4867)
* feat: bru.sendRequest api

* updated the postman-translations logic to handle `pm.sendRequest` to `bru.sendRequest` translations, and added unit tests

* ~ removed `maxRedirects` and `proxy` values for sendRequest axios-instance
~ fixed the imports for the `send-request-transformer` function
~ `sendRequest` and `runRequest` will return same response object in both safe and developer mode
~ sendRequest function optimization

* revert sendRequest to async function, added a testcase for sendRequest with url string

* sendRequest callback errors handling

* updated tests and added await for the callbacks

---------

Co-authored-by: lohit <lohit@usebruno.com>
2025-06-14 22:18:31 +05:30
lohit
a7ba23d97e Merge pull request #4886 from sanish-bruno/fix/bearer-undefined
fix: handle undefined bearer token to send an empty string instead
2025-06-14 21:50:08 +05:30
lohit
2521e980ea Merge pull request #4514 from jonman5/fix/digest-headers-split
Fix Digest auth header field key value extraction
2025-06-14 20:46:18 +05:30
lohit
1c118fa04a feat: add prompt for handling large responses (#4866)
* feat: add prompt for handling large responses

- Add `formatSize` utility function to format response size
- Add unit tests for `formatSize` utility function

* fix: update danger color in light theme
2025-06-14 20:44:08 +05:30
Anoop M D
b6fb5e02d4 Merge pull request #4893 from stupidly-logical/fix/watcher_err_handling
Fix watcher error message typo
2025-06-14 13:51:12 +05:30
Yash
5313704d84 Fix watcher error message typo 2025-06-14 13:25:21 +05:30
Anoop M D
b147f14fef Merge pull request #4758 from ShrutiShahi18/main
Added Hindi translation of Readme file
2025-06-13 22:31:06 +05:30
sanish-bruno
66fe1528df add: new Bearer Auth undefined test case and update Authorization header format 2025-06-13 14:42:57 +05:30
sanish-bruno
a598cda624 fix: handle undefined bearer token to send an empty string instead 2025-06-13 14:16:02 +05:30
Pragadesh-45
e1c12ea699 fix: update danger color in light theme 2025-06-11 22:57:45 +05:45
Pragadesh-45
9801e91720 feat: add prompt for handling large responses
- Add `formatSize` utility function to format response size
- Add unit tests for `formatSize` utility function
2025-06-11 22:57:29 +05:45
Pooja
364fb45e97 add: pre and post tests in runner (#4878) 2025-06-11 22:38:58 +05:30
Pooja
5c9981aca2 Fix: AWS v4 auth empty fields displaying "undefined" after save (#4814)
* Fix: AWS v4 auth empty fields displaying "undefined" after save
2025-06-11 14:27:45 +05:30
Pooja
fc697bf81b feat: support chai in scripts (#4552)
feat: support chai in scripts
2025-06-10 22:41:11 +05:30
lohit
9bc07afc77 initRunRequestEvent function for initializing request execution related details (#4863)
added a initRunRequestEvent function resetting and initializing request run event related details
2025-06-10 21:05:39 +05:30
Pooja
e4ae857df3 Merge pull request #4693 from pooja-bruno/mv/isValidValue-in-common-file
Fixed a bug causing secrets to appear as null instead of an empty value.

rm isValidValue and directly handle it in encryptString and `decryptString` function
2025-06-09 13:50:25 +05:30
Anoop M D
3d26833b8a Merge pull request #4837 from maintainer-bruno/feat/develop-hot-reload-js
feat(dev): enhance hot reload development setup
2025-06-07 13:21:13 +05:30
sreelakshmi-bruno
1089a52171 Tests for responseSize component (#4750)
---------

Co-authored-by: lohit <lohit@usebruno.com>
2025-06-06 01:54:01 +05:30
lohit
9dde2df475 Merge pull request #4661 from devendra-bruno/fix/gql-introspection-variable-interpolation
Added combined Vars for prepareGqlIntrospectionRequest for all interp…
2025-06-05 18:05:45 +05:30
Maintainer Bruno
1cc94e8ffe feat(dev): enhance hot reload development setup 2025-06-04 16:56:22 +05:30
lohit
223f79a3e2 Merge pull request #4694 from usebruno/feature/playwright
Improvements in Playwright setup and added tests for running bruno-testbench
2025-06-04 15:18:34 +05:30
lohit
5dc6f6757d Merge pull request #4765 from lohxt1/single_line_editor_onedit
fix: single line editor component onChange validations update
2025-06-04 14:48:48 +05:30
lohit
e20fe790a6 Merge pull request #4782 from ramki-bruno/fix/proxy-pass-encoding
Fix: Special URI characters in proxy username/password is giving error
2025-06-04 14:48:26 +05:30
ramki-bruno
cb611c6510 Fix: Special URI characters in proxy username/password is giving error
URI-encoding the _username_ and _password_ before creating the proxy URI
which then gets passed to `HttpsProxyAgent` and `HttpProxyAgent`
respectively.
2025-05-28 14:45:21 +05:30
devendra-bruno
6f9daadcfb Update index.js Removed duplicate variable 2025-05-27 15:44:07 +05:30
devendra-bruno
8d5d952026 Added runtimeVars in prepareGqlIntrospectionRequest 2025-05-27 14:38:48 +05:30
devendra-bruno
afb2d3dffd Updated resolved variable assignment and testcases 2025-05-26 22:52:37 +05:30
devendra-bruno
9f1aed3209 Refactored fetch-gql-schema-handler.spec.js 2025-05-26 16:42:18 +05:30
devendra-bruno
ce1110bdd4 Added interpolate for header values 2025-05-26 16:39:40 +05:30
devendra-bruno
788569a5f4 Added testcases for prepare-gql-introspection-request.spec.js 2025-05-26 16:39:07 +05:30
devendra-bruno
91397eaf57 Renamed fetchGqlSchema to fetchGqlSchemaHandler 2025-05-26 16:38:09 +05:30
devendra-bruno
c293ceefcf Refactored fetch-gql-schema-handler.spec.js 2025-05-26 16:37:28 +05:30
lohit
256f63dd38 single line editor comp onChange validations 2025-05-26 10:20:22 +05:30
devendra-bruno
0948964677 Revert changes to common.spec.js 2025-05-26 09:47:43 +05:30
Shruti Shahi
1b52bb27f7 Added Hindi translation of Readme file 2025-05-24 01:52:54 +05:30
devendra-bruno
3e714ab9f8 Updated handler fetch-gql-schema 2025-05-21 17:54:53 +05:30
devendra-bruno
f2e9a6a502 Added folder level variable support 2025-05-21 17:39:10 +05:30
devendra-bruno
b924e15afa Added testcases for fetch-gql-schema-handler 2025-05-21 17:35:47 +05:30
devendra-bruno
b0c74909ba Updated argument request object for useGraphqlSchema hook 2025-05-21 17:35:17 +05:30
devendra-bruno
548a6b4319 Rename combinedVars to resolvedVars 2025-05-21 17:34:36 +05:30
devendra-bruno
9c9afaf78f Extracted fetchGqlSchema handler seperate from ipc handler 2025-05-21 06:42:19 +05:30
devendra-bruno
6cde453032 Added test for prepareGqlIntrospectionRequest 2025-05-21 06:41:18 +05:30
devendra-bruno
8f06889996 Remove mergeEnvironmnetVariable method from spec file 2025-05-21 06:40:21 +05:30
devendra-bruno
52662f0766 Updated testcases in prepare-gql-introspection spec 2025-05-19 17:39:39 +05:30
devendra-bruno
5567e1b7f2 Fixed typo in prepareGqlIntrospectionRequest 2025-05-16 00:47:49 +05:30
devendra-bruno
3cd18d1e16 Added testcases for prepare-gql-introspection-request 2025-05-16 00:43:58 +05:30
devendra-bruno
9d3e42b5d4 Update prepareGqlIntrospectionRequest change assignment sequence 2025-05-16 00:43:27 +05:30
devendra-bruno
0f318c26c2 Updated precedence in combinedVars object 2025-05-16 00:42:27 +05:30
devendra-bruno
6598d23ff0 Removed mergeEnvrionmentVariables tests from common.spec.js 2025-05-15 15:57:43 +05:30
devendra-bruno
c83436655c Remove mergeEnvironmnetVariables from common utils 2025-05-15 15:57:00 +05:30
devendra-bruno
62595c519c Added lodash merge for combining vars before interpolateVars 2025-05-15 15:56:30 +05:30
devendra-bruno
8e91640084 Added mergeEnvironmentVariables method for gql prep method 2025-05-14 12:25:41 +05:30
devendra-bruno
0ca2891166 Added mergeEnvironmentVariables method in electron common utils export 2025-05-14 12:24:09 +05:30
devendra-bruno
5000bb8db3 Added testcases for mergeEnvironmentVariables method 2025-05-14 12:23:32 +05:30
devendra-bruno
9927424826 Added mergeEnvironmentVariables method in electron common utils 2025-05-14 12:22:39 +05:30
devendra-bruno
ad3f5de99a Added combined variable object for gqlIntrospectionRequest 2025-05-13 17:05:37 +05:30
devendra-bruno
2de7ba0d0c Added combined Vars for prepareGqlIntrospectionRequest for all interpolate 2025-05-13 16:06:20 +05:30
Jonathan Perlman
b5861dae39 Fix Digest auth header field key value extraction 2025-04-15 14:31:08 -04:00
78 changed files with 7081 additions and 690 deletions

View File

@@ -113,6 +113,10 @@ jobs:
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Install dependencies for test collection environment
run: |
npm ci --prefix packages/bruno-tests/collection
- name: Build libraries
run: |
npm run build:graphql-docs

151
docs/readme/readme_hi.md Normal file
View File

@@ -0,0 +1,151 @@
<br />
<img src="../../assets/images/logo-transparent.png" width="80"/>
### ब्रूनो - API इंटरफेस (API) का अन्वेषण और परीक्षण करने के लिए एक ओपन-सोर्स विकास वातावरण।
[![GitHub संस्करण](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![कमिट गतिविधि](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)
[![वेबसाइट](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com)
[![डाउनलोड](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads)
[English](../../readme.md)
| [Українська](./readme_ua.md)
| [Русский](./readme_ru.md)
| [Türkçe](./readme_tr.md)
| [Deutsch](./readme_de.md)
| [Français](./readme_fr.md)
| [Português (BR)](./readme_pt_br.md)
| [한국어](./readme_kr.md)
| [বাংলা](./readme_bn.md)
| [Español](./readme_es.md)
| [Italiano](./readme_it.md)
| [Română](./readme_ro.md)
| [Polski](./readme_pl.md)
| [简体中文](./readme_cn.md)
| [正體中文](./readme_zhtw.md)
| [العربية](./readme_ar.md)
| [日本語](./readme_ja.md)
| [ქართული](./readme_ka.md)
| **हिन्दी**
ब्रूनो एक नया और अभिनव API क्लाइंट है, जिसका उद्देश्य Postman और अन्य समान उपकरणों द्वारा प्रस्तुत स्थिति को बदलना है।
ब्रूनो आपकी कलेक्शनों को सीधे आपकी फाइल सिस्टम के एक फ़ोल्डर में संग्रहीत करता है। हम API अनुरोधों के बारे में जानकारी सहेजने के लिए एक सामान्य टेक्स्ट मार्कअप भाषा, Bru, का उपयोग करते हैं।
आप अपनी API कलेक्शनों पर सहयोग करने के लिए Git या अपनी पसंद के किसी भी संस्करण नियंत्रण प्रणाली का उपयोग कर सकते हैं।
ब्रूनो केवल ऑफ़लाइन उपयोग के लिए है। ब्रूनो में कभी भी क्लाउड-सिंक जोड़ने की कोई योजना नहीं है। हम आपके डेटा की गोपनीयता को महत्व देते हैं और मानते हैं कि इसे आपके डिवाइस पर ही रहना चाहिए। हमारी दीर्घकालिक दृष्टि [यहाँ](https://github.com/usebruno/bruno/discussions/269) पढ़ें।
📢 हमारे हालिया India FOSS 3.0 सम्मेलन में हमारे वार्तालाप को [यहाँ](https://www.youtube.com/watch?v=7bSMFpbcPiY) देखें।
![bruno](/assets/images/landing-2.png) <br /><br />
### गोल्डन संस्करण ✨
हमारी अधिकांश सुविधाएँ मुफ्त और ओपन-सोर्स हैं।
हम [पारदर्शिता और स्थिरता के सिद्धांतों](https://github.com/usebruno/bruno/discussions/269) के बीच एक सामंजस्यपूर्ण संतुलन प्राप्त करने का प्रयास करते हैं।
[गोल्डन संस्करण](https://www.usebruno.com/pricing) के लिए खरीदारी जल्द ही $9 की कीमत पर उपलब्ध होगी! <br/>
[यहाँ सदस्यता लें](https://usebruno.ck.page/4c65576bd4) ताकि आपको लॉन्च पर सूचनाएं मिलें।
### स्थापना
ब्रूनो Mac, Windows और Linux के लिए हमारे [वेबसाइट](https://www.usebruno.com/downloads) पर एक बाइनरी डाउनलोड के रूप में उपलब्ध है।
आप ब्रूनो को Homebrew, Chocolatey, Scoop, Snap, Flatpak और Apt जैसे पैकेज प्रबंधकों के माध्यम से भी स्थापित कर सकते हैं।
```sh
# Mac पर Homebrew के माध्यम से
brew install bruno
# Windows पर Chocolatey के माध्यम से
choco install bruno
# Windows पर Scoop के माध्यम से
scoop bucket add extras
scoop install bruno
# Linux पर Snap के माध्यम से
snap install bruno
# Linux पर Flatpak के माध्यम से
flatpak install com.usebruno.Bruno
# Linux पर Apt के माध्यम से
sudo mkdir -p /etc/apt/keyrings
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
कई प्लेटफार्मों पर चलाएं 🖥️
<br /><br />
Git के माध्यम से सहयोग करें 👩‍💻🧑‍💻
या अपनी पसंद के किसी भी संस्करण नियंत्रण प्रणाली का उपयोग करें
<br /><br />
महत्वपूर्ण लिंक 📌
हमारी दीर्घकालिक दृष्टि
रोडमैप
प्रलेखन
Stack Overflow
वेबसाइट
मूल्य निर्धारण
डाउनलोड
GitHub प्रायोजक
प्रस्तुतियाँ 🎥
प्रशंसापत्र
ज्ञान केंद्र
Scriptmania
समर्थन ❤️
यदि आप ब्रूनो को पसंद करते हैं और हमारे ओपन-सोर्स कार्य का समर्थन करना चाहते हैं, तो कृपया GitHub प्रायोजक के माध्यम से हमें प्रायोजित करने पर विचार करें।
प्रशंसापत्र साझा करें 📣
यदि ब्रूनो ने आपके और आपकी टीमों के लिए काम में मदद की है, तो कृपया हमारे GitHub चर्चा में अपने प्रशंसापत्र साझा करना न भूलें
नए पैकेज प्रबंधकों में प्रकाशित करना
अधिक जानकारी के लिए कृपया यहाँ देखें।
हमसे संपर्क करें 🌐
𝕏 (ट्विटर) <br />
वेबसाइट <br />
डिस्कॉर्ड <br />
लिंक्डइन
ट्रेडमार्क
नाम
ब्रूनो एक ट्रेडमार्क है जो अनूप एम डी के स्वामित्व में है।
लोगो
लोगो OpenMoji से लिया गया है। लाइसेंस: CC BY-SA 4.0
योगदान 👩‍💻🧑‍💻
हमें खुशी है कि आप ब्रूनो को बेहतर बनाने में रुचि रखते हैं। कृपया योगदान गाइड देखें।
यदि आप सीधे कोड के माध्यम से योगदान नहीं कर सकते, तो भी कृपया बग्स की रिपोर्ट करने और उन सुविधाओं का अनुरोध करने में संकोच न करें जिन्हें आपकी स्थिति को हल करने के लिए लागू किया जाना चाहिए।
लेखक
<div align="center"> <a href="https://github.com/usebruno/bruno/graphs/contributors"> <img src="https://contrib.rocks/image?repo=usebruno/bruno" /> </a> </div>
लाइसेंस 📄
MIT

View File

@@ -21,7 +21,7 @@ test.describe.parallel('Run Testbench Requests', () => {
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - 1);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
test.fixme('Run bruno-testbench in Safe Mode', async ({ pageWithUserData: page }) => {
@@ -44,6 +44,6 @@ test.describe.parallel('Run Testbench Requests', () => {
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - 1);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
});

3004
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,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\"",
"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",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",

View File

@@ -0,0 +1,9 @@
module.exports = {
presets: [
'@babel/preset-env',
['@babel/preset-react', {
runtime: 'automatic'
}]
],
plugins: ['babel-plugin-styled-components']
};

View File

@@ -12,5 +12,14 @@ module.exports = {
},
clearMocks: true,
moduleDirectories: ['node_modules', 'src'],
testEnvironment: 'node'
testEnvironment: 'jsdom',
transform: {
'^.+\\.[jt]sx?$': 'babel-jest'
},
transformIgnorePatterns: [
'/node_modules/(?!(nanoid|xml-formatter)/)'
],
testMatch: [
'<rootDir>/src/**/*.spec.[jt]s?(x)'
]
};

View File

@@ -1,6 +1,7 @@
{
"name": "@usebruno/app",
"version": "2.0.0",
"license": "MIT",
"private": true,
"scripts": {
"dev": "rsbuild dev",
@@ -11,7 +12,6 @@
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\""
},
"dependencies": {
"@babel/preset-env": "^7.26.0",
"@fontsource/inter": "^5.0.15",
"@prantlf/jsonlint": "^16.0.0",
"@reduxjs/toolkit": "^1.8.0",
@@ -82,19 +82,28 @@
"yup": "^0.32.11"
},
"devDependencies": {
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
"@rsbuild/core": "^1.1.2",
"@rsbuild/plugin-babel": "^1.0.3",
"@rsbuild/plugin-node-polyfill": "^1.2.0",
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",
"@testing-library/dom": "^9.3.3",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"autoprefixer": "10.4.20",
"babel-jest": "^29.7.0",
"babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
"babel-plugin-styled-components": "^2.1.4",
"cross-env": "^7.0.3",
"css-loader": "7.1.2",
"file-loader": "^6.2.0",
"html-loader": "^3.0.1",
"html-webpack-plugin": "^5.5.0",
"jest-environment-jsdom": "^29.7.0",
"mini-css-extract-plugin": "^2.4.5",
"postcss": "8.4.47",
"style-loader": "^3.3.1",
@@ -102,4 +111,4 @@
"webpack": "^5.64.4",
"webpack-cli": "^4.9.1"
}
}
}

View File

@@ -21,12 +21,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -38,12 +38,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -55,12 +55,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -72,12 +72,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -89,12 +89,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -106,12 +106,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: profileName || ''
}
})
);

View File

@@ -21,8 +21,8 @@ const BasicAuth = ({ collection }) => {
mode: 'basic',
collectionUid: collection.uid,
content: {
username: username,
password: basicAuth.password
username: username || '',
password: basicAuth.password || ''
}
})
);
@@ -34,8 +34,8 @@ const BasicAuth = ({ collection }) => {
mode: 'basic',
collectionUid: collection.uid,
content: {
username: basicAuth.username,
password: password
username: basicAuth.username || '',
password: password || ''
}
})
);

View File

@@ -21,8 +21,8 @@ const DigestAuth = ({ collection }) => {
mode: 'digest',
collectionUid: collection.uid,
content: {
username: username,
password: digestAuth.password
username: username || '',
password: digestAuth.password || ''
}
})
);
@@ -34,8 +34,8 @@ const DigestAuth = ({ collection }) => {
mode: 'digest',
collectionUid: collection.uid,
content: {
username: digestAuth.username,
password: password
username: digestAuth.username || '',
password: password || ''
}
})
);

View File

@@ -28,9 +28,9 @@ const NTLMAuth = ({ collection }) => {
mode: 'ntlm',
collectionUid: collection.uid,
content: {
username: username,
password: ntlmAuth.password,
domain: ntlmAuth.domain
username: username || '',
password: ntlmAuth.password || '',
domain: ntlmAuth.domain || ''
}
})
@@ -43,9 +43,9 @@ const NTLMAuth = ({ collection }) => {
mode: 'ntlm',
collectionUid: collection.uid,
content: {
username: ntlmAuth.username,
password: password,
domain: ntlmAuth.domain
username: ntlmAuth.username || '',
password: password || '',
domain: ntlmAuth.domain || ''
}
})
);
@@ -57,9 +57,9 @@ const NTLMAuth = ({ collection }) => {
mode: 'ntlm',
collectionUid: collection.uid,
content: {
username: ntlmAuth.username,
password: ntlmAuth.password,
domain: domain
username: ntlmAuth.username || '',
password: ntlmAuth.password || '',
domain: domain || ''
}
})
);

View File

@@ -21,8 +21,8 @@ const WsseAuth = ({ collection }) => {
mode: 'wsse',
collectionUid: collection.uid,
content: {
username,
password: wsseAuth.password
username: username || '',
password: wsseAuth.password || ''
}
})
);
@@ -34,8 +34,8 @@ const WsseAuth = ({ collection }) => {
mode: 'wsse',
collectionUid: collection.uid,
content: {
username: wsseAuth.username,
password
username: wsseAuth.username || '',
password: password || ''
}
})
);

View File

@@ -7,8 +7,10 @@ import Dropdown from '../../Dropdown';
const GraphQLSchemaActions = ({ item, collection, onSchemaLoad, toggleDocs }) => {
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
const pathname = item.draft ? get(item, 'draft.pathname', '') : get(item, 'pathname', '');
const uid = item.draft ? get(item, 'draft.uid', '') : get(item, 'uid', '');
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
const request = item.draft ? item.draft.request : item.request;
const request = item.draft ? { ...item.draft.request, pathname, uid } : { ...item.request, pathname, uid };
let {
schema,

View File

@@ -0,0 +1,65 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
.warning-container {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 1.5rem;
margin-top: 10%;
text-align: center;
max-width: 480px;
}
.warning-icon {
margin-bottom: 1rem;
color: ${(props) => props.theme.colors.text.yellow};
}
.warning-title {
font-weight: 600;
color: ${(props) => props.theme.text};
margin-bottom: 1rem;
}
.warning-description {
color: ${(props) => props.theme.colors.text.muted};
.size-highlight {
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.8rem;
}
.current-size {
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.colors.text.danger}15;
}
.supported-size {
color: ${(props) => props.theme.colors.text.yellow};
background: ${(props) => props.theme.colors.text.yellow}15;
}
}
.warning-actions {
display: flex;
gap: 0.75rem;
}
button {
align-items: center;
display: flex;
gap: 0.5rem;
background: ${(props) => props.theme.button.secondary.bg};
border-radius: 4px;
padding: 0.5rem 1rem;
cursor: pointer;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { IconDownload, IconCopy, IconEye, IconAlertTriangle } from '@tabler/icons';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import StyledWrapper from './StyledWrapper';
import { formatSize } from 'utils/common/index';
const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => {
const { ipcRenderer } = window;
const response = item.response || {};
const saveResponseToFile = () => {
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:save-response-to-file', response, item.requestSent.url)
.then(() => {
toast.success('Response saved to file');
resolve();
})
.catch((err) => {
toast.error(get(err, 'error.message') || 'Something went wrong!');
reject(err);
});
});
};
const copyResponse = () => {
try {
const textToCopy = typeof response.data === 'string'
? response.data
: JSON.stringify(response.data, null, 2);
navigator.clipboard.writeText(textToCopy).then(() => {
toast.success('Response copied to clipboard');
}).catch(() => {
toast.error('Failed to copy response');
});
} catch (error) {
toast.error('Failed to copy response');
}
};
return (
<StyledWrapper>
<div className="warning-container">
<div className="warning-icon">
<IconAlertTriangle size={45} strokeWidth={2} />
</div>
<div className="warning-content">
<div className="warning-title">
Large Response Warning
</div>
<div className="warning-description">
Handling responses over <span className="size-highlight supported-size">{formatSize(10 * 1024 * 1024)}</span> could degrade performance.
<br />
Size of current response: <span className="size-highlight current-size">{formatSize(responseSize)}</span>
</div>
</div>
</div>
<div className="warning-actions">
<button
className="btn-reveal"
onClick={onRevealResponse}
title="Show response content"
>
<IconEye size={18} strokeWidth={1.5} />
View
</button>
<button
className="btn-save"
onClick={saveResponseToFile}
disabled={!response.dataBuffer}
title="Save response to file"
>
<IconDownload size={18} strokeWidth={1.5} />
Save
</button>
<button
className="btn-copy"
onClick={copyResponse}
disabled={!response.data}
title="Copy response to clipboard"
>
<IconCopy size={18} strokeWidth={1.5} />
Copy
</button>
</div>
</StyledWrapper>
);
};
export default LargeResponseWarning;

View File

@@ -11,6 +11,7 @@ import StyledWrapper from './StyledWrapper';
import { useState, useMemo, useEffect } from 'react';
import { useTheme } from 'providers/Theme/index';
import { getEncoding, uuid } from 'utils/common/index';
import LargeResponseWarning from '../LargeResponseWarning';
const formatResponse = (data, dataBuffer, encoding, mode, filter) => {
if (data === undefined || !dataBuffer || !mode) {
@@ -77,6 +78,7 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
const [filter, setFilter] = useState(null);
const [showLargeResponse, setShowLargeResponse] = useState(false);
const responseEncoding = getEncoding(headers);
const formattedData = useMemo(
() => formatResponse(data, dataBuffer, responseEncoding, mode, filter),
@@ -84,6 +86,25 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
);
const { displayedTheme } = useTheme();
const responseSize = useMemo(() => {
const response = item.response || {};
if (typeof response.size === 'number') {
return response.size;
}
if (!dataBuffer) return 0;
try {
// dataBuffer is base64 encoded, so we need to calculate the actual size
const buffer = Buffer.from(dataBuffer, 'base64');
return buffer.length;
} catch (error) {
return 0;
}
}, [dataBuffer, item.response]);
const isLargeResponse = responseSize > 10 * 1024 * 1024; // 10 MB
const debouncedResultFilterOnChange = debounce((e) => {
setFilter(e.target.value);
}, 250);
@@ -160,6 +181,12 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
</div>
) : null}
</div>
) : isLargeResponse && !showLargeResponse ? (
<LargeResponseWarning
item={item}
responseSize={responseSize}
onRevealResponse={() => setShowLargeResponse(true)}
/>
) : (
<div className="h-full flex flex-col">
<div className="flex-1 relative">

View File

@@ -0,0 +1,110 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import ResponseSize from './index';
// Create minimal theme with only the properties needed for the component
const theme = {
requestTabPanel: {
responseStatus: '#666'
}
};
// Wrap component with theme provider for styled-components
const renderWithTheme = (component) => {
return render(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
describe('ResponseSize', () => {
describe('Invalid or excluded size values', () => {
it('should not render when size is undefined', () => {
const { container } = renderWithTheme(<ResponseSize size={undefined} />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when size is null', () => {
const { container } = renderWithTheme(<ResponseSize size={null} />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when size is NaN', () => {
const { container } = renderWithTheme(<ResponseSize size={NaN} />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when size is Infinity', () => {
const { container } = renderWithTheme(<ResponseSize size={Infinity} />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when size is -Infinity', () => {
const { container } = renderWithTheme(<ResponseSize size={-Infinity} />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when size is a string', () => {
const { container } = renderWithTheme(<ResponseSize size="1024" />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when size is an object', () => {
const { container } = renderWithTheme(<ResponseSize size={{value: 1024}} />);
expect(container).toBeEmptyDOMElement();
});
});
describe('Valid size values', () => {
it('should handle zero bytes', () => {
renderWithTheme(<ResponseSize size={0} />);
const element = screen.getByText(/0B/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^0B$/);
expect(element).toHaveAttribute('title', '0B');
});
it('should render bytes when size is less than 1024', () => {
renderWithTheme(<ResponseSize size={500} />);
const element = screen.getByText(/500B/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^500B$/);
expect(element).toHaveAttribute('title', '500B');
});
it('should handle exactly 1024 bytes as size', () => {
renderWithTheme(<ResponseSize size={1024} />);
const element = screen.getByText(/1024B/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^1024B$/);
expect(element).toHaveAttribute('title', '1,024B');
});
it('should render kilobytes when size is greater than 1024', () => {
renderWithTheme(<ResponseSize size={1500} />);
const element = screen.getByText(/1\.46KB/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
expect(element).toHaveAttribute('title', '1,500B');
});
it('should handle large size numbers', () => {
renderWithTheme(<ResponseSize size={10240} />);
const element = screen.getByText(/10\.0KB/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
expect(element).toHaveAttribute('title', '10,240B');
});
it('should handle decimal size numbers', () => {
renderWithTheme(<ResponseSize size={1126.5} />);
const element = screen.getByText(/1\.10KB/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
expect(element).toHaveAttribute('title', '1,126.5B');
});
});
});

View File

@@ -6,22 +6,48 @@ import StyledWrapper from './StyledWrapper';
const ScriptError = ({ item, onClose }) => {
const preRequestError = item?.preRequestScriptErrorMessage;
const postResponseError = item?.postResponseScriptErrorMessage;
const testScriptError = item?.testScriptErrorMessage;
if (!preRequestError && !postResponseError) return null;
if (!preRequestError && !postResponseError && !testScriptError) return null;
const errorMessage = preRequestError || postResponseError;
const errorTitle = preRequestError ? 'Pre-Request Script Error' : 'Post-Response Script Error';
const errors = [];
if (preRequestError) {
errors.push({
title: 'Pre-Request Script Error',
message: preRequestError
});
}
if (postResponseError) {
errors.push({
title: 'Post-Response Script Error',
message: postResponseError
});
}
if (testScriptError) {
errors.push({
title: 'Test Script Error',
message: testScriptError
});
}
return (
<StyledWrapper className="mt-4 mb-2">
<div className="flex items-start gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="error-title">
{errorTitle}
</div>
<div className="error-message">
{errorMessage}
</div>
{errors.map((error, index) => (
<div key={index}>
{index > 0 && <div className="border-t border-gray-300 my-3 dark:border-gray-600"></div>}
<div className="error-title">
{error.title}
</div>
<div className="error-message">
{error.message}
</div>
</div>
))}
</div>
<div
className="close-button flex-shrink-0 cursor-pointer"

View File

@@ -1,6 +1,18 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
.test-summary {
transition: background-color 0.2s;
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
color: ${(props) => props.theme.text};
&:hover {
background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
.test-success {
color: ${(props) => props.theme.colors.text.green};
}
@@ -9,12 +21,24 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.danger};
}
.test-success-count {
color: ${(props) => props.theme.colors.text.green};
}
.test-failure-count {
color: ${(props) => props.theme.colors.text.danger};
}
.error-message {
color: ${(props) => props.theme.colors.text.muted};
}
.skipped-request {
color: ${(props) => props.theme.colors.text.muted};
.test-results-list {
transition: all 0.3s ease;
}
.dropdown-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
}
`;

View File

@@ -1,63 +1,151 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import StyledWrapper from './StyledWrapper';
import {
IconChevronDown,
IconChevronRight,
IconCircleCheck,
IconCircleX
} from '@tabler/icons';
const TestResults = ({ results, assertionResults }) => {
const ResultIcon = ({ status }) => (
<span className={`inline-flex items-center ${status === 'pass' ? 'test-success' : 'test-failure'}`}>
{status === 'pass' ? (
<IconCircleCheck size={14} className="mr-1" aria-label="Test passed" />
) : (
<IconCircleX size={14} className="mr-1" aria-label="Test failed" />
)}
</span>
);
const ErrorMessage = ({ error }) => error && (
<>
<br />
<span className="error-message pl-8" role="alert">
{error}
</span>
</>
);
const ResultItem = ({ result, type }) => (
<div className="test-result-item">
<ResultIcon status={result.status} />
<span className={result.status === 'pass' ? 'test-success' : 'test-failure'}>
{type === 'assertion'
? `${result.lhsExpr}: ${result.rhsExpr}`
: result.description
}
</span>
<ErrorMessage error={result.error} />
</div>
);
const TestSection = ({
title,
results,
isExpanded,
onToggle,
type = 'test'
}) => {
const passedResults = results.filter((result) => result.status === 'pass');
const failedResults = results.filter((result) => result.status === 'fail');
if (results.length === 0) return null;
return (
<div className='mb-4'>
<div
className="font-medium test-summary flex items-center cursor-pointer hover:bg-opacity-10 hover:bg-gray-500 rounded py-2"
onClick={onToggle}
>
<span className="dropdown-icon mr-2 flex items-center">
{isExpanded ?
<IconChevronDown size={18} stroke={1.5} /> :
<IconChevronRight size={18} stroke={1.5} />
}
</span>
<span className="flex-grow">
{title} ({results.length}), Passed: {passedResults.length}, Failed: {failedResults.length}
</span>
</div>
{isExpanded && (
<ul className="ml-5">
{results.map((result) => (
<li key={result.uid} className="py-1">
<ResultItem result={result} type={type} />
</li>
))}
</ul>
)}
</div>
);
};
const TestResults = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
results = results || [];
assertionResults = assertionResults || [];
if (!results.length && !assertionResults.length) {
preRequestTestResults = preRequestTestResults || [];
postResponseTestResults = postResponseTestResults || [];
const [expandedSections, setExpandedSections] = useState({
preRequest: true,
tests: true,
postResponse: true,
assertions: true
});
useEffect(() => {
setExpandedSections({
preRequest: preRequestTestResults.length > 0,
tests: results.length > 0,
postResponse: postResponseTestResults.length > 0,
assertions: assertionResults.length > 0
});
}, [results.length, assertionResults.length, preRequestTestResults.length, postResponseTestResults.length]);
const toggleSection = (section) => {
setExpandedSections({
...expandedSections,
[section]: !expandedSections[section]
});
};
if (!results.length && !assertionResults.length && !preRequestTestResults.length && !postResponseTestResults.length) {
return <div className="px-3">No tests found</div>;
}
const passedTests = results.filter((result) => result.status === 'pass');
const failedTests = results.filter((result) => result.status === 'fail');
const passedAssertions = assertionResults.filter((result) => result.status === 'pass');
const failedAssertions = assertionResults.filter((result) => result.status === 'fail');
return (
<StyledWrapper className="flex flex-col">
<div className="pb-2 font-medium test-summary">
Tests ({results.length}/{results.length}), Passed: {passedTests.length}, Failed: {failedTests.length}
</div>
<ul className="">
{results.map((result) => (
<li key={result.uid} className="py-1">
{result.status === 'pass' ? (
<span className="test-success">&#x2714;&nbsp; {result.description}</span>
) : (
<>
<span className="test-failure">&#x2718;&nbsp; {result.description}</span>
<br />
<span className="error-message pl-8">{result.error}</span>
</>
)}
</li>
))}
</ul>
<StyledWrapper className="flex flex-col px-3">
<TestSection
title="Pre-Request Tests"
results={preRequestTestResults}
isExpanded={expandedSections.preRequest}
onToggle={() => toggleSection('preRequest')}
type="test"
/>
<div className="py-2 font-medium test-summary">
Assertions ({assertionResults.length}/{assertionResults.length}), Passed: {passedAssertions.length}, Failed:{' '}
{failedAssertions.length}
</div>
<ul className="">
{assertionResults.map((result) => (
<li key={result.uid} className="py-1">
{result.status === 'pass' ? (
<span className="test-success">
&#x2714;&nbsp; {result.lhsExpr}: {result.rhsExpr}
</span>
) : (
<>
<span className="test-failure">
&#x2718;&nbsp; {result.lhsExpr}: {result.rhsExpr}
</span>
<br />
<span className="error-message pl-8">{result.error}</span>
</>
)}
</li>
))}
</ul>
<TestSection
title="Post-Response Tests"
results={postResponseTestResults}
isExpanded={expandedSections.postResponse}
onToggle={() => toggleSection('postResponse')}
type="test"
/>
<TestSection
title="Tests"
results={results}
isExpanded={expandedSections.tests}
onToggle={() => toggleSection('tests')}
type="test"
/>
<TestSection
title="Assertions"
results={assertionResults}
isExpanded={expandedSections.assertions}
onToggle={() => toggleSection('assertions')}
type="assertion"
/>
</StyledWrapper>
);
};

View File

@@ -1,9 +1,13 @@
import React from 'react';
import { IconCircleCheck, IconCircleX } from '@tabler/icons';
const TestResultsLabel = ({ results, assertionResults }) => {
const TestResultsLabel = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
results = results || [];
assertionResults = assertionResults || [];
if (!results.length && !assertionResults.length) {
preRequestTestResults = preRequestTestResults || [];
postResponseTestResults = postResponseTestResults || [];
if (!results.length && !assertionResults.length && !preRequestTestResults.length && !postResponseTestResults.length) {
return 'Tests';
}
@@ -13,8 +17,14 @@ const TestResultsLabel = ({ results, assertionResults }) => {
const numberOfAssertions = assertionResults.length;
const numberOfFailedAssertions = assertionResults.filter((result) => result.status === 'fail').length;
const totalNumberOfTests = numberOfTests + numberOfAssertions;
const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions;
const numberOfPreRequestTests = preRequestTestResults.length;
const numberOfFailedPreRequestTests = preRequestTestResults.filter((result) => result.status === 'fail').length;
const numberOfPostResponseTests = postResponseTestResults.length;
const numberOfFailedPostResponseTests = postResponseTestResults.filter((result) => result.status === 'fail').length;
const totalNumberOfTests = numberOfTests + numberOfAssertions + numberOfPreRequestTests + numberOfPostResponseTests;
const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions + numberOfFailedPreRequestTests + numberOfFailedPostResponseTests;
return (
<div className="flex items-center">

View File

@@ -33,10 +33,10 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
});
useEffect(() => {
if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage) {
if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage) {
setShowScriptErrorCard(true);
}
}, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage]);
}, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage, item?.testScriptErrorMessage]);
const selectTab = (tab) => {
dispatch(
@@ -73,7 +73,12 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return <Timeline collection={collection} item={item} width={rightPaneWidth} />;
}
case 'tests': {
return <TestResults results={item.testResults} assertionResults={item.assertionResults} />;
return <TestResults
results={item.testResults}
assertionResults={item.assertionResults}
preRequestTestResults={item.preRequestTestResults}
postResponseTestResults={item.postResponseTestResults}
/>;
}
default: {
@@ -122,8 +127,8 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
};
const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0;
const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage;
const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage;
return (
<StyledWrapper className="flex flex-col h-full relative">
@@ -139,14 +144,19 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
Timeline
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
<TestResultsLabel results={item.testResults} assertionResults={item.assertionResults} />
<TestResultsLabel
results={item.testResults}
assertionResults={item.assertionResults}
preRequestTestResults={item.preRequestTestResults}
postResponseTestResults={item.postResponseTestResults}
/>
</div>
{!isLoading ? (
<div className="flex flex-grow justify-end items-center">
{hasScriptError && !showScriptErrorCard && (
<ScriptErrorIcon
itemUid={item.uid}
onClick={() => setShowScriptErrorCard(true)}
<ScriptErrorIcon
itemUid={item.uid}
onClick={() => setShowScriptErrorCard(true)}
/>
)}
{focusedTab?.responsePaneTab === "timeline" ? (
@@ -164,26 +174,32 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
) : null}
</div>
<section
className={`flex flex-col flex-grow relative pl-3 pr-4 ${focusedTab.responsePaneTab === 'response' ? '' : 'mt-4'}`}
className={`flex flex-col min-h-0 relative pl-3 pr-4 auto`}
style={{
flex: '1 1 0',
height: hasScriptError && showScriptErrorCard ? 'auto' : '100%'
}}
>
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{hasScriptError && showScriptErrorCard && (
<ScriptError
item={item}
onClose={() => setShowScriptErrorCard(false)}
<ScriptError
item={item}
onClose={() => setShowScriptErrorCard(false)}
/>
)}
{!item?.response ? (
focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? (
<Timeline
collection={collection}
item={item}
width={rightPaneWidth}
/>
) : null
) : (
<>{getTabPanel(focusedTab.responsePaneTab)}</>
)}
<div className='flex-1 min-h-[200px]'>
{!item?.response ? (
focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? (
<Timeline
collection={collection}
item={item}
width={rightPaneWidth}
/>
) : null
) : (
<>{getTabPanel(focusedTab.responsePaneTab)}</>
)}
</div>
</section>
</StyledWrapper>
);

View File

@@ -16,7 +16,7 @@ import RunnerTimeline from 'components/ResponsePane/RunnerTimeline';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const [selectedTab, setSelectedTab] = useState('response');
const { requestSent, responseReceived, testResults, assertionResults, error } = item;
const { requestSent, responseReceived, testResults, assertionResults, preRequestTestResults, postResponseTestResults, error } = item;
const headers = get(item, 'responseReceived.headers', []);
const status = get(item, 'responseReceived.status', 0);
@@ -49,7 +49,12 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return <RunnerTimeline request={requestSent} response={responseReceived} />;
}
case 'tests': {
return <TestResults results={testResults} assertionResults={assertionResults} />;
return <TestResults
results={testResults}
assertionResults={assertionResults}
preRequestTestResults={preRequestTestResults}
postResponseTestResults={postResponseTestResults}
/>;
}
default: {
@@ -86,7 +91,12 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
Timeline
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
<TestResultsLabel results={testResults} assertionResults={assertionResults} />
<TestResultsLabel
results={testResults}
assertionResults={assertionResults}
preRequestTestResults={preRequestTestResults}
postResponseTestResults={postResponseTestResults}
/>
</div>
<div className="flex flex-grow justify-end items-center">
<StatusCode status={status} />

View File

@@ -16,6 +16,28 @@ const getDisplayName = (fullPath, pathname, name = '') => {
return path.join(dir, name);
};
const getTestStatus = (results) => {
if (!results || !results.length) return 'pass';
const failed = results.filter((result) => result.status === 'fail');
return failed.length ? 'fail' : 'pass';
};
const allTestsPassed = (item) => {
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';
};
export default function RunnerResults({ collection }) {
const dispatch = useDispatch();
const [selectedItem, setSelectedItem] = useState(null);
@@ -56,19 +78,10 @@ export default function RunnerResults({ collection }) {
displayName: getDisplayName(collection.pathname, info.pathname, info.name)
};
if (newItem.status !== 'error' && newItem.status !== 'skipped') {
if (newItem.testResults) {
const failed = newItem.testResults.filter((result) => result.status === 'fail');
newItem.testStatus = failed.length ? 'fail' : 'pass';
} else {
newItem.testStatus = 'pass';
}
if (newItem.assertionResults) {
const failed = newItem.assertionResults.filter((result) => result.status === 'fail');
newItem.assertionStatus = failed.length ? 'fail' : 'pass';
} else {
newItem.assertionStatus = 'pass';
}
newItem.testStatus = getTestStatus(newItem.testResults);
newItem.assertionStatus = getTestStatus(newItem.assertionResults);
newItem.preRequestTestStatus = getTestStatus(newItem.preRequestTestResults);
newItem.postResponseTestStatus = getTestStatus(newItem.postResponseTestResults);
}
return newItem;
})
@@ -95,12 +108,8 @@ export default function RunnerResults({ collection }) {
};
const totalRequestsInCollection = getTotalRequestCountInCollection(collectionCopy);
const passedRequests = items.filter((item) => {
return item.status !== 'error' && item.testStatus === 'pass' && item.assertionStatus === 'pass';
});
const failedRequests = items.filter((item) => {
return (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail';
});
const passedRequests = items.filter(allTestsPassed);
const failedRequests = items.filter(anyTestFailed);
const skippedRequests = items.filter((item) => {
return item.status === 'skipped';
@@ -176,18 +185,18 @@ export default function RunnerResults({ collection }) {
<div className="item-path mt-2">
<div className="flex items-center">
<span>
{item.testStatus === 'pass' && item.assertionStatus === 'pass' ?
{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}
{item.status === 'error' || item.testStatus === 'fail' || item.assertionStatus === 'fail' ?
{anyTestFailed(item) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
:null}
</span>
<span
className={`mr-1 ml-2 ${item.status == 'skipped' ? 'skipped-request' : item.status === 'error' || item.testStatus === 'fail' || item.assertionStatus === 'fail' ? 'danger' : ''}`}
className={`mr-1 ml-2 ${item.status == 'skipped' ? 'skipped-request' : anyTestFailed(item) ? 'danger' : ''}`}
>
{item.displayName}
</span>
@@ -208,6 +217,46 @@ export default function RunnerResults({ collection }) {
{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">
<IconCheck size={18} strokeWidth={2} className="mr-2" />
{result.description}
</span>
) : (
<>
<span className="test-failure flex items-center">
<IconX size={18} strokeWidth={2} className="mr-2" />
{result.description}
</span>
<span className="error-message pl-8 text-xs">{result.error}</span>
</>
)}
</li>
))
: null}
{item.postResponseTestResults
? item.postResponseTestResults.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.description}
</span>
) : (
<>
<span className="test-failure flex items-center">
<IconX size={18} strokeWidth={2} className="mr-2" />
{result.description}
</span>
<span className="error-message pl-8 text-xs">{result.error}</span>
</>
)}
</li>
))
: null}
{item.testResults
? item.testResults.map((result) => (
<li key={result.uid}>
@@ -271,10 +320,10 @@ export default function RunnerResults({ collection }) {
<div className="flex items-center px-3 mb-4 font-medium">
<span className="mr-2">{selectedItem.displayName}</span>
<span>
{selectedItem.testStatus === 'pass' && selectedItem.assertionStatus === 'pass' ?
{allTestsPassed(selectedItem) ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{selectedItem.status === 'error' || selectedItem.testStatus === 'fail' || selectedItem.assertionStatus === 'fail' ?
{anyTestFailed(selectedItem) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
: null}
{selectedItem.status === 'skipped' ?

View File

@@ -83,7 +83,7 @@ class SingleLineEditor extends Component {
}
});
}
this.editor.setValue(String(this.props.value) || '');
this.editor.setValue(String(this.props.value ?? ''));
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
this._enableMaskedEditor(this.props.isSecret);
@@ -107,7 +107,7 @@ class SingleLineEditor extends Component {
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.cachedValue = this.editor.getValue();
if (this.props.onChange) {
if (this.props.onChange && (this.props.value !== this.cachedValue)) {
this.props.onChange(this.cachedValue);
}
}
@@ -129,7 +129,7 @@ class SingleLineEditor extends Component {
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
this.editor.setValue(String(this.props.value ?? ''));
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change

View File

@@ -35,9 +35,9 @@ import {
responseReceived,
updateLastAction,
setCollectionSecurityConfig,
setRequestStartTime,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl
collectionClearOauth2CredentialsByUrl,
initRunRequestEvent
} from './index';
import { each } from 'lodash';
@@ -221,21 +221,26 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const itemUid = item?.uid;
dispatch(setRequestStartTime({
itemUid: item.uid,
collectionUid: collectionUid,
timestamp: Date.now()
}));
return new Promise((resolve, reject) => {
return new Promise(async (resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const itemCopy = cloneDeep(item || {});
let collectionCopy = cloneDeep(collection);
const itemCopy = cloneDeep(item);
const requestUid = uuid();
itemCopy.requestUid = requestUid;
await dispatch(initRunRequestEvent({
requestUid,
itemUid,
collectionUid
}));
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
@@ -254,8 +259,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
return dispatch(
responseReceived({
itemUid: item.uid,
collectionUid: collectionUid,
itemUid,
collectionUid,
response: serializedResponse
})
);
@@ -266,8 +271,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
console.log('>> request cancelled');
dispatch(
responseReceived({
itemUid: item.uid,
collectionUid: collectionUid,
itemUid,
collectionUid,
response: null
})
);
@@ -284,8 +289,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
dispatch(
responseReceived({
itemUid: item.uid,
collectionUid: collectionUid,
itemUid,
collectionUid,
response: errorResponse
})
);

View File

@@ -276,6 +276,8 @@ export const collectionsSlice = createSlice({
if (item) {
item.response = null;
item.cancelTokenUid = null;
item.requestUid = null;
item.requestStartTime = null;
}
}
},
@@ -288,6 +290,7 @@ export const collectionsSlice = createSlice({
item.requestState = 'received';
item.response = action.payload.response;
item.cancelTokenUid = null;
item.requestStartTime = null;
if (!collection.timeline) {
collection.timeline = [];
@@ -1954,26 +1957,44 @@ export const collectionsSlice = createSlice({
collection.runnerResult = null;
}
},
initRunRequestEvent: (state, action) => {
const { requestUid, itemUid, collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, itemUid);
if (!item) return;
item.requestState = null;
item.requestUid = requestUid;
item.requestStartTime = Date.now();
},
runRequestEvent: (state, action) => {
const { itemUid, collectionUid, type, requestUid, hasError } = action.payload;
const { itemUid, collectionUid, type, requestUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
// ignore outdated updates in case multiple requests are fired rapidly to avoid state inconsistency
if (item.requestUid !== requestUid) return;
if (type === 'pre-request-script-execution') {
item.requestUid = requestUid;
item.preRequestScriptErrorMessage = action.payload.errorMessage;
}
if(type === 'post-response-script-execution') {
item.requestUid = requestUid;
item.postResponseScriptErrorMessage = action.payload.errorMessage;
}
if(type === 'test-script-execution') {
item.testScriptErrorMessage = action.payload.errorMessage;
}
if (type === 'request-queued') {
const { cancelTokenUid } = action.payload;
item.requestUid = requestUid;
// ignore if request is already in progress or completed
if (['sending', 'received'].includes(item.requestState)) return;
item.requestState = 'queued';
item.cancelTokenUid = cancelTokenUid;
}
@@ -1981,10 +2002,9 @@ export const collectionsSlice = createSlice({
if (type === 'request-sent') {
const { cancelTokenUid, requestSent } = action.payload;
item.requestSent = requestSent;
// sometimes the response is received before the request-sent event arrives
if (item.requestUid === requestUid && item.requestState === 'queued') {
item.requestUid = requestUid;
if (item.requestState === 'queued') {
item.requestState = 'sending';
item.cancelTokenUid = cancelTokenUid;
}
@@ -1999,6 +2019,16 @@ export const collectionsSlice = createSlice({
const { results } = action.payload;
item.testResults = results;
}
if (type === 'test-results-pre-request') {
const { results } = action.payload;
item.preRequestTestResults = results;
}
if (type === 'test-results-post-response') {
const { results } = action.payload;
item.postResponseTestResults = results;
}
}
}
},
@@ -2055,6 +2085,16 @@ export const collectionsSlice = createSlice({
item.testResults = action.payload.testResults;
}
if (type === 'test-results-pre-request') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.preRequestTestResults = action.payload.preRequestTestResults;
}
if (type === 'test-results-post-response') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.postResponseTestResults = action.payload.postResponseTestResults;
}
if (type === 'assertion-results') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.assertionResults = action.payload.assertionResults;
@@ -2105,17 +2145,6 @@ export const collectionsSlice = createSlice({
}
}
},
setRequestStartTime: (state, action) => {
const { itemUid, collectionUid, timestamp } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
item.requestStartTime = timestamp;
}
}
},
collectionAddOauth2CredentialsByUrl: (state, action) => {
const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -2309,13 +2338,13 @@ export const {
collectionAddEnvFileEvent,
collectionRenamedEvent,
resetRunResults,
initRunRequestEvent,
runRequestEvent,
runFolderEvent,
resetCollectionRunner,
updateRequestDocs,
updateFolderDocs,
moveCollection,
setRequestStartTime,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl,
collectionGetOauth2CredentialsByUrl,

View File

@@ -7,7 +7,7 @@ const lightTheme = {
colors: {
text: {
green: '#047857',
danger: 'rgb(185, 28, 28)',
danger: '#B91C1C',
muted: '#838383',
purple: '#8e44ad',
yellow: '#d97706'

View File

@@ -196,4 +196,23 @@ export const getEncoding = (headers) => {
export const multiLineMsg = (...messages) => {
return messages.filter(m => m !== undefined && m !== null && m !== '').join('\n');
}
export const formatSize = (bytes) => {
// Handle invalid inputs
if (isNaN(bytes) || typeof bytes !== 'number') {
return '0B';
}
if (bytes < 1024) {
return bytes + 'B';
}
if (bytes < 1024 * 1024) {
return (bytes / 1024).toFixed(1) + 'KB';
}
if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + 'MB';
}
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'GB';
}

View File

@@ -1,6 +1,6 @@
const { describe, it, expect } = require('@jest/globals');
import { normalizeFileName, startsWith, humanizeDate, relativeDate, getContentType } from './index';
import { normalizeFileName, startsWith, humanizeDate, relativeDate, getContentType, formatSize } from './index';
describe('common utils', () => {
describe('normalizeFileName', () => {
@@ -148,4 +148,40 @@ describe('common utils', () => {
expect(getContentType(undefined)).toBe('');
});
});
describe('formatSize', () => {
it('should format bytes', () => {
expect(formatSize(0)).toBe('0B');
expect(formatSize(1023)).toBe('1023B');
});
it('should format kilobytes', () => {
expect(formatSize(1024)).toBe('1.0KB');
expect(formatSize(1048575)).toBe('1024.0KB');
});
it('should format megabytes', () => {
expect(formatSize(1048576)).toBe('1.0MB');
expect(formatSize(1073741823)).toBe('1024.0MB');
});
it('should format gigabytes', () => {
expect(formatSize(1073741824)).toBe('1.0GB');
expect(formatSize(1099511627776)).toBe('1024.0GB');
});
it('should format decimal values', () => {
expect(formatSize(1126.5)).toBe('1.1KB');
expect(formatSize(1153433.6)).toBe('1.1MB');
expect(formatSize(1153433600)).toBe('1.1GB');
expect(formatSize(1024.1)).toBe('1.0KB');
expect(formatSize(1048576.1)).toBe('1.0MB');
});
it('should format invalid inputs', () => {
expect(formatSize(null)).toBe('0B');
expect(formatSize(undefined)).toBe('0B');
expect(formatSize(NaN)).toBe('0B');
});
});
});

View File

@@ -12,9 +12,23 @@ const { rpad } = require('../utils/common');
const { bruToJson, getOptions, collectionBruToJson } = require('../utils/bru');
const { dotenvToJson } = require('@usebruno/lang');
const constants = require('../constants');
const { findItemInCollection, getAllRequestsInFolder, createCollectionJsonFromPathname } = require('../utils/collection');
const command = 'run [filename]';
const desc = 'Run a request';
const { findItemInCollection, getAllRequestsInFolder, createCollectionJsonFromPathname, getCallStack } = require('../utils/collection');
const command = 'run [paths...]';
const desc = 'Run one or more requests/folders';
const formatTestSummary = (label, maxLength, passed, failed, total, errorCount = 0, skippedCount = 0) => {
const parts = [
`${rpad(label, maxLength)} ${chalk.green(`${passed} passed`)}`
];
if (failed > 0) parts.push(chalk.red(`${failed} failed`));
if (errorCount > 0) parts.push(chalk.red(`${errorCount} error`));
if (skippedCount > 0) parts.push(chalk.magenta(`${skippedCount} skipped`));
parts.push(`${total} total`);
return parts.join(', ');
};
const printRunSummary = (results) => {
const {
@@ -28,38 +42,40 @@ const printRunSummary = (results) => {
failedAssertions,
totalTests,
passedTests,
failedTests
failedTests,
totalPreRequestTests,
passedPreRequestTests,
failedPreRequestTests,
totalPostResponseTests,
passedPostResponseTests,
failedPostResponseTests
} = getRunnerSummary(results);
const maxLength = 12;
let requestSummary = `${rpad('Requests:', maxLength)} ${chalk.green(`${passedRequests} passed`)}`;
if (failedRequests > 0) {
requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`;
}
if (errorRequests > 0) {
requestSummary += `, ${chalk.red(`${errorRequests} error`)}`;
}
if (skippedRequests > 0) {
requestSummary += `, ${chalk.magenta(`${skippedRequests} skipped`)}`;
}
requestSummary += `, ${totalRequests} total`;
const requestSummary = formatTestSummary('Requests:', maxLength, passedRequests, failedRequests, totalRequests, errorRequests, skippedRequests);
const testSummary = formatTestSummary('Tests:', maxLength, passedTests, failedTests, totalTests);
const assertSummary = formatTestSummary('Assertions:', maxLength, passedAssertions, failedAssertions, totalAssertions);
let assertSummary = `${rpad('Tests:', maxLength)} ${chalk.green(`${passedTests} passed`)}`;
if (failedTests > 0) {
assertSummary += `, ${chalk.red(`${failedTests} failed`)}`;
let preRequestTestSummary = '';
if (totalPreRequestTests > 0) {
preRequestTestSummary = formatTestSummary('Pre-Request Tests:', maxLength, passedPreRequestTests, failedPreRequestTests, totalPreRequestTests);
}
assertSummary += `, ${totalTests} total`;
let testSummary = `${rpad('Assertions:', maxLength)} ${chalk.green(`${passedAssertions} passed`)}`;
if (failedAssertions > 0) {
testSummary += `, ${chalk.red(`${failedAssertions} failed`)}`;
let postResponseTestSummary = '';
if (totalPostResponseTests > 0) {
postResponseTestSummary = formatTestSummary('Post-Response Tests:', maxLength, passedPostResponseTests, failedPostResponseTests, totalPostResponseTests);
}
testSummary += `, ${totalAssertions} total`;
console.log('\n' + chalk.bold(requestSummary));
console.log(chalk.bold(assertSummary));
if (preRequestTestSummary) {
console.log(chalk.bold(preRequestTestSummary));
}
if (postResponseTestSummary) {
console.log(chalk.bold(postResponseTestSummary));
}
console.log(chalk.bold(testSummary));
console.log(chalk.bold(assertSummary));
return {
totalRequests,
@@ -72,7 +88,13 @@ const printRunSummary = (results) => {
failedAssertions,
totalTests,
passedTests,
failedTests
failedTests,
totalPreRequestTests,
passedPreRequestTests,
failedPreRequestTests,
totalPostResponseTests,
passedPostResponseTests,
failedPostResponseTests
}
};
@@ -106,6 +128,10 @@ const builder = async (yargs) => {
describe: 'Environment variables',
type: 'string'
})
.option('env-file', {
describe: 'Path to environment file (.bru) - can be absolute or relative path',
type: 'string'
})
.option('env-var', {
describe: 'Overwrite a single environment variable, multiple usages possible',
type: 'string'
@@ -175,8 +201,10 @@ const builder = async (yargs) => {
})
.example('$0 run request.bru', 'Run a request')
.example('$0 run request.bru --env local', 'Run a request with the environment set to local')
.example('$0 run request.bru --env-file env.bru', 'Run a request with the environment from env.bru file')
.example('$0 run folder', 'Run all requests in a folder')
.example('$0 run folder -r', 'Run all requests in a folder recursively')
.example('$0 run request.bru folder', 'Run a request and all requests in a folder')
.example('$0 run --reporter-skip-all-headers', 'Run all requests in a folder recursively with omitted headers from the reporter output')
.example(
'$0 run --reporter-skip-headers "Authorization"',
@@ -219,11 +247,12 @@ const builder = async (yargs) => {
const handler = async function (argv) {
try {
let {
filename,
paths,
cacert,
ignoreTruststore,
disableCookies,
env,
envFile,
envVar,
insecure,
r: recursive,
@@ -280,33 +309,31 @@ const handler = async function (argv) {
}
}
if (filename && filename.length) {
const pathExists = await exists(filename);
if (!pathExists) {
console.error(chalk.red(`File or directory ${filename} does not exist`));
process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND);
}
} else {
filename = './';
recursive = true;
}
const runtimeVariables = {};
let envVars = {};
if (env) {
const envFile = path.join(collectionPath, 'environments', `${env}.bru`);
const envPathExists = await exists(envFile);
if (env && envFile) {
console.error(chalk.red(`Cannot use both --env and --env-file options together`));
process.exit(constants.EXIT_STATUS.ERROR_MALFORMED_ENV_OVERRIDE);
}
if (envFile || env) {
const envFilePath = envFile
? path.resolve(collectionPath, envFile)
: path.join(collectionPath, 'environments', `${env}.bru`);
const envFileExists = await exists(envFilePath);
if (!envFileExists) {
const errorPath = envFile || `environments/${env}.bru`;
console.error(chalk.red(`Environment file not found: `) + chalk.dim(errorPath));
if (!envPathExists) {
console.error(chalk.red(`Environment file not found: `) + chalk.dim(`environments/${env}.bru`));
process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND);
}
const envBruContent = fs.readFileSync(envFile, 'utf8');
const envBruContent = fs.readFileSync(envFilePath, 'utf8').replace(/\r\n/g, '\n');
const envJson = bruToEnvJson(envBruContent);
envVars = getEnvVars(envJson);
envVars.__name__ = env;
envVars.__name__ = envFile ? path.basename(envFilePath, '.bru') : env;
}
if (envVar) {
@@ -401,45 +428,34 @@ const handler = async function (argv) {
});
}
const _isFile = isFile(filename);
let requestItems = [];
let results = [];
let requestItems = [];
if (_isFile) {
console.log(chalk.yellow('Running Request \n'));
const bruContent = fs.readFileSync(filename, 'utf8');
const requestItem = bruToJson(bruContent);
requestItem.pathname = path.resolve(collectionPath, filename);
requestItems.push(requestItem);
if (!paths || !paths.length) {
paths = ['./'];
recursive = true;
}
const _isDirectory = isDirectory(filename);
if (_isDirectory) {
if (!recursive) {
console.log(chalk.yellow('Running Folder \n'));
} else {
console.log(chalk.yellow('Running Folder Recursively \n'));
}
const resolvedFilepath = path.resolve(filename);
if (resolvedFilepath === collectionPath) {
requestItems = getAllRequestsInFolder(collection?.items, recursive);
} else {
const folderItem = findItemInCollection(collection, resolvedFilepath);
if (folderItem) {
requestItems = getAllRequestsInFolder(folderItem.items, recursive);
}
}
const resolvedPaths = paths.map(p => path.resolve(process.cwd(), p));
if (testsOnly) {
requestItems = requestItems.filter((iter) => {
const requestHasTests = iter.request?.tests;
const requestHasActiveAsserts = iter.request?.assertions.some((x) => x.enabled) || false;
return requestHasTests || requestHasActiveAsserts;
});
for (const resolvedPath of resolvedPaths) {
const pathExists = await exists(resolvedPath);
if (!pathExists) {
console.error(chalk.red(`Path not found: ${resolvedPath}`));
process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND);
}
}
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;
});
}
const runtime = getJsSandboxRuntime(sandbox);
const runSingleRequestByPathname = async (relativeItemPathname) => {
@@ -498,7 +514,7 @@ const handler = async function (argv) {
if(Number.isNaN(delay) && !isLastRun){
console.log(chalk.red(`Ignoring delay because it's not a valid number.`));
}
results.push({
...result,
runtime: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
@@ -539,7 +555,9 @@ const handler = async function (argv) {
const requestFailure = result?.error && !result?.skipped;
const testFailure = result?.testResults?.find((iter) => iter.status === 'fail');
const assertionFailure = result?.assertionResults?.find((iter) => iter.status === 'fail');
if (requestFailure || testFailure || assertionFailure) {
const preRequestTestFailure = result?.preRequestTestResults?.find((iter) => iter.status === 'fail');
const postResponseTestFailure = result?.postResponseTestResults?.find((iter) => iter.status === 'fail');
if (requestFailure || testFailure || assertionFailure || preRequestTestFailure || postResponseTestFailure) {
break;
}
}
@@ -550,7 +568,7 @@ const handler = async function (argv) {
if (result?.shouldStopRunnerExecution) {
break;
}
if (nextRequestName !== undefined) {
nJumps++;
if (nJumps > 10000) {
@@ -617,7 +635,7 @@ const handler = async function (argv) {
}
}
if ((summary.failedAssertions + summary.failedTests + summary.failedRequests > 0) || (summary?.errorRequests > 0)) {
if ((summary.failedAssertions + summary.failedTests + summary.failedPreRequestTests + summary.failedPostResponseTests + summary.failedRequests > 0) || (summary?.errorRequests > 0)) {
process.exit(constants.EXIT_STATUS.ERROR_FAILED_COLLECTION);
}
} catch (err) {

View File

@@ -47,7 +47,7 @@ const prepareRequest = (item = {}, collection = {}) => {
}
if (collectionAuth.mode === 'bearer') {
axiosRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`;
axiosRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token', '')}`;
}
if (collectionAuth.mode === 'apikey') {
@@ -174,7 +174,7 @@ const prepareRequest = (item = {}, collection = {}) => {
}
if (request.auth.mode === 'bearer') {
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token', '')}`;
}
if (request.auth.mode === 'wsse') {

View File

@@ -45,10 +45,33 @@ const runSingleRequest = async function (
) {
const { pathname: itemPathname } = item;
const relativeItemPathname = path.relative(collectionPath, itemPathname);
const logResults = (results, title) => {
if (results?.length) {
if (title) {
console.log(chalk.dim(title));
}
each(results, (r) => {
const message = r.description || `${r.lhsExpr}: ${r.rhsExpr}`;
if (r.status === 'pass') {
console.log(chalk.green(``) + chalk.dim(message));
} else {
console.log(chalk.red(``) + chalk.red(message));
if (r.error) {
console.log(chalk.red(` ${r.error}`));
}
}
});
}
};
try {
let request;
let nextRequestName;
let shouldStopRunnerExecution = false;
let preRequestTestResults = [];
let postResponseTestResults = [];
request = prepareRequest(item, collection);
request.__bruno__executionMode = 'cli';
@@ -103,9 +126,13 @@ const runSingleRequest = async function (
skipped: true,
assertionResults: [],
testResults: [],
preRequestTestResults: result?.results || [],
postResponseTestResults: [],
shouldStopRunnerExecution
};
}
preRequestTestResults = result?.results || [];
}
// interpolate variables inside request
@@ -211,8 +238,8 @@ const runSingleRequest = async function (
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
let proxyUri;
if (proxyAuthEnabled) {
const proxyAuthUsername = interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions);
const proxyAuthPassword = interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions);
const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
} else {
@@ -428,6 +455,8 @@ const runSingleRequest = async function (
status: 'error',
assertionResults: [],
testResults: [],
preRequestTestResults,
postResponseTestResults,
nextRequestName: nextRequestName,
shouldStopRunnerExecution
};
@@ -441,6 +470,9 @@ const runSingleRequest = async function (
chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`)
);
// Log pre-request test results
logResults(preRequestTestResults, 'Pre-Request Tests');
// run post-response vars
const postResponseVars = get(item, 'request.vars.res');
if (postResponseVars?.length) {
@@ -480,9 +512,11 @@ const runSingleRequest = async function (
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
postResponseTestResults = result?.results || [];
logResults(postResponseTestResults, 'Post-Response Tests');
}
// run assertions
let assertionResults = [];
const assertions = get(item, 'request.assertions');
if (assertions) {
@@ -495,15 +529,6 @@ const runSingleRequest = async function (
runtimeVariables,
processEnvVars
);
each(assertionResults, (r) => {
if (r.status === 'pass') {
console.log(chalk.green(``) + chalk.dim(`assert: ${r.lhsExpr}: ${r.rhsExpr}`));
} else {
console.log(chalk.red(``) + chalk.red(`assert: ${r.lhsExpr}: ${r.rhsExpr}`));
console.log(chalk.red(` ${r.error}`));
}
});
}
// run tests
@@ -533,17 +558,12 @@ const runSingleRequest = async function (
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
logResults(testResults, 'Tests');
}
if (testResults?.length) {
each(testResults, (testResult) => {
if (testResult.status === 'pass') {
console.log(chalk.green(``) + chalk.dim(testResult.description));
} else {
console.log(chalk.red(``) + chalk.red(testResult.description));
}
});
}
logResults(assertionResults, 'Assertions');
return {
test: {
@@ -566,6 +586,8 @@ const runSingleRequest = async function (
status: 'pass',
assertionResults,
testResults,
preRequestTestResults,
postResponseTestResults,
nextRequestName: nextRequestName,
shouldStopRunnerExecution
};
@@ -591,7 +613,9 @@ const runSingleRequest = async function (
status: 'error',
error: err.message,
assertionResults: [],
testResults: []
testResults: [],
preRequestTestResults: [],
postResponseTestResults: []
};
}
};

View File

@@ -349,6 +349,39 @@ const getAllRequestsAtFolderRoot = (folderItems = []) => {
return getAllRequestsInFolder(folderItems, false);
}
const getCallStack = (resolvedPaths = [], collection, {recursive}) => {
let requestItems = [];
if (!resolvedPaths || !resolvedPaths.length) {
return requestItems;
}
for (const resolvedPath of resolvedPaths) {
if (!resolvedPath || !resolvedPath.length) {
continue;
}
if (resolvedPath === collection.pathname) {
requestItems = requestItems.concat(getAllRequestsInFolder(collection.items, recursive));
continue;
}
const item = findItemInCollection(collection, resolvedPath);
if (!item) {
continue;
}
if (item.type === 'folder') {
requestItems = requestItems.concat(getAllRequestsInFolder(item.items, recursive));
} else {
requestItems.push(item);
}
}
return requestItems;
};
/**
* Safe write file implementation to handle errors
* @param {string} filePath - Path to write file
@@ -489,5 +522,6 @@ module.exports = {
createCollectionFromBrunoObject,
mergeAuth,
getAllRequestsInFolder,
getAllRequestsAtFolderRoot
getAllRequestsAtFolderRoot,
getCallStack
}

View File

@@ -0,0 +1,460 @@
const { describe, it, expect, beforeEach } = require('@jest/globals');
const { getCallStack } = require('../../../src/utils/collection');
const collection = {
brunoConfig: {
version: '1',
name: 'multirun-cli',
type: 'collection',
ignore: ['node_modules', '.git']
},
root: {
request: {
headers: [],
auth: {},
script: {},
vars: {},
tests: ''
}
},
pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20',
items: [
{
name: 'root-folder',
pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder',
type: 'folder',
items: [
{
name: 'root-child-folder',
pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder',
type: 'folder',
items: [
{
name: 'root-child-child-folder',
pathname:
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-folder',
type: 'folder',
items: [
{
name: 'root-child-child-child-req-0',
pathname:
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-folder/root-child-child-child-req-0.bru',
type: 'http-request',
seq: 1,
request: {
method: 'GET',
url: 'https://g.cn',
auth: {
mode: 'inherit'
},
params: [],
headers: [],
body: {
mode: 'none'
},
vars: [],
assertions: [],
script: {
req: 'console.log("root-child-child-child-file-0")'
},
tests: ''
}
},
{
name: 'root-child-child-child-req-1',
pathname:
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-folder/root-child-child-child-req-1.bru',
type: 'http-request',
seq: 2,
request: {
method: 'GET',
url: 'https://g.cn',
auth: {
mode: 'inherit'
},
params: [],
headers: [],
body: {
mode: 'none'
},
vars: [],
assertions: [],
script: {
req: 'console.log("root-child-child-child-file-1")'
},
tests: ''
}
}
],
root: {
request: {
headers: [],
auth: {},
script: {},
vars: {},
tests: ''
},
meta: {
name: 'root-child-child-folder',
seq: 3
}
},
seq: 3
},
{
name: 'root-child-child-req-0',
pathname:
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-req-0.bru',
type: 'http-request',
seq: 4,
request: {
method: 'GET',
url: 'https://g.cn',
auth: {
mode: 'inherit'
},
params: [],
headers: [],
body: {
mode: 'none'
},
vars: [],
assertions: [],
script: {
req: 'console.log("root-child-child-file-0")'
},
tests: ''
}
},
{
name: 'root-child-child-req-1',
pathname:
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-req-1.bru',
type: 'http-request',
seq: 5,
request: {
method: 'GET',
url: 'https://g.cn',
auth: {
mode: 'inherit'
},
params: [],
headers: [],
body: {
mode: 'none'
},
vars: [],
assertions: [],
script: {
req: 'console.log("root-child-child-file-1")'
},
tests: ''
}
}
],
root: {
request: {
headers: [],
auth: {},
script: {},
vars: {},
tests: ''
},
meta: {
name: 'root-child-folder',
seq: 6
}
},
seq: 6
},
{
name: 'root-child-req-0',
pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-req-0.bru',
type: 'http-request',
seq: 7,
request: {
method: 'GET',
url: 'https://g.cn',
auth: {
mode: 'inherit'
},
params: [],
headers: [],
body: {
mode: 'none'
},
vars: [],
assertions: [],
script: {
req: 'console.log("root-child-file-0")'
},
tests: ''
}
},
{
name: 'root-child-req-1',
pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-req-1.bru',
type: 'http-request',
seq: 8,
request: {
method: 'GET',
url: 'https://g.cn',
auth: {
mode: 'inherit'
},
params: [],
headers: [],
body: {
mode: 'none'
},
vars: [],
assertions: [],
script: {
req: 'console.log("root-child-file-1")'
},
tests: ''
}
}
],
root: {
request: {
headers: [],
auth: {},
script: {},
vars: {},
tests: ''
},
meta: {
name: 'root-folder',
seq: 9
}
},
seq: 9
},
{
name: 'root-req-0',
pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-0.bru',
type: 'http-request',
seq: 10,
request: {
method: 'GET',
url: 'https://g.cn',
auth: {
mode: 'inherit'
},
params: [],
headers: [],
body: {
mode: 'none'
},
vars: [],
assertions: [],
script: {
req: 'console.log("root-file-0")'
},
tests: ''
}
},
{
name: 'root-req-1',
pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-1.bru',
type: 'http-request',
seq: 11,
request: {
method: 'GET',
url: 'https://g.cn',
auth: {
mode: 'inherit'
},
params: [],
headers: [],
body: {
mode: 'none'
},
vars: [],
assertions: [],
script: {
req: 'console.log("root-file-1")'
},
tests: ''
}
},
{
name: 'root-req-2',
pathname: '/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-2.bru',
type: 'http-request',
seq: 12,
request: {
method: 'GET',
url: 'https://g.cn',
auth: {
mode: 'inherit'
},
params: [],
headers: [],
body: {
mode: 'none'
},
vars: [],
assertions: [],
script: {
req: 'console.log("root-file-2")'
},
tests: ''
}
}
]
};
const sequenceChangedCollection = {
brunoConfig: {
version: '1',
name: 'sequenceChangedCollection',
type: 'collection',
ignore: ['node_modules', '.git']
},
root: {},
pathname: '/Users/tempo/Downloads/t-temp/sequenceChangedCollection',
items: [
{
name: 'three',
pathname: '/Users/tempo/Downloads/t-temp/sequenceChangedCollection/three.bru',
type: 'http-request',
seq: 1,
request: {
method: 'GET',
url: 'https://usebruno.com',
auth: {
mode: 'inherit'
},
params: [],
headers: [],
body: {
mode: 'none'
},
vars: [],
assertions: [],
script: {},
tests: ''
}
},
{
name: 'one',
pathname: '/Users/tempo/Downloads/t-temp/sequenceChangedCollection/one.bru',
type: 'http-request',
seq: 2,
request: {
method: 'GET',
url: 'https://usebruno.com',
auth: {
mode: 'inherit'
},
params: [],
headers: [],
body: {
mode: 'none'
},
vars: [],
assertions: [],
script: {},
tests: ''
}
},
{
name: 'two',
pathname: '/Users/tempo/Downloads/t-temp/sequenceChangedCollection/two.bru',
type: 'http-request',
seq: 2,
request: {
method: 'GET',
url: 'https://usebruno.com',
auth: {
mode: 'inherit'
},
params: [],
headers: [],
body: {
mode: 'none'
},
vars: [],
assertions: [],
script: {},
tests: ''
}
}
]
};
describe('getCallStack', () => {
it('should return all requests in the collection', () => {
const callStack = getCallStack(['/Users/tempo/Downloads/t-temp/multirun-cli-20'], collection, { recursive: true });
const expectedCallStack = [
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-folder/root-child-child-child-req-0.bru',
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-folder/root-child-child-child-req-1.bru',
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-req-0.bru',
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-req-1.bru',
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-req-0.bru',
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-req-1.bru',
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-0.bru',
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-1.bru',
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-2.bru'
];
expect(callStack.map((item) => item.pathname)).toEqual(expectedCallStack);
});
it('should return all requests in the collection when sequence is changed', () => {
const callStack = getCallStack(
['/Users/tempo/Downloads/t-temp/sequenceChangedCollection'],
sequenceChangedCollection,
{
recursive: true
}
);
const expectedCallStack = [
'/Users/tempo/Downloads/t-temp/sequenceChangedCollection/three.bru',
'/Users/tempo/Downloads/t-temp/sequenceChangedCollection/one.bru',
'/Users/tempo/Downloads/t-temp/sequenceChangedCollection/two.bru'
];
expect(callStack.map((item) => item.pathname)).toEqual(expectedCallStack);
});
});
describe('getCallStack with collection sequence changed', () => {
it('should return an empty array', () => {
const callStack = getCallStack(
['/Users/tempo/Downloads/t-temp/sequenceChangedCollection'],
sequenceChangedCollection,
{
recursive: true
}
);
const expectedCallStack = [
'/Users/tempo/Downloads/t-temp/sequenceChangedCollection/three.bru',
'/Users/tempo/Downloads/t-temp/sequenceChangedCollection/one.bru',
'/Users/tempo/Downloads/t-temp/sequenceChangedCollection/two.bru'
];
expect(callStack.map((item) => item.pathname)).toEqual(expectedCallStack);
});
});
describe('getCallStack with muliple folders and requests run', () => {
it('should return an empty array', () => {
const callStack = getCallStack(
[
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-0.bru',
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-req-0.bru',
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-2.bru'
],
collection,
{
recursive: true
}
);
const expectedCallStack = [
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-0.bru',
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-folder/root-child-folder/root-child-child-req-0.bru',
'/Users/tempo/Downloads/t-temp/multirun-cli-20/root-req-2.bru'
];
expect(callStack.map((item) => item.pathname)).toEqual(expectedCallStack);
});
});

View File

@@ -13,12 +13,20 @@ export const getRunnerSummary = (results: T_RunnerRequestExecutionResult[]): T_R
let totalTests = 0;
let passedTests = 0;
let failedTests = 0;
let totalPreRequestTests = 0;
let passedPreRequestTests = 0;
let failedPreRequestTests = 0;
let totalPostResponseTests = 0;
let passedPostResponseTests = 0;
let failedPostResponseTests = 0;
for (const result of results || []) {
const { status, testResults, assertionResults } = result;
const { status, testResults, assertionResults, preRequestTestResults, postResponseTestResults } = result;
totalRequests += 1;
totalTests += Number(testResults?.length) || 0;
totalAssertions += Number(assertionResults?.length) || 0;
totalPreRequestTests += Number(preRequestTestResults?.length) || 0;
totalPostResponseTests += Number(postResponseTestResults?.length) || 0;
if (status === 'skipped') {
skippedRequests += 1;
@@ -42,6 +50,22 @@ export const getRunnerSummary = (results: T_RunnerRequestExecutionResult[]): T_R
failedAssertions += 1;
}
}
for (const preRequestTestResult of preRequestTestResults || []) {
if (preRequestTestResult.status === "pass") {
passedPreRequestTests += 1;
} else {
anyFailed = true;
failedPreRequestTests += 1;
}
}
for (const postResponseTestResult of postResponseTestResults || []) {
if (postResponseTestResult.status === "pass") {
passedPostResponseTests += 1;
} else {
anyFailed = true;
failedPostResponseTests += 1;
}
}
if (!anyFailed && status !== "error") {
passedRequests += 1;
@@ -64,5 +88,11 @@ export const getRunnerSummary = (results: T_RunnerRequestExecutionResult[]): T_R
totalTests,
passedTests,
failedTests,
totalPreRequestTests,
passedPreRequestTests,
failedPreRequestTests,
totalPostResponseTests,
passedPostResponseTests,
failedPostResponseTests,
};
};

View File

@@ -89,6 +89,8 @@ export type T_RunnerRequestExecutionResult = {
error: null | undefined | string;
assertionResults?: T_AssertionResult[];
testResults?: T_TestResult[];
preRequestTestResults?: T_TestResult[];
postResponseTestResults?: T_TestResult[];
runDuration: number;
}
@@ -112,4 +114,10 @@ export type T_RunSummary = {
totalTests: number;
passedTests: number;
failedTests: number;
totalPreRequestTests: number;
passedPreRequestTests: number;
failedPreRequestTests: number;
totalPostResponseTests: number;
passedPostResponseTests: number;
failedPostResponseTests: number;
}

View File

@@ -103,14 +103,14 @@ const importScriptsFromEvents = (events, requestObject) => {
}
if (event.listen === 'test') {
if (!requestObject.tests) {
requestObject.tests = {};
if (!requestObject.script) {
requestObject.script = {};
}
if (event.script.exec && event.script.exec.length > 0) {
requestObject.tests = postmanTranslation(event.script.exec)
requestObject.script.res = postmanTranslation(event.script.exec)
} else {
requestObject.tests = '';
requestObject.script.res = '';
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
@@ -376,16 +376,17 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, { useWorke
}
}
if (event.listen === 'test' && event.script && event.script.exec) {
if (!brunoRequestItem.request?.tests) {
brunoRequestItem.request.tests = {};
if (!brunoRequestItem.request?.script) {
brunoRequestItem.request.script = {};
}
if (event.script.exec && event.script.exec.length > 0) {
brunoRequestItem.request.tests = postmanTranslation(event.script.exec)
brunoRequestItem.request.script.res = postmanTranslation(event.script.exec)
} else {
brunoRequestItem.request.tests = '';
brunoRequestItem.request.script.res = '';
console.warn('Unexpected event.script.exec type', typeof event.script.exec);
}
}
});
}
}
@@ -581,15 +582,12 @@ const importPostmanV2Collection = async (collection, { useWorkers = false }) =>
if (!item.root.request.script) {
item.root.request.script = {};
}
if (!item.root.request.tests) {
item.root.request.tests = '';
}
const script = translatedScripts.get(item.uid).request?.script?.req;
const tests = translatedScripts.get(item.uid).request?.tests;
const tests = translatedScripts.get(item.uid).request?.script?.res;
item.root.request.script.req = script && script.length > 0 ? script : '';
item.root.request.tests = tests && tests.length > 0 ? tests : '';
item.root.request.script.res = tests && tests.length > 0 ? tests : '';
}
// Recursively apply to nested items
@@ -601,15 +599,12 @@ const importPostmanV2Collection = async (collection, { useWorkers = false }) =>
if (!item.request.script) {
item.request.script = {};
}
if (!item.request.tests) {
item.request.tests = '';
}
const script = translatedScripts.get(item.uid).request?.script?.req;
const tests = translatedScripts.get(item.uid).request?.tests;
const tests = translatedScripts.get(item.uid).request?.script?.res;
item.request.script.req = script && script.length > 0 ? script : '';
item.request.tests = tests && tests.length > 0 ? tests : '';
item.request.script.res = tests && tests.length > 0 ? tests : '';
}
}
});

View File

@@ -1,3 +1,4 @@
import sendRequestTransformer from './send-request-transformer';
const j = require('jscodeshift');
const cloneDeep = require('lodash/cloneDeep');
@@ -99,7 +100,14 @@ const simpleTranslations = {
* as a separate statement, which allows a single Postman expression to be
* transformed into multiple Bruno statements (e.g. for complex assertions).
*/
const complexTransformations = [
// pm.sendRequest transformation
{
pattern: 'pm.sendRequest',
transform: sendRequestTransformer
},
// pm.environment.has requires special handling
{
pattern: 'pm.environment.has',

View File

@@ -0,0 +1,277 @@
/**
* Convert Postman header array format to Bruno headers object
* @param {Object} j - jscodeshift API
* @param {Object} arrayValue - Array expression of key-value pair objects
* @returns {Object} - Object expression with key-value pairs
*/
const convertArrayToObject = (j, arrayValue) => {
const obj = j.objectExpression([]);
if (arrayValue.type === 'ArrayExpression') {
arrayValue.elements.forEach(elem => {
if (elem.type === 'ObjectExpression') {
const keyProp = elem.properties.find(p => (p.key.name === 'key' || p.key.value === 'key'));
const valueProp = elem.properties.find(p => (p.key.name === 'value' || p.key.value === 'value'));
if (keyProp && valueProp) {
obj.properties.push(
j.property(
'init',
j.literal(keyProp.value.value),
valueProp.value
)
);
}
}
});
}
return obj;
};
/**
* Add or update a specific header in the request options
* @param {Object} j - jscodeshift API
* @param {Object} requestOptions - Request options object
* @param {string} headerName - Header name to add/update
* @param {string} headerValue - Header value
*/
const addOrUpdateHeader = (j, requestOptions, headerName, headerValue) => {
let headersProp = requestOptions.properties.find(p => (p.key.name === 'headers' || p.key.value === 'headers'));
if (!headersProp) {
headersProp = j.property('init', j.identifier('headers'), j.objectExpression([]));
requestOptions.properties.push(headersProp);
} else if (headersProp.value.type !== 'ObjectExpression') {
headersProp.value = j.objectExpression([]);
}
// filter out existing header with same name (case-insensitive)
headersProp.value.properties = headersProp.value.properties.filter(p =>
p.key.type !== 'Literal' ||
p.key.value.toLowerCase() !== headerName.toLowerCase()
);
headersProp.value.properties.push(
j.property(
'init',
j.literal(headerName),
j.literal(headerValue)
)
);
};
/**
* Transform headers property from array to object format
* @param {Object} j - jscodeshift API
* @param {Object} requestOptions - Request options object
*/
const transformHeaders = (j, requestOptions) => {
if (requestOptions.type !== 'ObjectExpression') return;
requestOptions.properties.forEach(prop => {
// find and rename 'header' property to 'headers'
if (prop.key.name === 'header' || prop.key.value === 'header') {
prop.key.name = 'headers';
prop.key.value = 'headers';
// Handle array of header objects
if (prop.value.type === 'ArrayExpression') {
prop.value = convertArrayToObject(j, prop.value);
}
}
});
};
/**
* Transform body property based on body mode
* @param {Object} j - jscodeshift API
* @param {Object} requestOptions - Request options object
* @returns {Array|null} - Array of statements if formdata is used, null otherwise
*/
const transformBody = (j, requestOptions) => {
if (requestOptions.type !== 'ObjectExpression') return null;
requestOptions.properties.forEach(prop => {
if (prop.key.name === 'body' || prop.key.value === 'body') {
if (prop.value.type === 'ObjectExpression') {
const bodyProps = prop.value.properties;
const modeProp = bodyProps.find(p => (p.key.name === 'mode' || p.key.value === 'mode'));
if (modeProp && modeProp.value.type === 'Literal') {
const bodyMode = modeProp.value.value;
// Handle raw mode (text, json, xml, etc.)
if (bodyMode === 'raw') {
const rawProp = bodyProps.find(p => (p.key.name === 'raw' || p.key.value === 'raw'));
if (rawProp) {
// Replace body with data
prop.key.name = 'data';
prop.key.value = 'data';
prop.value = rawProp.value;
}
}
// Handle urlencoded mode
else if (bodyMode === 'urlencoded') {
const urlencodedProp = bodyProps.find(p => (p.key.name === 'urlencoded' || p.key.value === 'urlencoded') && p.value.type === 'ArrayExpression');
if (urlencodedProp) {
// Replace the body property with a 'data' property
prop.key.name = 'data';
prop.key.value = 'data';
// Transform the urlencoded array to an object
prop.value = convertArrayToObject(j, urlencodedProp.value);
// Add Content-Type header for urlencoded
addOrUpdateHeader(j, requestOptions, 'Content-Type', 'application/x-www-form-urlencoded');
}
}
// Handle formdata mode
else if (bodyMode === 'formdata') {
const formdataProp = bodyProps.find(p => (p.key.name === 'formdata' || p.key.value === 'formdata') && p.value.type === 'ArrayExpression');
if (formdataProp) {
// Replace the body property with a 'data' property
prop.key.name = 'data';
prop.key.value = 'data';
// Transform the urlencoded array to an object
prop.value = convertArrayToObject(j, formdataProp.value);
// Add Content-Type header for urlencoded
addOrUpdateHeader(j, requestOptions, 'Content-Type', 'multipart/form-data');
}
}
}
}
}
});
};
/**
* Transform callback function to Bruno format
* @param {Object} j - jscodeshift API
* @param {Object} callback - Callback function expression
* @returns {Object} - Transformed callback function
*/
const transformCallback = (j, callback) => {
if (!callback || (callback.type !== 'FunctionExpression' && callback.type !== 'ArrowFunctionExpression')) return null;
const params = callback.params;
const callbackBody = callback.body;
// Get the response parameter name (typically the second param)
let responseVarName = 'response'; // Default if not found
if (params.length >= 2 && params[1].type === 'Identifier') {
responseVarName = params[1].name;
}
let errorVarName = 'error'; // Default if not found
if (params.length >= 1 && params[0].type === 'Identifier') {
errorVarName = params[0].name;
}
// Define translations for callback response properties
const responsePropertyMap = {
'json': 'data',
'text': 'data',
'code': 'status',
'status': 'statusText',
};
// Process the callback body to transform response property references
j(callbackBody).find(j.MemberExpression, {
object: {
type: 'Identifier',
name: responseVarName
}
}).forEach(memberPath => {
const property = memberPath.node.property;
// Handle property access
if (property.type === 'Identifier' && responsePropertyMap[property.name]) {
const bruProperty = responsePropertyMap[property.name];
if (bruProperty) {
// Check if memberPath is part of a CallExpression
const parentPath = memberPath.parent;
if (parentPath && parentPath.node.type === 'CallExpression') {
// Replace the entire CallExpression with a property access
j(parentPath).replaceWith(
j.memberExpression(
j.identifier(responseVarName),
j.identifier(bruProperty)
)
);
} else {
// Regular property access replacement
j(memberPath).replaceWith(
j.memberExpression(
j.identifier(responseVarName),
j.identifier(bruProperty)
)
);
}
}
}
});
// Create the callback block
return j.functionExpression(
null,
[j.identifier(errorVarName), j.identifier(responseVarName)],
j.blockStatement(callbackBody.body)
);
};
const sendRequestTransformer = (path, j) => {
const callExpr = path.parent.value;
if (callExpr.type !== 'CallExpression') return;
// Clone the argument object for modification
const args = [...callExpr.arguments];
if (!args.length) return;
const requestOptions = args[0];
const callback = args[1];
// Check if original call was awaited
const wasAwaited = path.parent.parent.value.type === 'AwaitExpression';
// transform the request config options
if (requestOptions.type === 'ObjectExpression') {
// Transform headers
transformHeaders(j, requestOptions);
// Transform body
transformBody(j, requestOptions);
}
// Create the callback block and promise chain if there's a callback
if (callback) {
const transformedCallback = transformCallback(j, callback);
// Add async keyword to the callback function
if (transformedCallback && (transformedCallback.type === 'FunctionExpression' || transformedCallback.type === 'ArrowFunctionExpression')) {
transformedCallback.async = true;
}
// Create expression: await bru.sendRequest(requestConfig, callback);
const sendRequestCall = j.callExpression(
j.identifier('bru.sendRequest'),
transformedCallback ? [requestOptions, transformedCallback] : [requestOptions]
);
return wasAwaited ? sendRequestCall : j.awaitExpression(sendRequestCall);
}
// If there's no callback, just transform to await bru.sendRequest
const sendRequestCall = j.callExpression(
j.identifier('bru.sendRequest'),
[requestOptions]
);
return wasAwaited ? sendRequestCall : j.awaitExpression(sendRequestCall);
};
export default sendRequestTransformer;

View File

@@ -6,8 +6,7 @@ parentPort.on('message', (workerData) => {
const { scripts } = workerData;
const modScripts = scripts.map(([uid, { events }]) => {
const requestObject = {
script: {},
tests: {}
script: {}
}
if (events && Array.isArray(events)) {
@@ -23,9 +22,9 @@ parentPort.on('message', (workerData) => {
if(event.listen === 'test') {
if(event.script.exec && event.script.exec.length > 0) {
requestObject.tests = postmanTranslation(event.script.exec);
requestObject.script.res = postmanTranslation(event.script.exec);
} else {
requestObject.tests = '';
requestObject.script.res = '';
}
}
}

View File

@@ -0,0 +1,688 @@
import translateCode from '../../../../../src/utils/jscode-shift-translator';
describe('Send Request Translation', () => {
describe('Raw Body Mode', () => {
it('should transform raw JSON string body', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: {
mode: 'raw',
raw: JSON.stringify({
"x": 1
})
}
}, async function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
data: JSON.stringify({
"x": 1
})
}, async function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.data;
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`);
});
it('should transform raw JSON object body', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: {
mode: 'raw',
raw: {
"x": 1
}
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
data: {
"x": 1
}
}, async function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.data;
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`);
});
it('should transform raw text body', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {
'Content-Type': 'text/plain',
},
body: {
mode: 'raw',
raw: 'Hello World'
}
}, function (error, response) {
console.log(response.text());
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
'Content-Type': 'text/plain',
},
data: 'Hello World'
}, async function(error, response) {
console.log(response.data);
});
`);
});
});
describe('URL-encoded Body Mode', () => {
it('should transform urlencoded body with single key-value pair', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: {
mode: 'urlencoded',
urlencoded: [
{ key: "key", value: "value" }
]
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
'Accept': 'application/json',
"Content-Type": "application/x-www-form-urlencoded",
},
data: {
"key": "value"
}
}, async function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.data;
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`);
});
it('should transform urlencoded body with multiple key-value pairs', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {},
body: {
mode: 'urlencoded',
urlencoded: [
{ key: "firstName", value: "John" },
{ key: "lastName", value: "Doe" },
{ key: "email", value: "john.doe@example.com" }
]
}
}, function (error, response) {
console.log(response.json());
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
data: {
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com"
}
}, async function(error, response) {
console.log(response.data);
});
`);
});
it('should transform urlencoded body when no Content-Type header exists', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
body: {
mode: 'urlencoded',
urlencoded: [
{ key: "key1", value: "value1" },
{ key: "key2", value: "value2" }
]
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
data: {
"key1": "value1",
"key2": "value2"
},
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
});
`);
});
it('should transform urlencoded body with incorrect Content-Type header', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "text/plain"
},
body: {
mode: 'urlencoded',
urlencoded: [
{ key: "key1", value: "value1" },
{ key: "key2", value: "value2" }
]
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
data: {
"key1": "value1",
"key2": "value2"
}
});
`);
});
});
describe('Multi-part Form Data Body Mode', () => {
it('should transform formdata body with single key-value pair', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {
'Content-Type': 'multipart/form-data',
},
body: {
mode: 'formdata',
formdata: [
{ key: "key", value: "value" }
]
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "multipart/form-data",
},
data: {
"key": "value"
}
}, async function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.data;
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`);
});
it('should transform formdata body with multiple key-value pair', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
header: {
'Content-Type': 'multipart/form-data',
},
body: {
mode: 'formdata',
formdata: [
{ key: "firstName", value: "John" },
{ key: "lastName", value: "Doe" },
{ key: "email", value: "john.doe@example.com" }
]
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "multipart/form-data",
},
data: {
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com"
}
}, async function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.data;
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`);
});
it('should transform formdata body when no Content-Type header exists', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
body: {
mode: 'formdata',
formdata: [
{ key: "firstName", value: "John" },
{ key: "lastName", value: "Doe" },
{ key: "email", value: "john.doe@example.com" }
]
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
data: {
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com"
},
headers: {
"Content-Type": "multipart/form-data"
}
}, async function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.data;
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`);
});
it('should transform formdata body with incorrect Content-Type header', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "text/plain"
},
body: {
mode: 'formdata',
formdata: [
{ key: "firstName", value: "John" },
{ key: "lastName", value: "Doe" },
{ key: "email", value: "john.doe@example.com" }
]
}
}, function (error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.json();
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
headers: {
"Content-Type": "multipart/form-data"
},
data: {
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com"
}
}, async function(error, response) {
if (error) {
const errorCode = error.code;
console.log(errorCode);
}
if (response) {
const response_body = response.data;
const response_headers = response.headers;
console.log(response_body, response_headers);
}
});
`);
});
});
describe('Headers and Content-Type Handling', () => {
it('should rename header property to headers', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'GET',
header: {
'X-Custom-Header': 'custom-value',
'Authorization': 'Bearer token'
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'GET',
headers: {
'X-Custom-Header': 'custom-value',
'Authorization': 'Bearer token'
}
});
`);
});
it('should handle header array format', () => {
const code = `
pm.sendRequest({
url: 'https://echo.usebruno.com',
method: 'GET',
header: [
{ key: 'X-Custom-Header', value: 'custom-value' },
{ key: 'Authorization', value: 'Bearer token' }
]
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'GET',
headers: {
"X-Custom-Header": 'custom-value',
"Authorization": 'Bearer token'
}
});
`);
});
});
describe('Response Handling', () => {
it('should transform response property access', () => {
const code = `
pm.sendRequest('https://echo.usebruno.com', function (error, response) {
const status = response.code;
const statusText = response.status;
const headers = response.headers;
const body = response.json();
const responseTime = response.responseTime;
const text = response.text();
if (status === 200) {
console.log('Success!');
}
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain(`const status = response.status;
const statusText = response.statusText;`);
expect(translatedCode).toContain('const headers = response.headers');
expect(translatedCode).toContain('const body = response.data');
expect(translatedCode).toContain('const responseTime = response.responseTime');
expect(translatedCode).toContain('const text = response.data');
});
});
describe('Async/Await', () => {
it('Should not add await if already present', () => {
const code = `
try {
const response = await pm.sendRequest({
url: "https://echo.usebruno.com",
method: "GET"
});
console.log(response.json());
} catch (err) {
console.error(err);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
try {
const response = await bru.sendRequest({
url: "https://echo.usebruno.com",
method: "GET"
});
console.log(response.json());
} catch (err) {
console.error(err);
}
`);
});
it('Should handle arrow function callbacks', () => {
const code = `
try {
pm.sendRequest({
url: "https://echo.usebruno.com",
method: "GET"
}, (error, response) => {
console.log(response.json());
});
} catch (err) {
console.error(err);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
try {
await bru.sendRequest({
url: "https://echo.usebruno.com",
method: "GET"
}, async function(error, response) {
console.log(response.data);
});
} catch (err) {
console.error(err);
}
`);
});
it('Should handle async arrow function callbacks', () => {
const code = `
try {
pm.sendRequest({
url: "https://echo.usebruno.com",
method: "GET"
}, async (error, response) => {
await new Promise(resolve => {
setTimeout(() => {
resolve();
}, 1000)
});
console.log(response.json());
});
} catch (err) {
console.error(err);
}
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
try {
await bru.sendRequest({
url: "https://echo.usebruno.com",
method: "GET"
}, async function(error, response) {
await new Promise(resolve => {
setTimeout(() => {
resolve();
}, 1000)
});
console.log(response.data);
});
} catch (err) {
console.error(err);
}
`);
});
});
});

View File

@@ -565,7 +565,7 @@ class Watcher {
`\nCould not start watcher for ${watchPath}:`,
'ENOSPC: System limit for number of file watchers reached!',
'Trying again with polling, this will be slower!\n',
'Update you system config to allow more concurrently watched files with:',
'Update your system config to allow more concurrently watched files with:',
'"echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p"'
);
this.addWatcher(win, watchPath, collectionUid, brunoConfig, true, useWorkerThread);

View File

@@ -9,7 +9,7 @@ const contentDispositionParser = require('content-disposition');
const mime = require('mime-types');
const FormData = require('form-data');
const { ipcMain } = require('electron');
const { each, get, extend, cloneDeep } = require('lodash');
const { each, get, extend, cloneDeep, merge } = require('lodash');
const { NtlmClient } = require('axios-ntlm');
const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js');
const { interpolateString } = require('./interpolate-string');
@@ -24,7 +24,7 @@ const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse, parseData
const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem');
const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies');
const { createFormData } = require('../../utils/form-data');
const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars } = require('../../utils/collection');
const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars } = require('../../utils/collection');
const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials } = require('../../utils/oauth2');
const { preferencesUtil } = require('../../store/preferences');
const { getProcessEnvVars } = require('../../store/process-env');
@@ -317,6 +317,77 @@ const configureRequest = async (
return axiosInstance;
};
const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, collection) => {
try {
const requestTreePath = getTreePathFromCollectionToItem(collection, _request);
// Create a clone of the request to avoid mutating the original
const resolvedRequest = cloneDeep(_request);
// mergeVars modifies the request in place, but we'll assign it to ensure consistency
mergeVars(collection, resolvedRequest, requestTreePath);
const envVars = getEnvVars(environment);
const globalEnvironmentVars = collection.globalEnvironmentVariables;
const folderVars = resolvedRequest.folderVariables;
const requestVariables = resolvedRequest.requestVariables;
const collectionVariables = resolvedRequest.collectionVariables;
const runtimeVars = collection.runtimeVariables;
// Precedence: runtimeVars > requestVariables > folderVars > envVars > collectionVariables > globalEnvironmentVars
const resolvedVars = merge(
{},
globalEnvironmentVars,
collectionVariables,
envVars,
folderVars,
requestVariables,
runtimeVars
);
const collectionRoot = get(collection, 'root', {});
const request = prepareGqlIntrospectionRequest(endpoint, resolvedVars, _request, collectionRoot);
request.timeout = preferencesUtil.getRequestTimeout();
if (!preferencesUtil.shouldVerifyTls()) {
request.httpsAgent = new https.Agent({
rejectUnauthorized: false
});
}
const collectionPath = collection.pathname;
const processEnvVars = getProcessEnvVars(collection.uid);
const axiosInstance = await configureRequest(
collection.uid,
request,
envVars,
collection.runtimeVariables,
processEnvVars,
collectionPath
);
const response = await axiosInstance(request);
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data
};
} catch (error) {
if (error.response) {
return {
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data
};
}
return Promise.reject(error);
}
};
const registerNetworkIpc = (mainWindow) => {
const onConsoleLog = (type, args) => {
console[type](...args);
@@ -486,7 +557,8 @@ const registerNetworkIpc = (mainWindow) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const cancelTokenUid = uuid();
const requestUid = uuid();
// requestUid is passed when a request is triggered; defaults to uuid() if not provided (e.g., bru.runRequest())
const requestUid = item.requestUid || uuid();
const runRequestByItemPathname = async (relativeItemPathname) => {
return new Promise(async (resolve, reject) => {
@@ -524,7 +596,7 @@ const registerNetworkIpc = (mainWindow) => {
try {
await runPreRequest(
const preRequestScriptResult = await runPreRequest(
request,
requestUid,
envVars,
@@ -537,6 +609,16 @@ const registerNetworkIpc = (mainWindow) => {
runRequestByItemPathname
);
if (preRequestScriptResult?.results) {
mainWindow.webContents.send('main:run-request-event', {
type: 'test-results-pre-request',
results: preRequestScriptResult.results,
itemUid: item.uid,
requestUid,
collectionUid
});
}
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'pre-request-script-execution',
requestUid,
@@ -628,7 +710,7 @@ const registerNetworkIpc = (mainWindow) => {
// timeline prop won't be accessible in the usual way in the renderer process if we reject the promise
return {
statusText: error.statusText,
error: error.message,
error: error.message || 'Error occured while executing the request!',
timeline: error.timeline
}
}
@@ -652,7 +734,7 @@ const registerNetworkIpc = (mainWindow) => {
mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
try {
await runPostResponse(
const postResponseScriptResult = await runPostResponse(
request,
response,
requestUid,
@@ -665,6 +747,16 @@ const registerNetworkIpc = (mainWindow) => {
scriptingConfig,
runRequestByItemPathname
);
if (postResponseScriptResult?.results) {
mainWindow.webContents.send('main:run-request-event', {
type: 'test-results-post-response',
results: postResponseScriptResult.results,
itemUid: item.uid,
requestUid,
collectionUid
});
}
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'post-response-script-execution',
requestUid,
@@ -713,19 +805,39 @@ const registerNetworkIpc = (mainWindow) => {
const collectionName = collection?.name
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const testResults = await testRuntime.runTests(
decomment(testFile),
request,
response,
envVars,
runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname,
collectionName
);
let testResults = null;
let testError = null;
try {
testResults = await testRuntime.runTests(
decomment(testFile),
request,
response,
envVars,
runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname,
collectionName
);
} catch (error) {
testError = error;
if (error.partialResults) {
testResults = error.partialResults;
} else {
testResults = {
request,
envVariables: envVars,
runtimeVariables,
globalEnvironmentVariables: request?.globalEnvironmentVariables || {},
results: [],
nextRequestName: null
};
}
}
!runInBackground && mainWindow.webContents.send('main:run-request-event', {
type: 'test-results',
@@ -747,6 +859,20 @@ const registerNetworkIpc = (mainWindow) => {
});
collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
const testScriptExecutionEvent = {
type: 'test-script-execution',
requestUid,
collectionUid,
itemUid: item.uid,
errorMessage: null,
}
if (testError) {
const errorMessage = testError?.message || 'An error occurred in test script';
testScriptExecutionEvent.errorMessage = errorMessage;
}
!runInBackground && mainWindow.webContents.send('main:run-request-event', testScriptExecutionEvent);
}
return {
@@ -766,7 +892,7 @@ const registerNetworkIpc = (mainWindow) => {
// timeline prop won't be accessible in the usual way in the renderer process if we reject the promise
return {
status: error?.status,
error: error?.message || 'an error ocurred: debug',
error: error?.message || 'Error occured while executing the request!',
timeline: error?.timeline
};
}
@@ -804,84 +930,8 @@ const registerNetworkIpc = (mainWindow) => {
});
});
ipcMain.handle('fetch-gql-schema', async (event, endpoint, environment, _request, collection) => {
try {
const envVars = getEnvVars(environment);
const collectionRoot = get(collection, 'root', {});
const request = prepareGqlIntrospectionRequest(endpoint, envVars, _request, collectionRoot);
request.timeout = preferencesUtil.getRequestTimeout();
if (!preferencesUtil.shouldVerifyTls()) {
request.httpsAgent = new https.Agent({
rejectUnauthorized: false
});
}
const requestUid = uuid();
const collectionPath = collection.pathname;
const collectionUid = collection.uid;
const runtimeVariables = collection.runtimeVariables;
const processEnvVars = getProcessEnvVars(collectionUid);
const brunoConfig = getBrunoConfig(collection.uid);
const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = getJsSandboxRuntime(collection);
await runPreRequest(
request,
requestUid,
envVars,
collectionPath,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig
);
interpolateVars(request, envVars, collection.runtimeVariables, processEnvVars);
const axiosInstance = await configureRequest(
collection.uid,
request,
envVars,
collection.runtimeVariables,
processEnvVars,
collectionPath
);
const response = await axiosInstance(request);
await runPostResponse(
request,
response,
requestUid,
envVars,
collectionPath,
collection,
collectionUid,
runtimeVariables,
processEnvVars,
scriptingConfig
);
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data
};
} catch (error) {
if (error.response) {
return {
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data
};
}
return Promise.reject(error);
}
});
// handler for fetch-gql-schema
ipcMain.handle('fetch-gql-schema', fetchGqlSchemaHandler)
ipcMain.handle(
'renderer:run-collection-folder',
@@ -1002,6 +1052,15 @@ const registerNetworkIpc = (mainWindow) => {
stopRunnerExecution = true;
}
// Send pre-request test results if available
if (preRequestScriptResult?.results) {
mainWindow.webContents.send('main:run-folder-event', {
type: 'test-results-pre-request',
preRequestTestResults: preRequestScriptResult.results,
...eventData
});
}
if (preRequestScriptResult?.skipRequest) {
mainWindow.webContents.send('main:run-folder-event', {
type: 'runner-request-skipped',
@@ -1133,7 +1192,7 @@ const registerNetworkIpc = (mainWindow) => {
}
}
const postRequestScriptResult = await runPostResponse(
const postResponseScriptResult = await runPostResponse(
request,
response,
requestUid,
@@ -1147,14 +1206,23 @@ const registerNetworkIpc = (mainWindow) => {
runRequestByItemPathname
);
if (postRequestScriptResult?.nextRequestName !== undefined) {
nextRequestName = postRequestScriptResult.nextRequestName;
if (postResponseScriptResult?.nextRequestName !== undefined) {
nextRequestName = postResponseScriptResult.nextRequestName;
}
if (postRequestScriptResult?.stopExecution) {
if (postResponseScriptResult?.stopExecution) {
stopRunnerExecution = true;
}
// Send post-response test results if available
if (postResponseScriptResult?.results) {
mainWindow.webContents.send('main:run-folder-event', {
type: 'test-results-post-response',
postResponseTestResults: postResponseScriptResult.results,
...eventData
});
}
// run assertions
const assertions = get(item, 'request.assertions');
if (assertions) {
@@ -1179,42 +1247,67 @@ const registerNetworkIpc = (mainWindow) => {
const testFile = get(request, 'tests');
const collectionName = collection?.name
if (typeof testFile === 'string') {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const testResults = await testRuntime.runTests(
decomment(testFile),
request,
response,
envVars,
runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname,
collectionName
);
try {
const testRuntime = new TestRuntime({ runtime: scriptingConfig?.runtime });
const testResults = await testRuntime.runTests(
decomment(testFile),
request,
response,
envVars,
runtimeVariables,
collectionPath,
onConsoleLog,
processEnvVars,
scriptingConfig,
runRequestByItemPathname,
collectionName
);
if (testResults?.nextRequestName !== undefined) {
nextRequestName = testResults.nextRequestName;
if (testResults?.nextRequestName !== undefined) {
nextRequestName = testResults.nextRequestName;
}
mainWindow.webContents.send('main:run-folder-event', {
type: 'test-results',
testResults: testResults.results,
...eventData
});
mainWindow.webContents.send('main:script-environment-update', {
envVariables: testResults.envVariables,
runtimeVariables: testResults.runtimeVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
} catch (testError) {
if (testError.partialResults && testError.partialResults.results.length > 0) {
// Send the partial test results
mainWindow.webContents.send('main:run-folder-event', {
type: 'test-results',
testResults: testError.partialResults.results,
...eventData
});
mainWindow.webContents.send('main:script-environment-update', {
envVariables: testError.partialResults.envVariables,
runtimeVariables: testError.partialResults.runtimeVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testError.partialResults.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = testError.partialResults.globalEnvironmentVariables;
}
}
mainWindow.webContents.send('main:run-folder-event', {
type: 'test-results',
testResults: testResults.results,
...eventData
});
mainWindow.webContents.send('main:script-environment-update', {
envVariables: testResults.envVariables,
runtimeVariables: testResults.runtimeVariables,
collectionUid
});
mainWindow.webContents.send('main:global-environment-variables-update', {
globalEnvironmentVariables: testResults.globalEnvironmentVariables
});
collection.globalEnvironmentVariables = testResults.globalEnvironmentVariables;
}
} catch (error) {
mainWindow.webContents.send('main:run-folder-event', {
@@ -1342,3 +1435,4 @@ const registerNetworkIpc = (mainWindow) => {
module.exports = registerNetworkIpc;
module.exports.configureRequest = configureRequest;
module.exports.getCertsAndProxyConfig = getCertsAndProxyConfig;
module.exports.fetchGqlSchemaHandler = fetchGqlSchemaHandler;

View File

@@ -3,9 +3,9 @@ const { interpolate } = require('@usebruno/common');
const { getIntrospectionQuery } = require('graphql');
const { setAuthHeaders } = require('./prepare-request');
const prepareGqlIntrospectionRequest = (endpoint, envVars, request, collectionRoot) => {
const prepareGqlIntrospectionRequest = (endpoint, resolvedVars, request, collectionRoot) => {
if (endpoint && endpoint.length) {
endpoint = interpolate(endpoint, envVars);
endpoint = interpolate(endpoint, resolvedVars);
}
const queryParams = {
@@ -16,7 +16,7 @@ const prepareGqlIntrospectionRequest = (endpoint, envVars, request, collectionRo
method: 'POST',
url: endpoint,
headers: {
...mapHeaders(request.headers, get(collectionRoot, 'request.headers', [])),
...mapHeaders(request.headers, get(collectionRoot, 'request.headers', []), resolvedVars),
Accept: 'application/json',
'Content-Type': 'application/json'
},
@@ -26,19 +26,20 @@ const prepareGqlIntrospectionRequest = (endpoint, envVars, request, collectionRo
return setAuthHeaders(axiosRequest, request, collectionRoot);
};
const mapHeaders = (requestHeaders, collectionHeaders) => {
const mapHeaders = (requestHeaders, collectionHeaders, resolvedVars) => {
const headers = {};
each(requestHeaders, (h) => {
// Add collection headers first
each(collectionHeaders, (h) => {
if (h.enabled) {
headers[h.name] = h.value;
headers[h.name] = interpolate(h.value, resolvedVars);
}
});
// collection headers
each(collectionHeaders, (h) => {
// Then add request headers, which will overwrite if names overlap
each(requestHeaders, (h) => {
if (h.enabled) {
headers[h.name] = h.value;
headers[h.name] = interpolate(h.value, resolvedVars);
}
});

View File

@@ -27,7 +27,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
};
break;
case 'bearer':
axiosRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`;
axiosRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token', '')}`;
break;
case 'digest':
axiosRequest.digestConfig = {
@@ -152,7 +152,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
};
break;
case 'bearer':
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token', '')}`;
break;
case 'digest':
axiosRequest.digestConfig = {

View File

@@ -27,17 +27,13 @@ class EnvironmentSecretsStore {
});
}
isValidValue(val) {
return typeof val === 'string' && val.length >= 0;
}
storeEnvSecrets(collectionPathname, environment) {
const envVars = [];
_.each(environment.variables, (v) => {
if (v.secret) {
envVars.push({
name: v.name,
value: this.isValidValue(v.value) ? encryptString(v.value) : ''
value: encryptString(v.value)
});
}
});

View File

@@ -10,15 +10,11 @@ class GlobalEnvironmentsStore {
});
}
isValidValue(val) {
return typeof val === 'string' && val.length >= 0;
}
encryptGlobalEnvironmentVariables({ globalEnvironments }) {
return globalEnvironments?.map(env => {
const variables = env.variables?.map(v => ({
...v,
value: v?.secret ? (this.isValidValue(v.value) ? encryptString(v.value) : '') : v?.value
value: v?.secret ? encryptString(v.value) : v?.value
})) || [];
return {
@@ -32,7 +28,7 @@ class GlobalEnvironmentsStore {
return globalEnvironments?.map(env => {
const variables = env.variables?.map(v => ({
...v,
value: v?.secret ? (this.isValidValue(v.value) ? decryptString(v.value) : '') : v?.value
value: v?.secret ? decryptString(v.value) : v?.value
})) || [];
return {

View File

@@ -89,6 +89,9 @@ function encryptString(str) {
if (typeof str !== 'string') {
throw new Error('Encrypt failed: invalid string');
}
if (str.length === 0) {
return '';
}
let encryptedString = '';
@@ -104,9 +107,12 @@ function encryptString(str) {
}
function decryptString(str) {
if (!str) {
if (typeof str !== 'string') {
throw new Error('Decrypt failed: unrecognized string format');
}
if (str.length === 0) {
return '';
}
// Find the index of the first colon
const colonIndex = str.indexOf(':');

View File

@@ -324,8 +324,8 @@ function setupProxyAgents({
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
let proxyUri;
if (proxyAuthEnabled) {
const proxyAuthUsername = interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions);
const proxyAuthPassword = interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions);
const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
} else {
proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`;

View File

@@ -0,0 +1,371 @@
const prepareGqlIntrospectionRequest = require('../../src/ipc/network/prepare-gql-introspection-request');
const { fetchGqlSchemaHandler } = require('../../src/ipc/network');
// Mock only the prepare-gql-introspection-request to avoid network calls
jest.mock('../../src/ipc/network/prepare-gql-introspection-request', () => {
return jest.fn().mockImplementation((endpoint, vars, request, root) => {
return {
url: endpoint,
method: 'POST',
headers: request?.headers || {},
data: {
query: '{ __schema { types { name } } }'
}
};
});
});
describe('fetchGqlSchemaHandler - variable precedence', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should override global environment variables with environment variables', async () => {
const endpoint = 'https://example.com/';
const environment = {
variables: [
{ name: 'SHARED_VAR', value: 'env-value', enabled: true }
]
};
const request = {
uid: 'test-request',
vars: {
req: [] // No request variables
}
};
const collection = {
uid: 'test-collection',
pathname: '/test',
runtimeVariables: {},
globalEnvironmentVariables: {
SHARED_VAR: 'global-value'
},
items: [
{
uid: 'test-request',
request: {
vars: {
req: [] // No request variables
}
}
}
],
root: {
request: {
headers: [],
vars: {
req: [] // No collection variables
}
}
}
};
await fetchGqlSchemaHandler(null, endpoint, environment, request, collection);
expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith(
endpoint,
expect.objectContaining({
SHARED_VAR: 'env-value'
}),
request,
collection.root
);
});
it('should override environment variables with folder-level variables', async () => {
const endpoint = 'https://example.com/';
const environment = {
variables: [
{ name: 'SHARED_VAR', value: 'env-value', enabled: true }
]
};
const request = {
uid: 'test-request',
vars: {
req: [] // No request variables
}
};
const collection = {
uid: 'test-collection',
pathname: '/test',
runtimeVariables: {},
globalEnvironmentVariables: {},
items: [
{
uid: 'test-folder',
type: 'folder',
root: {
request: {
vars: {
req: [
{ name: 'SHARED_VAR', value: 'folder-value', enabled: true }
]
}
}
},
items: [
{
uid: 'test-request',
request: {
vars: {
req: [] // No request variables
}
}
}
]
}
],
root: {
request: {
headers: [],
vars: {
req: [] // No collection variables
}
}
}
};
await fetchGqlSchemaHandler(null, endpoint, environment, request, collection);
expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith(
endpoint,
expect.objectContaining({
SHARED_VAR: 'folder-value'
}),
request,
collection.root
);
});
it('should override folder-level variables with request variables', async () => {
const endpoint = 'https://example.com/';
const environment = {
variables: []
};
const request = {
uid: 'test-request',
vars: {
req: [
{ name: 'SHARED_VAR', value: 'request-value', enabled: true }
]
}
};
const collection = {
uid: 'test-collection',
pathname: '/test',
runtimeVariables: {},
globalEnvironmentVariables: {},
items: [
{
uid: 'test-folder',
type: 'folder',
root: {
request: {
vars: {
req: [
{ name: 'SHARED_VAR', value: 'folder-value', enabled: true }
]
}
}
},
items: [
{
uid: 'test-request',
request: {
vars: {
req: [
{ name: 'SHARED_VAR', value: 'request-value', enabled: true }
]
}
}
}
]
}
],
root: {
request: {
headers: [],
vars: {
req: [] // No collection variables
}
}
}
};
await fetchGqlSchemaHandler(null, endpoint, environment, request, collection);
expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith(
endpoint,
expect.objectContaining({
SHARED_VAR: 'request-value'
}),
request,
collection.root
);
});
it('should override global environment variables with collection variables', async () => {
const endpoint = 'https://example.com/';
const environment = {
variables: []
};
const request = {
uid: 'test-request',
vars: {
req: [] // No request variables
}
};
const collection = {
uid: 'test-collection',
pathname: '/test',
runtimeVariables: {},
globalEnvironmentVariables: {
SHARED_VAR: 'global-value'
},
items: [
{
uid: 'test-request',
request: {
vars: {
req: [] // No request variables
}
}
}
],
root: {
request: {
headers: [],
vars: {
req: [
{ name: 'SHARED_VAR', value: 'collection-value', enabled: true }
]
}
}
}
};
await fetchGqlSchemaHandler(null, endpoint, environment, request, collection);
expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith(
endpoint,
expect.objectContaining({
SHARED_VAR: 'collection-value'
}),
request,
collection.root
);
});
it('should override collection variables with environment variables', async () => {
const endpoint = 'https://example.com/';
const environment = {
variables: [
{ name: 'SHARED_VAR', value: 'env-value', enabled: true }
]
};
const request = {
uid: 'test-request',
vars: {
req: [] // No request variables
}
};
const collection = {
uid: 'test-collection',
pathname: '/test',
runtimeVariables: {},
globalEnvironmentVariables: {},
items: [
{
uid: 'test-request',
request: {
vars: {
req: [] // No request variables
}
}
}
],
root: {
request: {
headers: [],
vars: {
req: [
{ name: 'SHARED_VAR', value: 'collection-value', enabled: true }
]
}
}
}
};
await fetchGqlSchemaHandler(null, endpoint, environment, request, collection);
expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith(
endpoint,
expect.objectContaining({
SHARED_VAR: 'env-value'
}),
request,
collection.root
);
});
it('should override request variables with runtime variables', async () => {
const endpoint = 'https://example.com/';
const environment = {
variables: []
};
const request = {
uid: 'test-request',
vars: {
req: [
{ name: 'SHARED_VAR', value: 'request-value', enabled: true }
]
}
};
const collection = {
uid: 'test-collection',
pathname: '/test',
runtimeVariables: {
SHARED_VAR: 'runtime-value'
},
items: [
{
uid: 'test-request',
request: {
vars: {
req: [
{ name: 'SHARED_VAR', value: 'request-value', enabled: true }
]
}
}
}
],
root: {
request: {
headers: [],
vars: {
req: [] // No collection variables
}
}
}
};
await fetchGqlSchemaHandler(null, endpoint, environment, request, collection);
expect(prepareGqlIntrospectionRequest).toHaveBeenCalledWith(
endpoint,
expect.objectContaining({
SHARED_VAR: 'runtime-value'
}),
request,
collection.root
);
})
});

View File

@@ -0,0 +1,66 @@
const prepareGqlIntrospectionRequest = require('../../src/ipc/network/prepare-gql-introspection-request');
describe('prepareGqlIntrospectionRequest', () => {
const createBasicSetup = () => ({
endpoint: 'https://example.com/',
request: {
headers: []
},
collectionRoot: {
request: {
headers: []
}
}
});
it('should handle environment variables in headers', () => {
const setup = createBasicSetup();
setup.request.headers = [
{ name: 'Authorization', value: 'Bearer {{AUTH_TOKEN}}', enabled: true }
];
const vars = {
AUTH_TOKEN: 'token-value'
};
const result = prepareGqlIntrospectionRequest(setup.endpoint, vars, setup.request, setup.collectionRoot);
expect(result.headers['Authorization']).toBe('Bearer token-value');
expect(result.method).toBe('POST');
expect(result.url).toBe(setup.endpoint);
});
it('should override collection headers with request headers', () => {
const setup = createBasicSetup();
setup.collectionRoot.request.headers = [
{ name: 'X-Header', value: 'collection-value', enabled: true }
];
setup.request.headers = [
{ name: 'X-Header', value: 'request-value', enabled: true }
];
const result = prepareGqlIntrospectionRequest(setup.endpoint, {}, setup.request, setup.collectionRoot);
expect(result.headers['X-Header']).toBe('request-value');
});
it('should handle enabled and disabled headers', () => {
const setup = createBasicSetup();
setup.request.headers = [
{ name: 'X-Enabled', value: 'enabled', enabled: true },
{ name: 'X-Disabled', value: 'disabled', enabled: false }
];
const result = prepareGqlIntrospectionRequest(setup.endpoint, {}, setup.request, setup.collectionRoot);
expect(result.headers['X-Enabled']).toBe('enabled');
expect(result.headers['X-Disabled']).toBeUndefined();
});
it('should always include required GraphQL headers', () => {
const setup = createBasicSetup();
const result = prepareGqlIntrospectionRequest(setup.endpoint, {}, setup.request, setup.collectionRoot);
expect(result.headers['Accept']).toBe('application/json');
expect(result.headers['Content-Type']).toBe('application/json');
});
});

View File

@@ -12,13 +12,23 @@ describe('Encryption and Decryption Tests', () => {
expect(decrypted).toBe(plaintext);
});
it('should handle empty strings in encryptString', () => {
const result = encryptString('');
expect(result).toBe('');
});
it('should handle empty strings in decryptString', () => {
const result = decryptString('');
expect(result).toBe('');
});
it('encrypt should throw an error for invalid string', () => {
expect(() => encryptString(null)).toThrow('Encrypt failed: invalid string');
expect(() => encryptString(undefined)).toThrow('Encrypt failed: invalid string');
});
it('decrypt should throw an error for invalid string', () => {
expect(() => decryptString(null)).toThrow('Decrypt failed: unrecognized string format');
expect(() => decryptString('')).toThrow('Decrypt failed: unrecognized string format');
expect(() => decryptString('garbage')).toThrow('Decrypt failed: unrecognized string format');
});

View File

@@ -9,7 +9,8 @@
"package.json"
],
"scripts": {
"build": "rollup -c"
"build": "rollup -c",
"watch": "rollup -c -w"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",

View File

@@ -1,5 +1,6 @@
const { cloneDeep } = require('lodash');
const { interpolate: _interpolate } = require('@usebruno/common');
const { sendRequest } = require('@usebruno/requests').scripting;
const variableNameRegex = /^[\w-.]*$/;
@@ -15,6 +16,7 @@ class Bru {
this.oauth2CredentialVariables = oauth2CredentialVariables || {};
this.collectionPath = collectionPath;
this.collectionName = collectionName;
this.sendRequest = sendRequest;
this.runner = {
skipRequest: () => {
this.skipRequest = true;

View File

@@ -12,7 +12,10 @@ const { get } = require('lodash');
const Bru = require('../bru');
const BrunoRequest = require('../bruno-request');
const BrunoResponse = require('../bruno-response');
const Test = require('../test');
const TestResults = require('../test-results');
const { cleanJson } = require('../utils');
const { createBruTestResultMethods } = require('../utils/results');
// Inbuilt Library Support
const ajv = require('ajv');
@@ -57,6 +60,7 @@ class ScriptRuntime {
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const assertionResults = request?.assertionResults || [];
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName);
const req = new BrunoRequest(request);
const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
@@ -78,9 +82,16 @@ class ScriptRuntime {
}
}
// extend bru with result getter methods
const { __brunoTestResults, test } = createBruTestResultMethods(bru, assertionResults, chai);
const context = {
bru,
req
req,
test,
expect: chai.expect,
assert: chai.assert,
__brunoTestResults: __brunoTestResults
};
if (onConsoleLog && typeof onConsoleLog === 'function') {
@@ -114,6 +125,7 @@ class ScriptRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution
@@ -168,6 +180,7 @@ class ScriptRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution
@@ -192,6 +205,7 @@ class ScriptRuntime {
const collectionVariables = request?.collectionVariables || {};
const folderVariables = request?.folderVariables || {};
const requestVariables = request?.requestVariables || {};
const assertionResults = request?.assertionResults || [];
const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName);
const req = new BrunoRequest(request);
const res = new BrunoResponse(response);
@@ -214,10 +228,17 @@ class ScriptRuntime {
}
}
// extend bru with result getter methods
const { __brunoTestResults, test } = createBruTestResultMethods(bru, assertionResults, chai);
const context = {
bru,
req,
res
res,
test,
expect: chai.expect,
assert: chai.assert,
__brunoTestResults: __brunoTestResults
};
if (onConsoleLog && typeof onConsoleLog === 'function') {
@@ -251,6 +272,7 @@ class ScriptRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution
@@ -305,6 +327,7 @@ class ScriptRuntime {
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest,
skipRequest: bru.skipRequest,
stopExecution: bru.stopExecution

View File

@@ -16,6 +16,7 @@ const BrunoResponse = require('../bruno-response');
const Test = require('../test');
const TestResults = require('../test-results');
const { cleanJson } = require('../utils');
const { createBruTestResultMethods } = require('../utils/results');
// Inbuilt Library Support
const ajv = require('ajv');
@@ -35,25 +36,6 @@ const cheerio = require('cheerio');
const tv4 = require('tv4');
const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
const getResultsSummary = (results) => {
const summary = {
total: results.length,
passed: 0,
failed: 0,
skipped: 0,
};
results.forEach((r) => {
const passed = r.status === "pass";
if (passed) summary.passed += 1;
else if (r.status === "fail") summary.failed += 1;
else summary.skipped += 1;
});
return summary;
}
class TestRuntime {
constructor(props) {
this.runtime = props?.runtime || 'vm2';
@@ -99,9 +81,8 @@ class TestRuntime {
}
}
const __brunoTestResults = new TestResults();
const test = Test(__brunoTestResults, chai);
// extend bru with result getter methods
const { __brunoTestResults, test } = createBruTestResultMethods(bru, assertionResults, chai);
if (!testsFile || !testsFile.length) {
return {
@@ -114,36 +95,6 @@ class TestRuntime {
};
}
bru.getTestResults = async () => {
let results = await __brunoTestResults.getResults();
const summary = getResultsSummary(results);
return {
summary,
results: results?.map?.(r => ({
status: r?.status,
description: r?.description,
expected: r?.expected,
actual: r?.actual,
error: r?.error
}))
};
}
bru.getAssertionResults = async () => {
let results = assertionResults;
const summary = getResultsSummary(results);
return {
summary,
results: results?.map?.(r => ({
status: r?.status,
lhsExpr: r?.lhsExpr,
rhsExpr: r?.rhsExpr,
operator: r?.operator,
rhsOperand: r?.rhsOperand,
error: r?.error
}))
};
}
const context = {
test,
bru,
@@ -173,56 +124,63 @@ class TestRuntime {
context.bru.runRequest = runRequestByItemPathname;
}
if (this.runtime === 'quickjs') {
await executeQuickJsVmAsync({
script: testsFile,
context: context
});
} else {
// default runtime is vm2
const vm = new NodeVM({
sandbox: context,
require: {
context: 'sandbox',
external: true,
root: [collectionPath, ...additionalContextRootsAbsolute],
mock: {
// node libs
path,
stream,
util,
url,
http,
https,
punycode,
zlib,
// 3rd party libs
ajv,
'ajv-formats': addFormats,
btoa,
atob,
lodash,
moment,
uuid,
nanoid,
axios,
chai,
'node-fetch': fetch,
'crypto-js': CryptoJS,
'xml2js': xml2js,
cheerio,
tv4,
...whitelistedModules,
fs: allowScriptFilesystemAccess ? fs : undefined,
'node-vault': NodeVault
let scriptError = null;
try {
if (this.runtime === 'quickjs') {
await executeQuickJsVmAsync({
script: testsFile,
context: context
});
} else {
// default runtime is vm2
const vm = new NodeVM({
sandbox: context,
require: {
context: 'sandbox',
external: true,
root: [collectionPath, ...additionalContextRootsAbsolute],
mock: {
// node libs
path,
stream,
util,
url,
http,
https,
punycode,
zlib,
// 3rd party libs
ajv,
'ajv-formats': addFormats,
btoa,
atob,
lodash,
moment,
uuid,
nanoid,
axios,
chai,
'node-fetch': fetch,
'crypto-js': CryptoJS,
'xml2js': xml2js,
cheerio,
tv4,
...whitelistedModules,
fs: allowScriptFilesystemAccess ? fs : undefined,
'node-vault': NodeVault
}
}
}
});
const asyncVM = vm.run(`module.exports = async () => { ${testsFile}}`, path.join(collectionPath, 'vm.js'));
await asyncVM();
});
const asyncVM = vm.run(`module.exports = async () => { ${testsFile}}`, path.join(collectionPath, 'vm.js'));
await asyncVM();
}
} catch (error) {
scriptError = error;
console.error('Test script execution error:', error);
}
return {
const result = {
request,
envVariables: cleanJson(envVariables),
runtimeVariables: cleanJson(runtimeVariables),
@@ -230,6 +188,13 @@ class TestRuntime {
results: cleanJson(__brunoTestResults.getResults()),
nextRequestName: bru.nextRequest
};
if (scriptError) {
scriptError.partialResults = result;
throw scriptError;
}
return result;
}
}

View File

@@ -142,10 +142,10 @@ const executeQuickJsVmAsync = async ({ script: externalScript, context: external
const { bru, req, res, test, __brunoTestResults, console: consoleFn } = externalContext;
consoleFn && addConsoleShimToContext(vm, consoleFn);
bru && addBruShimToContext(vm, bru);
req && addBrunoRequestShimToContext(vm, req);
res && addBrunoResponseShimToContext(vm, res);
consoleFn && addConsoleShimToContext(vm, consoleFn);
addLocalModuleLoaderShimToContext(vm, collectionPath);
addPathShimToContext(vm);

View File

@@ -1,4 +1,4 @@
const { cleanJson } = require('../../../utils');
const { cleanJson, cleanCircularJson } = require('../../../utils');
const { marshallToVm } = require('../utils');
const addBruShimToContext = (vm, bru) => {
@@ -210,8 +210,7 @@ const addBruShimToContext = (vm, bru) => {
bru
.runRequest(vm.dump(args))
.then((response) => {
const { status, headers, data, dataBuffer, size, statusText } = response || {};
promise.resolve(marshallToVm(cleanJson({ status, statusText, headers, data, dataBuffer, size }), vm));
promise.resolve(marshallToVm(cleanCircularJson(response), vm));
})
.catch((err) => {
promise.resolve(
@@ -228,6 +227,26 @@ const addBruShimToContext = (vm, bru) => {
});
runRequestHandle.consume((handle) => vm.setProp(bruObject, 'runRequest', handle));
let sendRequestHandle = vm.newFunction('_sendRequest', (args) => {
const promise = vm.newPromise();
bru
.sendRequest(vm.dump(args))
.then((response) => {
promise.resolve(marshallToVm(cleanCircularJson(response), vm));
})
.catch((err) => {
promise.reject(
marshallToVm(
cleanJson(err),
vm
)
);
});
promise.settled.then(vm.runtime.executePendingJobs);
return promise.handle;
});
sendRequestHandle.consume((handle) => vm.setProp(bruObject, '_sendRequest', handle));
const sleep = vm.newFunction('sleep', (timer) => {
const t = vm.getString(timer);
const promise = vm.newPromise();
@@ -242,6 +261,29 @@ const addBruShimToContext = (vm, bru) => {
vm.setProp(bruObject, 'runner', bruRunnerObject);
vm.setProp(vm.global, 'bru', bruObject);
bruObject.dispose();
vm.evalCode(`
globalThis.bru.sendRequest = async (requestConfig, callback) => {
if (!callback) return await globalThis.bru._sendRequest(requestConfig);
try {
const response = await globalThis.bru._sendRequest(requestConfig);
try {
await callback(null, response);
}
catch(error) {
return Promise.reject(error);
}
}
catch(error) {
try {
await callback(JSON.parse(JSON.stringify(error)), null);
}
catch(err) {
return Promise.reject(err);
}
}
}
`);
};
module.exports = addBruShimToContext;

View File

@@ -144,10 +144,37 @@ const cleanJson = (data) => {
}
};
const cleanCircularJson = (data) => {
try {
// Handle circular references by keeping track of seen objects
const seen = new WeakSet();
const replacer = (key, value) => {
// Skip non-objects and null
if (typeof value !== 'object' || value === null) {
return value;
}
// Detect circular reference
if (seen.has(value)) {
return '[Circular Reference]';
}
seen.add(value);
return value;
};
return JSON.parse(JSON.stringify(data, replacer));
} catch (e) {
return data;
}
};
module.exports = {
evaluateJsExpression,
evaluateJsTemplateLiteral,
createResponseParser,
internalExpressionCache,
cleanJson
cleanJson,
cleanCircularJson
};

View File

@@ -0,0 +1,80 @@
const TestResults = require('../test-results');
const Test = require('../test');
// Calculate summary statistics for test results
const getResultsSummary = (results) => {
const summary = {
total: results.length,
passed: 0,
failed: 0,
skipped: 0,
};
results.forEach((r) => {
const passed = r.status === 'pass';
if (passed) summary.passed += 1;
else if (r.status === 'fail') summary.failed += 1;
else summary.skipped += 1;
});
return summary;
};
const createBruTestResultMethods = (bru, assertionResults, chai) => {
const __brunoTestResults = new TestResults();
const test = Test(__brunoTestResults, chai);
setupBruTestMethods(bru, __brunoTestResults, assertionResults);
return { __brunoTestResults, test };
};
const setupBruTestMethods = (bru, __brunoTestResults, assertionResults) => {
const getTestResults = async () => {
let results = await __brunoTestResults.getResults();
const summary = getResultsSummary(results);
return {
summary,
results: results.map(r => ({
status: r.status,
description: r.description,
expected: r.expected,
actual: r.actual,
error: r.error
}))
};
};
const getAssertionResults = async () => {
let results = assertionResults;
const summary = getResultsSummary(results);
return {
summary,
results: results.map(r => ({
status: r.status,
lhsExpr: r.lhsExpr,
rhsExpr: r.rhsExpr,
operator: r.operator,
rhsOperand: r.rhsOperand,
error: r.error
}))
};
};
// Set methods on bru object if provided
if (bru) {
bru.getTestResults = getTestResults;
bru.getAssertionResults = getAssertionResults;
}
// Also return the methods for direct use
return {
getTestResults,
getAssertionResults
};
};
module.exports = {
getResultsSummary,
createBruTestResultMethods,
setupBruTestMethods
};

View File

@@ -68,7 +68,7 @@ const mapArrayListToKeyValPairs = (arrayList = []) => {
return {
name,
value: null,
value: '',
enabled
};
});

View File

@@ -185,7 +185,7 @@ vars:secret [
},
{
name: 'token',
value: null,
value: '',
enabled: true,
secret: true
}
@@ -220,19 +220,19 @@ vars:secret [
},
{
name: 'access_token',
value: null,
value: '',
enabled: true,
secret: true
},
{
name: 'access_secret',
value: null,
value: '',
enabled: true,
secret: true
},
{
name: 'access_password',
value: null,
value: '',
enabled: false,
secret: true
}
@@ -262,7 +262,7 @@ vars:secret [access_key]
},
{
name: 'access_key',
value: null,
value: '',
enabled: true,
secret: true
}
@@ -292,19 +292,19 @@ vars:secret [access_key,access_secret, access_password ]
},
{
name: 'access_key',
value: null,
value: '',
enabled: true,
secret: true
},
{
name: 'access_secret',
value: null,
value: '',
enabled: true,
secret: true
},
{
name: 'access_password',
value: null,
value: '',
enabled: true,
secret: true
}

View File

@@ -15,6 +15,7 @@
"test": "jest",
"prebuild": "npm run clean",
"build": "rollup -c",
"watch": "rollup -c -w",
"prepack": "npm run test && npm run build"
},
"devDependencies": {

View File

@@ -14,6 +14,7 @@
"clean": "rimraf dist",
"prebuild": "npm run clean",
"build": "rollup -c",
"watch": "rollup -c -w",
"prepack": "npm run test && npm run build"
},
"devDependencies": {
@@ -30,6 +31,7 @@
"rollup": "3.29.5"
},
"dependencies": {
"@types/qs": "^6.9.18"
"@types/qs": "^6.9.18",
"axios": "^1.9.0"
}
}

View File

@@ -9,6 +9,13 @@ function stripQuotes(str) {
return str.replace(/"/g, '');
}
function splitAuthHeaderKeyValue(str) {
const indexOfEqual = str.indexOf('=');
const key = str.substring(0, indexOfEqual).trim();
const value = str.substring(indexOfEqual + 1);
return [key, value];
}
function containsDigestHeader(response) {
const authHeader = response?.headers?.['www-authenticate'];
return authHeader ? authHeader.trim().toLowerCase().startsWith('digest') : false;
@@ -55,7 +62,7 @@ export function addDigestInterceptor(axiosInstance, request) {
const authDetails = error.response.headers['www-authenticate']
.split(',')
.map((pair) => pair.split('=').map((item) => item.trim()).map(stripQuotes))
.map((pair) => splitAuthHeaderKeyValue(pair).map((item) => item.trim()).map(stripQuotes))
.reduce((acc, [key, value]) => {
const normalizedKey = key.toLowerCase().replace('digest ', '');
if (normalizedKey && value !== undefined) {

View File

@@ -1,3 +1,7 @@
export { addDigestInterceptor, getOAuth2Token } from './auth';
export * as utils from './utils';
export * as network from './network';
export * as scripting from './scripting';

View File

@@ -0,0 +1,76 @@
import { default as axios, AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
/**
*
* @param {Object} customRequestConfig options - partial AxiosRequestConfig
*
* @returns {import('axios').AxiosInstance} Configured Axios instance
*
* @example
* const instance = makeAxiosInstance({
* maxRedirects: 0,
* proxy: false,
* headers: {
* "User-Agent": `bruno-runtime/_version_`
* },
* });
*/
type ModifiedInternalAxiosRequestConfig = InternalAxiosRequestConfig & {
startTime: number;
}
type ModifiedAxiosResponse = AxiosResponse & {
responseTime: number;
}
const baseRequestConfig: Partial<AxiosRequestConfig> = {
transformRequest: function transformRequest(data: any, headers: AxiosRequestHeaders) {
const contentType = headers.getContentType() || '';
const hasJSONContentType = contentType.includes('json');
if (typeof data === 'string' && hasJSONContentType) {
return data;
}
if (Array.isArray(axios.defaults.transformRequest)) {
axios.defaults.transformRequest.forEach((tr) => {
data = tr.call(this, data, headers);
});
}
return data;
}
}
const makeAxiosInstance = (customRequestConfig?: AxiosRequestConfig) => {
customRequestConfig = customRequestConfig || {};
const axiosInstance = axios.create({
...baseRequestConfig,
...customRequestConfig
});
axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const modifiedConfig: ModifiedInternalAxiosRequestConfig = {
...config,
startTime: Date.now()
}
return modifiedConfig;
});
axiosInstance.interceptors.response.use((response: AxiosResponse) => {
const config = response.config as ModifiedInternalAxiosRequestConfig;
const startTime = config.startTime;
const endTime = Date.now();
const modifiedResponse: ModifiedAxiosResponse = {
...response,
responseTime: endTime - startTime
};
return modifiedResponse;
});
return axiosInstance;
};
export {
makeAxiosInstance
};

View File

@@ -0,0 +1 @@
export { makeAxiosInstance } from './axios-instance';

View File

@@ -0,0 +1 @@
export { default as sendRequest } from './send-request';

View File

@@ -0,0 +1,30 @@
import { AxiosRequestConfig } from 'axios';
import { makeAxiosInstance } from '../network';
type T_SendRequestCallback = (error: any, response: any) => void;
const sendRequest = async (requestConfig: AxiosRequestConfig, callback: T_SendRequestCallback) => {
const axiosInstance = makeAxiosInstance();
if (!callback) {
return await axiosInstance(requestConfig);
}
try {
const response = await axiosInstance(requestConfig);
try {
await callback(null, response);
}
catch(error) {
return Promise.reject(error);
}
}
catch (error) {
try {
await callback(error, null);
}
catch(err) {
return Promise.reject(err);
}
}
};
export default sendRequest;

View File

@@ -0,0 +1,27 @@
meta {
name: Bearer Auth undefined
type: http
seq: 2
}
get {
url: {{host}}/api/auth/bearer/protected
body: none
auth: bearer
}
headers {
Authorization: Bearer {{bearer_auth_token}}
}
assert {
res.body.message: eq Unauthorized
res.status: eq 401
}
tests {
test("selected auth overrides Authorization header always", function() {
const authHeader = req.getHeader("Authorization")
expect(authHeader).to.eql("Bearer ")
})
}

View File

@@ -11,7 +11,7 @@ get {
}
headers {
Authorization: Bearer your_secret_token
Authorization: Bearer {{bearer_auth_token}}
}
vars:pre-request {

View File

@@ -0,0 +1,8 @@
meta {
name: send-request
seq: 16
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,18 @@
meta {
name: get-url-string
type: http
seq: 1
}
post {
url: https://echo.usebruno.com
body: none
auth: inherit
}
tests {
await test("send request with a get url string", async () => {
const res = await bru.sendRequest("https://testbench-sanity.usebruno.com/ping");
expect(res.data).to.eql('pong');
});
}

View File

@@ -0,0 +1,80 @@
meta {
name: usage-patterns
type: http
seq: 1
}
post {
url: https://echo.usebruno.com
body: none
auth: inherit
}
tests {
// pattern 1: using async/await
await test("post request with async/await - success case", async () => {
const res = await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
data: 'ping'
});
expect(res.data).to.eql('ping');
});
await test("post request with async/await - error case", async () => {
try {
await bru.sendRequest({
url: 'https://echo.usebruno.com/invalid',
method: 'POST',
data: 'ping'
});
}
catch(err) {
expect(err.status).to.eql(404);
}
});
// pattern 2: using promise (.then/.catch)
await test("post request with promise chain - success case", async () => {
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
data: 'ping'
})
.then(res => {
expect(res.data).to.eql('ping');
});
});
await test("post request with promise chain - error case", async () => {
await bru.sendRequest({
url: 'https://echo.usebruno.com/invalid',
method: 'POST',
data: 'ping'
})
.catch(err => {
expect(err.status).to.eql(404);
});
});
// pattern 3: using callbacks
await test("post request with callback - success case", async () => {
await bru.sendRequest({
url: 'https://echo.usebruno.com',
method: 'POST',
data: 'ping'
}, function(error, response) {
expect(response.data).to.eql('ping');
});
});
await test("post request with callback - error case", async () => {
await bru.sendRequest({
url: 'https://echo.usebruno.com/invalid',
method: 'POST',
data: 'ping'
}, function(error, response) {
expect(error.status).to.eql(404);
});
});
}

240
scripts/dev-hot-reload.js Normal file
View File

@@ -0,0 +1,240 @@
#!/usr/bin/env node
/**
# Bruno Development Script
#
# This script sets up and runs the Bruno development environment with hot-reloading.
# It manages concurrent processes for various packages and provides cleanup on exit.
#
# Usage:
# From the root of the project, run:
# node ./scripts/dev-hot-reload.js [options]
# or
# npm run dev:watch -- [options]
*/
const { execSync } = require('child_process');
const { readFileSync } = require('fs');
// Get major version from .nvmrc (e.g. v22.1.0 -> v22)
const NODE_VERSION = readFileSync('.nvmrc', 'utf8').trim().split('.')[0];
// Configuration
const CONFIG = {
NODE_VERSION,
ELECTRON_WATCH_PATHS: [
'packages/**/dist/',
'packages/bruno-electron/src/',
'packages/bruno-lang/src/',
'packages/bruno-lang/v2/src/',
'packages/bruno-js/src/',
'packages/bruno-schema/src/'
],
ELECTRON_START_DELAY: 10, // seconds
NODEMON_WATCH_DELAY: 1000 // milliseconds
};
const COLORS = {
red: '\x1b[0;31m',
green: '\x1b[0;32m',
yellow: '\x1b[1;33m',
blue: '\x1b[0;34m',
nc: '\x1b[0m' // No Color
};
const LOG_LEVELS = {
INFO: 'INFO',
WARN: 'WARN',
ERROR: 'ERROR',
DEBUG: 'DEBUG',
SUCCESS: 'SUCCESS'
};
function log(level, msg) {
let color = COLORS.nc;
switch (level) {
case LOG_LEVELS.INFO:
case LOG_LEVELS.SUCCESS: color = COLORS.green; break;
case LOG_LEVELS.WARN: color = COLORS.yellow; break;
case LOG_LEVELS.ERROR: color = COLORS.red; break;
case LOG_LEVELS.DEBUG: color = COLORS.blue; break;
}
const output = `${color}[${level}]${COLORS.nc} ${msg}`;
if (level === LOG_LEVELS.ERROR) {
console.error(output);
} else {
console.log(output);
}
}
// Show help documentation
function showHelp() {
console.log(`
Development Environment Setup for Bruno
Usage:
From the root of the project, run:
npm run dev:watch -- [options]
or
node scripts/dev-hot-reload.js [options]
Options:
-s, --setup Clean all node_modules folders and re-install dependencies before starting
-h, --help Show this help message
Examples:
# Start development environment
npm run dev:watch
# Start after cleaning node_modules
npm run dev:watch -- --setup
# Show this help
npm run dev:watch -- --help
`);
}
function commandExists(command) {
try {
execSync(`command -v ${command}`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
// Install global NPM package if not present
function ensureGlobalPackage(packageName) {
if (!commandExists(packageName)) {
log(LOG_LEVELS.INFO, `Installing ${packageName} globally...`);
execSync(`npm install -g ${packageName}`, { stdio: 'inherit' });
}
}
// Ensure correct node version
function ensureNodeVersion(requiredVersion) {
const currentVersion = process.version;
if (!currentVersion.includes(requiredVersion)) {
log(LOG_LEVELS.ERROR, `Node ${requiredVersion} is required but currently installed version is ${currentVersion}`);
log(LOG_LEVELS.ERROR, `Please install node ${requiredVersion} and try again.`);
log(LOG_LEVELS.ERROR, `You can run 'nvm install ${requiredVersion}' to install it, or 'nvm use ${requiredVersion}' if it's already installed.`);
process.exit(1);
}
}
function cleanNodeModules() {
log(LOG_LEVELS.INFO, 'Removing all node_modules directories...');
execSync('find . -name "node_modules" -type d -prune -exec rm -rf {} +', { stdio: 'inherit' });
log(LOG_LEVELS.SUCCESS, 'Node modules cleanup completed');
}
function reinstallDependencies() {
log(LOG_LEVELS.INFO, 'Re-installing dependencies...');
execSync('npm install --legacy-peer-deps', { stdio: 'inherit' });
log(LOG_LEVELS.SUCCESS, 'Dependencies re-installation completed');
}
// Setup development environment
function startDevelopment() {
log(LOG_LEVELS.INFO, 'Starting development servers...');
const concurrently = require('concurrently');
const watchPaths = CONFIG.ELECTRON_WATCH_PATHS.map(path => `--watch "${path}"`).join(' ');
// concurrently command objects: { command, name, prefixColor, env, cwd, ipc }
const commandObjects = [
{
command: 'npm run watch --workspace=packages/bruno-common',
name: 'common',
prefixColor: 'magenta'
},
{
command: 'npm run watch --workspace=packages/bruno-converters',
name: 'converters',
prefixColor: 'green'
},
{
command: 'npm run watch --workspace=packages/bruno-query',
name: 'query',
prefixColor: 'blue'
},
{
command: 'npm run watch --workspace=packages/bruno-graphql-docs',
name: 'graphql',
prefixColor: 'white'
},
{
command: 'npm run watch --workspace=packages/bruno-requests',
name: 'requests',
prefixColor: 'gray'
},
{
command: 'npm run dev:web',
name: 'react',
prefixColor: 'cyan'
},
{
command: `sleep ${CONFIG.ELECTRON_START_DELAY} && nodemon ${watchPaths} --ext js,jsx,ts,tsx --delay ${CONFIG.NODEMON_WATCH_DELAY}ms --exec "npm run dev --workspace=packages/bruno-electron"`,
name: 'electron',
prefixColor: 'yellow',
delay: CONFIG.ELECTRON_START_DELAY
}
];
const { result } = concurrently(commandObjects, {
prefix: '[{name}: {pid}]',
killOthers: ['failure', 'success'],
restartTries: 3,
restartDelay: 1000
});
result
.then(() => log(LOG_LEVELS.SUCCESS, 'All processes completed successfully'))
.catch(err => {
log(LOG_LEVELS.ERROR, 'Development environment failed to start');
console.error(err);
process.exit(1);
});
}
// Main function
(async function main() {
const args = process.argv.slice(2);
let runSetup = false;
// Parse command line arguments
for (const arg of args) {
if (arg === '-s' || arg === '--setup') {
runSetup = true;
} else if (arg === '-h' || arg === '--help') {
showHelp();
process.exit(0);
} else {
log(LOG_LEVELS.ERROR, `Unknown parameter: ${arg}`);
showHelp();
process.exit(1);
}
}
log(LOG_LEVELS.INFO, 'Initializing Bruno development environment...');
// Ensure required global packages and node version
ensureNodeVersion(CONFIG.NODE_VERSION);
ensureGlobalPackage('nodemon');
ensureGlobalPackage('concurrently');
// Run setup if requested
if (runSetup) {
cleanNodeModules();
reinstallDependencies();
}
// Start development environment
startDevelopment();
})().catch(err => {
log(LOG_LEVELS.ERROR, 'An error occurred:');
console.error(err);
process.exit(1);
});