diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 08a657144..0fa88640a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -58,6 +58,8 @@ jobs: run: npm run test --workspace=packages/bruno-converters - name: Test Package bruno-electron run: npm run test --workspace=packages/bruno-electron + - name: Test Package bruno-requests + run: npm run test --workspace=packages/bruno-requests cli-test: name: CLI Tests diff --git a/.gitignore b/.gitignore index d2f68f452..fb650b306 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ yarn-error.log* bruno.iml .idea .vscode +.cursor # Playwright /blob-report/ diff --git a/assets/images/landing-2-dark.png b/assets/images/landing-2-dark.png new file mode 100644 index 000000000..8f69d5278 Binary files /dev/null and b/assets/images/landing-2-dark.png differ diff --git a/assets/images/landing-2-light.png b/assets/images/landing-2-light.png new file mode 100644 index 000000000..e18b23ab3 Binary files /dev/null and b/assets/images/landing-2-light.png differ diff --git a/docs/readme/readme_ar.md b/docs/readme/readme_ar.md index 6c03277df..b9f2f4aa0 100644 --- a/docs/readme/readme_ar.md +++ b/docs/readme/readme_ar.md @@ -3,7 +3,7 @@ ### برونو - بيئة تطوير مفتوحة المصدر لاستكشاف واختبار واجهات برمجة التطبيقات (APIs). -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_bn.md b/docs/readme/readme_bn.md index cf9c2110f..601e2c98e 100644 --- a/docs/readme/readme_bn.md +++ b/docs/readme/readme_bn.md @@ -3,7 +3,7 @@ ### ব্রুনো - API অন্বেষণ এবং পরীক্ষা করার জন্য ওপেনসোর্স IDE। -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_cn.md b/docs/readme/readme_cn.md index f52a6f23f..01f86c137 100644 --- a/docs/readme/readme_cn.md +++ b/docs/readme/readme_cn.md @@ -3,7 +3,7 @@ ### Bruno - 开源 IDE,用于探索和测试 API。 -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_de.md b/docs/readme/readme_de.md index 93eb09b22..572b7f25d 100644 --- a/docs/readme/readme_de.md +++ b/docs/readme/readme_de.md @@ -3,7 +3,7 @@ ### Bruno - Opensource IDE zum Erkunden und Testen von APIs. -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_es.md b/docs/readme/readme_es.md index bf747d49f..40e5f7c2b 100644 --- a/docs/readme/readme_es.md +++ b/docs/readme/readme_es.md @@ -3,7 +3,7 @@ ### Bruno - IDE de código abierto para explorar y probar APIs. -[![Versión en Github](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![Versión en 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) [![Actividad de Commits](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) diff --git a/docs/readme/readme_fa.md b/docs/readme/readme_fa.md index 3e495d426..9335dc580 100644 --- a/docs/readme/readme_fa.md +++ b/docs/readme/readme_fa.md @@ -3,7 +3,7 @@ ### برونو یا Bruno - محیط توسعه متن باز برای تست و توسعه API ها -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_fr.md b/docs/readme/readme_fr.md index 777ad42fc..0cd8897b3 100644 --- a/docs/readme/readme_fr.md +++ b/docs/readme/readme_fr.md @@ -3,7 +3,7 @@ ### Bruno - IDE Opensource pour explorer et tester des APIs. -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_it.md b/docs/readme/readme_it.md index c74e01cdc..12e0888b2 100644 --- a/docs/readme/readme_it.md +++ b/docs/readme/readme_it.md @@ -3,7 +3,7 @@ ### Bruno - Opensource IDE per esplorare e testare gli APIs. -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_ja.md b/docs/readme/readme_ja.md index 1c60c25ce..39598a0a6 100644 --- a/docs/readme/readme_ja.md +++ b/docs/readme/readme_ja.md @@ -3,7 +3,7 @@ ### Bruno - API の検証・動作テストのためのオープンソース IDE. -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_ka.md b/docs/readme/readme_ka.md index ba8deb717..38c495e2f 100644 --- a/docs/readme/readme_ka.md +++ b/docs/readme/readme_ka.md @@ -3,7 +3,7 @@ ### ბრუნო - ღია წყაროების IDE API-ების შესწავლისა და ტესტირებისათვის. -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_kr.md b/docs/readme/readme_kr.md index 219617a0a..1b63f518f 100644 --- a/docs/readme/readme_kr.md +++ b/docs/readme/readme_kr.md @@ -3,7 +3,7 @@ ### Bruno - API 탐색 및 테스트를 위한 오픈소스 IDE. -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_nl.md b/docs/readme/readme_nl.md index 35fe292f8..6d35625f8 100644 --- a/docs/readme/readme_nl.md +++ b/docs/readme/readme_nl.md @@ -3,7 +3,7 @@ ### Bruno - Open source IDE voor het verkennen en testen van API's. -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_pl.md b/docs/readme/readme_pl.md index 8c455a460..4193cfe68 100644 --- a/docs/readme/readme_pl.md +++ b/docs/readme/readme_pl.md @@ -3,7 +3,7 @@ ### Bruno - Otwartoźródłowe IDE do eksploracji i testów APIs. -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_pt_br.md b/docs/readme/readme_pt_br.md index 3e1d0793e..fdc504abe 100644 --- a/docs/readme/readme_pt_br.md +++ b/docs/readme/readme_pt_br.md @@ -3,7 +3,7 @@ ### Bruno - IDE de código aberto para explorar e testar APIs. -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_ro.md b/docs/readme/readme_ro.md index 1c2ecf244..5e129d1a7 100644 --- a/docs/readme/readme_ro.md +++ b/docs/readme/readme_ro.md @@ -3,7 +3,7 @@ ### Bruno - Mediu integrat de dezvoltare cu sursă deschisă pentru explorarea și testarea API-urilor. -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_ru.md b/docs/readme/readme_ru.md index 9963d504c..30e7692aa 100644 --- a/docs/readme/readme_ru.md +++ b/docs/readme/readme_ru.md @@ -3,7 +3,7 @@ ### Bruno - IDE с открытым исходным кодом для изучения и тестирования API. -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_tr.md b/docs/readme/readme_tr.md index ed7153bde..4738e9255 100644 --- a/docs/readme/readme_tr.md +++ b/docs/readme/readme_tr.md @@ -3,7 +3,7 @@ ### Bruno - API'leri keşfetmek ve test etmek için açık kaynaklı IDE. -[![GitHub sürümü](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub sürümü](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) [![Commit Activity](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) diff --git a/docs/readme/readme_ua.md b/docs/readme/readme_ua.md index a8a4bfd5a..ae0ab57fe 100644 --- a/docs/readme/readme_ua.md +++ b/docs/readme/readme_ua.md @@ -3,7 +3,7 @@ ### Bruno - IDE із відкритим кодом для тестування та дослідження API -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/docs/readme/readme_zhtw.md b/docs/readme/readme_zhtw.md index 183b7d25d..550bd5d21 100644 --- a/docs/readme/readme_zhtw.md +++ b/docs/readme/readme_zhtw.md @@ -3,7 +3,7 @@ ### Bruno - 探索和測試 API 的開源 IDE 工具 -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) diff --git a/package-lock.json b/package-lock.json index e7838e281..b3035fead 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6458,56 +6458,6 @@ "url": "https://opencollective.com/popperjs" } }, - "node_modules/@postman/form-data": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@postman/form-data/-/form-data-3.1.1.tgz", - "integrity": "sha512-vjh8Q2a8S6UCm/KKs31XFJqEEgmbjBmpPNVV2eVav6905wyFAwaUOBGA1NPBI4ERH9MMZc6w0umFgM6WbEPMdg==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@postman/tough-cookie": { - "version": "4.1.3-postman.1", - "resolved": "https://registry.npmjs.org/@postman/tough-cookie/-/tough-cookie-4.1.3-postman.1.tgz", - "integrity": "sha512-txpgUqZOnWYnUHZpHjkfb0IwVH4qJmyq77pPnJLlfhMtdCLMFTEeQHlzQiK906aaNCe4NEB5fGJHo9uzGbFMeA==", - "license": "BSD-3-Clause", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@postman/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/@postman/tunnel-agent": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@postman/tunnel-agent/-/tunnel-agent-0.6.4.tgz", - "integrity": "sha512-CJJlq8V7rNKhAw4sBfjixKpJW00SHqebqNUQKxMoepgeWZIbdPcD+rguRcivGhS4N12PymDcKgUgSD4rVC+RjQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/@prantlf/jsonlint": { "version": "16.0.0", "resolved": "https://registry.npmjs.org/@prantlf/jsonlint/-/jsonlint-16.0.0.tgz", @@ -11429,15 +11379,6 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, "node_modules/asn1.js": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", @@ -11476,6 +11417,7 @@ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "license": "MIT", + "optional": true, "engines": { "node": ">=0.8" } @@ -11626,15 +11568,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, "node_modules/aws4": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", @@ -12028,15 +11961,6 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "license": "BSD-3-Clause", - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -12229,15 +12153,6 @@ "dev": true, "license": "MIT" }, - "node_modules/brotli": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", - "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", - "license": "MIT", - "dependencies": { - "base64-js": "^1.1.2" - } - }, "node_modules/browserify-aes": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", @@ -12750,12 +12665,6 @@ "node": ">=4" } }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "license": "Apache-2.0" - }, "node_modules/chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -14375,18 +14284,6 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -15110,22 +15007,6 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "license": "MIT", - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, - "node_modules/ecc-jsbn/node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "license": "MIT" - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -16424,12 +16305,6 @@ "node": ">= 0.6" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, "node_modules/extract-files": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/extract-files/-/extract-files-9.0.0.tgz", @@ -16555,6 +16430,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "devOptional": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -17037,15 +16913,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, "node_modules/fork-ts-checker-webpack-plugin": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", @@ -17524,15 +17391,6 @@ "node": ">=6.0" } }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0" - } - }, "node_modules/github-markdown-css": { "version": "5.8.1", "resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.8.1.tgz", @@ -17815,57 +17673,12 @@ "@grpc/grpc-js": "^1.12.6" } }, - "node_modules/har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", - "license": "ISC", - "engines": { - "node": ">=4" - } - }, - "node_modules/har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "deprecated": "this library is no longer supported", - "license": "MIT", - "dependencies": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/har-validator-compiled": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/har-validator-compiled/-/har-validator-compiled-1.0.0.tgz", "integrity": "sha512-dher7nFSx+Ef6OoqVveLClh8itAR3vd8Qx70Lh/hEgP1iGeARAolbci7Y8JBrHIYgFCT6xRdvvL16AR9Zh07Dw==", "license": "MIT" }, - "node_modules/har-validator/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/har-validator/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "license": "MIT" - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -18338,20 +18151,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/http-signature": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", - "integrity": "sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==", - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^2.0.2", - "sshpk": "^1.14.1" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -19168,12 +18967,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "license": "MIT" - }, "node_modules/is-valid-path": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-valid-path/-/is-valid-path-0.1.1.tgz", @@ -19236,12 +19029,6 @@ "node": ">=0.10.0" } }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "license": "MIT" - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -20822,12 +20609,6 @@ "node": "*" } }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -20851,7 +20632,9 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" + "dev": true, + "license": "ISC", + "optional": true }, "node_modules/json5": { "version": "2.2.3", @@ -20892,44 +20675,6 @@ "node": "*" } }, - "node_modules/jsprim": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", - "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - } - }, - "node_modules/jsprim/node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "engines": [ - "node >=0.6.0" - ], - "license": "MIT" - }, - "node_modules/jsprim/node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -22030,15 +21775,6 @@ "node": ">= 6.0.0" } }, - "node_modules/mustache": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", - "license": "MIT", - "bin": { - "mustache": "bin/mustache" - } - }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -22259,44 +21995,6 @@ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "license": "MIT" }, - "node_modules/node-vault": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/node-vault/-/node-vault-0.10.2.tgz", - "integrity": "sha512-//uc9/YImE7Dx0QHdwMiAzLaOumiKUnOUP8DymgtkZ8nsq6/V2LKvEu6kw91Lcruw8lWUfj4DO7CIXNPRWBuuA==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "mustache": "^4.2.0", - "postman-request": "^2.88.1-postman.33", - "tv4": "^1.3.0" - }, - "engines": { - "node": ">= 16.0.0" - } - }, - "node_modules/node-vault/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/node-vault/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -22367,15 +22065,6 @@ "dev": true, "license": "MIT" }, - "node_modules/oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "license": "Apache-2.0", - "engines": { - "node": "*" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -23090,12 +22779,6 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -24075,57 +23758,6 @@ "node": ">=15.0.0" } }, - "node_modules/postman-request": { - "version": "2.88.1-postman.40", - "resolved": "https://registry.npmjs.org/postman-request/-/postman-request-2.88.1-postman.40.tgz", - "integrity": "sha512-uE4AiIqhjtHKp4pj9ei7fkdfNXEX9IqDBlK1plGAQne6y79UUlrTdtYLhwXoO0AMOvqyl9Ar+BU6Eo6P/MPgfg==", - "license": "Apache-2.0", - "dependencies": { - "@postman/form-data": "~3.1.1", - "@postman/tough-cookie": "~4.1.3-postman.1", - "@postman/tunnel-agent": "^0.6.4", - "aws-sign2": "~0.7.0", - "aws4": "^1.12.0", - "brotli": "^1.3.3", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "har-validator": "~5.1.3", - "http-signature": "~1.3.1", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "^2.1.35", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.3", - "safe-buffer": "^5.1.2", - "stream-length": "^1.0.2", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">= 16" - } - }, - "node_modules/postman-request/node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/postman-request/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -24427,6 +24059,7 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -24471,6 +24104,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -27386,37 +27020,6 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "license": "MIT", - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sshpk/node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "license": "MIT" - }, "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", @@ -27587,21 +27190,6 @@ "node": ">= 6" } }, - "node_modules/stream-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stream-length/-/stream-length-1.0.2.tgz", - "integrity": "sha512-aI+qKFiwoDV4rsXiS7WRoCt+v2RX1nUj17+KJC5r2gfh5xoSJIfP6Y3Do/HtvesFcTSWthIuJ3l1cvKQY/+nZg==", - "license": "WTFPL", - "dependencies": { - "bluebird": "^2.6.2" - } - }, - "node_modules/stream-length/node_modules/bluebird": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", - "integrity": "sha512-UfFSr22dmHPQqPP9XWHRhq+gWnHCYguQGkXQlbyPtW5qTnhFWA8/iXg765tH0cAjy7l/zPJ1aBTO0g5XgA7kvQ==", - "license": "MIT" - }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -29184,12 +28772,6 @@ "node": ">= 0.8.0" } }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "license": "Unlicense" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -29454,6 +29036,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -30477,7 +30060,7 @@ "polished": "^4.3.1", "posthog-node": "4.2.1", "prettier": "^2.7.1", - "qs": "^6.11.0", + "qs": "^6.14.1", "query-string": "^7.0.1", "react": "19.0.0", "react-copy-to-clipboard": "^5.1.0", @@ -30492,6 +30075,7 @@ "react-player": "^2.16.0", "react-redux": "^7.2.9", "react-tooltip": "^5.5.2", + "react-virtuoso": "^4.18.1", "sass": "^1.46.0", "semver": "^7.7.1", "shell-quote": "^1.8.3", @@ -31932,6 +31516,31 @@ "url": "https://opencollective.com/express" } }, + "packages/bruno-app/node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "packages/bruno-app/node_modules/react-virtuoso": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.1.tgz", + "integrity": "sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16 || >=17 || >= 18 || >= 19", + "react-dom": ">=16 || >=17 || >= 18 || >=19" + } + }, "packages/bruno-app/node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", @@ -32017,7 +31626,7 @@ "iconv-lite": "^0.6.3", "js-yaml": "^4.1.1", "lodash": "^4.17.21", - "qs": "^6.11.0", + "qs": "^6.14.1", "socks-proxy-agent": "^8.0.2", "xmlbuilder": "^15.1.1", "yargs": "^17.6.2" @@ -33067,6 +32676,21 @@ "node": ">=12" } }, + "packages/bruno-cli/node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "packages/bruno-common": { "name": "@usebruno/common", "version": "0.1.0", @@ -33652,9 +33276,9 @@ "license": "MIT" }, "packages/bruno-converters/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -33757,7 +33381,7 @@ "lodash": "^4.17.21", "mime-types": "^2.1.35", "nanoid": "3.3.8", - "qs": "^6.11.0", + "qs": "^6.14.1", "socks-proxy-agent": "^8.0.2", "tough-cookie": "^6.0.0", "uuid": "^9.0.0", @@ -35243,6 +34867,21 @@ "dev": true, "license": "MIT" }, + "packages/bruno-electron/node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "packages/bruno-electron/node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -35492,7 +35131,6 @@ "moment": "^2.29.4", "nanoid": "3.3.8", "node-fetch": "^2.7.0", - "node-vault": "^0.10.2", "path": "^0.12.7", "quickjs-emscripten": "^0.29.2", "tv4": "^1.3.0", @@ -35607,7 +35245,10 @@ "debug": "^4.4.3", "google-protobuf": "^4.0.0", "grpc-js-reflection-client": "^1.3.0", + "http-proxy-agent": "~7.0.2", + "https-proxy-agent": "~7.0.6", "is-ip": "^5.0.1", + "socks-proxy-agent": "~8.0.5", "system-ca": "^2.0.1", "tough-cookie": "^6.0.0", "ws": "^8.18.3" @@ -35707,9 +35348,7 @@ "version": "0.7.0", "license": "MIT", "dependencies": { - "nanoid": "3.3.8" - }, - "peerDependencies": { + "nanoid": "3.3.8", "yup": "^0.32.11" } }, @@ -36004,4 +35643,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index b15e02d9d..46fc41de5 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "build:electron:rpm": "./scripts/build-electron.sh rpm", "build:electron:snap": "./scripts/build-electron.sh snap", "watch:common": "npm run watch --workspace=packages/bruno-common", + "watch:requests": "npm run watch --workspace=packages/bruno-requests", "test:codegen": "node playwright/codegen.ts", "test:e2e": "playwright test --project=default", "test:e2e:ssl": "playwright test --project=ssl", diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index b866aa15c..70a7c8663 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -69,7 +69,7 @@ "polished": "^4.3.1", "posthog-node": "4.2.1", "prettier": "^2.7.1", - "qs": "^6.11.0", + "qs": "^6.14.1", "query-string": "^7.0.1", "react": "19.0.0", "react-copy-to-clipboard": "^5.1.0", @@ -84,6 +84,7 @@ "react-player": "^2.16.0", "react-redux": "^7.2.9", "react-tooltip": "^5.5.2", + "react-virtuoso": "^4.18.1", "sass": "^1.46.0", "semver": "^7.7.1", "shell-quote": "^1.8.3", diff --git a/packages/bruno-app/src/components/AppTitleBar/index.js b/packages/bruno-app/src/components/AppTitleBar/index.js index f2a621b29..0e40c9ea9 100644 --- a/packages/bruno-app/src/components/AppTitleBar/index.js +++ b/packages/bruno-app/src/components/AppTitleBar/index.js @@ -18,9 +18,9 @@ import ImportWorkspace from 'components/WorkspaceSidebar/ImportWorkspace'; import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index'; import StyledWrapper from './StyledWrapper'; -import { toTitleCase } from 'utils/common/index'; import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle'; import { isMacOS, isWindowsOS, isLinuxOS } from 'utils/common/platform'; +import classNames from 'classnames'; const getOsClass = () => { if (isMacOS()) return 'os-mac'; @@ -29,6 +29,12 @@ const getOsClass = () => { return 'os-other'; }; +// Helper to get display name for workspace +export const getWorkspaceDisplayName = (name) => { + if (!name) return 'Untitled Workspace'; + return name; +}; + const AppTitleBar = () => { const dispatch = useDispatch(); const [isFullScreen, setIsFullScreen] = useState(false); @@ -115,7 +121,7 @@ const AppTitleBar = () => { const WorkspaceName = forwardRef((props, ref) => { return (
- {toTitleCase(activeWorkspace?.name) || 'Default Workspace'} + {getWorkspaceDisplayName(activeWorkspace?.name)}
); @@ -127,7 +133,7 @@ const AppTitleBar = () => { const handleWorkspaceSwitch = (workspaceUid) => { dispatch(switchWorkspace(workspaceUid)); - toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`); + toast.success(`Switched to ${getWorkspaceDisplayName(workspaces.find((w) => w.uid === workspaceUid)?.name)}`); }; const handleOpenWorkspace = async () => { @@ -178,7 +184,7 @@ const AppTitleBar = () => { return { id: workspace.uid, - label: toTitleCase(workspace.name), + label: getWorkspaceDisplayName(workspace.name), onClick: () => handleWorkspaceSwitch(workspace.uid), className: `workspace-item ${isActive ? 'active' : ''}`, rightSection: ( @@ -190,11 +196,7 @@ const AppTitleBar = () => { label={isPinned ? 'Unpin workspace' : 'Pin workspace'} size="sm" > - {isPinned ? ( - - ) : ( - - )} + {isPinned ? : } )} {isActive && } @@ -247,12 +249,7 @@ const AppTitleBar = () => {
{/* Left section: Home + Workspace */}
- + diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 0c2d9b759..3adb1fbf5 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -8,7 +8,7 @@ import React from 'react'; import { isEqual, escapeRegExp } from 'lodash'; import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror'; -import { setupAutoComplete } from 'utils/codemirror/autocomplete'; +import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete'; import StyledWrapper from './StyledWrapper'; import * as jsonlint from '@prantlf/jsonlint'; import { JSHINT } from 'jshint'; @@ -111,8 +111,12 @@ export default class CodeEditor extends React.Component { : cm.replaceSelection(' ', 'end'); }, 'Shift-Tab': 'indentLess', - 'Ctrl-Space': 'autocomplete', - 'Cmd-Space': 'autocomplete', + 'Ctrl-Space': (cm) => { + showRootHints(cm, this.props.showHintsFor); + }, + 'Cmd-Space': (cm) => { + showRootHints(cm, this.props.showHintsFor); + }, 'Ctrl-Y': 'foldAll', 'Cmd-Y': 'foldAll', 'Ctrl-I': 'unfoldAll', diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js index 2c558152a..abb6ccdcd 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js @@ -59,7 +59,7 @@ const CreateEnvironment = ({ collection, onClose, onEnvironmentCreated }) => { return ( {children}, (prevProps, nextProps) => { + const prevUid = prevProps?.item?.uid; + const nextUid = nextProps?.item?.uid; + return prevUid === nextUid && prevProps.children === nextProps.children; +}); + +const MIN_H = 35 * 2; // 2 rows worth of height + const EnvironmentVariables = ({ environment, setIsModified, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); + const [tableHeight, setTableHeight] = React.useState(MIN_H); + const environmentsDraft = collection?.environmentsDraft; const hasDraftForThisEnv = environmentsDraft?.environmentUid === environment.uid; + const handleTotalHeightChanged = React.useCallback((h) => { + setTableHeight(h); + }, []); + // Track environment changes for draft restoration const prevEnvUidRef = React.useRef(null); const mountedRef = React.useRef(false); @@ -384,111 +399,114 @@ const EnvironmentVariables = ({ environment, setIsModified, collection }) => { return ( -
- - - - - - - - - - - - {formik.values.map((variable, index) => { - const isLastRow = index === formik.values.length - 1; - const isEmptyRow = !variable.name || variable.name.trim() === ''; - const isLastEmptyRow = isLastRow && isEmptyRow; + ( + + + + + + + + )} + fixedItemHeight={35} + computeItemKey={(index, variable) => variable.uid} + itemContent={(index, variable) => { + const isLastRow = index === formik.values.length - 1; + const isEmptyRow = !variable.name || variable.name.trim() === ''; + const isLastEmptyRow = isLastRow && isEmptyRow; - return ( - - - - - - - - ); - })} - -
NameValueSecret
NameValueSecret
- {!isLastEmptyRow && ( - - )} - -
- handleNameChange(index, e)} - onBlur={() => handleNameBlur(index)} - onKeyDown={(e) => handleNameKeyDown(index, e)} - /> - -
-
-
- formik.setFieldValue(`${index}.value`, newValue, true)} - onSave={handleSave} - /> -
- {typeof variable.value !== 'string' && ( - - - - - )} - {!variable.secret && hasSensitiveUsage(variable.name) && ( - - )} -
- {!isLastEmptyRow && ( - - )} - - {!isLastEmptyRow && ( - - )} -
-
+ return ( + <> + + {!isLastEmptyRow && ( + + )} + + +
+ handleNameChange(index, e)} + onBlur={() => handleNameBlur(index)} + onKeyDown={(e) => handleNameKeyDown(index, e)} + /> + +
+ + +
+ formik.setFieldValue(`${index}.value`, newValue, true)} + onSave={handleSave} + /> +
+ {typeof variable.value !== 'string' && ( + + + + + )} + {!variable.secret && hasSensitiveUsage(variable.name) && ( + + )} + + + {!isLastEmptyRow && ( + + )} + + + {!isLastEmptyRow && ( + + )} + + + ); + }} + />
diff --git a/packages/bruno-app/src/components/FolderSettings/Auth/index.js b/packages/bruno-app/src/components/FolderSettings/Auth/index.js index d91ba6089..a1995a205 100644 --- a/packages/bruno-app/src/components/FolderSettings/Auth/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Auth/index.js @@ -3,7 +3,7 @@ import get from 'lodash/get'; import StyledWrapper from './StyledWrapper'; import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'; import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index'; -import { updateFolderAuth } from 'providers/ReduxStore/slices/collections'; +import { updateFolderAuth as _updateFolderAuth } from 'providers/ReduxStore/slices/collections'; import { useDispatch } from 'react-redux'; import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index'; import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index'; @@ -20,7 +20,7 @@ import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth'; import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index'; import Button from 'ui/Button'; -const GrantTypeComponentMap = ({ collection, folder }) => { +const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => { const dispatch = useDispatch(); const save = () => { @@ -90,6 +90,13 @@ const Auth = ({ collection, folder }) => { dispatch(saveFolderRoot(collection.uid, folder.uid)); }; + const updateFolderAuth = ({ itemUid, ...rest }) => { + return _updateFolderAuth({ + ...rest, + folderUid: folder.uid + }); + }; + const getAuthView = () => { switch (authMode) { case 'basic': { @@ -178,7 +185,7 @@ const Auth = ({ collection, folder }) => { collection={collection} item={folder} /> - + ); } diff --git a/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js index 8b92c93cb..ee8197ede 100644 --- a/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js @@ -14,6 +14,10 @@ const Wrapper = styled.div` z-index: 0; } + .scroll-chevrons.hidden { + display: none; + } + .tabs-scroll-container { overflow-x: auto; overflow-y: clip; @@ -192,10 +196,6 @@ const Wrapper = styled.div` } } - &.has-chevrons ul { - padding-left: 0; - } - .special-tab-icon { color: ${(props) => props.theme.primary.text}; } diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js index 0de864955..5f7fa68c5 100644 --- a/packages/bruno-app/src/components/RequestTabs/index.js +++ b/packages/bruno-app/src/components/RequestTabs/index.js @@ -103,14 +103,9 @@ const RequestTabs = () => { }); }; - const getRootClassname = () => { - return classnames({ - 'has-chevrons': showChevrons - }); - }; // Todo: Must support ephemeral requests return ( - + {newRequestModalOpen && ( setNewRequestModalOpen(false)} /> )} @@ -118,12 +113,11 @@ const RequestTabs = () => { <>
- - {showChevrons ? ( +
- ) : null} +
{/* Moved to post mvp */} {/*
  • @@ -175,11 +169,11 @@ const RequestTabs = () => { )} - {showChevrons ? ( +
    - ) : null} +
    {/* Moved to post mvp */} {/*
  • diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js index 6d62f38c0..b6f40be58 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js @@ -49,11 +49,11 @@ export const useInitialResponseFormat = (dataBuffer, headers) => { // Wait until both content types are available if (detectedContentType === null || contentType === undefined) { - return { initialFormat: null, initialTab: null }; + return { initialFormat: null, initialTab: null, contentType: contentType }; } const initial = getDefaultResponseFormat(contentType); - return { initialFormat: initial.format, initialTab: initial.tab }; + return { initialFormat: initial.format, initialTab: initial.tab, contentType: contentType }; }, [dataBuffer, headers]); }; @@ -66,6 +66,7 @@ export const useResponsePreviewFormatOptions = (dataBuffer, headers) => { const byteFormatTypes = ['image', 'video', 'audio', 'pdf', 'zip']; const isByteFormatType = (contentType) => { + if (contentType.toLowerCase().includes('svg')) return false; // SVG is text-based return byteFormatTypes.some((type) => contentType.includes(type)); }; @@ -203,7 +204,7 @@ const QueryResult = ({ dataBuffer={dataBuffer} formattedData={formattedData} item={item} - contentType={contentType} + contentType={detectedContentType ?? contentType} previewMode={previewMode} codeMirrorMode={codeMirrorMode} collection={collection} diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js index 19098970a..142b45b7d 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js @@ -53,6 +53,17 @@ const StyledWrapper = styled.div` align-items: center; margin-left: 10px; } + + div.tabs .action-icon { + color: ${(props) => props.theme.dropdown.iconColor}; + opacity: 0.8; + + &:hover { + color: ${(props) => props.theme.text}; + opacity: 1; + background-color: ${(props) => props.theme.workspace.button.bg}; + } + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js index a0e8c744e..00175777c 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js @@ -1,7 +1,9 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - overflow-y: auto; + flex: 1; + min-height: 0; + height: 100%; .empty-state { padding: 1rem; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js index 233be1988..e30e102b1 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js @@ -1,13 +1,11 @@ -import React from 'react'; +import React, { useState, useRef, useEffect, useCallback, memo } from 'react'; import classnames from 'classnames'; import StyledWrapper from './StyledWrapper'; import { IconExclamationCircle, IconChevronRight, IconInfoCircle, IconChevronDown, IconArrowUpRight, IconArrowDownLeft } from '@tabler/icons'; import CodeEditor from 'components/CodeEditor/index'; import { useTheme } from 'providers/Theme'; -import { useState } from 'react'; import { useSelector } from 'react-redux'; -import { useRef } from 'react'; -import { useEffect } from 'react'; +import { Virtuoso } from 'react-virtuoso'; const getContentMeta = (content) => { if (typeof content === 'object') { @@ -61,8 +59,7 @@ const TypeIcon = ({ type }) => { }[type]; }; -const WSMessageItem = ({ message, inFocus }) => { - const [isOpen, setIsOpen] = useState(false); +const WSMessageItem = memo(({ message, isOpen, onToggle }) => { const [showHex, setShowHex] = useState(false); const preferences = useSelector((state) => state.app.preferences); const { displayedTheme } = useTheme(); @@ -82,21 +79,23 @@ const WSMessageItem = ({ message, inFocus }) => { const dateDiff = Date.now() - new Date(message.timestamp).getTime(); if (dateDiff < 1000 * 10) { setIsNew(true); - setTimeout(() => { + const timer = setTimeout(() => { notified.current = true; setIsNew(false); }, 2500); + return () => clearTimeout(timer); } - }, [message]); + }, [message.timestamp]); const canOpenMessage = !isInfo && !isError; + const handleToggle = () => { + if (!canOpenMessage) return; + onToggle?.(message.timestamp); + }; + return (
    { - if (!node) return; - if (inFocus) node.scrollIntoView(); - }} className={classnames('ws-message flex flex-col p-2', { 'ws-incoming': isIncoming, 'ws-outgoing': isOutgoing, @@ -111,10 +110,7 @@ const WSMessageItem = ({ message, inFocus }) => { 'cursor-pointer': canOpenMessage, 'cursor-not-allowed': !canOpenMessage })} - onClick={(e) => { - if (!canOpenMessage) return; - setIsOpen(!isOpen); - }} + onClick={handleToggle} >
    @@ -176,23 +172,87 @@ const WSMessageItem = ({ message, inFocus }) => { )}
    ); -}; +}); + +const WSMessagesList = ({ messages = [] }) => { + const virtuosoRef = useRef(null); + const [scrollerElement, setScrollerElement] = useState(null); + const [openMessages, setOpenMessages] = useState(new Set()); + const userScrolledAwayRef = useRef(false); + + // Toggle message open/closed state by timestamp + const handleMessageToggle = useCallback((timestamp) => { + setOpenMessages((prev) => { + const next = new Set(prev); + if (next.has(timestamp)) { + next.delete(timestamp); + } else { + next.add(timestamp); + } + return next; + }); + }, []); + + useEffect(() => { + if (!scrollerElement) return; + + const handleWheel = (e) => { + // deltaY < 0 means scrolling up + if (e.deltaY < 0) { + userScrolledAwayRef.current = true; + } + }; + + scrollerElement.addEventListener('wheel', handleWheel, { passive: true }); + + return () => { + scrollerElement.removeEventListener('wheel', handleWheel); + }; + }, [scrollerElement]); + + const handleAtBottomStateChange = useCallback((atBottom) => { + if (atBottom) { + // User scrolled back to bottom, re-enable auto-scroll + userScrolledAwayRef.current = false; + } + }, []); + + const followOutput = useCallback((isAtBottom) => { + // Don't auto-scroll if user has scrolled away or has messages open + if (userScrolledAwayRef.current || openMessages.size > 0) { + return false; + } + if (isAtBottom) { + return 'smooth'; + } + return false; + }, [openMessages.size]); + + const renderItem = useCallback((_, msg) => { + const isOpen = openMessages.has(msg.timestamp); + return ; + }, [openMessages, handleMessageToggle]); + + const computeItemKey = useCallback((_, msg) => { + return msg.seq ?? msg.timestamp; + }, []); -const WSMessagesList = ({ order = -1, messages = [] }) => { if (!messages.length) { return
    No messages yet.
    ; } - // sort based on order, seq was newly added and might be missing in some cases and when missing, - // the timestamp will be used instead - const ordered = messages.toSorted((x, y) => ((x.seq ?? x.timestamp) - (y.seq ?? y.timestamp)) * (-order)); - return ( - {ordered.map((msg, idx, src) => { - const inFocus = order === -1 ? src.length - 1 === idx : idx === 0; - return ; - })} + ); }; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js index 28cc50efc..5722a4166 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js @@ -13,11 +13,10 @@ import StyledWrapper from './StyledWrapper'; import ResponseLayoutToggle from '../ResponseLayoutToggle'; import ResponsiveTabs from 'ui/ResponsiveTabs'; import WSMessagesList from './WSMessagesList'; -import WSResponseSortOrder from './WSResponseSortOrder'; import WSResponseHeaders from './WSResponseHeaders'; const WSResult = ({ response }) => { - return ; + return ; }; const WSResponsePane = ({ item, collection }) => { @@ -116,7 +115,6 @@ const WSResponsePane = ({ item, collection }) => { <> - { const focusedTab = find(tabs, (t) => t.uid === activeTabUid); // Initialize format and tab only once when data loads. - const { initialFormat, initialTab } = useInitialResponseFormat(response?.dataBuffer, response?.headers); + const { initialFormat, initialTab, contentType } = useInitialResponseFormat(response?.dataBuffer, response?.headers); const previewFormatOptions = useResponsePreviewFormatOptions(response?.dataBuffer, response?.headers); + // Track previous response headers to detect when content-type changes + const previousContentRef = useRef(contentType); + const persistedFormat = focusedTab?.responseFormat; const persistedViewTab = focusedTab?.responseViewTab; @@ -56,13 +59,19 @@ const ResponsePane = ({ item, collection }) => { if (!focusedTab || initialFormat === null || initialTab === null) { return; } - if (persistedFormat === null) { + + // Check if response headers (content-type) changed using deep comparison + const contentTypeChanged = contentType !== previousContentRef.current; + if (contentTypeChanged) { + previousContentRef.current = contentType; + } + if (contentTypeChanged || persistedFormat === null) { dispatch(updateResponseFormat({ uid: item.uid, responseFormat: initialFormat })); } - if (persistedViewTab === null) { + if (contentTypeChanged || persistedViewTab === null) { dispatch(updateResponseViewTab({ uid: item.uid, responseViewTab: initialTab })); } - }, [initialFormat, initialTab, persistedFormat, persistedViewTab, focusedTab, item.uid, dispatch]); + }, [contentType, initialFormat, initialTab, persistedFormat, persistedViewTab, focusedTab, item.uid, dispatch]); const handleFormatChange = useCallback((newFormat) => { dispatch(updateResponseFormat({ uid: item.uid, responseFormat: newFormat })); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js index 66c0fcd62..6dbb69064 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js @@ -20,7 +20,14 @@ const CloneCollection = ({ onClose, collectionUid }) => { const [isEditing, toggleEditing] = useState(false); const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); const preferences = useSelector((state) => state.app.preferences); - const defaultLocation = get(preferences, 'general.defaultCollectionLocation', ''); + const workspaces = useSelector((state) => state.workspaces?.workspaces || []); + const workspaceUid = useSelector((state) => state.workspaces?.activeWorkspaceUid); + const activeWorkspace = workspaces.find((w) => w.uid === workspaceUid); + const isDefaultWorkspace = activeWorkspace?.type === 'default'; + + const defaultLocation = isDefaultWorkspace + ? get(preferences, 'general.defaultCollectionLocation', '') + : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : ''); const { name } = collection; const formik = useFormik({ diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js index d7372784a..660d7ea91 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js @@ -110,12 +110,14 @@ const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exa // Resolve auth inheritance const resolvedRequest = resolveInheritedAuth(item, collection); - // Create the final item for code generation + // requestData.request contains either the normal request or example request data. + // We explicitly set auth from resolvedRequest to ensure inherited auth + // (from folders/collection) is resolved correctly in generated code. const finalItem = { ...item, request: { - ...resolvedRequest, ...requestData.request, + auth: resolvedRequest.auth, url: finalUrl } }; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js index 03c4cd973..f52faf118 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js @@ -20,7 +20,7 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false // Add auth headers if needed if (request.auth && request.auth.mode !== 'none') { const collectionAuth = collection?.draft?.root ? get(collection, 'draft.root.request.auth', null) : get(collection, 'root.request.auth', null); - const authHeaders = getAuthHeaders(collectionAuth, request.auth); + const authHeaders = getAuthHeaders(collectionAuth, request.auth, collection, item); headers = [...headers, ...authHeaders]; } diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js index e20f35a22..0946ce2bc 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js @@ -554,3 +554,223 @@ describe('generateSnippet with edge-case bodies', () => { expect(result).toMatch(/^curl -X POST/); }); }); + +describe('generateSnippet with OAuth2 authentication', () => { + const language = { target: 'shell', client: 'curl' }; + const baseCollection = { root: { request: { auth: { mode: 'none' }, headers: [] } } }; + + beforeEach(() => { + jest.clearAllMocks(); + // Mock getAuthHeaders to return OAuth2 headers based on the auth config + const authUtils = require('utils/codegenerator/auth'); + authUtils.getAuthHeaders.mockImplementation((collectionRootAuth, requestAuth, collection = null, item = null) => { + if (requestAuth?.mode === 'oauth2') { + const oauth2Config = requestAuth.oauth2 || {}; + const tokenPlacement = oauth2Config.tokenPlacement || 'header'; + // Use the actual value from config, defaulting to 'Bearer' only if undefined + // Empty string should be preserved to test no-prefix scenarios + const tokenHeaderPrefix = oauth2Config.tokenHeaderPrefix !== undefined + ? oauth2Config.tokenHeaderPrefix + : 'Bearer'; + let accessToken = oauth2Config.accessToken || ''; + + // If collection and item are provided, try to look up stored credentials + if (collection && item && collection.oauth2Credentials) { + const grantType = oauth2Config.grantType || ''; + const urlToLookup = grantType === 'implicit' + ? oauth2Config.authorizationUrl || '' + : oauth2Config.accessTokenUrl || ''; + const credentialsId = oauth2Config.credentialsId || 'credentials'; + const collectionUid = collection.uid; + + if (urlToLookup && collectionUid) { + // Look up stored credentials (simplified - assumes URL is already interpolated in test data) + const credentialsData = collection.oauth2Credentials.find( + (creds) => + creds?.url === urlToLookup + && creds?.collectionUid === collectionUid + && creds?.credentialsId === credentialsId + ); + + if (credentialsData?.credentials?.access_token) { + accessToken = credentialsData.credentials.access_token; + } + } + } + + if (tokenPlacement === 'header') { + // Always trim the final result for consistent formatting + const headerValue = tokenHeaderPrefix + ? `${tokenHeaderPrefix} ${accessToken}`.trim() + : accessToken.trim(); + return [ + { + enabled: true, + name: 'Authorization', + value: headerValue + } + ]; + } + } + return []; + }); + }); + + it('should include OAuth2 Bearer token in Authorization header when tokenPlacement is header', () => { + const item = { + uid: 'oauth-req', + request: { + method: 'GET', + url: 'https://api.example.com/users', + headers: [], + auth: { + mode: 'oauth2', + oauth2: { + grantType: 'client_credentials', + tokenPlacement: 'header', + tokenHeaderPrefix: 'Bearer', + accessToken: 'test-access-token-123' + } + } + } + }; + + generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false }); + + const harUtils = require('utils/codegenerator/har'); + const harCall = harUtils.buildHarRequest.mock.calls[0][0]; + expect(harCall.headers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Authorization', + value: 'Bearer test-access-token-123' + }) + ]) + ); + }); + + it('should use custom tokenHeaderPrefix when provided', () => { + const item = { + uid: 'oauth-req-custom', + request: { + method: 'GET', + url: 'https://api.example.com/users', + headers: [], + auth: { + mode: 'oauth2', + oauth2: { + grantType: 'client_credentials', + tokenPlacement: 'header', + tokenHeaderPrefix: 'OAuth', + accessToken: 'custom-token-456' + } + } + } + }; + + generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false }); + + const harUtils = require('utils/codegenerator/har'); + const harCall = harUtils.buildHarRequest.mock.calls[0][0]; + expect(harCall.headers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Authorization', + value: 'OAuth custom-token-456' + }) + ]) + ); + }); + + it('should not include Authorization header when tokenPlacement is url', () => { + const item = { + uid: 'oauth-req-url', + request: { + method: 'GET', + url: 'https://api.example.com/users', + headers: [], + auth: { + mode: 'oauth2', + oauth2: { + grantType: 'client_credentials', + tokenPlacement: 'url', + tokenQueryKey: 'access_token', + accessToken: 'token-in-url' + } + } + } + }; + + generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false }); + + const harUtils = require('utils/codegenerator/har'); + const harCall = harUtils.buildHarRequest.mock.calls[0][0]; + const authHeader = harCall.headers.find((h) => h.name === 'Authorization'); + expect(authHeader).toBeUndefined(); + }); + + it('should use placeholder when accessToken is not available', () => { + const item = { + uid: 'oauth-req-placeholder', + request: { + method: 'GET', + url: 'https://api.example.com/users', + headers: [], + auth: { + mode: 'oauth2', + oauth2: { + grantType: 'client_credentials', + tokenPlacement: 'header', + tokenHeaderPrefix: 'Bearer' + } + } + } + }; + + generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false }); + + const harUtils = require('utils/codegenerator/har'); + const harCall = harUtils.buildHarRequest.mock.calls[0][0]; + expect(harCall.headers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Authorization', + value: 'Bearer ' + }) + ]) + ); + }); + + it('should handle empty tokenHeaderPrefix', () => { + const item = { + uid: 'oauth-req-no-prefix', + request: { + method: 'GET', + url: 'https://api.example.com/users', + headers: [], + auth: { + mode: 'oauth2', + oauth2: { + grantType: 'client_credentials', + tokenPlacement: 'header', + tokenHeaderPrefix: '', + accessToken: 'token-without-prefix' + } + } + } + }; + + generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false }); + + const harUtils = require('utils/codegenerator/har'); + const harCall = harUtils.buildHarRequest.mock.calls[0][0]; + expect(harCall.headers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Authorization', + value: 'token-without-prefix' + }) + ]) + ); + }); +}); diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js index 95ea0bae4..73a741948 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js @@ -1,4 +1,5 @@ import React, { useCallback, useRef } from 'react'; +import { TableVirtuoso } from 'react-virtuoso'; import cloneDeep from 'lodash/cloneDeep'; import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons'; import { useTheme } from 'providers/Theme'; @@ -19,6 +20,14 @@ import { Tooltip } from 'react-tooltip'; import { getGlobalEnvironmentVariables } from 'utils/collections'; import Button from 'ui/Button'; +const MIN_H = 35 * 2; + +const TableRow = React.memo(({ children, item }) => {children}, (prevProps, nextProps) => { + const prevUid = prevProps?.item?.uid; + const nextUid = nextProps?.item?.uid; + return prevUid === nextUid && prevProps.children === nextProps.children; +}); + const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); @@ -28,6 +37,12 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV const hasDraftForThisEnv = globalEnvironmentDraft?.environmentUid === environment.uid; + const [tableHeight, setTableHeight] = React.useState(MIN_H); + + const handleTotalHeightChanged = React.useCallback((h) => { + setTableHeight(h); + }, []); + // Track environment changes for draft restoration const prevEnvUidRef = React.useRef(null); const mountedRef = React.useRef(false); @@ -322,109 +337,108 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV return ( -
    - - - - - - - - - - - - {formik.values.map((variable, index) => { - const isLastRow = index === formik.values.length - 1; - const isEmptyRow = !variable.name || variable.name.trim() === ''; - const isLastEmptyRow = isLastRow && isEmptyRow; + variable.uid} + fixedHeaderContent={() => ( + + + + + + + + )} + itemContent={(index, variable) => { + const isLastRow = index === formik.values.length - 1; + const isEmptyRow = !variable.name || variable.name.trim() === ''; + const isLastEmptyRow = isLastRow && isEmptyRow; - return ( - - - - - - - - ); - })} - -
    NameValueSecret
    NameValueSecret
    - {!isLastEmptyRow && ( - - )} - -
    - handleNameChange(index, e)} - onBlur={() => handleNameBlur(index)} - onKeyDown={(e) => handleNameKeyDown(index, e)} - /> - -
    -
    -
    - formik.setFieldValue(`${index}.value`, newValue, true)} - onSave={handleSave} - /> -
    - {typeof variable.value !== 'string' && ( - - - - - )} -
    - {!isLastEmptyRow && ( - - )} - - {!isLastEmptyRow && ( - - )} -
    -
    + return ( + <> + + {!isLastEmptyRow && ( + + )} + + +
    + handleNameChange(index, e)} + onBlur={() => handleNameBlur(index)} + onKeyDown={(e) => handleNameKeyDown(index, e)} + /> + +
    + + +
    + formik.setFieldValue(`${index}.value`, newValue, true)} + onSave={handleSave} + /> +
    + {typeof variable.value !== 'string' && ( + + + + + )} + + + {!isLastEmptyRow && ( + + )} + + + {!isLastEmptyRow && ( + + )} + + + ); + }} + />
    diff --git a/packages/bruno-app/src/components/WorkspaceHome/index.js b/packages/bruno-app/src/components/WorkspaceHome/index.js index db27824b0..4ec91aefe 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/index.js @@ -11,6 +11,8 @@ import WorkspaceTabs from 'components/WorkspaceTabs'; import StyledWrapper from './StyledWrapper'; import Dropdown from 'components/Dropdown'; import { getRevealInFolderLabel } from 'utils/common/platform'; +import { getWorkspaceDisplayName } from 'components/AppTitleBar'; +import classNames from 'classnames'; const WorkspaceHome = () => { const dispatch = useDispatch(); @@ -208,7 +210,7 @@ const WorkspaceHome = () => {
    ) : ( - {activeWorkspace.name} + {getWorkspaceDisplayName(activeWorkspace.name)} )}
    diff --git a/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js b/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js index fdb32d86d..d28517238 100644 --- a/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js +++ b/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js @@ -4,10 +4,11 @@ import filter from 'lodash/filter'; import groupBy from 'lodash/groupBy'; import { useSelector } from 'react-redux'; import { useDispatch } from 'react-redux'; -import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges } from 'utils/collections'; +import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges, findEnvironmentInCollection } from 'utils/collections'; import { pluralizeWord } from 'utils/common'; import { completeQuitFlow } from 'providers/ReduxStore/slices/app'; -import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders } from 'providers/ReduxStore/slices/collections/actions'; +import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions'; +import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; import { IconAlertTriangle } from '@tabler/icons'; import Modal from 'components/Modal'; import Button from 'ui/Button'; @@ -16,12 +17,15 @@ const SaveRequestsModal = ({ onClose }) => { const MAX_UNSAVED_ITEMS_TO_SHOW = 5; const collections = useSelector((state) => state.collections.collections); const tabs = useSelector((state) => state.tabs.tabs); + const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments); + const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft); const dispatch = useDispatch(); const allDrafts = useMemo(() => { const requestDrafts = []; const collectionDrafts = []; const folderDrafts = []; + const environmentDrafts = []; const tabsByCollection = groupBy(tabs, (t) => t.collectionUid); Object.keys(tabsByCollection).forEach((collectionUid) => { @@ -36,6 +40,21 @@ const SaveRequestsModal = ({ onClose }) => { }); } + // Check for collection environment draft + if (collection.environmentsDraft) { + const { environmentUid, variables } = collection.environmentsDraft; + const environment = findEnvironmentInCollection(collection, environmentUid); + if (environment && variables) { + environmentDrafts.push({ + type: 'collection-environment', + name: environment.name, + environmentUid, + variables, + collectionUid: collectionUid + }); + } + } + // Check for request and folder drafts const items = flattenItems(collection.items); @@ -62,8 +81,22 @@ const SaveRequestsModal = ({ onClose }) => { } }); - return [...collectionDrafts, ...folderDrafts, ...requestDrafts]; - }, [collections, tabs]); + // Check for global environment draft + if (globalEnvironmentDraft) { + const { environmentUid, variables } = globalEnvironmentDraft; + const environment = globalEnvironments?.find((env) => env.uid === environmentUid); + if (environment && variables) { + environmentDrafts.push({ + type: 'global-environment', + name: environment.name, + environmentUid, + variables + }); + } + } + + return [...collectionDrafts, ...folderDrafts, ...environmentDrafts, ...requestDrafts]; + }, [collections, tabs, globalEnvironments, globalEnvironmentDraft]); const totalDraftsCount = allDrafts.length; @@ -84,6 +117,8 @@ const SaveRequestsModal = ({ onClose }) => { const collectionDrafts = allDrafts.filter((d) => d.type === 'collection'); const folderDrafts = allDrafts.filter((d) => d.type === 'folder'); const requestDrafts = allDrafts.filter((d) => d.type === 'request'); + const collectionEnvironmentDrafts = allDrafts.filter((d) => d.type === 'collection-environment'); + const globalEnvironmentDrafts = allDrafts.filter((d) => d.type === 'global-environment'); // Save all collection drafts if (collectionDrafts.length > 0) { @@ -100,6 +135,16 @@ const SaveRequestsModal = ({ onClose }) => { await dispatch(saveMultipleRequests(requestDrafts)); } + // Save all collection environment drafts + for (const draft of collectionEnvironmentDrafts) { + await dispatch(saveEnvironment(draft.variables, draft.environmentUid, draft.collectionUid)); + } + + // Save all global environment drafts + for (const draft of globalEnvironmentDrafts) { + await dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid })); + } + dispatch(completeQuitFlow()); onClose(); } catch (error) { @@ -134,12 +179,23 @@ const SaveRequestsModal = ({ onClose }) => {
      {allDrafts.slice(0, MAX_UNSAVED_ITEMS_TO_SHOW).map((item, index) => { - const prefix - = item.type === 'collection' - ? 'Collection: ' - : item.type === 'folder' - ? 'Folder: ' - : 'Request: '; + let prefix; + switch (item.type) { + case 'collection': + prefix = 'Collection: '; + break; + case 'folder': + prefix = 'Folder: '; + break; + case 'collection-environment': + prefix = 'Collection Environment: '; + break; + case 'global-environment': + prefix = 'Global Environment: '; + break; + default: + prefix = 'Request: '; + } return (
    • {prefix} diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index da9c55bf6..096e13244 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -15,6 +15,7 @@ import { collectionUnlinkEnvFileEvent, collectionUnlinkFileEvent, processEnvUpdateEvent, + workspaceEnvUpdateEvent, requestCancelled, runFolderEvent, runRequestEvent, @@ -23,6 +24,7 @@ import { } from 'providers/ReduxStore/slices/collections'; import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions'; import { workspaceOpenedEvent, workspaceConfigUpdatedEvent } from 'providers/ReduxStore/slices/workspaces/actions'; +import { workspaceDotEnvUpdateEvent } from 'providers/ReduxStore/slices/workspaces'; import toast from 'react-hot-toast'; import { useDispatch } from 'react-redux'; import { isElectron } from 'utils/common/platform'; @@ -214,6 +216,11 @@ const useIpcEvents = () => { dispatch(processEnvUpdateEvent(val)); }); + const removeWorkspaceDotEnvUpdatesListener = ipcRenderer.on('main:workspace-dotenv-update', (val) => { + dispatch(workspaceDotEnvUpdateEvent(val)); + dispatch(workspaceEnvUpdateEvent({ processEnvVariables: val.processEnvVariables })); + }); + const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => { console[val.type](...val.args); dispatch(addLog({ @@ -293,6 +300,7 @@ const useIpcEvents = () => { removeRunFolderEventListener(); removeRunRequestEventListener(); removeProcessEnvUpdatesListener(); + removeWorkspaceDotEnvUpdatesListener(); removeConsoleLogListener(); removeConfigUpdatesListener(); removeShowPreferencesListener(); diff --git a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js index 74aa2970a..291476a82 100644 --- a/packages/bruno-app/src/providers/Hotkeys/keyMappings.js +++ b/packages/bruno-app/src/providers/Hotkeys/keyMappings.js @@ -32,7 +32,10 @@ const KeyMapping = { name: 'Move Tab Right' }, closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' }, - collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' } + collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' }, + zoomIn: { mac: 'command+=', windows: 'ctrl+=', name: 'Zoom In' }, + zoomOut: { mac: 'command+-', windows: 'ctrl+-', name: 'Zoom Out' }, + resetZoom: { mac: 'command+0', windows: 'ctrl+0', name: 'Reset Zoom' } }; /** diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js index ad3312b54..1357c95b2 100644 --- a/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js +++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/autosave/middleware.js @@ -1,4 +1,5 @@ -import { saveRequest, saveCollectionSettings, saveFolderRoot } from '../../slices/collections/actions'; +import { saveRequest, saveCollectionSettings, saveFolderRoot, saveEnvironment } from '../../slices/collections/actions'; +import { saveGlobalEnvironment } from '../../slices/global-environments'; import { flattenItems, isItemARequest, isItemAFolder } from 'utils/collections'; const actionsToIntercept = [ @@ -46,6 +47,11 @@ const actionsToIntercept = [ 'collections/updateRequestDocs', 'collections/runRequestEvent', 'collections/updateCollectionPresets', + 'collections/setRequestVars', + 'collections/setRequestAssertions', + 'collections/updateItemSettings', + 'collections/addRequestTag', + 'collections/deleteRequestTag', // Folder-level actions 'collections/addFolderHeader', @@ -80,7 +86,11 @@ const actionsToIntercept = [ 'collections/updateCollectionDocs', 'collections/updateCollectionClientCertificates', 'collections/updateCollectionProtobuf', - 'collections/updateCollectionProxy' + 'collections/updateCollectionProxy', + + // Environment draft actions + 'collections/setEnvironmentsDraft', + 'globalEnvironments/setGlobalEnvironmentDraft' ]; // Simple object to track pending save timers @@ -100,7 +110,8 @@ const scheduleAutoSave = (key, save, interval) => { // Helper to find and schedule saves for all existing drafts const saveExistingDrafts = (dispatch, getState, interval) => { - const collections = getState().collections.collections; + const state = getState(); + const collections = state.collections.collections; collections.forEach((collection) => { // Check collection-level draft @@ -109,6 +120,15 @@ const saveExistingDrafts = (dispatch, getState, interval) => { scheduleAutoSave(key, () => dispatch(saveCollectionSettings(collection.uid, null, true)), interval); } + // Check collection environment drafts + if (collection.environmentsDraft) { + const { environmentUid, variables } = collection.environmentsDraft; + if (environmentUid && variables) { + const key = `environment-${collection.uid}-${environmentUid}`; + scheduleAutoSave(key, () => dispatch(saveEnvironment(variables, environmentUid, collection.uid)), interval); + } + } + // Check all items (requests and folders) for drafts const allItems = flattenItems(collection.items); allItems.forEach((item) => { @@ -123,6 +143,77 @@ const saveExistingDrafts = (dispatch, getState, interval) => { } }); }); + + // Check global environment drafts + const globalEnvironmentDraft = state.globalEnvironments?.globalEnvironmentDraft; + if (globalEnvironmentDraft) { + const { environmentUid, variables } = globalEnvironmentDraft; + if (environmentUid && variables) { + const key = `global-environment-${environmentUid}`; + scheduleAutoSave(key, () => dispatch(saveGlobalEnvironment({ variables, environmentUid })), interval); + } + } +}; + +// Helper to determine entity type and create save handler +const determineSaveHandler = (actionType, payload, dispatch, getState) => { + const { itemUid, folderUid, collectionUid, environmentUid } = payload; + + // Handle environment drafts + if (actionType === 'collections/setEnvironmentsDraft') { + if (!environmentUid || !collectionUid) return null; + return { + key: `environment-${collectionUid}-${environmentUid}`, + save: () => { + const state = getState(); + const collection = state.collections.collections.find((c) => c.uid === collectionUid); + const draft = collection?.environmentsDraft; + if (draft?.environmentUid === environmentUid && draft?.variables) { + dispatch(saveEnvironment(draft.variables, environmentUid, collectionUid)); + } + } + }; + } + + if (actionType === 'globalEnvironments/setGlobalEnvironmentDraft') { + if (!environmentUid) return null; + return { + key: `global-environment-${environmentUid}`, + save: () => { + const state = getState(); + const draft = state.globalEnvironments?.globalEnvironmentDraft; + if (draft?.environmentUid === environmentUid && draft?.variables) { + dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid })); + } + } + }; + } + + // Handle folder actions + if (folderUid) { + return { + key: `folder-${folderUid}`, + save: () => dispatch(saveFolderRoot(collectionUid, folderUid, true)) + }; + } + + // Handle request actions + if (itemUid) { + return { + key: `request-${itemUid}`, + save: () => dispatch(saveRequest(itemUid, collectionUid, true)) + }; + } + + // Handle collection-level changes + if (collectionUid) { + return { + key: `collection-${collectionUid}`, + save: () => dispatch(saveCollectionSettings(collectionUid, null, true)) + }; + } + + return null; }; export const autosaveMiddleware = ({ dispatch, getState }) => (next) => (action) => { @@ -150,28 +241,9 @@ export const autosaveMiddleware = ({ dispatch, getState }) => (next) => (action) // Only handle actions that create dirty state if (!actionsToIntercept.includes(action.type)) return result; - const { itemUid, folderUid, collectionUid } = action.payload; - const interval = autoSave.interval; - - // Determine what to save based on what IDs are present - let key, save; - - if (itemUid) { - // Request change - key = `request-${itemUid}`; - save = () => dispatch(saveRequest(itemUid, collectionUid, true)); - } else if (folderUid) { - // Folder change - key = `folder-${folderUid}`; - save = () => dispatch(saveFolderRoot(collectionUid, folderUid, true)); - } else if (collectionUid) { - // Collection change - key = `collection-${collectionUid}`; - save = () => dispatch(saveCollectionSettings(collectionUid, null, true)); - } - - if (key && save) { - scheduleAutoSave(key, save, interval); + const handler = determineSaveHandler(action.type, action.payload, dispatch, getState); + if (handler) { + scheduleAutoSave(handler.key, handler.save, autoSave.interval); } return result; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index e5748b3ac..ec1f071eb 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -35,6 +35,7 @@ import { sortCollections as _sortCollections, updateCollectionMountStatus, moveCollection, + workspaceEnvUpdateEvent, requestCancelled, resetRunResults, responseReceived, @@ -816,7 +817,6 @@ export const renameItem return ipcRenderer .invoke('renderer:rename-item-filename', { oldPath: item.pathname, newPath, newName, newFilename, collectionPathname: collection.pathname }) .catch((err) => { - toast.error('Failed to rename the file'); console.error(err); throw new Error('Failed to rename the file'); }); @@ -2255,6 +2255,7 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge return new Promise((resolve, reject) => { const state = getState(); const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid); + const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables || {}; // Check if collection already exists in Redux state const existingCollection = state.collections.collections.find( @@ -2296,6 +2297,8 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge }); } + dispatch(workspaceEnvUpdateEvent({ processEnvVariables: workspaceProcessEnvVariables })); + resolve(); return; } @@ -2308,6 +2311,7 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge pathname: pathname, items: [], runtimeVariables: {}, + workspaceProcessEnvVariables, brunoConfig: brunoConfig }; @@ -2326,6 +2330,9 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge ); if (currentWorkspace) { + // Set collection-workspace mapping for workspace env vars + ipcRenderer.invoke('renderer:set-collection-workspace', uid, currentWorkspace.pathname); + const alreadyInWorkspace = currentWorkspace.collections?.some( (c) => normalizePath(c.path) === normalizePath(pathname) ); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 68c0e12ad..7fe15aa08 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -405,6 +405,12 @@ export const collectionsSlice = createSlice({ collection.processEnvVariables = processEnvVariables; } }, + workspaceEnvUpdateEvent: (state, action) => { + const { processEnvVariables } = action.payload; + state.collections.forEach((collection) => { + collection.workspaceProcessEnvVariables = processEnvVariables; + }); + }, requestCancelled: (state, action) => { const { itemUid, collectionUid, seq, timestamp } = action.payload; const collection = findCollectionByUid(state.collections, collectionUid); @@ -2332,7 +2338,7 @@ export const collectionsSlice = createSlice({ const collection = findCollectionByUid(state.collections, action.payload.collectionUid); if (!collection) return; - const folder = collection ? findItemInCollection(collection, action.payload.itemUid) : null; + const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null; if (!folder) return; if (folder) { @@ -3148,13 +3154,13 @@ export const collectionsSlice = createSlice({ const item = findItemInCollection(collection, itemUid); if (data.data) { item.response.data ||= []; - item.response.data = [{ + item.response.data.push({ type: 'incoming', seq, message: data.data, messageHexdump: hexdump(data.data), timestamp: timestamp || Date.now() - }].concat(item.response.data); + }); } if (item.response.dataBuffer && item.response.dataBuffer.length && data.dataBuffer) { item.response.dataBuffer = Buffer.concat([Buffer.from(item.response.dataBuffer), Buffer.from(data.dataBuffer)]); @@ -3424,6 +3430,7 @@ export const { cloneItem, scriptEnvironmentUpdateEvent, processEnvUpdateEvent, + workspaceEnvUpdateEvent, requestCancelled, responseReceived, runGrpcRequestEvent, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index f1f35298a..1f439acbd 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -169,8 +169,8 @@ export const removeCollectionFromWorkspaceAction = (workspaceUid, collectionPath }; const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => { - const openCollectionsFunction = (collectionPaths, workspaceId) => { - return dispatch(openMultipleCollections(collectionPaths, { workspaceId })); + const openCollectionsFunction = (collectionPaths, workspacePath) => { + return dispatch(openMultipleCollections(collectionPaths, { workspacePath })); }; try { @@ -418,7 +418,7 @@ export const workspaceConfigUpdatedEvent = (workspacePath, workspaceUid, workspa if (uniqueNewCollectionPaths.length > 0) { try { - await dispatch(openMultipleCollections(uniqueNewCollectionPaths, { workspaceId: workspace.pathname })); + await dispatch(openMultipleCollections(uniqueNewCollectionPaths, { workspacePath: workspace.pathname })); } catch (error) { } } diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js index e25e3920a..d8bb63f1f 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js @@ -76,6 +76,14 @@ export const workspacesSlice = createSlice({ if (workspace) { workspace.loadingState = loadingState; } + }, + + workspaceDotEnvUpdateEvent: (state, action) => { + const { workspaceUid, processEnvVariables } = action.payload; + const workspace = state.workspaces.find((w) => w.uid === workspaceUid); + if (workspace) { + workspace.processEnvVariables = processEnvVariables; + } } } }); @@ -87,7 +95,8 @@ export const { updateWorkspace, addCollectionToWorkspace, removeCollectionFromWorkspace, - updateWorkspaceLoadingState + updateWorkspaceLoadingState, + workspaceDotEnvUpdateEvent } = workspacesSlice.actions; export default workspacesSlice.reducer; diff --git a/packages/bruno-app/src/utils/codegenerator/auth.js b/packages/bruno-app/src/utils/codegenerator/auth.js index ad28970eb..eefb82cfe 100644 --- a/packages/bruno-app/src/utils/codegenerator/auth.js +++ b/packages/bruno-app/src/utils/codegenerator/auth.js @@ -1,6 +1,9 @@ import get from 'lodash/get'; +import { find } from 'lodash'; +import { interpolate } from '@usebruno/common'; +import { getAllVariables } from 'utils/collections/index'; -export const getAuthHeaders = (collectionRootAuth, requestAuth) => { +export const getAuthHeaders = (collectionRootAuth, requestAuth, collection = null, item = null) => { // Discovered edge case where code generation fails when you create a collection which has not been saved yet: // Collection auth therefore null, and request inherits from collection, therefore it is also null // TypeError: Cannot read properties of undefined (reading 'mode') @@ -48,6 +51,72 @@ export const getAuthHeaders = (collectionRootAuth, requestAuth) => { ]; } return []; + case 'oauth2': { + const oauth2Config = get(auth, 'oauth2', {}); + const tokenPlacement = get(oauth2Config, 'tokenPlacement', 'header'); + const tokenHeaderPrefix = get(oauth2Config, 'tokenHeaderPrefix', 'Bearer'); + + // Only add header if token placement is 'header' + if (tokenPlacement === 'header') { + // Try to get access token from persisted credentials + let accessToken = ''; + + if (collection && item) { + try { + const grantType = get(oauth2Config, 'grantType', ''); + // For implicit grant type, use authorizationUrl; for others, use accessTokenUrl + const urlToLookup = grantType === 'implicit' + ? get(oauth2Config, 'authorizationUrl', '') + : get(oauth2Config, 'accessTokenUrl', ''); + const credentialsId = get(oauth2Config, 'credentialsId', 'credentials'); + const collectionUid = get(collection, 'uid'); + + if (urlToLookup && collectionUid) { + // Interpolate the URL with variables + const variables = getAllVariables(collection, item); + const interpolatedUrl = interpolate(urlToLookup, variables); + + // Look up stored credentials + const credentialsData = find( + collection?.oauth2Credentials || [], + (creds) => + creds?.url === interpolatedUrl + && creds?.collectionUid === collectionUid + && creds?.credentialsId === credentialsId + ); + + if (credentialsData?.credentials?.access_token) { + accessToken = credentialsData.credentials.access_token; + } + } + } catch (error) { + console.error('Error retrieving OAuth2 access token:', error); + // Fall back to placeholder if lookup fails + } + } + + // Build the authorization header value + // If tokenHeaderPrefix is empty, just use the token + // Otherwise, use the format: "prefix token" + // Always trim the final result for consistent formatting + const headerValue = ( + tokenHeaderPrefix + ? `${tokenHeaderPrefix} ${accessToken}` + : accessToken + ).trim(); + + return [ + { + enabled: true, + name: 'Authorization', + value: headerValue + } + ]; + } + // If tokenPlacement is 'url', this function does not add any auth headers; + // token placement in the URL/query params must be handled elsewhere. + return []; + } default: return []; } diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.js b/packages/bruno-app/src/utils/codemirror/autocomplete.js index 8776d7cc3..6db1fd370 100644 --- a/packages/bruno-app/src/utils/codemirror/autocomplete.js +++ b/packages/bruno-app/src/utils/codemirror/autocomplete.js @@ -80,6 +80,7 @@ const STATIC_API_HINTS = { 'bru.getTestResults()', 'bru.sleep(ms)', 'bru.getCollectionName()', + 'bru.isSafeMode()', 'bru.getGlobalEnvVar(key)', 'bru.setGlobalEnvVar(key, value)', 'bru.runner', @@ -298,9 +299,14 @@ const calculateWordReplacementPositions = (cursor, start, end, word) => { * @returns {string} The determined context */ const determineWordContext = (word) => { - if (word.startsWith('req') || word.startsWith('res') || word.startsWith('bru')) { + const isApiHint = Object.keys(STATIC_API_HINTS).some( + (apiRoot) => apiRoot.toLowerCase().startsWith(word.toLowerCase()) || word.toLowerCase().startsWith(apiRoot.toLowerCase()) + ); + + if (isApiHint) { return 'api'; } + return 'anyword'; }; @@ -517,6 +523,34 @@ const createStandardHintList = (filteredHints, from, to) => { }; }; +/** + * Show root-level API hints when the editor is empty + * @param {Object} cm - CodeMirror instance + * @param {string[]} showHintsFor - Array of hint types to show (e.g., ['req', 'res', 'bru']) + * @returns {boolean} True if hints were shown, false otherwise + */ +export const showRootHints = (cm, showHintsFor = []) => { + const wordInfo = getCurrentWordWithContext(cm); + // If user is currently typing a word, let handleKeyupForAutocomplete + // handle it instead of showing root hints. + if (wordInfo) { + return false; + } + + const hints = Object.keys(STATIC_API_HINTS).filter((rootHint) => showHintsFor.includes(rootHint)); + + if (hints.length === 0) return false; + + const cursor = cm.getCursor(); + const hintList = createStandardHintList(hints, cursor, cursor); + + cm.showHint({ + hint: () => hintList, + completeSingle: false + }); + return true; +}; + /** * Bruno AutoComplete Helper - Main function with context awareness * @param {Object} cm - CodeMirror instance @@ -628,7 +662,8 @@ const handleKeyupForAutocomplete = (cm, event, options) => { const hints = getAutoCompleteHints(cm, allVariables, anywordAutocompleteHints, options); if (!hints) { - if (cm.state.completionActive) { + const wordInfo = getCurrentWordWithContext(cm); + if (cm.state.completionActive && wordInfo) { cm.state.completionActive.close(); } return; diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js b/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js index 124fc0cb0..16e5a2882 100644 --- a/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js +++ b/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js @@ -482,7 +482,7 @@ describe('Bruno Autocomplete', () => { mockedCodemirror.state.completionActive = mockCompletion; mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 0 }); - mockedCodemirror.getLine.mockReturnValue(' '); + mockedCodemirror.getLine.mockReturnValue('req.bodyy'); mockedCodemirror.getRange.mockReturnValue(''); const mockEvent = { key: 'a' }; diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js index d26a1eb5e..2a4b32659 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js @@ -6,7 +6,7 @@ * LICENSE file at https://github.com/graphql/codemirror-graphql/tree/v0.8.3 */ -import { interpolate, mockDataFunctions } from '@usebruno/common'; +import { interpolate, mockDataFunctions, timeBasedDynamicVars } from '@usebruno/common'; import { getVariableScope, isVariableSecret, getAllVariables } from 'utils/collections'; import { updateVariableInScope } from 'providers/ReduxStore/slices/collections/actions'; import store from 'providers/ReduxStore'; @@ -194,11 +194,13 @@ export const renderVarInfo = (token, options) => { } else if (variableName.startsWith('$')) { const fakerKeyword = variableName.substring(1); // Remove the $ prefix const fakerFunction = mockDataFunctions[fakerKeyword]; + const isTimeBased = timeBasedDynamicVars.has(fakerKeyword); scopeInfo = { type: 'dynamic', value: '', data: null, - isValidFakerVariable: !!fakerFunction + isValidDynamicVariable: !!fakerFunction, + isTimeBased }; } else if (variableName.startsWith('process.env.')) { // Check if this is a process.env variable (starts with "process.env.") @@ -300,8 +302,8 @@ export const renderVarInfo = (token, options) => { return into; } - // Show warning for invalid faker variable (starts with $ but not a valid faker function) - if (scopeInfo.type === 'dynamic' && !scopeInfo.isValidFakerVariable) { + // Show warning for invalid dynamic variable (starts with $ but not a valid dynamic function) + if (scopeInfo.type === 'dynamic' && !scopeInfo.isValidDynamicVariable) { const warningNote = document.createElement('div'); warningNote.className = 'var-warning-note'; warningNote.textContent = `Unknown dynamic variable "${variableName}". Check the variable name.`; @@ -309,11 +311,13 @@ export const renderVarInfo = (token, options) => { return into; } - // For valid dynamic variables, just show the read-only note (no value display since it's generated at runtime) - if (scopeInfo.type === 'dynamic' && scopeInfo.isValidFakerVariable) { + // For valid dynamic variables, show appropriate read-only note based on type + if (scopeInfo.type === 'dynamic' && scopeInfo.isValidDynamicVariable) { const readOnlyNote = document.createElement('div'); readOnlyNote.className = 'var-readonly-note'; - readOnlyNote.textContent = 'Generates random value on each request'; + readOnlyNote.textContent = scopeInfo.isTimeBased + ? 'Generates current timestamp on each request' + : 'Generates random value on each request'; into.appendChild(readOnlyNote); return into; } @@ -678,54 +682,89 @@ if (!SERVER_RENDERED) { const state = cm.state.brunoVarInfo; const options = state.options; - let token = cm.getTokenAt(pos, true); - if (token) { - const line = cm.getLine(pos.line); + // Get the full line text where the hover happened + const line = cm.getLine(pos.line); + if (!line) return; - // Find the opening {{ before the cursor - let start = token.start; - while (start > 0 && !line.substring(start - 2, start).includes('{{')) { - // Stop if we encounter }} - we've gone past the start of our variable - if (line.substring(start - 2, start) === '}}') { - break; - } - start--; - } - if (line.substring(start - 2, start) === '{{') { - start = start - 2; + // If the line doesn't even contain both braces, no need to run loops + if (!line.includes('{{') || !line.includes('}}')) { + return; + } + + // lastIndexOf searches backward from the cursor indexOf searches forward + if (line.lastIndexOf('{{', pos.ch) === -1 || line.indexOf('}}', pos.ch) === -1) { + return; + } + let start = pos.ch; + let end = pos.ch; + + // ---------- Find opening '{{' to the LEFT ---------- + while (start > 0) { + const leftTwo = line.substring(start - 2, start); + + // If we find opening braces, stop + if (leftTwo === '{{') { + start -= 2; + break; } - // Find the closing }} after the cursor - let end = token.end; - while (end < line.length && !line.substring(end, end + 2).includes('}}')) { - // Stop if we encounter {{ - we've gone past the end of our variable - if (line.substring(end, end + 2) === '{{') { - break; - } - end++; - } - if (line.substring(end, end + 2) === '}}') { - end = end + 2; + // If we cross a closing braces before finding '{{', we're not inside a variable + if (leftTwo === '}}') { + return; } - // Extract the full variable string including {{ and }} - const fullVariableString = line.substring(start, end); + start--; + } - // Only use the expanded string if it looks like a complete variable - if (fullVariableString.startsWith('{{') && fullVariableString.endsWith('}}')) { - token = { - ...token, - string: fullVariableString, - start: start, - end: end - }; + // If we reached the start of the line and didn't match '{{', return + if (start < 0 || line.substring(start, start + 2) !== '{{') { + return; + } + + // ---------- Find closing '}}' to the RIGHT ---------- + while (end < line.length) { + const rightTwo = line.substring(end, end + 2); + + // If we find closing braces, stop + if (rightTwo === '}}') { + end += 2; + break; } - const brunoVarInfo = renderVarInfo(token, options); - if (brunoVarInfo) { - showPopup(cm, box, brunoVarInfo); + // If we hit another '{{' before a '}}', then this isn't a valid enclosing pair + if (rightTwo === '{{') { + return; } + + end++; + } + // If we reached end-of-line without finding '}}', return + if (end > line.length || line.substring(end - 2, end) !== '}}') { + return; + } + + const fullVariableString = line.substring(start, end); + + // Basic validation to ensure it's a non-empty variable + if (!fullVariableString.startsWith('{{') || !fullVariableString.endsWith('}}')) { + return; + } + + // Prevent tooltips for empty variables like {{ }} + const inner = fullVariableString.slice(2, -2).trim(); + if (!inner) return; + + // Build a token object compatible with renderVarInfo + const token = { + string: fullVariableString, + start: start, + end: end + }; + + const brunoVarInfo = renderVarInfo(token, options); + if (brunoVarInfo) { + showPopup(cm, box, brunoVarInfo); } } diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js index 5595b826d..412bd715d 100644 --- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js +++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.spec.js @@ -8,8 +8,11 @@ jest.mock('@usebruno/common', () => ({ randomFirstName: jest.fn(() => 'John'), randomLastName: jest.fn(() => 'Doe'), randomEmail: jest.fn(() => 'john.doe@example.com'), - randomUUID: jest.fn(() => '123e4567-e89b-12d3-a456-426614174000') - } + randomUUID: jest.fn(() => '123e4567-e89b-12d3-a456-426614174000'), + timestamp: jest.fn(() => '1704067200'), + isoTimestamp: jest.fn(() => '2024-01-01T00:00:00.000Z') + }, + timeBasedDynamicVars: new Set(['timestamp', 'isoTimestamp']) })); jest.mock('providers/ReduxStore', () => ({ @@ -467,6 +470,29 @@ describe('renderVarInfo', () => { expect(warningNote).not.toBeNull(); expect(warningNote.textContent).toContain('Unknown dynamic variable'); }); + + it('should show time-based note for $timestamp variable', () => { + const { readOnlyNote, scopeBadge } = setupDynamicRender('$timestamp'); + + expect(scopeBadge.textContent).toBe('Dynamic'); + expect(readOnlyNote).not.toBeNull(); + expect(readOnlyNote.textContent).toBe('Generates current timestamp on each request'); + }); + + it('should show time-based note for $isoTimestamp variable', () => { + const { readOnlyNote, scopeBadge } = setupDynamicRender('$isoTimestamp'); + + expect(scopeBadge.textContent).toBe('Dynamic'); + expect(readOnlyNote).not.toBeNull(); + expect(readOnlyNote.textContent).toBe('Generates current timestamp on each request'); + }); + + it('should show random note for non-time-based dynamic variables', () => { + const { readOnlyNote } = setupDynamicRender('$randomEmail'); + + expect(readOnlyNote).not.toBeNull(); + expect(readOnlyNote.textContent).toBe('Generates random value on each request'); + }); }); describe('OAuth2 variable rendering', () => { diff --git a/packages/bruno-app/src/utils/codemirror/linkAware.js b/packages/bruno-app/src/utils/codemirror/linkAware.js index 3849abeeb..501cfd7b1 100644 --- a/packages/bruno-app/src/utils/codemirror/linkAware.js +++ b/packages/bruno-app/src/utils/codemirror/linkAware.js @@ -59,7 +59,18 @@ function markUrls(editor, linkify, linkClass, linkHint) { const matches = linkify.match(lineContent); if (!matches) continue; + const variablePatterns = []; + const variablePattern = /\{\{[^}]*\}\}/g; + let varMatch; + while ((varMatch = variablePattern.exec(lineContent)) !== null) { + variablePatterns.push({ start: varMatch.index, end: varMatch.index + varMatch[0].length }); + } matches.forEach(({ index, lastIndex, url }) => { + const isInVariable = variablePatterns.some( + ({ start, end }) => index < end && lastIndex > start + ); + if (isInVariable) return; + try { editor.markText( { line: lineNum, ch: index }, diff --git a/packages/bruno-app/src/utils/collections/export.js b/packages/bruno-app/src/utils/collections/export.js index 669446a29..43b25efb2 100644 --- a/packages/bruno-app/src/utils/collections/export.js +++ b/packages/bruno-app/src/utils/collections/export.js @@ -99,6 +99,7 @@ export const exportCollection = (collection, version) => { // delete process variables delete collection.processEnvVariables; + delete collection.workspaceProcessEnvVariables; deleteUidsInItems(collection.items); deleteUidsInEnvs(collection.environments); diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 6eb2bd78d..dcad3b168 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -1174,7 +1174,14 @@ export const getAllVariables = (collection, item) => { const pathParams = getPathParams(item); const { globalEnvironmentVariables = {} } = collection; - const { processEnvVariables = {}, runtimeVariables = {}, promptVariables = {} } = collection; + const { processEnvVariables = {}, runtimeVariables = {}, promptVariables = {}, workspaceProcessEnvVariables = {} } = collection; + + // Merge workspace and collection processEnvVariables (collection takes priority) + const mergedProcessEnvVariables = { + ...workspaceProcessEnvVariables, + ...processEnvVariables + }; + const mergedVariables = { ...folderVariables, ...requestVariables, @@ -1216,7 +1223,7 @@ export const getAllVariables = (collection, item) => { maskedEnvVariables: uniqueMaskedVariables, process: { env: { - ...processEnvVariables + ...mergedProcessEnvVariables } } }; diff --git a/packages/bruno-app/src/utils/response/index.js b/packages/bruno-app/src/utils/response/index.js index 5e19bb2f2..18ab27fe3 100644 --- a/packages/bruno-app/src/utils/response/index.js +++ b/packages/bruno-app/src/utils/response/index.js @@ -92,6 +92,36 @@ const isLikelyText = (buffer) => { return (textChars / sampleSize) > 0.85; }; +/** + * Helper to detect SVG content from text buffer + * SVG files may start with XML declaration, comments, or whitespace before the { + const length = buffer.length; + if (length < 4 || buffer[0] !== 0x3C) return false; + + // Fast path: { if (bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) { return 'image/webp'; } + if (bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70 + && bytes[8] === 0x61 && bytes[9] === 0x76 && bytes[10] === 0x69 && bytes[11] === 0x66) { + return 'image/avif'; + } if (bytes[0] === 0x42 && bytes[1] === 0x4D) { return 'image/bmp'; } @@ -169,7 +203,9 @@ export const detectContentTypeFromBuffer = (buffer) => { if (bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00) { return 'image/x-icon'; } - + if (bytes[0] === 0x3C && bytes[1] === 0x73 && bytes[2] === 0x76 && bytes[3] === 0x67 && bytes[4] === 0x20) { + return 'image/svg+xml'; + } // PDF if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) { return 'application/pdf'; @@ -237,6 +273,10 @@ export const detectContentTypeFromBase64 = (base64) => { // 2. If not binary → decode up to 512 bytes for text detection const textHead = decodeBase64Head(base64, 512); + if (isSvgContent(textHead)) { + return 'image/svg+xml'; + } + if (isLikelyText(textHead)) return 'text/plain'; return null; diff --git a/packages/bruno-cli/package.json b/packages/bruno-cli/package.json index 806f43152..4c40d86ef 100644 --- a/packages/bruno-cli/package.json +++ b/packages/bruno-cli/package.json @@ -66,7 +66,7 @@ "iconv-lite": "^0.6.3", "js-yaml": "^4.1.1", "lodash": "^4.17.21", - "qs": "^6.11.0", + "qs": "^6.14.1", "socks-proxy-agent": "^8.0.2", "xmlbuilder": "^15.1.1", "yargs": "^17.6.2" diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index e0cb460e7..78b9dc259 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -17,6 +17,7 @@ const { parseDotEnv, parseEnvironment } = require('@usebruno/filestore'); const constants = require('../constants'); const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG } = require('../utils/collection'); const { hasExecutableTestInScript } = require('../utils/request'); +const { createSkippedFileResults } = require('../utils/run'); const command = 'run [paths...]'; const desc = 'Run one or more requests/folders'; @@ -353,50 +354,63 @@ const handler = async function (argv) { const runtimeVariables = {}; let envVars = {}; - 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); - } + // Helper to load environment variables from a file + const loadEnvFromFile = (filePath, nameOverride) => { + const fileExt = path.extname(filePath).toLowerCase(); + let result = {}; - if (envFile || env) { - const envExt = FORMAT_CONFIG[collection.format].ext; - const envFilePath = envFile - ? path.resolve(collectionPath, envFile) - : path.join(collectionPath, 'environments', `${env}${envExt}`); - - const envFileExists = await exists(envFilePath); - if (!envFileExists) { - const errorPath = envFile || `environments/${env}${envExt}`; - console.error(chalk.red(`Environment file not found: `) + chalk.dim(errorPath)); - - process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND); + if (fileExt === '.json') { + const content = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(content); + const normalizedEnv = parseEnvironmentJson(parsed); + result = getEnvVars(normalizedEnv); + const rawName = normalizedEnv?.name; + const trimmedName = typeof rawName === 'string' ? rawName.trim() : ''; + result.__name__ = trimmedName || path.basename(filePath, '.json'); + } else if (fileExt === '.yml' || fileExt === '.yaml') { + const content = fs.readFileSync(filePath, 'utf8'); + const envJson = parseEnvironment(content, { format: 'yml' }); + result = getEnvVars(envJson); + result.__name__ = nameOverride || path.basename(filePath, fileExt); + } else { + const content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); + const envJson = parseEnvironment(content); + result = getEnvVars(envJson); + result.__name__ = nameOverride || path.basename(filePath, '.bru'); } - const fileExt = path.extname(envFilePath).toLowerCase(); - if (fileExt === '.json') { - let envJsonContent; - try { - envJsonContent = fs.readFileSync(envFilePath, 'utf8'); - const parsed = JSON.parse(envJsonContent); - const normalizedEnv = parseEnvironmentJson(parsed); - envVars = getEnvVars(normalizedEnv); - const rawName = normalizedEnv?.name; - const trimmedName = typeof rawName === 'string' ? rawName.trim() : ''; - envVars.__name__ = trimmedName || path.basename(envFilePath, '.json'); - } catch (err) { - console.error(chalk.red(`Failed to parse Environment JSON: ${err.message}`)); - process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE); - } - } else if (fileExt === '.yml' || fileExt === '.yaml') { - const envContent = fs.readFileSync(envFilePath, 'utf8'); - const envJson = parseEnvironment(envContent, { format: 'yml' }); - envVars = getEnvVars(envJson); - envVars.__name__ = envFile ? path.basename(envFilePath, fileExt) : env; - } else { - const envBruContent = fs.readFileSync(envFilePath, 'utf8').replace(/\r\n/g, '\n'); - const envJson = parseEnvironment(envBruContent); - envVars = getEnvVars(envJson); - envVars.__name__ = envFile ? path.basename(envFilePath, '.bru') : env; + return result; + }; + + // Load --env-file if provided + if (envFile) { + const envFilePath = path.resolve(collectionPath, envFile); + if (!(await exists(envFilePath))) { + console.error(chalk.red(`Environment file not found: `) + chalk.dim(envFile)); + process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND); + } + try { + envVars = loadEnvFromFile(envFilePath); + } catch (err) { + console.error(chalk.red(`Failed to parse environment file: ${err.message}`)); + process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE); + } + } + + // Load --env and merge (collection env takes precedence) + if (env) { + const envExt = FORMAT_CONFIG[collection.format].ext; + const collectionEnvFilePath = path.join(collectionPath, 'environments', `${env}${envExt}`); + if (!(await exists(collectionEnvFilePath))) { + console.error(chalk.red(`Environment file not found: `) + chalk.dim(`environments/${env}${envExt}`)); + process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND); + } + try { + const collectionEnvVars = loadEnvFromFile(collectionEnvFilePath, env); + envVars = { ...envVars, ...collectionEnvVars }; + } catch (err) { + console.error(chalk.red(`Failed to parse Environment file: ${err.message}`)); + process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE); } } @@ -661,7 +675,8 @@ const handler = async function (argv) { ...result, runDuration: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9, suitename: pathname.replace('.bru', ''), - name + name, + path: result.test?.filename || path.relative(collectionPath, pathname) }); if (reporterSkipAllHeaders) { @@ -734,6 +749,9 @@ const handler = async function (argv) { } } + const skippedFileResults = createSkippedFileResults(global.brunoSkippedFiles || [], collectionPath); + results.push(...skippedFileResults); + const summary = printRunSummary(results); const runCompletionTime = new Date().toISOString(); const totalTime = results.reduce((acc, res) => acc + res.response.responseTime, 0); diff --git a/packages/bruno-cli/src/runner/interpolate-string.js b/packages/bruno-cli/src/runner/interpolate-string.js index e210be339..20c395d71 100644 --- a/packages/bruno-cli/src/runner/interpolate-string.js +++ b/packages/bruno-cli/src/runner/interpolate-string.js @@ -1,13 +1,19 @@ const { forOwn, cloneDeep } = require('lodash'); -const { interpolate } = require('@usebruno/common'); - -const interpolateString = (str, { envVars, runtimeVariables, processEnvVars }) => { - if (!str || !str.length || typeof str !== 'string') { - return str; - } +const { interpolate, interpolateObject: interpolateObjectCommon } = require('@usebruno/common'); +const buildCombinedVars = ({ + collectionVariables, + envVars, + folderVariables, + requestVariables, + runtimeVariables, + processEnvVars +}) => { processEnvVars = processEnvVars || {}; runtimeVariables = runtimeVariables || {}; + collectionVariables = collectionVariables || {}; + folderVariables = folderVariables || {}; + requestVariables = requestVariables || {}; // we clone envVars because we don't want to modify the original object envVars = envVars ? cloneDeep(envVars) : {}; @@ -25,8 +31,11 @@ const interpolateString = (str, { envVars, runtimeVariables, processEnvVars }) = }); // runtimeVariables take precedence over envVars - const combinedVars = { + return { + ...collectionVariables, ...envVars, + ...folderVariables, + ...requestVariables, ...runtimeVariables, process: { env: { @@ -34,10 +43,26 @@ const interpolateString = (str, { envVars, runtimeVariables, processEnvVars }) = } } }; +}; +const interpolateString = (str, interpolationOptions) => { + if (!str || !str.length || typeof str !== 'string') { + return str; + } + + const combinedVars = buildCombinedVars(interpolationOptions); return interpolate(str, combinedVars); }; -module.exports = { - interpolateString +/** + * recursively interpolating all string values in a object + */ +const interpolateObject = (obj, interpolationOptions) => { + const combinedVars = buildCombinedVars(interpolationOptions); + return interpolateObjectCommon(obj, combinedVars); +}; + +module.exports = { + interpolateString, + interpolateObject }; diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index 4022152e8..5fb8de3a7 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -5,7 +5,7 @@ const fs = require('fs'); const { forOwn, isUndefined, isNull, each, extend, get, compact } = require('lodash'); const prepareRequest = require('./prepare-request'); const interpolateVars = require('./interpolate-vars'); -const { interpolateString } = require('./interpolate-string'); +const { interpolateString, interpolateObject } = require('./interpolate-string'); const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime } = require('@usebruno/js'); const { stripExtension } = require('../utils/filesystem'); const { getOptions } = require('../utils/bru'); @@ -21,8 +21,8 @@ const { getCookieStringForUrl, saveCookies } = require('../utils/cookies'); const { createFormData } = require('../utils/form-data'); const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; const { NtlmClient } = require('axios-ntlm'); -const { addDigestInterceptor } = require('@usebruno/requests'); -const { getCACertificates } = require('@usebruno/requests'); +const { addDigestInterceptor, getHttpHttpsAgents, makeAxiosInstance: makeAxiosInstanceForOauth2 } = require('@usebruno/requests'); +const { getCACertificates, transformProxyConfig } = require('@usebruno/requests'); const { getOAuth2Token } = require('../utils/oauth2'); const { encodeUrl, buildFormUrlEncodedPayload, extractPromptVariables, isFormData } = require('@usebruno/common').utils; @@ -288,25 +288,27 @@ const runSingleRequest = async function ( let proxyMode = 'off'; let proxyConfig = {}; - const collectionProxyConfig = get(brunoConfig, 'proxy', {}); - const collectionProxyEnabled = get(collectionProxyConfig, 'enabled', false); + const collectionProxyConfig = transformProxyConfig(get(brunoConfig, 'proxy', {})); + const collectionProxyDisabled = get(collectionProxyConfig, 'disabled', false); + const collectionProxyInherit = get(collectionProxyConfig, 'inherit', true); + const collectionProxyConfigData = get(collectionProxyConfig, 'config', {}); - if (noproxy) { - // If noproxy flag is set, don't use any proxy + if (noproxy || collectionProxyDisabled) { + // If noproxy flag is set or collection proxy is disabled, don't use any proxy proxyMode = 'off'; - } else if (collectionProxyEnabled === true) { - // If collection proxy is enabled, use it - proxyConfig = collectionProxyConfig; + } else if (!collectionProxyDisabled && !collectionProxyInherit) { + // Use collection-specific proxy + proxyConfig = collectionProxyConfigData; proxyMode = 'on'; - } else if (collectionProxyEnabled === 'global') { - // If collection proxy is set to 'global', use system proxy + } else if (!collectionProxyDisabled && collectionProxyInherit) { + // Inherit from system proxy const { http_proxy, https_proxy } = getSystemProxyEnvVariables(); if (http_proxy?.length || https_proxy?.length) { proxyMode = 'system'; } - } else { - proxyMode = 'off'; + // else: no system proxy available, proxyMode stays 'off' } + // else: collection proxy is disabled, proxyMode stays 'off' if (proxyMode === 'on') { const shouldProxy = shouldUseProxy(request.url, get(proxyConfig, 'bypassProxy', '')); @@ -314,7 +316,7 @@ const runSingleRequest = async function ( const proxyProtocol = interpolateString(get(proxyConfig, 'protocol'), interpolationOptions); const proxyHostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions); const proxyPort = interpolateString(get(proxyConfig, 'port'), interpolationOptions); - const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false); + const proxyAuthEnabled = !get(proxyConfig, 'auth.disabled', false); const socksEnabled = proxyProtocol.includes('socks'); let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`; let proxyUri; @@ -458,7 +460,54 @@ const runSingleRequest = async function ( // Handle OAuth2 authentication if (request.oauth2) { try { - const token = await getOAuth2Token(request.oauth2); + // Prepare interpolation options with all available variables + const oauth2InterpolationOptions = { + envVars: envVariables, + runtimeVariables, + processEnvVars, + collectionVariables: request.collectionVariables || {}, + folderVariables: request.folderVariables || {}, + requestVariables: request.requestVariables || {} + }; + + const accessTokenUrl = request.oauth2.accessTokenUrl ? interpolateString(request.oauth2.accessTokenUrl, oauth2InterpolationOptions) : undefined; + const refreshTokenUrl = request.oauth2.refreshTokenUrl ? interpolateString(request.oauth2.refreshTokenUrl, oauth2InterpolationOptions) : undefined; + const oauth2RequestUrl = accessTokenUrl || refreshTokenUrl; + + let token; + if (oauth2RequestUrl) { + const tlsOptions = { + noproxy: options.noproxy, + shouldVerifyTls: !insecure, + shouldUseCustomCaCertificate: !!options['cacert'], + customCaCertificateFilePath: options['cacert'], + shouldKeepDefaultCaCertificates: !options['ignoreTruststore'] + }; + + const clientCertificates = get(brunoConfig, 'clientCertificates'); + const proxyConfig = get(brunoConfig, 'proxy'); + const interpolatedClientCertificates = clientCertificates ? interpolateObject(clientCertificates, oauth2InterpolationOptions) : undefined; + const interpolatedProxyConfig = proxyConfig ? interpolateObject(proxyConfig, oauth2InterpolationOptions) : undefined; + + const { httpAgent: oauth2HttpAgent, httpsAgent: oauth2HttpsAgent } = await getHttpHttpsAgents({ + requestUrl: oauth2RequestUrl, + collectionPath, + options: tlsOptions, + clientCertificates: interpolatedClientCertificates, + collectionLevelProxy: interpolatedProxyConfig, + systemProxyConfig: getSystemProxyEnvVariables() + }); + + const oauth2AxiosInstance = makeAxiosInstanceForOauth2({ + requestMaxRedirects: requestMaxRedirects, + disableCookies: options.disableCookies, + httpAgent: oauth2HttpAgent, + httpsAgent: oauth2HttpsAgent + }); + + token = await getOAuth2Token(request.oauth2, oauth2AxiosInstance); + } + if (token) { const { tokenPlacement = 'header', tokenHeaderPrefix = '', tokenQueryKey = 'access_token' } = request.oauth2; diff --git a/packages/bruno-cli/src/utils/oauth2.js b/packages/bruno-cli/src/utils/oauth2.js index ece6d1407..8b344e8c1 100644 --- a/packages/bruno-cli/src/utils/oauth2.js +++ b/packages/bruno-cli/src/utils/oauth2.js @@ -21,10 +21,10 @@ const getFormattedOauth2Credentials = () => { return credentialsVariables; }; -const getOAuth2Token = (oauth2Config) => { +const getOAuth2Token = (oauth2Config, axiosInstance) => { let options = getOptions(); let verbose = options?.verbose; - return _getOAuth2Token(oauth2Config, tokenStore, verbose); + return _getOAuth2Token(oauth2Config, tokenStore, verbose, axiosInstance); }; module.exports = { diff --git a/packages/bruno-cli/src/utils/run.js b/packages/bruno-cli/src/utils/run.js new file mode 100644 index 000000000..44d68f027 --- /dev/null +++ b/packages/bruno-cli/src/utils/run.js @@ -0,0 +1,40 @@ +const path = require('path'); +const { stripExtension } = require('./filesystem'); + +const createSkippedFileResults = (skippedFiles, collectionPath) => { + return skippedFiles.map((skippedFile) => { + const relativePath = path.relative(collectionPath, skippedFile.path); + return { + test: { + filename: relativePath + }, + request: { + method: null, + url: null, + headers: null, + data: null + }, + response: { + status: 'skipped', + statusText: skippedFile.error, + data: null, + responseTime: 0 + }, + error: skippedFile.error, + status: 'skipped', + skipped: true, + assertionResults: [], + testResults: [], + preRequestTestResults: [], + postResponseTestResults: [], + runDuration: 0, + suitename: stripExtension(relativePath), + name: path.basename(skippedFile.path), + path: relativePath + }; + }); +}; + +module.exports = { + createSkippedFileResults +}; diff --git a/packages/bruno-cli/tests/runner/collection-json-from-pathname.spec.js b/packages/bruno-cli/tests/runner/collection-json-from-pathname.spec.js index 2be4d9e66..d0025cbf6 100644 --- a/packages/bruno-cli/tests/runner/collection-json-from-pathname.spec.js +++ b/packages/bruno-cli/tests/runner/collection-json-from-pathname.spec.js @@ -36,7 +36,6 @@ describe('create collection json from pathname', () => { expect(c).toHaveProperty('brunoConfig.proxy.auth.password', ''); expect(c).toHaveProperty('brunoConfig.proxy.bypassProxy', ''); expect(c).toHaveProperty('brunoConfig.scripts.moduleWhitelist', ['crypto', 'buffer']); - expect(c).toHaveProperty('brunoConfig.scripts.filesystemAccess.allow', true); expect(c).toHaveProperty('brunoConfig.clientCertificates.enabled', true); expect(c).toHaveProperty('brunoConfig.clientCertificates.certs', []); diff --git a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/bruno.json b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/bruno.json index 366f84472..516572e5e 100644 --- a/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/bruno.json +++ b/packages/bruno-cli/tests/runner/fixtures/collection-json-from-pathname/collection/bruno.json @@ -19,10 +19,7 @@ "bypassProxy": "" }, "scripts": { - "moduleWhitelist": ["crypto", "buffer"], - "filesystemAccess": { - "allow": true - } + "moduleWhitelist": ["crypto", "buffer"] }, "clientCertificates": { "enabled": true, diff --git a/packages/bruno-cli/tests/runner/report-metadata.spec.js b/packages/bruno-cli/tests/runner/report-metadata.spec.js index 2db861b35..550a592b6 100644 --- a/packages/bruno-cli/tests/runner/report-metadata.spec.js +++ b/packages/bruno-cli/tests/runner/report-metadata.spec.js @@ -49,4 +49,65 @@ describe('HTML Report Generation', () => { expect(htmlString).toContain('{{ totalDataReceived }}'); expect(htmlString).toContain('{{ averageResponseTime }}'); }); + + it('should include skipped requests with parsing errors in the HTML report', async () => { + const mockResults = [ + { + iterationIndex: 0, + results: [ + { + test: { + filename: 'invalid-request.bru' + }, + request: { + method: null, + url: null, + headers: null, + data: null + }, + response: { + status: 'skipped', + statusText: 'Unexpected token', + data: null, + responseTime: 0 + }, + error: 'Unexpected token', + status: 'skipped', + skipped: true, + assertionResults: [], + testResults: [], + preRequestTestResults: [], + postResponseTestResults: [], + name: 'invalid-request.bru', + path: 'invalid-request.bru', + runDuration: 0 + } + ], + summary: { + totalRequests: 1, + passedRequests: 0, + failedRequests: 0, + errorRequests: 0, + skippedRequests: 1, + totalAssertions: 0, + passedAssertions: 0, + failedAssertions: 0, + totalTests: 0, + passedTests: 0, + failedTests: 0 + } + } + ]; + + const htmlString = generateHtmlReport({ + runnerResults: mockResults, + version: 'usebruno v1.16.0', + environment: null, + runCompletionTime: '2024-01-15T14:30:45.123Z' + }); + + expect(htmlString).toContain('Request Skipped'); + expect(htmlString).toContain('summarySkippedRequests'); + expect(htmlString).toContain('result.response.status === \'skipped\''); + }); }); diff --git a/packages/bruno-common/src/index.ts b/packages/bruno-common/src/index.ts index 40d298e78..3becb2973 100644 --- a/packages/bruno-common/src/index.ts +++ b/packages/bruno-common/src/index.ts @@ -1,5 +1,5 @@ -export { mockDataFunctions } from './utils/faker-functions'; -export { default as interpolate } from './interpolate'; +export { mockDataFunctions, timeBasedDynamicVars } from './utils/faker-functions'; +export { default as interpolate, interpolateObject } from './interpolate'; export { default as isRequestTagsIncluded } from './tags'; export * as utils from './utils'; diff --git a/packages/bruno-common/src/interpolate/index.spec.ts b/packages/bruno-common/src/interpolate/index.spec.ts index 98d4e3223..05206ee60 100644 --- a/packages/bruno-common/src/interpolate/index.spec.ts +++ b/packages/bruno-common/src/interpolate/index.spec.ts @@ -1,4 +1,4 @@ -import interpolate from './index'; +import interpolate, { interpolateObject } from './index'; import moment from 'moment'; const BRUNO_BIRTH_DATE = new Date('2019-08-08'); @@ -375,6 +375,62 @@ describe('interpolate - recursive', () => { "x": "baz bar" }`); }); + + it('should replace variables pointing to mock data functions', () => { + const inputString = 'Timestamp: {{folderVar}}'; + const inputObject = { + folderVar: '{{$isoTimestamp}}' + }; + + const result = interpolate(inputString, inputObject); + + // Validate that the result is a valid ISO timestamp + const timestampPattern = /^Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + expect(timestampPattern.test(result)).toBe(true); + }); + + it('should replace nested variables pointing to mock data functions', () => { + const inputString = 'Random values: {{var1}} and {{var2}}'; + const inputObject = { + var1: '{{nestedVar}}', + nestedVar: '{{$randomInt}}', + var2: '{{$randomBoolean}}' + }; + + const result = interpolate(inputString, inputObject); + + // Validate the result + const parts = result.split(' and '); + expect(parts.length).toBe(2); + + const randomInt = parts[0].replace('Random values: ', ''); + const randomBoolean = parts[1]; + + // Check if randomInt is a number + expect(!isNaN(Number(randomInt))).toBe(true); + expect(Number(randomInt)).toBeGreaterThanOrEqual(0); + expect(Number(randomInt)).toBeLessThanOrEqual(1000); + + // Check if randomBoolean is a boolean + expect(['true', 'false'].includes(randomBoolean)).toBe(true); + }); + + it('should replace variables pointing to mock data functions with escapeJSONStrings option', () => { + const inputString = '{"timestamp": "{{folderVar}}"}'; + const inputObject = { + folderVar: '{{$isoTimestamp}}' + }; + + const result = interpolate(inputString, inputObject, { escapeJSONStrings: true }); + + // Should produce valid JSON + expect(() => { + const parsed = JSON.parse(result); + // Validate that the timestamp is a valid ISO timestamp + const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + expect(timestampPattern.test(parsed.timestamp)).toBe(true); + }).not.toThrow(); + }); }); describe('interpolate - object handling', () => { @@ -534,6 +590,37 @@ describe('interpolate - mock variable interpolation', () => { JSON.parse(result); // This should throw an error }).toThrow(); }); + + it('should process mock variables in nested objects', () => { + const inputString = '{{user.data}}'; + const inputObject = { + user: { + data: { + id: '{{$randomUUID}}', + timestamp: '{{$isoTimestamp}}', + nested: { + randomInt: '{{$randomInt}}' + } + } + } + }; + + const result = interpolate(inputString, inputObject); + const parsed = JSON.parse(result); + + // Validate UUID format + const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + expect(uuidPattern.test(parsed.id)).toBe(true); + + // Validate ISO timestamp format + const isoTimestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + expect(isoTimestampPattern.test(parsed.timestamp)).toBe(true); + + // Validate nested randomInt + expect(!isNaN(Number(parsed.nested.randomInt))).toBe(true); + expect(Number(parsed.nested.randomInt)).toBeGreaterThanOrEqual(0); + expect(Number(parsed.nested.randomInt)).toBeLessThanOrEqual(1000); + }); }); describe('interpolate - Date() handling', () => { @@ -591,3 +678,182 @@ describe('interpolate - moment() handling', () => { expect(result).toBe('Date is {"now":"2025-04-17T15:33:41.117Z"}'); }); }); + +describe('interpolateObject', () => { + it('should interpolate strings in a flat object', () => { + const obj = { + url: '{{baseUrl}}/api/users', + name: '{{userName}}' + }; + const variables = { baseUrl: 'https://api.example.com', userName: 'Bruno' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + url: 'https://api.example.com/api/users', + name: 'Bruno' + }); + }); + + it('should interpolate strings in nested objects', () => { + const obj = { + request: { + url: '{{baseUrl}}/api', + headers: { + Authorization: 'Bearer {{token}}' + } + } + }; + const variables = { baseUrl: 'https://api.example.com', token: 'abc123' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + request: { + url: 'https://api.example.com/api', + headers: { + Authorization: 'Bearer abc123' + } + } + }); + }); + + it('should interpolate strings in arrays', () => { + const obj = { + urls: ['{{baseUrl}}/one', '{{baseUrl}}/two'] + }; + const variables = { baseUrl: 'https://api.example.com' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + urls: ['https://api.example.com/one', 'https://api.example.com/two'] + }); + }); + + it('should preserve non-string values', () => { + const obj = { + name: '{{name}}', + age: 5, + active: true, + data: null + }; + const variables = { name: 'Bruno' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + name: 'Bruno', + age: 5, + active: true, + data: null + }); + }); + + it('should return null and undefined as-is', () => { + expect(interpolateObject(null, {})).toBeNull(); + expect(interpolateObject(undefined, {})).toBeUndefined(); + }); + + it('should throw on circular references', () => { + const obj: any = { a: 1 }; + obj.self = obj; + + expect(() => interpolateObject(obj, {})).toThrow('Circular reference detected during interpolation.'); + }); + + it('should handle shared object references without throwing false positives', () => { + const shared = { value: '{{sharedValue}}' }; + const obj = { + x: shared, + y: shared + }; + const variables = { sharedValue: 'test' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + x: { value: 'test' }, + y: { value: 'test' } + }); + }); + + it('should handle shared object references in arrays', () => { + const shared = { id: '{{id}}' }; + const obj = { + items: [shared, shared, shared] + }; + const variables = { id: '123' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + items: [{ id: '123' }, { id: '123' }, { id: '123' }] + }); + }); + + it('should handle shared object references in nested structures', () => { + const shared = { name: '{{name}}' }; + const obj = { + user: shared, + profile: { + user: shared, + metadata: { + user: shared + } + } + }; + const variables = { name: 'Bruno' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + user: { name: 'Bruno' }, + profile: { + user: { name: 'Bruno' }, + metadata: { + user: { name: 'Bruno' } + } + } + }); + }); + + it('should handle shared array references', () => { + const shared = ['{{item1}}', '{{item2}}']; + const obj = { + list1: shared, + list2: shared + }; + const variables = { item1: 'a', item2: 'b' }; + + const result = interpolateObject(obj, variables); + + expect(result).toEqual({ + list1: ['a', 'b'], + list2: ['a', 'b'] + }); + }); + + it('should still detect actual circular references', () => { + const obj: any = { + a: { value: '{{val}}' }, + b: { value: '{{val}}' } + }; + obj.a.circular = obj.a; // Circular reference + + expect(() => interpolateObject(obj, { val: 'test' })).toThrow('Circular reference detected during interpolation.'); + }); + + it('should handle deeply nested circular references', () => { + const obj: any = { + level1: { + level2: { + level3: {} + } + } + }; + obj.level1.level2.level3.circular = obj.level1; + + expect(() => interpolateObject(obj, {})).toThrow('Circular reference detected during interpolation.'); + }); +}); diff --git a/packages/bruno-common/src/interpolate/index.ts b/packages/bruno-common/src/interpolate/index.ts index 4cd8a2b16..7ecc550ea 100644 --- a/packages/bruno-common/src/interpolate/index.ts +++ b/packages/bruno-common/src/interpolate/index.ts @@ -12,7 +12,58 @@ */ import { mockDataFunctions } from '../utils/faker-functions'; -import { get } from 'lodash-es'; +import { get, isPlainObject, mapValues } from 'lodash-es'; + +// regex to match {{$keyword}} +const MOCK_PATTERN = /\{\{\$(\w+)\}\}/g; +const JSON_SPECIAL_CHARS = /[\\\n\r\t\"]/; + +const escapeJSONString = (str: string): string => { + if (!JSON_SPECIAL_CHARS.test(str)) { + return str; + } + + return str + .replace(/\\/g, '\\\\') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + .replace(/\"/g, '\\"'); +}; + +const prepareMock = (str: string, escapeJSONStrings: boolean): string => { + return str.replace(MOCK_PATTERN, (match, keyword) => { + let generatedValue = mockDataFunctions[keyword as keyof typeof mockDataFunctions]?.(); + + if (generatedValue === undefined) { + return match; + } + + generatedValue = String(generatedValue); + + return escapeJSONStrings ? escapeJSONString(generatedValue) : generatedValue; + }); +}; + +const prepareMockObj = ( + obj: Record, + escapeJSONStrings: boolean +): Record => { + const processed: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string') { + processed[key] = prepareMock(value, escapeJSONStrings); + } else if (isPlainObject(value)) { + // plain object is used to skip special objects like Date, RegExp, etc. + processed[key] = prepareMockObj(value, escapeJSONStrings); + } else { + processed[key] = value; + } + } + + return processed; +}; const interpolate = ( str: string, @@ -25,32 +76,14 @@ const interpolate = ( const { escapeJSONStrings } = options; - const patternRegex = /\{\{\$(\w+)\}\}/g; - str = str.replace(patternRegex, (match, keyword) => { - let replacement = mockDataFunctions[keyword as keyof typeof mockDataFunctions]?.(); - - if (replacement === undefined) return match; - replacement = String(replacement); - - if (!escapeJSONStrings) return replacement; - - // All the below chars inside of a JSON String field - // will make it invalid JSON. So we will have to escape them with `\`. - // This is not exhaustive but selective to what faker-js can output. - if (!/[\\\n\r\t\"]/.test(replacement)) return replacement; - return replacement - .replace(/\\/g, '\\\\') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t') - .replace(/\"/g, '\\"'); - }); + const preparedStr = prepareMock(str, escapeJSONStrings ?? false); if (!obj || typeof obj !== 'object') { - return str; + return preparedStr; } - - return replace(str, obj); + // process the object with the mock data functions + const preparedObj = prepareMockObj(obj, escapeJSONStrings ?? false); + return replace(preparedStr, preparedObj); }; const replace = ( @@ -96,4 +129,33 @@ const replace = ( return resultStr; }; +export const interpolateObject = (obj: unknown, variables: Record): unknown => { + const seen = new WeakSet(); + const walk = (value: unknown): unknown => { + if (value == null) return value; + if (typeof value === 'string') { + return interpolate(value, variables); + } + if (typeof value === 'object') { + if (seen.has(value as object)) { + throw new Error('Circular reference detected during interpolation.'); + } + seen.add(value as object); + try { + if (Array.isArray(value)) { + return value.map(walk); + } + if (isPlainObject(value)) { + return mapValues(value as Record, walk); + } + return value; + } finally { + seen.delete(value as object); + } + } + return value; + }; + return walk(obj); +}; + export default interpolate; diff --git a/packages/bruno-common/src/runner/reports/html/template.ts b/packages/bruno-common/src/runner/reports/html/template.ts index ca49cb337..bbc87331f 100644 --- a/packages/bruno-common/src/runner/reports/html/template.ts +++ b/packages/bruno-common/src/runner/reports/html/template.ts @@ -310,7 +310,7 @@ export const htmlTemplateString = (resutsJsonString: string) => ` :bordered="false" > @@ -365,7 +365,7 @@ export const htmlTemplateString = (resutsJsonString: string) => ` - + {{result.error}} @@ -757,7 +757,7 @@ export const htmlTemplateString = (resutsJsonString: string) => ` return (props?.result?.testResults?.length || 0) + (props?.result?.assertionResults?.length || 0); }); - const hasError = computed(() => !!props?.result?.error || props?.result?.status === 'error'); + const hasError = computed(() => !!props?.result?.error || props?.result?.status === 'error' || (props?.result?.response?.status === 'skipped' && props?.result?.error)); const hasFailure = computed(() => total.value !== totalPassed.value); const testDuration = computed(() => Math.round(props?.result?.runDuration * 1000) + ' ms'); const resultTitle = computed(() => props?.result?.path + ' ' + props?.result?.response?.status + ' ' + props?.result?.response?.statusText); diff --git a/packages/bruno-common/src/utils/faker-functions.ts b/packages/bruno-common/src/utils/faker-functions.ts index a86a8bb89..4d08a9762 100644 --- a/packages/bruno-common/src/utils/faker-functions.ts +++ b/packages/bruno-common/src/utils/faker-functions.ts @@ -1,5 +1,7 @@ import { faker } from '@faker-js/faker'; +export const timeBasedDynamicVars = new Set(['timestamp', 'isoTimestamp']); + export const mockDataFunctions = { guid: () => faker.string.uuid(), timestamp: () => Math.floor(Date.now() / 1000).toString(), diff --git a/packages/bruno-converters/src/openapi/openapi-to-bruno.js b/packages/bruno-converters/src/openapi/openapi-to-bruno.js index 85f031c0f..b910c443e 100644 --- a/packages/bruno-converters/src/openapi/openapi-to-bruno.js +++ b/packages/bruno-converters/src/openapi/openapi-to-bruno.js @@ -340,32 +340,71 @@ const transformOpenapiRequestItem = (request, usedNames = new Set()) => { }; each(_operationObject.parameters || [], (param) => { - if (param.in === 'query') { - brunoRequestItem.request.params.push({ - uid: uuid(), - name: param.name, - value: '', - description: param.description || '', - enabled: param.required, - type: 'query' - }); - } else if (param.in === 'path') { - brunoRequestItem.request.params.push({ - uid: uuid(), - name: param.name, - value: '', - description: param.description || '', - enabled: param.required, - type: 'path' - }); - } else if (param.in === 'header') { - brunoRequestItem.request.headers.push({ - uid: uuid(), - name: param.name, - value: '', - description: param.description || '', - enabled: param.required + // Check if parameter schema is an object type with properties + // If so, expand the properties into individual parameters + const isObjectSchema = param.schema && param.schema.properties; + + if (isObjectSchema) { + // Expand object schema properties into individual parameters + each(param.schema.properties, (prop, propName) => { + const isRequired = Array.isArray(param.schema.required) && param.schema.required.includes(propName); + + if (param.in === 'query') { + brunoRequestItem.request.params.push({ + uid: uuid(), + name: propName, + value: '', + description: prop.description || '', + enabled: isRequired, + type: 'query' + }); + } else if (param.in === 'path') { + brunoRequestItem.request.params.push({ + uid: uuid(), + name: propName, + value: '', + description: prop.description || '', + enabled: isRequired, + type: 'path' + }); + } else if (param.in === 'header') { + brunoRequestItem.request.headers.push({ + uid: uuid(), + name: propName, + value: '', + description: prop.description || '', + enabled: isRequired + }); + } }); + } else { + if (param.in === 'query') { + brunoRequestItem.request.params.push({ + uid: uuid(), + name: param.name, + value: '', + description: param.description || '', + enabled: param.required, + type: 'query' + }); + } else if (param.in === 'path') { + brunoRequestItem.request.params.push({ + uid: uuid(), + name: param.name, + value: '', + description: param.description || '', + enabled: param.required, + type: 'path' + }); + } else if (param.in === 'header') { + brunoRequestItem.request.headers.push({ + uid: uuid(), + name: param.name, + value: '', + description: param.description || '', + enabled: param.required + }); + } } }); diff --git a/packages/bruno-converters/src/postman/bruno-to-postman.js b/packages/bruno-converters/src/postman/bruno-to-postman.js index 8b81ca271..654d6e1ea 100644 --- a/packages/bruno-converters/src/postman/bruno-to-postman.js +++ b/packages/bruno-converters/src/postman/bruno-to-postman.js @@ -1,5 +1,6 @@ import map from 'lodash/map'; import { deleteSecretsInEnvs, deleteUidsInEnvs, deleteUidsInItems, isItemARequest } from '../common'; +import translateBruToPostman from '../utils/bruno-to-postman-translator'; /** * Transforms a given URL string into an object representing the protocol, host, path, query, and variables. @@ -167,6 +168,15 @@ export const brunoToPostman = (collection) => { return Array.from(finalVarsMap.values()); }; + const translateScriptSafely = (script = '') => { + try { + return translateBruToPostman(script); + } catch (err) { + console.warn('Bru→Postman script translation failed, leaving script as-is', err); + return script; + } + }; + const generateEventSection = (item) => { const eventArray = []; // Request: item.script, Folder: item.root.request.script, Collection: item.request.script @@ -175,13 +185,14 @@ export const brunoToPostman = (collection) => { const testsBlock = item?.tests || item?.root?.request?.tests || item?.request?.tests; if (scriptBlock.req && typeof scriptBlock.req === 'string') { + const translated = translateScriptSafely(scriptBlock.req); eventArray.push({ listen: 'prerequest', script: { type: 'text/javascript', packages: {}, requests: {}, - exec: scriptBlock.req.split('\n') + exec: translated.split('\n') } }); } @@ -189,14 +200,16 @@ export const brunoToPostman = (collection) => { if (scriptBlock.res || testsBlock) { const exec = []; if (scriptBlock.res && typeof scriptBlock.res === 'string') { - exec.push(...scriptBlock.res.split('\n')); + const translated = translateScriptSafely(scriptBlock.res); + exec.push(...translated.split('\n')); } if (testsBlock && typeof testsBlock === 'string') { + const translatedTests = translateScriptSafely(testsBlock); if (exec.length > 0) { exec.push(''); } exec.push('// Tests'); - exec.push(...testsBlock.split('\n')); + exec.push(...translatedTests.split('\n')); } // Only push the event if exec has content diff --git a/packages/bruno-converters/src/postman/postman-translations.js b/packages/bruno-converters/src/postman/postman-translations.js index 3f11b0db7..5ca8a9c7b 100644 --- a/packages/bruno-converters/src/postman/postman-translations.js +++ b/packages/bruno-converters/src/postman/postman-translations.js @@ -1,4 +1,4 @@ -import translateCode from '../utils/jscode-shift-translator'; +import translateCode from '../utils/postman-to-bruno-translator'; const replacements = { 'pm\\.environment\\.get\\(': 'bru.getEnvVar(', diff --git a/packages/bruno-converters/src/utils/ast-utils.js b/packages/bruno-converters/src/utils/ast-utils.js new file mode 100644 index 000000000..6d212b39c --- /dev/null +++ b/packages/bruno-converters/src/utils/ast-utils.js @@ -0,0 +1,93 @@ +const j = require('jscodeshift'); + +/** + * Efficiently builds a string representation of a member expression + * without using toSource() for better performance. + * + * @param {Object} node - The member expression node from the AST + * @returns {string} - String representation of the member expression (e.g., "pm.environment.get") + * + * @example + * // For AST node representing `pm.environment.get` + * getMemberExpressionString(node) // returns "pm.environment.get" + * + * // For AST node representing `obj["prop"]` + * getMemberExpressionString(node) // returns "obj.prop" + * + * // For AST node representing `bru.cookies.jar()` + * getMemberExpressionString(node) // returns "bru.cookies.jar()" + */ +export function getMemberExpressionString(node) { + if (node.type === 'Identifier') { + return node.name; + } + + if (node.type === 'CallExpression') { + const calleeStr = getMemberExpressionString(node.callee); + return `${calleeStr}()`; + } + + if (node.type === 'MemberExpression') { + const objectStr = getMemberExpressionString(node.object); + + // For computed properties like obj[prop] + if (node.computed) { + // For string literals like obj["prop"], include them in the string + if (node.property.type === 'Literal' && typeof node.property.value === 'string') { + return `${objectStr}.${node.property.value}`; + } + // For other computed properties, we can't reliably represent them + return `${objectStr}.[computed]`; + } + + // For regular property access like obj.prop + if (node.property.type === 'Identifier') { + return `${objectStr}.${node.property.name}`; + } + } + + return '[unsupported]'; +} + +/** + * Builds a member expression AST node from a dotted string path. + * + * @param {string} str - Dotted path string (e.g., "pm.variables.get") + * @returns {Object} - jscodeshift MemberExpression or Identifier node + * + * @example + * buildMemberExpressionFromString("pm.variables.get") + * // Returns AST for: pm.variables.get + * + * buildMemberExpressionFromString("pm") + * // Returns AST for: pm (just an Identifier) + */ +export function buildMemberExpressionFromString(str) { + const parts = str.split('.'); + let expr = j.identifier(parts[0]); + for (let i = 1; i < parts.length; i += 1) { + expr = j.memberExpression(expr, j.identifier(parts[i])); + } + return expr; +} + +/** + * Checks if a node is an identifier with a specific name. + * + * @param {Object} node - The AST node to check + * @param {string} name - The expected identifier name + * @returns {boolean} - True if node is an identifier with the given name + */ +export function isIdentifierNamed(node, name) { + return node && node.type === 'Identifier' && node.name === name; +} + +/** + * Checks if a node is the null literal. + * + * @param {Object} node - The AST node to check + * @returns {boolean} - True if node is a null literal + */ +export function isNullLiteral(node) { + return node && node.type === 'Literal' && node.value === null; +} diff --git a/packages/bruno-converters/src/utils/bruno-to-postman-translator.js b/packages/bruno-converters/src/utils/bruno-to-postman-translator.js new file mode 100644 index 000000000..58a95cd08 --- /dev/null +++ b/packages/bruno-converters/src/utils/bruno-to-postman-translator.js @@ -0,0 +1,343 @@ +import { + getMemberExpressionString, + buildMemberExpressionFromString +} from './ast-utils'; +const j = require('jscodeshift'); + +// ============================================================================= +// SIMPLE TRANSLATIONS +// ============================================================================= + +/** + * Simple 1:1 translations from Bruno helpers to Postman helpers. + * These are direct member expression replacements. + */ +const simpleTranslations = { + // Global variables + 'bru.getGlobalEnvVar': 'pm.globals.get', + 'bru.setGlobalEnvVar': 'pm.globals.set', + + // Environment variables + 'bru.getEnvVar': 'pm.environment.get', + 'bru.setEnvVar': 'pm.environment.set', + 'bru.hasEnvVar': 'pm.environment.has', + 'bru.deleteEnvVar': 'pm.environment.unset', + // Note: bru.getEnvName() is handled in complexTransformations because it's a function -> property conversion + + // Runtime variables + 'bru.getVar': 'pm.variables.get', + 'bru.setVar': 'pm.variables.set', + 'bru.hasVar': 'pm.variables.has', + 'bru.deleteVar': 'pm.variables.unset', + // 'bru.deleteAllVars': Postman does not have a way to delete all variables + + // Collection variables + 'bru.getCollectionVar': 'pm.variables.get', + /* Bruno does not have a way to set, has or delete collection variables */ + + // Folder variables + 'bru.getFolderVar': 'pm.variables.get', + /* Bruno does not have a way to set, has or delete folder variables */ + + // Request variables (map to pm.variables.*) + 'bru.getRequestVar': 'pm.variables.get', + /* Bruno does not have a way to set, has or delete request variables */ + + // Interpolation + 'bru.interpolate': 'pm.variables.replaceIn', + + // Execution control + 'bru.setNextRequest': 'pm.execution.setNextRequest', + 'bru.runner.skipRequest': 'pm.execution.skipRequest', + 'bru.runner.setNextRequest': 'pm.execution.setNextRequest', + + // Request helpers + // Note: req.getUrl(), req.getMethod(), req.getHeaders(), req.getBody(), req.getName() are handled + // in complexTransformations because they're function -> property conversions + 'req.getHeader': 'pm.request.headers.get', + 'req.setHeader': 'pm.request.headers.set', + + // Response helpers + // Note: res.getStatus(), res.getResponseTime(), res.getHeaders() are handled + // in complexTransformations because they're function -> property conversions + 'res.status': 'pm.response.code', + 'res.statusText': 'pm.response.status', + 'res.body': 'pm.response.body', + 'res.getBody': 'pm.response.json', + 'res.getHeader': 'pm.response.headers.get', + 'res.getSize': 'pm.response.size', + + // Cookies jar + 'bru.cookies.jar': 'pm.cookies.jar', + + // Testing + 'expect.fail': 'pm.expect.fail' +}; + +// ============================================================================= +// COMPLEX TRANSFORMATIONS +// ============================================================================= + +/** + * Complex transformations that require custom handling beyond simple replacements. + * Each transformation has a pattern to match and a transform function. + * + * Note: These are processed in order, so more specific patterns should come first. + */ +const complexTransformations = [ + // bru.runner.stopExecution() -> pm.execution.setNextRequest(null) + { + pattern: 'bru.runner.stopExecution', + transform: (path) => { + return j.callExpression( + buildMemberExpressionFromString('pm.execution.setNextRequest'), + [j.literal(null)] + ); + } + }, + + // JSON.stringify(res.getBody()) -> pm.response.text() + { + pattern: 'JSON.stringify', + condition: (path) => { + const args = path.value.arguments; + if (args.length !== 1) return false; + + const arg = args[0]; + if (arg.type !== 'CallExpression' || arg.callee.type !== 'MemberExpression') return false; + + return getMemberExpressionString(arg.callee) === 'res.getBody'; + }, + transform: () => { + return j.callExpression( + buildMemberExpressionFromString('pm.response.text'), + [] + ); + } + }, + + // bru.getEnvName() -> pm.environment.name (function to property) + { + pattern: 'bru.getEnvName', + transform: () => { + // Replace the entire call expression with just the member expression (property access) + return buildMemberExpressionFromString('pm.environment.name'); + } + }, + + // Request helpers: function -> property conversions + // req.getUrl() -> pm.request.url + { + pattern: 'req.getUrl', + transform: () => buildMemberExpressionFromString('pm.request.url') + }, + // req.getMethod() -> pm.request.method + { + pattern: 'req.getMethod', + transform: () => buildMemberExpressionFromString('pm.request.method') + }, + // req.getHeaders() -> pm.request.headers + { + pattern: 'req.getHeaders', + transform: () => buildMemberExpressionFromString('pm.request.headers') + }, + // req.getBody() -> pm.request.body + { + pattern: 'req.getBody', + transform: () => buildMemberExpressionFromString('pm.request.body') + }, + // req.getName() -> pm.info.requestName + { + pattern: 'req.getName', + transform: () => buildMemberExpressionFromString('pm.info.requestName') + }, + + // Response helpers: function -> property conversions + // res.getStatus() -> pm.response.code + { + pattern: 'res.getStatus', + transform: () => buildMemberExpressionFromString('pm.response.code') + }, + // res.getStatusText() -> pm.response.status + { + pattern: 'res.getStatusText', + transform: () => buildMemberExpressionFromString('pm.response.status') + }, + // res.getResponseTime() -> pm.response.responseTime + { + pattern: 'res.getResponseTime', + transform: () => buildMemberExpressionFromString('pm.response.responseTime') + }, + // res.getHeaders() -> pm.response.headers + { + pattern: 'res.getHeaders', + transform: () => buildMemberExpressionFromString('pm.response.headers') + } +]; + +// Create a map for O(1) lookups of complex transformations +const complexTransformationsMap = new Map(); +complexTransformations.forEach((t) => { + complexTransformationsMap.set(t.pattern, t); +}); + +// Cookie jar method mappings (Bruno -> PM) +// Note: Bruno's setCookie with cookie object form is not supported (Postman only accepts url, name, value, callback?) +// Note: getCookies(url, callback?) -> getAll(url, options?, callback?) +// PM docs treat callback as 2nd arg, likely handled internally to detect function vs options object +const cookieMethodMapping = { + getCookie: 'get', // (url, name, callback?) -> (url, name, callback?) + getCookies: 'getAll', // (url, callback?) -> (url, callback?) - PM handles internally + setCookie: 'set', // (url, name, value, callback?) -> (url, name, value?, callback?) + deleteCookie: 'unset', // (url, name, callback?) -> (url, name, callback?) + deleteCookies: 'clear' // (url, callback?) -> (url, callback?) +}; + +// ============================================================================= +// TRANSFORMATION FUNCTIONS +// ============================================================================= + +/** + * Process simple member expression translations (bru.* -> pm.*) + * and complex transformations in a single pass. + * + * @param {Object} ast - jscodeshift AST + */ +function processAllTransformations(ast) { + // First handle CallExpressions for complex transformations + ast.find(j.CallExpression).forEach((path) => { + const { callee } = path.value; + if (callee.type !== 'MemberExpression') return; + + const memberExprStr = getMemberExpressionString(callee); + const transform = complexTransformationsMap.get(memberExprStr); + + if (transform) { + // Check condition if present + if (transform.condition && !transform.condition(path)) return; + + const replacement = transform.transform(path); + if (replacement !== null) { + j(path).replaceWith(replacement); + } + } + }); + + // Then handle simple member expression translations + ast.find(j.MemberExpression).forEach((path) => { + const memberExprStr = getMemberExpressionString(path.value); + + if (!Object.prototype.hasOwnProperty.call(simpleTranslations, memberExprStr)) return; + + const replacement = simpleTranslations[memberExprStr]; + j(path).replaceWith(buildMemberExpressionFromString(replacement)); + }); +} + +/** + * Transform cookie jar method calls. + * Handles both direct calls and variables assigned to cookie jars. + * + * @param {Object} ast - jscodeshift AST + */ +function transformCookieJarMethods(ast) { + // Track variables assigned to cookie jar instances + const cookieJarVars = new Set(); + + // Find variables assigned to cookie jar + ast.find(j.VariableDeclarator).forEach((path) => { + if (path.value.init?.type === 'CallExpression' && path.value.init.callee.type === 'MemberExpression') { + const calleeStr = getMemberExpressionString(path.value.init.callee); + if (calleeStr === 'bru.cookies.jar' || calleeStr === 'pm.cookies.jar') { + if (path.value.id.type === 'Identifier') { + cookieJarVars.add(path.value.id.name); + } + } + } + }); + + // Transform method calls on cookie jars + ast.find(j.CallExpression).forEach((path) => { + const { callee } = path.value; + if (callee.type !== 'MemberExpression' || callee.property.type !== 'Identifier') return; + + const methodName = callee.property.name; + if (!cookieMethodMapping[methodName]) return; + + // Check if object is a direct jar() call or a jar variable + const isDirectJarCall = callee.object.type === 'CallExpression' + && callee.object.callee.type === 'MemberExpression' + && ['bru.cookies.jar', 'pm.cookies.jar'].includes(getMemberExpressionString(callee.object.callee)); + + const isJarVariable = callee.object.type === 'Identifier' && cookieJarVars.has(callee.object.name); + + if (isDirectJarCall || isJarVariable) { + path.value.callee.property.name = cookieMethodMapping[methodName]; + } + }); +} + +/** + * Transform test() -> pm.test() and expect() -> pm.expect() + * + * @param {Object} ast - jscodeshift AST + */ +function transformTestsAndExpect(ast) { + // Transform test(...) -> pm.test(...) + ast.find(j.CallExpression, { callee: { type: 'Identifier', name: 'test' } }) + .forEach((path) => { + j(path.get('callee')).replaceWith( + j.memberExpression(j.identifier('pm'), j.identifier('test')) + ); + }); + + // Transform expect(...) -> pm.expect(...) + ast.find(j.CallExpression, { callee: { type: 'Identifier', name: 'expect' } }) + .forEach((path) => { + j(path.get('callee')).replaceWith( + j.memberExpression(j.identifier('pm'), j.identifier('expect')) + ); + }); +} + +// ============================================================================= +// MAIN EXPORT +// ============================================================================= + +/** + * Translate Bruno scripts back to Postman-compatible scripts. + * + * This function transforms Bruno API calls (bru.*, req.*, res.*, test(), expect()) + * back to their Postman equivalents (pm.*, pm.request.*, pm.response.*, pm.test(), pm.expect()). + * + * @param {string} code - Bruno script string + * @returns {string} - Postman-compatible script string + * + * @example + * translateBruToPostman('bru.getEnvVar("test");') + * // Returns: 'pm.environment.get("test");' + * + * @example + * translateBruToPostman('const data = res.getBody();') + * // Returns: 'const data = pm.response.json();' + */ +function translateBruToPostman(code) { + if (!code || typeof code !== 'string') { + return ''; + } + + try { + const ast = j(code); + + processAllTransformations(ast); + transformCookieJarMethods(ast); + transformTestsAndExpect(ast); + + return ast.toSource(); + } catch (e) { + console.warn('Error in Bruno to Postman translation:', e); + return code; + } +} + +export default translateBruToPostman; diff --git a/packages/bruno-converters/src/utils/jscode-shift-translator.js b/packages/bruno-converters/src/utils/postman-to-bruno-translator.js similarity index 95% rename from packages/bruno-converters/src/utils/jscode-shift-translator.js rename to packages/bruno-converters/src/utils/postman-to-bruno-translator.js index acd621a32..7a21d07e1 100644 --- a/packages/bruno-converters/src/utils/jscode-shift-translator.js +++ b/packages/bruno-converters/src/utils/postman-to-bruno-translator.js @@ -1,47 +1,8 @@ import sendRequestTransformer from './send-request-transformer'; +import { getMemberExpressionString } from './ast-utils'; const j = require('jscodeshift'); const cloneDeep = require('lodash/cloneDeep'); -/** - * Efficiently builds a string representation of a member expression without using toSource() - * - * @param {Object} node - The member expression node from the AST - * @return {string} - String representation of the member expression (e.g., "pm.environment.get") - */ -function getMemberExpressionString(node) { - // Handle base case: if this is an Identifier - if (node.type === 'Identifier') { - return node.name; - } - - if (node.type === 'CallExpression') { - const calleeStr = getMemberExpressionString(node.callee); - return `${calleeStr}()`; - } - - // Handle member expressions - if (node.type === 'MemberExpression') { - const objectStr = getMemberExpressionString(node.object); - - // For computed properties like obj[prop], we need special handling - if (node.computed) { - // For literals like obj["prop"], we can include them in the string - if (node.property.type === 'Literal' && typeof node.property.value === 'string') { - return `${objectStr}.${node.property.value}`; - } - // For other computed properties, we can't reliably represent them as a simple string - return `${objectStr}.[computed]`; - } - - // For regular property access like obj.prop - if (node.property.type === 'Identifier') { - return `${objectStr}.${node.property.name}`; - } - } - - return '[unsupported]'; -} - // Simple 1:1 translations for straightforward replacements const simpleTranslations = { // Global Variables diff --git a/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/cookies.test.js b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/cookies.test.js new file mode 100644 index 000000000..717965afd --- /dev/null +++ b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/cookies.test.js @@ -0,0 +1,147 @@ +import translateBruToPostman from '../../../src/utils/bruno-to-postman-translator'; + +describe('Bruno to Postman Cookies Translation', () => { + // Cookie jar translation + it('should translate bru.cookies.jar to pm.cookies.jar', () => { + const code = 'const jar = bru.cookies.jar();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const jar = pm.cookies.jar();'); + }); + + // Cookie method translations with direct jar call chaining + it('should translate getCookie to get (direct chaining)', () => { + const code = 'const sessionId = bru.cookies.jar().getCookie("https://example.com", "sessionId");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const sessionId = pm.cookies.jar().get("https://example.com", "sessionId");'); + }); + + it('should translate getCookies to getAll (direct chaining)', () => { + const code = 'const allCookies = bru.cookies.jar().getCookies("https://example.com");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const allCookies = pm.cookies.jar().getAll("https://example.com");'); + }); + + it('should translate setCookie to set (direct chaining)', () => { + const code = 'bru.cookies.jar().setCookie("https://example.com", "token", "abc123");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.cookies.jar().set("https://example.com", "token", "abc123");'); + }); + + it('should translate deleteCookie to unset (direct chaining)', () => { + const code = 'bru.cookies.jar().deleteCookie("https://example.com", "sessionId");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.cookies.jar().unset("https://example.com", "sessionId");'); + }); + + it('should translate deleteCookies to clear (direct chaining)', () => { + const code = 'bru.cookies.jar().deleteCookies("https://example.com");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.cookies.jar().clear("https://example.com");'); + }); + + // Cookie method translations with jar variable + it('should translate getCookie to get (jar variable)', () => { + const code = ` +const jar = bru.cookies.jar(); +const sessionId = jar.getCookie("https://example.com", "sessionId"); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const jar = pm.cookies.jar();'); + expect(translatedCode).toContain('const sessionId = jar.get("https://example.com", "sessionId");'); + }); + + it('should translate getCookies to getAll (jar variable)', () => { + const code = ` +const jar = bru.cookies.jar(); +const allCookies = jar.getCookies("https://example.com"); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const jar = pm.cookies.jar();'); + expect(translatedCode).toContain('const allCookies = jar.getAll("https://example.com");'); + }); + + it('should translate setCookie to set (jar variable)', () => { + const code = ` +const jar = bru.cookies.jar(); +jar.setCookie("https://example.com", "token", "abc123"); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const jar = pm.cookies.jar();'); + expect(translatedCode).toContain('jar.set("https://example.com", "token", "abc123");'); + }); + + it('should translate deleteCookie to unset (jar variable)', () => { + const code = ` +const jar = bru.cookies.jar(); +jar.deleteCookie("https://example.com", "sessionId"); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const jar = pm.cookies.jar();'); + expect(translatedCode).toContain('jar.unset("https://example.com", "sessionId");'); + }); + + it('should translate deleteCookies to clear (jar variable)', () => { + const code = ` +const jar = bru.cookies.jar(); +jar.deleteCookies("https://example.com"); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const jar = pm.cookies.jar();'); + expect(translatedCode).toContain('jar.clear("https://example.com");'); + }); + + // Complex cookie scenarios + it('should handle multiple cookie operations together', () => { + const code = ` +const jar = bru.cookies.jar(); +const domain = "https://api.example.com"; + +// Check existing cookie +const existingToken = jar.getCookie(domain, "authToken"); + +if (!existingToken) { + // Set new cookie + jar.setCookie(domain, "authToken", bru.getEnvVar("token")); +} + +// Get all cookies for logging +const allCookies = jar.getCookies(domain); +console.log("Current cookies:", allCookies); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const jar = pm.cookies.jar();'); + expect(translatedCode).toContain('const existingToken = jar.get(domain, "authToken");'); + expect(translatedCode).toContain('jar.set(domain, "authToken", pm.environment.get("token"));'); + expect(translatedCode).toContain('const allCookies = jar.getAll(domain);'); + }); + + it('should handle cookie cleanup scenario', () => { + const code = ` +const jar = bru.cookies.jar(); +const domain = bru.getEnvVar("apiDomain"); + +// Clear specific cookies +jar.deleteCookie(domain, "session"); +jar.deleteCookie(domain, "tempToken"); + +// Or clear all cookies +if (bru.getVar("clearAll") === "true") { + jar.deleteCookies(domain); +} +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const jar = pm.cookies.jar();'); + expect(translatedCode).toContain('const domain = pm.environment.get("apiDomain");'); + expect(translatedCode).toContain('jar.unset(domain, "session");'); + expect(translatedCode).toContain('jar.unset(domain, "tempToken");'); + expect(translatedCode).toContain('if (pm.variables.get("clearAll") === "true") {'); + expect(translatedCode).toContain('jar.clear(domain);'); + }); +}); diff --git a/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/environment.test.js b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/environment.test.js new file mode 100644 index 000000000..e3d7040d3 --- /dev/null +++ b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/environment.test.js @@ -0,0 +1,111 @@ +import translateBruToPostman from '../../../src/utils/bruno-to-postman-translator'; + +describe('Bruno to Postman Environment Variable Translation', () => { + it('should translate bru.getEnvVar', () => { + const code = 'bru.getEnvVar("test");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.environment.get("test");'); + }); + + it('should translate bru.setEnvVar', () => { + const code = 'bru.setEnvVar("test", "value");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.environment.set("test", "value");'); + }); + + it('should translate bru.deleteEnvVar', () => { + const code = 'bru.deleteEnvVar("test");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.environment.unset("test");'); + }); + + it('should translate bru.hasEnvVar', () => { + const code = 'bru.hasEnvVar("apiKey");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.environment.has("apiKey");'); + }); + + it('should translate bru.getEnvName() to pm.environment.name (function to property)', () => { + const code = 'const envName = bru.getEnvName();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const envName = pm.environment.name;'); + }); + + it('should handle nested Postman API calls with environment', () => { + const code = 'bru.setEnvVar("computed", bru.getVar("base") + "-suffix");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.environment.set("computed", pm.variables.get("base") + "-suffix");'); + }); + + it('should handle JSON operations with environment variables', () => { + const code = 'bru.setEnvVar("user", JSON.stringify({ id: 123, name: "John" }));'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.environment.set("user", JSON.stringify({ id: 123, name: "John" }));'); + }); + + it('should handle JSON.parse with environment variables', () => { + const code = 'const userData = JSON.parse(bru.getEnvVar("user"));'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const userData = JSON.parse(pm.environment.get("user"));'); + }); + + it('should handle all environment variable methods together', () => { + const code = ` +// All environment variable methods +const token = bru.getEnvVar("token"); +bru.setEnvVar("timestamp", new Date().toISOString()); + +console.log(\`Token: \${token}\`); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const token = pm.environment.get("token");'); + expect(translatedCode).toContain('pm.environment.set("timestamp", new Date().toISOString());'); + }); + + it('should handle environment variables with computed property names', () => { + const code = ` +const prefix = "api"; +const suffix = "Key"; +bru.setEnvVar(prefix + "_" + suffix, "abc123"); +const computedValue = bru.getEnvVar(prefix + "_" + suffix); +`; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toContain('pm.environment.set(prefix + "_" + suffix, "abc123");'); + expect(translatedCode).toContain('const computedValue = pm.environment.get(prefix + "_" + suffix);'); + }); + + it('should handle environment variables in complex object structures', () => { + const code = ` +const config = { + baseUrl: bru.getEnvVar("apiUrl"), + headers: { + "Authorization": "Bearer " + bru.getEnvVar("token"), + "X-Api-Key": bru.getEnvVar("apiKey") || "default-key" + }, + timeout: parseInt(bru.getEnvVar("timeout") || "5000") +}; +`; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toContain('baseUrl: pm.environment.get("apiUrl"),'); + expect(translatedCode).toContain('"Authorization": "Bearer " + pm.environment.get("token"),'); + expect(translatedCode).toContain('"X-Api-Key": pm.environment.get("apiKey") || "default-key"'); + expect(translatedCode).toContain('timeout: parseInt(pm.environment.get("timeout") || "5000")'); + }); + + it('should handle environment variables in try-catch blocks', () => { + const code = ` +try { + const configStr = bru.getEnvVar("config"); + const config = JSON.parse(configStr); + console.log("Config loaded:", config.version); +} catch (error) { + console.error("Failed to parse config"); + bru.setEnvVar("configError", error.message); +} +`; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toContain('const configStr = pm.environment.get("config");'); + expect(translatedCode).toContain('pm.environment.set("configError", error.message);'); + }); +}); diff --git a/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/execution.test.js b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/execution.test.js new file mode 100644 index 000000000..2171cceb5 --- /dev/null +++ b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/execution.test.js @@ -0,0 +1,109 @@ +import translateBruToPostman from '../../../src/utils/bruno-to-postman-translator'; + +describe('Bruno to Postman Execution Control Translation', () => { + // setNextRequest translations + it('should translate bru.setNextRequest', () => { + const code = 'bru.setNextRequest("Get User Details");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.execution.setNextRequest("Get User Details");'); + }); + + it('should translate bru.runner.setNextRequest', () => { + const code = 'bru.runner.setNextRequest("Create Order");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.execution.setNextRequest("Create Order");'); + }); + + // skipRequest translation + it('should translate bru.runner.skipRequest', () => { + const code = 'bru.runner.skipRequest();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.execution.skipRequest();'); + }); + + // stopExecution translation + it('should translate bru.runner.stopExecution() to pm.execution.setNextRequest(null)', () => { + const code = 'bru.runner.stopExecution();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.execution.setNextRequest(null);'); + }); + + // Conditional execution control + it('should handle setNextRequest in conditionals', () => { + const code = ` +if (res.getStatus() === 401) { + bru.setNextRequest("Refresh Token"); +} else if (res.getStatus() === 200) { + bru.setNextRequest("Process Data"); +} +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('if (pm.response.code === 401) {'); + expect(translatedCode).toContain('pm.execution.setNextRequest("Refresh Token");'); + expect(translatedCode).toContain('} else if (pm.response.code === 200) {'); + expect(translatedCode).toContain('pm.execution.setNextRequest("Process Data");'); + }); + + it('should handle stopExecution in error handling', () => { + const code = ` +if (res.getStatus() >= 500) { + console.error("Server error, stopping execution"); + bru.runner.stopExecution(); +} +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('if (pm.response.code >= 500) {'); + expect(translatedCode).toContain('console.error("Server error, stopping execution");'); + expect(translatedCode).toContain('pm.execution.setNextRequest(null);'); + }); + + it('should handle skipRequest with condition', () => { + const code = ` +const shouldSkip = bru.getEnvVar("skipNextRequest") === "true"; +if (shouldSkip) { + bru.runner.skipRequest(); +} +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const shouldSkip = pm.environment.get("skipNextRequest") === "true";'); + expect(translatedCode).toContain('if (shouldSkip) {'); + expect(translatedCode).toContain('pm.execution.skipRequest();'); + }); + + it('should handle all execution control methods together', () => { + const code = ` +const status = res.getStatus(); +const data = res.getBody(); + +if (status === 200 && data.hasMore) { + bru.setNextRequest("Fetch Next Page"); +} else if (status === 429) { + console.log("Rate limited, skipping"); + bru.runner.skipRequest(); +} else if (status >= 500) { + bru.runner.stopExecution(); +} +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const status = pm.response.code;'); + expect(translatedCode).toContain('const data = pm.response.json();'); + expect(translatedCode).toContain('pm.execution.setNextRequest("Fetch Next Page");'); + expect(translatedCode).toContain('pm.execution.skipRequest();'); + expect(translatedCode).toContain('pm.execution.setNextRequest(null);'); + }); + + it('should handle dynamic request names in setNextRequest', () => { + const code = ` +const nextRequest = bru.getVar("nextRequestName"); +bru.setNextRequest(nextRequest); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const nextRequest = pm.variables.get("nextRequestName");'); + expect(translatedCode).toContain('pm.execution.setNextRequest(nextRequest);'); + }); +}); diff --git a/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/request.test.js b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/request.test.js new file mode 100644 index 000000000..5fd504217 --- /dev/null +++ b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/request.test.js @@ -0,0 +1,91 @@ +import translateBruToPostman from '../../../src/utils/bruno-to-postman-translator'; + +describe('Bruno to Postman Request Translation', () => { + it('should translate req.getUrl() to pm.request.url (function to property)', () => { + const code = 'const url = req.getUrl();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const url = pm.request.url;'); + }); + + it('should translate req.getMethod() to pm.request.method (function to property)', () => { + const code = 'const method = req.getMethod();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const method = pm.request.method;'); + }); + + it('should translate req.getHeaders() to pm.request.headers (function to property)', () => { + const code = 'const headers = req.getHeaders();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const headers = pm.request.headers;'); + }); + + it('should translate req.getBody() to pm.request.body (function to property)', () => { + const code = 'const body = req.getBody();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const body = pm.request.body;'); + }); + + it('should translate req.getName() to pm.info.requestName (function to property)', () => { + const code = 'const name = req.getName();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const name = pm.info.requestName;'); + }); + + it('should translate req.getHeader() to pm.request.headers.get()', () => { + const code = 'const contentType = req.getHeader("Content-Type");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const contentType = pm.request.headers.get("Content-Type");'); + }); + + it('should translate req.setHeader() to pm.request.headers.set()', () => { + const code = 'req.setHeader("Authorization", "Bearer token123");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.request.headers.set("Authorization", "Bearer token123");'); + }); + + it('should handle all request properties together', () => { + const code = ` +// All request properties +const url = req.getUrl(); +const method = req.getMethod(); +const headers = req.getHeaders(); +const body = req.getBody(); +const name = req.getName(); + +console.log(\`Request: \${method} \${url} - \${name}\`); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const url = pm.request.url;'); + expect(translatedCode).toContain('const method = pm.request.method;'); + expect(translatedCode).toContain('const headers = pm.request.headers;'); + expect(translatedCode).toContain('const body = pm.request.body;'); + expect(translatedCode).toContain('const name = pm.info.requestName;'); + }); + + it('should handle request properties in conditionals', () => { + const code = ` +if (req.getMethod() === 'POST' || req.getMethod() === 'PUT') { + const body = req.getBody(); + console.log("Request body:", body); +} +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('if (pm.request.method === \'POST\' || pm.request.method === \'PUT\') {'); + expect(translatedCode).toContain('const body = pm.request.body;'); + }); + + it('should handle request logging', () => { + const code = ` +console.log("Making request to:", req.getUrl()); +console.log("Method:", req.getMethod()); +console.log("Headers:", JSON.stringify(req.getHeaders())); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('console.log("Making request to:", pm.request.url);'); + expect(translatedCode).toContain('console.log("Method:", pm.request.method);'); + expect(translatedCode).toContain('console.log("Headers:", JSON.stringify(pm.request.headers));'); + }); +}); diff --git a/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/response.test.js b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/response.test.js new file mode 100644 index 000000000..0bf18e355 --- /dev/null +++ b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/response.test.js @@ -0,0 +1,189 @@ +import translateBruToPostman from '../../../src/utils/bruno-to-postman-translator'; + +describe('Bruno to Postman Response Translation', () => { + // Basic response property tests + it('should translate res.getBody()', () => { + const code = 'const jsonData = res.getBody();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const jsonData = pm.response.json();'); + }); + + it('should translate res.getStatus() to pm.response.code (function to property)', () => { + const code = 'if (res.getStatus() === 200) { console.log("Success"); }'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('if (pm.response.code === 200) { console.log("Success"); }'); + }); + + it('should translate JSON.stringify(res.getBody()) to pm.response.text()', () => { + const code = 'const responseText = JSON.stringify(res.getBody());'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const responseText = pm.response.text();'); + }); + + it('should translate res.getResponseTime() to pm.response.responseTime (function to property)', () => { + const code = 'console.log("Response time:", res.getResponseTime());'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('console.log("Response time:", pm.response.responseTime);'); + }); + + it('should translate res.statusText to pm.response.status (property to property)', () => { + const code = 'console.log("Status text:", res.statusText);'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('console.log("Status text:", pm.response.status);'); + }); + + it('should translate res.status to pm.response.code (property to property)', () => { + const code = 'const code = res.status;'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const code = pm.response.code;'); + }); + + it('should translate res.getStatusText() to pm.response.status (function to property)', () => { + const code = 'const statusText = res.getStatusText();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const statusText = pm.response.status;'); + }); + + it('should translate res.getHeaders() to pm.response.headers (function to property)', () => { + const code = 'console.log("Headers:", res.getHeaders());'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('console.log("Headers:", pm.response.headers);'); + }); + + it('should translate res.getHeader()', () => { + const code = 'const contentType = res.getHeader("Content-Type");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const contentType = pm.response.headers.get("Content-Type");'); + }); + + // Response assertions - translated to pm.expect with response properties + it('should transform expect(res.getStatus()).to.equal() to pm.expect(pm.response.code).to.equal()', () => { + const code = 'expect(res.getStatus()).to.equal(201);'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.expect(pm.response.code).to.equal(201);'); + }); + + it('should transform expect(res.getHeaders()).to.have.property() to pm.expect(pm.response.headers).to.have.property()', () => { + const code = 'expect(res.getHeaders()).to.have.property("Content-Type".toLowerCase());'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.expect(pm.response.headers).to.have.property("Content-Type".toLowerCase());'); + }); + + it('should transform expect(res.getBody()).to.equal() to pm.expect(pm.response.json()).to.equal()', () => { + const code = 'expect(res.getBody()).to.equal("Expected response body");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.expect(pm.response.json()).to.equal("Expected response body");'); + }); + + // getSize translations + it('should translate res.getSize()', () => { + const code = 'const size = res.getSize();'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const size = pm.response.size();'); + }); + + it('should translate res.getSize().body', () => { + const code = 'const bodySize = res.getSize().body;'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const bodySize = pm.response.size().body;'); + }); + + it('should translate res.getSize().header', () => { + const code = 'const headerSize = res.getSize().header;'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const headerSize = pm.response.size().header;'); + }); + + it('should translate res.getSize().total', () => { + const code = 'const totalSize = res.getSize().total;'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const totalSize = pm.response.size().total;'); + }); + + // Complex response handling + it('should handle response data with destructuring', () => { + const code = ` +const { id, name, items } = res.getBody(); +const [first, second] = items; +bru.setEnvVar("userId", id); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const { id, name, items } = pm.response.json();'); + expect(translatedCode).toContain('const [first, second] = items;'); + expect(translatedCode).toContain('pm.environment.set("userId", id);'); + }); + + it('should handle response JSON with optional chaining', () => { + const code = ` +const userId = res.getBody()?.user?.id ?? "anonymous"; +const items = res.getBody()?.data?.items || []; +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const userId = pm.response.json()?.user?.id ?? "anonymous";'); + expect(translatedCode).toContain('const items = pm.response.json()?.data?.items || [];'); + }); + + it('should handle response in complex conditionals', () => { + const code = ` +if (res.getStatus() >= 200 && res.getStatus() < 300) { + if (res.getHeader('Content-Type').includes('application/json')) { + const data = res.getBody(); + + if (data.success === true && data.token) { + bru.setEnvVar("authToken", data.token); + } else if (data.error) { + console.error("API error:", data.error); + } + } +} else if (res.getStatus() === 404) { + console.log("Resource not found"); +} else { + console.error("Request failed with status:", res.getStatus()); +} +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('if (pm.response.code >= 200 && pm.response.code < 300) {'); + expect(translatedCode).toContain('if (pm.response.headers.get(\'Content-Type\').includes(\'application/json\')) {'); + expect(translatedCode).toContain('const data = pm.response.json();'); + expect(translatedCode).toContain('pm.environment.set("authToken", data.token);'); + expect(translatedCode).toContain('} else if (pm.response.code === 404) {'); + expect(translatedCode).toContain('console.error("Request failed with status:", pm.response.code);'); + }); + + it('should handle all response property methods together', () => { + const code = ` +// All response property methods +const statusCode = res.getStatus(); +const responseBody = res.getBody(); +const statusText = res.statusText; +const responseTime = res.getResponseTime(); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const statusCode = pm.response.code;'); + expect(translatedCode).toContain('const responseBody = pm.response.json();'); + expect(translatedCode).toContain('const statusText = pm.response.status;'); + expect(translatedCode).toContain('const responseTime = pm.response.responseTime;'); + }); + + it('should handle response processing in arrow functions', () => { + const code = ` +const processItems = () => { + const items = res.getBody().items; + return items.map(item => item.id); +}; + +const itemIds = processItems(); +bru.setEnvVar("itemIds", JSON.stringify(itemIds)); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const items = pm.response.json().items;'); + expect(translatedCode).toContain('return items.map(item => item.id);'); + expect(translatedCode).toContain('const itemIds = processItems();'); + expect(translatedCode).toContain('pm.environment.set("itemIds", JSON.stringify(itemIds));'); + }); +}); diff --git a/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/testing-framework.test.js b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/testing-framework.test.js new file mode 100644 index 000000000..d8ec7831f --- /dev/null +++ b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/testing-framework.test.js @@ -0,0 +1,232 @@ +import translateBruToPostman from '../../../src/utils/bruno-to-postman-translator'; + +describe('Bruno to Postman Testing Framework Translation', () => { + // Basic testing framework translations + it('should translate test() to pm.test()', () => { + const code = 'test("Status code is 200", function() { expect(res.getStatus()).to.equal(200); });'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.test("Status code is 200", function() { pm.expect(pm.response.code).to.equal(200); });'); + }); + + it('should translate expect() to pm.expect()', () => { + const code = 'expect(jsonData.success).to.be.true;'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.expect(jsonData.success).to.be.true;'); + }); + + it('should translate expect.fail() to pm.expect.fail()', () => { + const code = 'if (!isValid) expect.fail("Data is invalid");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('if (!isValid) pm.expect.fail("Data is invalid");'); + }); + + // Tests with response assertions + it('should translate test with status check', () => { + const code = ` +test("Check environment and call successful", function () { + expect(bru.getEnvName()).to.equal("ENVIRONMENT_NAME"); + expect(res.getStatus()).to.equal(200); +});`; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe(` +pm.test("Check environment and call successful", function () { + pm.expect(pm.environment.name).to.equal("ENVIRONMENT_NAME"); + pm.expect(pm.response.code).to.equal(200); +});`); + }); + + // Test with arrow functions + it('should translate test with arrow functions', () => { + const code = ` +test("Status code is 200", () => { + expect(res.getStatus()).to.equal(200); +}); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('pm.test("Status code is 200", () => {'); + expect(translatedCode).toContain('pm.expect(pm.response.code).to.equal(200);'); + }); + + it('should handle multiple test assertions in one function', () => { + const code = ` +test("The response has all properties", () => { + const responseJson = res.getBody(); + expect(responseJson.type).to.eql('vip'); + expect(responseJson.name).to.be.a('string'); + expect(responseJson.id).to.have.lengthOf(1); +}); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('pm.test("The response has all properties", () => {'); + expect(translatedCode).toContain('const responseJson = pm.response.json();'); + expect(translatedCode).toContain('pm.expect(responseJson.type).to.eql(\'vip\');'); + expect(translatedCode).toContain('pm.expect(responseJson.name).to.be.a(\'string\');'); + expect(translatedCode).toContain('pm.expect(responseJson.id).to.have.lengthOf(1);'); + }); + + // Tests inside different code structures + it('should translate test commands inside tests with nested functions', () => { + const code = ` +test("Auth flow works", function() { + const response = res.getBody(); + expect(response.authenticated).to.be.true; + bru.setEnvVar("userId", response.user.id); + bru.setVar("sessionId", response.session.id); +}); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('pm.test("Auth flow works", function() {'); + expect(translatedCode).toContain('const response = pm.response.json();'); + expect(translatedCode).toContain('pm.expect(response.authenticated).to.be.true;'); + expect(translatedCode).toContain('pm.environment.set("userId", response.user.id);'); + expect(translatedCode).toContain('pm.variables.set("sessionId", response.session.id);'); + }); + + it('should handle nested test functions', () => { + const code = ` +test("Main test group", function() { + const responseJson = res.getBody(); + + test("User data validation", function() { + expect(responseJson.user).to.be.an('object'); + expect(responseJson.user.id).to.be.a('string'); + }); + + test("Settings validation", function() { + expect(responseJson.settings).to.be.an('object'); + expect(responseJson.settings.notifications).to.be.a('boolean'); + }); +}); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('pm.test("Main test group", function() {'); + expect(translatedCode).toContain('const responseJson = pm.response.json();'); + expect(translatedCode).toContain('pm.test("User data validation", function() {'); + expect(translatedCode).toContain('pm.expect(responseJson.user).to.be.an(\'object\');'); + expect(translatedCode).toContain('pm.test("Settings validation", function() {'); + expect(translatedCode).toContain('pm.expect(responseJson.settings.notifications).to.be.a(\'boolean\');'); + }); + + it('should handle test with dynamic test names', () => { + const code = ` +const endpoint = bru.getVar("currentEndpoint"); + +test(\`\${endpoint} returns correct data\`, function() { + const responseJson = res.getBody(); + expect(responseJson).to.be.an('object'); +}); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const endpoint = pm.variables.get("currentEndpoint");'); + expect(translatedCode).toContain('pm.test(`${endpoint} returns correct data`, function() {'); + expect(translatedCode).toContain('const responseJson = pm.response.json();'); + expect(translatedCode).toContain('pm.expect(responseJson).to.be.an(\'object\');'); + }); + + it('should handle test with conditional execution', () => { + const code = ` +const responseJson = res.getBody(); + +if (responseJson.type === 'user') { + test("User validation", function() { + expect(responseJson.name).to.be.a('string'); + expect(responseJson.email).to.be.a('string'); + }); +} else if (responseJson.type === 'admin') { + test("Admin validation", function() { + expect(responseJson.accessLevel).to.be.above(5); + expect(responseJson.permissions).to.be.an('array'); + }); +} +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const responseJson = pm.response.json();'); + expect(translatedCode).toContain('if (responseJson.type === \'user\') {'); + expect(translatedCode).toContain('pm.test("User validation", function() {'); + expect(translatedCode).toContain('pm.expect(responseJson.name).to.be.a(\'string\');'); + expect(translatedCode).toContain('} else if (responseJson.type === \'admin\') {'); + expect(translatedCode).toContain('pm.test("Admin validation", function() {'); + expect(translatedCode).toContain('pm.expect(responseJson.accessLevel).to.be.above(5);'); + }); + + it('should handle assertions with logical operators', () => { + const code = ` +test("Response has valid structure", function() { + const data = res.getBody(); + + expect(data.id && data.name).to.be.ok; + expect(data.active || data.pending).to.be.true; + expect(!data.deleted).to.be.true; +}); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('pm.test("Response has valid structure", function() {'); + expect(translatedCode).toContain('const data = pm.response.json();'); + expect(translatedCode).toContain('pm.expect(data.id && data.name).to.be.ok;'); + expect(translatedCode).toContain('pm.expect(data.active || data.pending).to.be.true;'); + expect(translatedCode).toContain('pm.expect(!data.deleted).to.be.true;'); + }); + + it('should handle array and object assertions', () => { + const code = ` +test("Array and object validations", function() { + const data = res.getBody(); + + // Array validations + expect(data.items).to.be.an('array'); + expect(data.items).to.have.lengthOf.at.least(1); + expect(data.items[0]).to.have.property('id'); + + // Object validations + expect(data.user).to.be.an('object'); + expect(data.user).to.have.all.keys('id', 'name', 'email'); + expect(data.user).to.include({active: true}); +}); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('pm.test("Array and object validations", function() {'); + expect(translatedCode).toContain('const data = pm.response.json();'); + expect(translatedCode).toContain('pm.expect(data.items).to.be.an(\'array\');'); + expect(translatedCode).toContain('pm.expect(data.items).to.have.lengthOf.at.least(1);'); + expect(translatedCode).toContain('pm.expect(data.items[0]).to.have.property(\'id\');'); + expect(translatedCode).toContain('pm.expect(data.user).to.be.an(\'object\');'); + expect(translatedCode).toContain('pm.expect(data.user).to.have.all.keys(\'id\', \'name\', \'email\');'); + expect(translatedCode).toContain('pm.expect(data.user).to.include({active: true});'); + }); + + it('should handle expect.fail with conditions', () => { + const code = ` +test("Validate critical fields", function() { + const data = res.getBody(); + + if (!data.id) { + expect.fail("Missing ID field"); + } + + if (data.status !== 'active' && data.status !== 'pending') { + expect.fail("Invalid status: " + data.status); + } + + // Continue with normal assertions + expect(data.name).to.be.a('string'); +}); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('pm.test("Validate critical fields", function() {'); + expect(translatedCode).toContain('const data = pm.response.json();'); + expect(translatedCode).toContain('if (!data.id) {'); + expect(translatedCode).toContain('pm.expect.fail("Missing ID field");'); + expect(translatedCode).toContain('if (data.status !== \'active\' && data.status !== \'pending\') {'); + expect(translatedCode).toContain('pm.expect.fail("Invalid status: " + data.status);'); + expect(translatedCode).toContain('pm.expect(data.name).to.be.a(\'string\');'); + }); +}); diff --git a/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/variables.test.js b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/variables.test.js new file mode 100644 index 000000000..74924d9c9 --- /dev/null +++ b/packages/bruno-converters/tests/bruno/bruno-to-postman-translations/variables.test.js @@ -0,0 +1,103 @@ +import translateBruToPostman from '../../../src/utils/bruno-to-postman-translator'; + +describe('Bruno to Postman Variables Translation', () => { + // Regular variables tests + it('should translate bru.getVar', () => { + const code = 'bru.getVar("test");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.variables.get("test");'); + }); + + it('should translate bru.setVar', () => { + const code = 'bru.setVar("test", "value");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.variables.set("test", "value");'); + }); + + it('should translate bru.hasVar', () => { + const code = 'bru.hasVar("userId");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.variables.has("userId");'); + }); + + it('should translate bru.deleteVar', () => { + const code = 'bru.deleteVar("tempVar");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.variables.unset("tempVar");'); + }); + + it('should translate bru.interpolate', () => { + const code = 'bru.interpolate("Hello {{name}}");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.variables.replaceIn("Hello {{name}}");'); + }); + + it('should translate bru.interpolate with complex template', () => { + const code = 'const greeting = bru.interpolate("Hello {{name}}, your user id is {{userId}}");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const greeting = pm.variables.replaceIn("Hello {{name}}, your user id is {{userId}}");'); + }); + + // Global variables tests + it('should translate bru.getGlobalEnvVar', () => { + const code = 'bru.getGlobalEnvVar("test");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.globals.get("test");'); + }); + + it('should translate bru.setGlobalEnvVar', () => { + const code = 'bru.setGlobalEnvVar("test", "value");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.globals.set("test", "value");'); + }); + + // Collection variables tests + it('should translate bru.getCollectionVar', () => { + const code = 'bru.getCollectionVar("baseUrl");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.variables.get("baseUrl");'); + }); + + // Folder variables tests + it('should translate bru.getFolderVar', () => { + const code = 'bru.getFolderVar("folderToken");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.variables.get("folderToken");'); + }); + + // Request variables tests + it('should translate bru.getRequestVar', () => { + const code = 'bru.getRequestVar("requestId");'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.variables.get("requestId");'); + }); + + // Combined tests + it('should handle conditional expressions with variable calls', () => { + const code = 'const userStatus = bru.hasVar("userId") ? "logged-in" : "guest";'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('const userStatus = pm.variables.has("userId") ? "logged-in" : "guest";'); + }); + + it('should handle all variable methods together', () => { + const code = ` +// All variable methods +const hasUserId = bru.hasVar("userId"); +const userId = bru.getVar("userId"); +bru.setVar("requestTime", new Date().toISOString()); + +console.log(\`Has userId: \${hasUserId}, User ID: \${userId}\`); +`; + const translatedCode = translateBruToPostman(code); + + expect(translatedCode).toContain('const hasUserId = pm.variables.has("userId");'); + expect(translatedCode).toContain('const userId = pm.variables.get("userId");'); + expect(translatedCode).toContain('pm.variables.set("requestTime", new Date().toISOString());'); + }); + + it('should handle nested expressions with variables', () => { + const code = 'bru.setVar("fullPath", bru.getEnvVar("baseUrl") + bru.getVar("endpoint"));'; + const translatedCode = translateBruToPostman(code); + expect(translatedCode).toBe('pm.variables.set("fullPath", pm.environment.get("baseUrl") + pm.variables.get("endpoint"));'); + }); +}); diff --git a/packages/bruno-converters/tests/bruno/bruno-to-postman-with-tests.spec.js b/packages/bruno-converters/tests/bruno/bruno-to-postman-with-tests.spec.js index e8d91e053..a1806680e 100644 --- a/packages/bruno-converters/tests/bruno/bruno-to-postman-with-tests.spec.js +++ b/packages/bruno-converters/tests/bruno/bruno-to-postman-with-tests.spec.js @@ -34,7 +34,7 @@ describe('Bruno to Postman Converter with Tests and Scripts', () => { }, vars: {}, assertions: [], - tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.text).not.to.eql("");\n});', + tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.body).not.to.eql("");\n});', docs: '', auth: { mode: 'none' @@ -56,7 +56,7 @@ describe('Bruno to Postman Converter with Tests and Scripts', () => { req: 'console.log("scripts-folder script line 1");\nconsole.log("scripts-folder script line 2")', res: 'console.log("scripts-folder script line 1");\nconsole.log("scripts-folder script line 2")' }, - tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.text).not.to.eql("");\n});' + tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.body).not.to.eql("");\n});' }, meta: { name: 'Scripts Folder', @@ -93,7 +93,7 @@ describe('Bruno to Postman Converter with Tests and Scripts', () => { }, vars: {}, assertions: [], - tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.text).not.to.eql("");\n});', + tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.body).not.to.eql("");\n});', docs: '', auth: { mode: 'none' @@ -115,7 +115,7 @@ describe('Bruno to Postman Converter with Tests and Scripts', () => { req: 'console.log("scripts-inner-folder script line 1");\nconsole.log("scripts-inner-folder script line 2")', res: 'console.log("scripts-inner-folder script line 1");\nconsole.log("scripts-inner-folder script line 2")' }, - tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.text).not.to.eql("");\n});' + tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.body).not.to.eql("");\n});' }, meta: { name: 'Scripts Inner Folder', @@ -152,7 +152,7 @@ describe('Bruno to Postman Converter with Tests and Scripts', () => { }, vars: {}, assertions: [], - tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.text).not.to.eql("");\n});', + tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.body).not.to.eql("");\n});', docs: '', auth: { mode: 'none' @@ -171,7 +171,7 @@ describe('Bruno to Postman Converter with Tests and Scripts', () => { req: 'console.log("root-request script line 1");\nconsole.log("root-request script line 2")', res: 'console.log("root-request script line 1");\nconsole.log("root-request script line 2")' }, - tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.text).not.to.eql("");\n});' + tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.body).not.to.eql("");\n});' } }, brunoConfig: { @@ -204,11 +204,11 @@ describe('Bruno to Postman Converter with Tests and Scripts', () => { 'console.log("root-request script line 2")', '', '// Tests', - 'test("Status code is 200", () => {', - ' expect(res.status).to.eql(200);', + 'pm.test("Status code is 200", () => {', + ' pm.expect(pm.response.code).to.eql(200);', '});', - 'test("Body is not empty", () => {', - ' expect(res.text).not.to.eql("");', + 'pm.test("Body is not empty", () => {', + ' pm.expect(pm.response.body).not.to.eql("");', '});' ]); }); @@ -230,11 +230,11 @@ describe('Bruno to Postman Converter with Tests and Scripts', () => { 'console.log("scripts-folder script line 2")', '', '// Tests', - 'test("Status code is 200", () => {', - ' expect(res.status).to.eql(200);', + 'pm.test("Status code is 200", () => {', + ' pm.expect(pm.response.code).to.eql(200);', '});', - 'test("Body is not empty", () => {', - ' expect(res.text).not.to.eql("");', + 'pm.test("Body is not empty", () => {', + ' pm.expect(pm.response.body).not.to.eql("");', '});' ]); }); @@ -257,11 +257,11 @@ describe('Bruno to Postman Converter with Tests and Scripts', () => { 'console.log("scripts-inner-folder script line 2")', '', '// Tests', - 'test("Status code is 200", () => {', - ' expect(res.status).to.eql(200);', + 'pm.test("Status code is 200", () => {', + ' pm.expect(pm.response.code).to.eql(200);', '});', - 'test("Body is not empty", () => {', - ' expect(res.text).not.to.eql("");', + 'pm.test("Body is not empty", () => {', + ' pm.expect(pm.response.body).not.to.eql("");', '});' ]); }); @@ -282,11 +282,11 @@ describe('Bruno to Postman Converter with Tests and Scripts', () => { 'console.log("root-request script line 2")', '', '// Tests', - 'test("Status code is 200", () => {', - ' expect(res.status).to.eql(200);', + 'pm.test("Status code is 200", () => {', + ' pm.expect(pm.response.code).to.eql(200);', '});', - 'test("Body is not empty", () => {', - ' expect(res.text).not.to.eql("");', + 'pm.test("Body is not empty", () => {', + ' pm.expect(pm.response.body).not.to.eql("");', '});' ]); }); diff --git a/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-to-bruno.spec.js b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-to-bruno.spec.js index d2e087961..8e83e81e3 100644 --- a/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-to-bruno.spec.js +++ b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-to-bruno.spec.js @@ -357,3 +357,87 @@ const expectedOutput = { uid: 'mockeduuidvalue123456', version: '1' }; + +describe('openapi-collection: object schema parameters', () => { + it('should expand object schema query parameters with $ref into individual properties', () => { + const openApiSpec = ` +openapi: '3.0.3' +info: + title: 'Test API for Object Schema Parameters' + version: '1.0.0' +servers: + - url: 'https://api.example.com/v1' +paths: + /items: + get: + summary: 'Get items with pagination' + operationId: 'getItems' + parameters: + - name: date + in: query + required: true + schema: + type: string + format: date + description: 'Filter by date' + - name: paginationParams + in: query + required: true + schema: + $ref: '#/components/schemas/PaginationParams' + responses: + '200': + description: 'Successful response' +components: + schemas: + PaginationParams: + type: object + properties: + page: + type: integer + format: int32 + minimum: 0 + description: 'Page number' + size: + type: integer + format: int32 + maximum: 100 + minimum: 1 + description: 'Page size' + required: + - page + - size +`; + + const result = openApiToBruno(openApiSpec); + + // Find the request item + const requestItem = result.items[0]; + + // Verify that we have 3 query parameters: date, page, size + const queryParams = requestItem.request.params.filter((p) => p.type === 'query'); + expect(queryParams.length).toBe(3); + + // Check that 'date' parameter exists + const dateParam = queryParams.find((p) => p.name === 'date'); + expect(dateParam).toBeDefined(); + expect(dateParam.description).toBe('Filter by date'); + expect(dateParam.enabled).toBe(true); + + // Check that 'page' parameter exists (expanded from PaginationParams) + const pageParam = queryParams.find((p) => p.name === 'page'); + expect(pageParam).toBeDefined(); + expect(pageParam.description).toBe('Page number'); + expect(pageParam.enabled).toBe(true); // required in schema + + // Check that 'size' parameter exists (expanded from PaginationParams) + const sizeParam = queryParams.find((p) => p.name === 'size'); + expect(sizeParam).toBeDefined(); + expect(sizeParam.description).toBe('Page size'); + expect(sizeParam.enabled).toBe(true); // required in schema + + // Verify that 'paginationParams' does NOT exist as a parameter + const paginationParam = queryParams.find((p) => p.name === 'paginationParams'); + expect(paginationParam).toBeUndefined(); + }); +}); diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-comments.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-comments.spec.js index 7cb5df0e5..9b58a0528 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/postman-comments.spec.js +++ b/packages/bruno-converters/tests/postman/postman-translations/postman-comments.spec.js @@ -1,4 +1,4 @@ -const { default: postmanTranslation } = require('../../../src/postman/postman-translations'); +import postmanTranslation from '../../../src/postman/postman-translations'; describe('postmanTranslations - comment handling', () => { test('should not translate non-pm commands', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-cookie-conversions.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-cookie-conversions.spec.js index 9b32883a8..7ee9ee997 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/postman-cookie-conversions.spec.js +++ b/packages/bruno-converters/tests/postman/postman-translations/postman-cookie-conversions.spec.js @@ -1,4 +1,4 @@ -const { default: postmanTranslation } = require('../../../src/postman/postman-translations'); +import postmanTranslation from '../../../src/postman/postman-translations'; describe('postmanTranslations - cookie API conversions', () => { test('should convert pm.cookies.jar().get to bru.cookies.jar().getCookie', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-edge-cases.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-edge-cases.spec.js index a70226102..904e1521f 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/postman-edge-cases.spec.js +++ b/packages/bruno-converters/tests/postman/postman-translations/postman-edge-cases.spec.js @@ -1,4 +1,4 @@ -const { default: postmanTranslation } = require('../../../src/postman/postman-translations'); +import postmanTranslation from '../../../src/postman/postman-translations'; describe('postmanTranslations - edge cases', () => { test('should handle nested commands and edge cases', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-test-commands.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-test-commands.spec.js index 5091b32f5..71210f324 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/postman-test-commands.spec.js +++ b/packages/bruno-converters/tests/postman/postman-translations/postman-test-commands.spec.js @@ -1,4 +1,4 @@ -const { default: postmanTranslation } = require('../../../src/postman/postman-translations'); +import postmanTranslation from '../../../src/postman/postman-translations'; describe('postmanTranslations - test commands', () => { test('should handle test commands', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/postman-variables.spec.js b/packages/bruno-converters/tests/postman/postman-translations/postman-variables.spec.js index 3ce310908..13413527b 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/postman-variables.spec.js +++ b/packages/bruno-converters/tests/postman/postman-translations/postman-variables.spec.js @@ -1,4 +1,4 @@ -const { default: postmanTranslation } = require('../../../src/postman/postman-translations'); +import postmanTranslation from '../../../src/postman/postman-translations'; describe('postmanTranslations - variables commands', () => { test('should translate variable commands correctly', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/combined.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/combined.test.js index 86eb58eed..565a5cf36 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/combined.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/combined.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Combined API Features Translation', () => { // Basic translation test diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/environment.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/environment.test.js index 47c46ce19..1060886b8 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/environment.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/environment.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Environment Variable Translation', () => { it('should translate pm.environment.get', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/exec-flow.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/exec-flow.test.js index 838b6a838..d52f08519 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/exec-flow.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/exec-flow.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Execution Flow Translation', () => { // Request flow control diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-global-apis.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-global-apis.test.js index 549611acd..c4e72d4fa 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-global-apis.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-global-apis.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator.js'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Legacy Postman API Translation', () => { describe('handleLegacyGlobalAPIs - No Conflicts', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-tests-syntax.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-tests-syntax.test.js index f10203bbe..b8beb8aea 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-tests-syntax.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/legacy-tests-syntax.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Legacy Tests[] Syntax Translation', () => { it('should handle tests[] commands', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/multiline-syntax.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/multiline-syntax.test.js index cb7a61685..94930a02f 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/multiline-syntax.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/multiline-syntax.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Multiline Syntax Handling', () => { it('should handle basic multiline variable syntax with indentation', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/postman-references.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/postman-references.test.js index 99adb5447..a7618561f 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/postman-references.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/postman-references.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Postman to PM References Conversion', () => { // Basic conversions diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/request.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/request.test.js index 989fd3817..56a80be39 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/request.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/request.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Request Translation', () => { it('should translate pm.request.url', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/response.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/response.test.js index d154ba9ef..dd191ea9f 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/response.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/response.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Response Translation', () => { // Basic response property tests diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/scoped-variables.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/scoped-variables.test.js index d6c8966b2..e98e95885 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/scoped-variables.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/scoped-variables.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Scoped Variables', () => { it.skip('should handle scoped variables correctly', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/testing-framework.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/testing-framework.test.js index ab6c690b6..9a0bc2194 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/testing-framework.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/testing-framework.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Testing Framework Translation', () => { // Basic testing framework translations diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/transformers/send-request.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/transformers/send-request.test.js index dadb15830..409e5231d 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/transformers/send-request.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/transformers/send-request.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../../src/utils/postman-to-bruno-translator'; describe('Send Request Translation', () => { describe('Raw Body Mode', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variable-chaining.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variable-chaining.test.js index 86b987b6d..b2d6a58f1 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variable-chaining.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variable-chaining.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Variable Chaining Resolution', () => { test('should resolve a simple variable chain (variable pointing to another variable)', () => { diff --git a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variables.test.js b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variables.test.js index 43824a0f8..1acee22d1 100644 --- a/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variables.test.js +++ b/packages/bruno-converters/tests/postman/postman-translations/transpiler-tests/variables.test.js @@ -1,4 +1,4 @@ -import translateCode from '../../../../src/utils/jscode-shift-translator'; +import translateCode from '../../../../src/utils/postman-to-bruno-translator'; describe('Variables Translation', () => { // Regular variables tests diff --git a/packages/bruno-converters/tests/utils/getMemberExpressionString.test.js b/packages/bruno-converters/tests/utils/getMemberExpressionString.test.js index fe1f9b5fc..db0803ec8 100644 --- a/packages/bruno-converters/tests/utils/getMemberExpressionString.test.js +++ b/packages/bruno-converters/tests/utils/getMemberExpressionString.test.js @@ -1,5 +1,5 @@ -import { describe, it, expect } from '@jest/globals'; -import { getMemberExpressionString } from '../../src/utils/jscode-shift-translator'; +const { describe, it, expect } = require('@jest/globals'); +const { getMemberExpressionString } = require('../../src/utils/ast-utils'); const j = require('jscodeshift'); describe('getMemberExpressionString', () => { diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index d9c8db556..55c8dbdb7 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -68,7 +68,7 @@ "lodash": "^4.17.21", "mime-types": "^2.1.35", "nanoid": "3.3.8", - "qs": "^6.11.0", + "qs": "^6.14.1", "socks-proxy-agent": "^8.0.2", "tough-cookie": "^6.0.0", "uuid": "^9.0.0", diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js index 52a2998aa..ec2aae053 100644 --- a/packages/bruno-electron/src/app/collection-watcher.js +++ b/packages/bruno-electron/src/app/collection-watcher.js @@ -583,7 +583,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => { const fileStats = fs.statSync(pathname); if (fileStats.size >= MAX_FILE_SIZE && format === 'bru') { - file.data = await parseLargeRequestWithRedaction(content); + file.data = await parseLargeRequestWithRedaction(content, 'bru'); } else { file.data = await parseRequest(content, { format }); } diff --git a/packages/bruno-electron/src/app/workspace-watcher.js b/packages/bruno-electron/src/app/workspace-watcher.js index f9c3d97cc..aecaa27d2 100644 --- a/packages/bruno-electron/src/app/workspace-watcher.js +++ b/packages/bruno-electron/src/app/workspace-watcher.js @@ -5,9 +5,10 @@ const chokidar = require('chokidar'); const yaml = require('js-yaml'); const { generateUidBasedOnHash, uuid } = require('../utils/common'); const { getWorkspaceUid } = require('../utils/workspace-config'); -const { parseEnvironment } = require('@usebruno/filestore'); +const { parseEnvironment, parseDotEnv } = require('@usebruno/filestore'); const EnvironmentSecretsStore = require('../store/env-secrets'); const { decryptStringSafe } = require('../utils/encryption'); +const { setWorkspaceDotEnvVars, clearWorkspaceDotEnvVars } = require('../store/process-env'); const environmentSecretsStore = new EnvironmentSecretsStore(); @@ -122,15 +123,51 @@ const handleGlobalEnvironmentFileUnlink = async (win, pathname, workspaceUid) => } }; +const handleWorkspaceDotEnvFile = (win, workspacePath, workspaceUid) => { + try { + const dotEnvPath = path.join(workspacePath, '.env'); + if (!fs.existsSync(dotEnvPath)) { + return; + } + + const content = fs.readFileSync(dotEnvPath, 'utf8'); + const jsonData = parseDotEnv(content); + + setWorkspaceDotEnvVars(workspacePath, jsonData); + win.webContents.send('main:workspace-dotenv-update', { + workspaceUid, + workspacePath, + processEnvVariables: { ...jsonData } + }); + } catch (error) { + console.error('Error handling workspace .env file:', error); + } +}; + +const handleWorkspaceDotEnvUnlink = (win, workspacePath, workspaceUid) => { + try { + clearWorkspaceDotEnvVars(workspacePath); + win.webContents.send('main:workspace-dotenv-update', { + workspaceUid, + workspacePath, + processEnvVariables: {} + }); + } catch (error) { + console.error('Error handling workspace .env file unlink:', error); + } +}; + class WorkspaceWatcher { constructor() { this.watchers = {}; this.environmentWatchers = {}; + this.dotEnvWatchers = {}; } addWatcher(win, workspacePath) { const workspaceFilePath = path.join(workspacePath, 'workspace.yml'); const environmentsDir = path.join(workspacePath, 'environments'); + const dotEnvFilePath = path.join(workspacePath, '.env'); const workspaceUid = getWorkspaceUid(workspacePath); if (this.watchers[workspacePath]) { @@ -139,6 +176,9 @@ class WorkspaceWatcher { if (this.environmentWatchers[workspacePath]) { this.environmentWatchers[workspacePath].close(); } + if (this.dotEnvWatchers[workspacePath]) { + this.dotEnvWatchers[workspacePath].close(); + } const self = this; setTimeout(() => { @@ -146,6 +186,9 @@ class WorkspaceWatcher { return; } + // Load initial .env file if exists + handleWorkspaceDotEnvFile(win, workspacePath, workspaceUid); + const watcher = chokidar.watch(workspaceFilePath, { ignoreInitial: true, persistent: true, @@ -164,6 +207,22 @@ class WorkspaceWatcher { self.watchers[workspacePath] = watcher; + const dotEnvWatcher = chokidar.watch(dotEnvFilePath, { + ignoreInitial: true, + persistent: true, + ignorePermissionErrors: true, + awaitWriteFinish: { + stabilityThreshold: 80, + pollInterval: 250 + } + }); + + dotEnvWatcher.on('add', () => handleWorkspaceDotEnvFile(win, workspacePath, workspaceUid)); + dotEnvWatcher.on('change', () => handleWorkspaceDotEnvFile(win, workspacePath, workspaceUid)); + dotEnvWatcher.on('unlink', () => handleWorkspaceDotEnvUnlink(win, workspacePath, workspaceUid)); + + self.dotEnvWatchers[workspacePath] = dotEnvWatcher; + if (fs.existsSync(environmentsDir)) { const envWatcher = chokidar.watch(path.join(environmentsDir, `*.yml`), { ignoreInitial: true, @@ -216,6 +275,12 @@ class WorkspaceWatcher { this.environmentWatchers[workspacePath].close(); delete this.environmentWatchers[workspacePath]; } + if (this.dotEnvWatchers[workspacePath]) { + this.dotEnvWatchers[workspacePath].close(); + delete this.dotEnvWatchers[workspacePath]; + } + // Clear workspace env vars when watcher is removed + clearWorkspaceDotEnvVars(workspacePath); } catch (error) { console.error('Error removing workspace watcher:', error); } diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index c6b6e577c..db585259e 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -220,9 +220,15 @@ app.on('ready', async () => { } }); + let boundsTimeout; const handleBoundsChange = () => { if (!mainWindow.isMaximized()) { - saveBounds(mainWindow); + if (boundsTimeout) { + clearTimeout(boundsTimeout); + } + boundsTimeout = setTimeout(() => { + saveBounds(mainWindow); + }, 100); } }; diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 9dc9b5be3..3667562a8 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -25,6 +25,7 @@ const { wsClient } = require('../ipc/network/ws-event-handlers'); const { hasSubDirectories } = require('../utils/filesystem'); const { + DEFAULT_GITIGNORE, writeFile, hasBruExtension, isDirectory, @@ -57,6 +58,7 @@ const EnvironmentSecretsStore = require('../store/env-secrets'); const CollectionSecurityStore = require('../store/collection-security'); const UiStateSnapshotStore = require('../store/ui-state-snapshot'); const interpolateVars = require('./network/interpolate-vars'); +const { interpolateString } = require('./network/interpolate-string'); const { getEnvVars, getTreePathFromCollectionToItem, mergeVars, parseBruFileMeta, hydrateRequestWithUuid, transformRequestToSaveToFilesystem } = require('../utils/collection'); const { getProcessEnvVars } = require('../store/process-env'); const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, refreshOauth2Token } = require('../utils/oauth2'); @@ -160,6 +162,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { throw new Error(`Invalid format: ${format}`); } + await writeFile(path.join(dirPath, '.gitignore'), DEFAULT_GITIGNORE); + const { size, filesCount } = await getCollectionStats(dirPath); brunoConfig.size = size; brunoConfig.filesCount = filesCount; @@ -379,14 +383,14 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { }); // Helper: Parse file content based on scope type - const parseFileByType = async (fileContent, scopeType) => { + const parseFileByType = async (fileContent, scopeType, format) => { switch (scopeType) { case 'request': - return await parseRequestViaWorker(fileContent); + return await parseRequestViaWorker(fileContent, { format }); case 'folder': - return parseFolder(fileContent); + return parseFolder(fileContent, { format }); case 'collection': - return parseCollection(fileContent); + return parseCollection(fileContent, { format }); default: throw new Error(`Invalid scope type: ${scopeType}`); } @@ -427,7 +431,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { // Read and parse the file const fileContent = fs.readFileSync(pathname, 'utf8'); - const parsedData = await parseFileByType(fileContent, scopeType); + const parsedData = await parseFileByType(fileContent, scopeType, format); // Update the specific variable or create it if it doesn't exist const varsPath = 'request.vars.req'; @@ -820,9 +824,24 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } }); - ipcMain.handle('renderer:open-multiple-collections', async (e, collectionPaths) => { + ipcMain.handle('renderer:open-multiple-collections', async (e, collectionPaths, options = {}) => { if (watcher && mainWindow) { await openCollectionsByPathname(mainWindow, watcher, collectionPaths); + if (options.workspacePath) { + const { setCollectionWorkspace } = require('../store/process-env'); + const { generateUidBasedOnHash } = require('../utils/common'); + for (const collectionPath of collectionPaths) { + const collectionUid = generateUidBasedOnHash(collectionPath); + setCollectionWorkspace(collectionUid, options.workspacePath); + } + } + } + }); + + ipcMain.handle('renderer:set-collection-workspace', (event, collectionUid, workspacePath) => { + if (workspacePath) { + const { setCollectionWorkspace } = require('../store/process-env'); + setCollectionWorkspace(collectionUid, workspacePath); } }); @@ -835,6 +854,10 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } } + // Clean up + const { clearCollectionWorkspace } = require('../store/process-env'); + clearCollectionWorkspace(collectionUid); + if (workspacePath && workspacePath !== 'default') { try { const { removeCollectionFromWorkspace } = require('../utils/workspace-config'); @@ -1268,19 +1291,63 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { const requestTreePath = getTreePathFromCollectionToItem(collection, partialItem); mergeVars(collection, requestCopy, requestTreePath); const globalEnvironmentVariables = collection.globalEnvironmentVariables; - + const promptVariables = collection.promptVariables; interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars); - const certsAndProxyConfig = await getCertsAndProxyConfig({ - collectionUid, - collection, - request: requestCopy, - envVars, - runtimeVariables, - processEnvVars, - collectionPath, - globalEnvironmentVariables - }); - const { oauth2: { grantType } } = requestCopy || {}; + const { oauth2: { grantType, accessTokenUrl, refreshTokenUrl }, collectionVariables, folderVariables, requestVariables } = requestCopy || {}; + + // For OAuth2 token requests, use accessTokenUrl for cert/proxy config instead of main request URL + let certsAndProxyConfigForTokenUrl = null; + let certsAndProxyConfigForRefreshUrl = null; + + if (accessTokenUrl && grantType !== 'implicit') { + const interpolatedTokenUrl = interpolateString(accessTokenUrl, { + globalEnvironmentVariables, + collectionVariables, + envVars, + folderVariables, + requestVariables, + runtimeVariables, + processEnvVars, + promptVariables + }); + let tokenRequestForConfig = { ...requestCopy, url: interpolatedTokenUrl }; + certsAndProxyConfigForTokenUrl = await getCertsAndProxyConfig({ + collectionUid, + collection, + request: tokenRequestForConfig, + envVars, + runtimeVariables, + processEnvVars, + collectionPath, + globalEnvironmentVariables + }); + } + + // For refresh token requests, use refreshTokenUrl if available, otherwise accessTokenUrl + const tokenUrlForRefresh = refreshTokenUrl || accessTokenUrl; + if (tokenUrlForRefresh && grantType !== 'implicit') { + const interpolatedRefreshUrl = interpolateString(tokenUrlForRefresh, { + globalEnvironmentVariables, + collectionVariables, + envVars, + folderVariables, + requestVariables, + runtimeVariables, + processEnvVars, + promptVariables + }); + let refreshRequestForConfig = { ...requestCopy, url: interpolatedRefreshUrl }; + certsAndProxyConfigForRefreshUrl = await getCertsAndProxyConfig({ + collectionUid, + collection, + request: refreshRequestForConfig, + envVars, + runtimeVariables, + processEnvVars, + collectionPath, + globalEnvironmentVariables + }); + } const handleOAuth2Response = (response) => { if (response.error && !response.debugInfo) { @@ -1296,7 +1363,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { request: requestCopy, collectionUid, forceFetch: true, - certsAndProxyConfig + certsAndProxyConfigForTokenUrl, + certsAndProxyConfigForRefreshUrl }).then(handleOAuth2Response); case 'client_credentials': @@ -1305,7 +1373,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { request: requestCopy, collectionUid, forceFetch: true, - certsAndProxyConfig + certsAndProxyConfigForTokenUrl, + certsAndProxyConfigForRefreshUrl }).then(handleOAuth2Response); case 'password': @@ -1314,7 +1383,8 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { request: requestCopy, collectionUid, forceFetch: true, - certsAndProxyConfig + certsAndProxyConfigForTokenUrl, + certsAndProxyConfigForRefreshUrl }).then(handleOAuth2Response); case 'implicit': @@ -1407,7 +1477,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { file.size = sizeInMB(fileStats?.size); hydrateRequestWithUuid(file.data, pathname); mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file); - file.data = await parseRequestViaWorker(bruContent); + file.data = await parseRequestViaWorker(bruContent, { format: 'bru' }); file.partial = false; file.loading = true; file.size = sizeInMB(fileStats?.size); @@ -1514,7 +1584,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { await mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file); try { - const parsedData = await parseLargeRequestWithRedaction(bruContent); + const parsedData = await parseLargeRequestWithRedaction(bruContent, 'bru'); file.data = parsedData; file.loading = false; diff --git a/packages/bruno-electron/src/ipc/network/cert-utils.js b/packages/bruno-electron/src/ipc/network/cert-utils.js index 4a285a76c..e3cea3fb9 100644 --- a/packages/bruno-electron/src/ipc/network/cert-utils.js +++ b/packages/bruno-electron/src/ipc/network/cert-utils.js @@ -28,14 +28,20 @@ const getCertsAndProxyConfig = async ({ httpsAgentRequestFields['rejectUnauthorized'] = false; } - let caCertFilePath = preferencesUtil.shouldUseCustomCaCertificate() && preferencesUtil.getCustomCaCertificateFilePath(); - let caCertificatesData = getCACertificates({ - caCertFilePath, - shouldKeepDefaultCerts: preferencesUtil.shouldKeepDefaultCaCertificates() - }); + let caCertificates = ''; + let caCertificatesCount = { system: 0, root: 0, custom: 0, extra: 0 }; - let caCertificates = caCertificatesData.caCertificates; - let caCertificatesCount = caCertificatesData.caCertificatesCount; + // Only load CA certificates if SSL validation is enabled (otherwise they're unused) + if (preferencesUtil.shouldVerifyTls()) { + let caCertFilePath = preferencesUtil.shouldUseCustomCaCertificate() && preferencesUtil.getCustomCaCertificateFilePath(); + let caCertificatesData = getCACertificates({ + caCertFilePath, + shouldKeepDefaultCerts: preferencesUtil.shouldKeepDefaultCaCertificates() + }); + + caCertificates = caCertificatesData.caCertificates; + caCertificatesCount = caCertificatesData.caCertificatesCount; + } // configure HTTPS agent with aggregated CA certificates httpsAgentRequestFields['caCertificatesCount'] = caCertificatesCount; diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index f91673b0a..7aea48f39 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -159,12 +159,66 @@ const configureRequest = async ( if (request.oauth2) { let requestCopy = cloneDeep(request); - const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {}; + const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey, accessTokenUrl, refreshTokenUrl } = {}, collectionVariables, folderVariables, requestVariables } = requestCopy || {}; + + // Get cert/proxy configs for token and refresh URLs + let certsAndProxyConfigForTokenUrl = certsAndProxyConfig; + let certsAndProxyConfigForRefreshUrl = certsAndProxyConfig; + + if (accessTokenUrl && grantType !== 'implicit') { + const interpolatedTokenUrl = interpolateString(accessTokenUrl, { + globalEnvironmentVariables, + collectionVariables, + envVars, + folderVariables, + requestVariables, + runtimeVariables, + processEnvVars, + promptVariables + }); + const tokenRequestForConfig = { ...requestCopy, url: interpolatedTokenUrl }; + certsAndProxyConfigForTokenUrl = await getCertsAndProxyConfig({ + collectionUid, + collection, + request: tokenRequestForConfig, + envVars, + runtimeVariables, + processEnvVars, + collectionPath, + globalEnvironmentVariables + }); + } + + const tokenUrlForRefresh = refreshTokenUrl || accessTokenUrl; + if (tokenUrlForRefresh && grantType !== 'implicit') { + const interpolatedRefreshUrl = interpolateString(tokenUrlForRefresh, { + globalEnvironmentVariables, + collectionVariables, + envVars, + folderVariables, + requestVariables, + runtimeVariables, + processEnvVars, + promptVariables + }); + const refreshRequestForConfig = { ...requestCopy, url: interpolatedRefreshUrl }; + certsAndProxyConfigForRefreshUrl = await getCertsAndProxyConfig({ + collectionUid, + collection, + request: refreshRequestForConfig, + envVars, + runtimeVariables, + processEnvVars, + collectionPath, + globalEnvironmentVariables + }); + } + let credentials, credentialsId, oauth2Url, debugInfo; switch (grantType) { case 'authorization_code': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); - ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, certsAndProxyConfig })); + ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl })); request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header' && credentials?.access_token) { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim(); @@ -192,7 +246,7 @@ const configureRequest = async ( break; case 'client_credentials': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); - ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig })); + ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl })); request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header' && credentials?.access_token) { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim(); @@ -206,7 +260,7 @@ const configureRequest = async ( break; case 'password': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); - ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, certsAndProxyConfig })); + ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl })); request.oauth2Credentials = { credentials, url: oauth2Url, collectionUid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; if (tokenPlacement == 'header' && credentials?.access_token) { request.headers['Authorization'] = `${tokenHeaderPrefix} ${credentials.access_token}`.trim(); @@ -932,9 +986,9 @@ const registerNetworkIpc = (mainWindow) => { statusText: response.statusText, headers: response.headers, data: response.data, - dataBuffer: response.dataBuffer.toString('base64'), stream: isResponseStream ? axiosDataStream : null, cancelTokenUid: cancelTokenUid, + dataBuffer: response.dataBuffer.toString('base64'), size: Buffer.byteLength(response.dataBuffer), duration: responseTime ?? 0, url: response.request ? response.request.protocol + '//' + response.request.host + response.request.path : null, @@ -1420,7 +1474,7 @@ const registerNetworkIpc = (mainWindow) => { } if (error?.response) { - error.response.data = await promisifyStream(error.response.data, currentAbortController, true); + error.response.data = await promisifyStream(error.response.data, currentAbortController, false); const { data, dataBuffer } = parseDataFromResponse(error.response); error.response.responseTime = error.response.headers.get('request-duration'); error.response.headers.delete('request-duration'); diff --git a/packages/bruno-electron/src/ipc/network/prepare-grpc-request.js b/packages/bruno-electron/src/ipc/network/prepare-grpc-request.js index f4dec64d3..e8e68b647 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-grpc-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-grpc-request.js @@ -4,6 +4,8 @@ const { getEnvVars, getTreePathFromCollectionToItem, mergeHeaders, mergeScripts, const { getProcessEnvVars } = require('../../store/process-env'); const { getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingAuthorizationCode } = require('../../utils/oauth2'); const { setAuthHeaders } = require('./prepare-request'); +const { getCertsAndProxyConfig } = require('./cert-utils'); +const { interpolateString } = require('./interpolate-string'); const processHeaders = (headers) => { Object.entries(headers).forEach(([key, value]) => { @@ -30,25 +32,80 @@ const placeOAuth2Token = (grpcRequest, credentials, tokenPlacement, tokenHeaderP const configureRequest = async (grpcRequest, request, collection, envVars, runtimeVariables, processEnvVars, promptVariables, certsAndProxyConfig) => { if (grpcRequest.oauth2) { let requestCopy = cloneDeep(grpcRequest); - const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {}; + const { uid: collectionUid, pathname: collectionPath, globalEnvironmentVariables } = collection; + const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey, accessTokenUrl, refreshTokenUrl } = {}, collectionVariables, folderVariables, requestVariables } = requestCopy || {}; let credentials, credentialsId, oauth2Url, debugInfo; + + // Get cert/proxy configs for token and refresh URLs + let certsAndProxyConfigForTokenUrl = certsAndProxyConfig; + let certsAndProxyConfigForRefreshUrl = certsAndProxyConfig; + + if (accessTokenUrl && grantType !== 'implicit') { + const interpolatedTokenUrl = interpolateString(accessTokenUrl, { + globalEnvironmentVariables, + collectionVariables, + envVars, + folderVariables, + requestVariables, + runtimeVariables, + processEnvVars, + promptVariables + }); + const tokenRequestForConfig = { ...requestCopy, url: interpolatedTokenUrl }; + certsAndProxyConfigForTokenUrl = await getCertsAndProxyConfig({ + collectionUid, + collection, + request: tokenRequestForConfig, + envVars, + runtimeVariables, + processEnvVars, + collectionPath, + globalEnvironmentVariables + }); + } + + const tokenUrlForRefresh = refreshTokenUrl || accessTokenUrl; + if (tokenUrlForRefresh && grantType !== 'implicit') { + const interpolatedRefreshUrl = interpolateString(tokenUrlForRefresh, { + globalEnvironmentVariables, + collectionVariables, + envVars, + folderVariables, + requestVariables, + runtimeVariables, + processEnvVars, + promptVariables + }); + const refreshRequestForConfig = { ...requestCopy, url: interpolatedRefreshUrl }; + certsAndProxyConfigForRefreshUrl = await getCertsAndProxyConfig({ + collectionUid, + collection, + request: refreshRequestForConfig, + envVars, + runtimeVariables, + processEnvVars, + collectionPath, + globalEnvironmentVariables + }); + } + try { switch (grantType) { case 'authorization_code': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); - ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig })); + ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl })); grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; placeOAuth2Token(grpcRequest, credentials, tokenPlacement, tokenHeaderPrefix, tokenQueryKey); break; case 'client_credentials': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); - ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig })); + ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl })); grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; placeOAuth2Token(grpcRequest, credentials, tokenPlacement, tokenHeaderPrefix, tokenQueryKey); break; case 'password': interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars, promptVariables); - ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfig })); + ({ credentials, url: oauth2Url, credentialsId, debugInfo } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid: collection.uid, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl })); grpcRequest.oauth2Credentials = { credentials, url: oauth2Url, collectionUid: collection.uid, credentialsId, debugInfo, folderUid: request.oauth2Credentials?.folderUid }; placeOAuth2Token(grpcRequest, credentials, tokenPlacement, tokenHeaderPrefix, tokenQueryKey); break; diff --git a/packages/bruno-electron/src/ipc/network/ws-event-handlers.js b/packages/bruno-electron/src/ipc/network/ws-event-handlers.js index cd7e8e37f..3f24ad8ba 100644 --- a/packages/bruno-electron/src/ipc/network/ws-event-handlers.js +++ b/packages/bruno-electron/src/ipc/network/ws-event-handlers.js @@ -71,6 +71,7 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables, const envVars = getEnvVars(environment); const processEnvVars = getProcessEnvVars(collection.uid); + const { promptVariables = {} } = collection; let wsRequest = { uid: item.uid, @@ -94,7 +95,61 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables, if (wsRequest.oauth2) { let requestCopy = cloneDeep(wsRequest); - const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey } = {} } = requestCopy || {}; + const { oauth2: { grantType, tokenPlacement, tokenHeaderPrefix, tokenQueryKey, accessTokenUrl, refreshTokenUrl } = {}, collectionVariables, folderVariables, requestVariables } = requestCopy || {}; + + // Get cert/proxy configs for token and refresh URLs + let certsAndProxyConfigForTokenUrl = certsAndProxyConfig; + let certsAndProxyConfigForRefreshUrl = certsAndProxyConfig; + + if (accessTokenUrl && grantType !== 'implicit') { + const interpolatedTokenUrl = interpolateString(accessTokenUrl, { + globalEnvironmentVariables: request.globalEnvironmentVariables, + collectionVariables, + envVars, + folderVariables, + requestVariables, + runtimeVariables, + processEnvVars, + promptVariables + }); + const tokenRequestForConfig = { ...requestCopy, url: interpolatedTokenUrl }; + certsAndProxyConfigForTokenUrl = await getCertsAndProxyConfig({ + collectionUid: collection.uid, + collection, + request: tokenRequestForConfig, + envVars, + runtimeVariables, + processEnvVars, + collectionPath: collection.pathname, + globalEnvironmentVariables: request.globalEnvironmentVariables + }); + } + + const tokenUrlForRefresh = refreshTokenUrl || accessTokenUrl; + if (tokenUrlForRefresh && grantType !== 'implicit') { + const interpolatedRefreshUrl = interpolateString(tokenUrlForRefresh, { + globalEnvironmentVariables: request.globalEnvironmentVariables, + collectionVariables, + envVars, + folderVariables, + requestVariables, + runtimeVariables, + processEnvVars, + promptVariables + }); + const refreshRequestForConfig = { ...requestCopy, url: interpolatedRefreshUrl }; + certsAndProxyConfigForRefreshUrl = await getCertsAndProxyConfig({ + collectionUid: collection.uid, + collection, + request: refreshRequestForConfig, + envVars, + runtimeVariables, + processEnvVars, + collectionPath: collection.pathname, + globalEnvironmentVariables: request.globalEnvironmentVariables + }); + } + let credentials, credentialsId, oauth2Url, debugInfo; switch (grantType) { @@ -108,7 +163,8 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables, } = await getOAuth2TokenUsingAuthorizationCode({ request: requestCopy, collectionUid: collection.uid, - certsAndProxyConfig + certsAndProxyConfigForTokenUrl, + certsAndProxyConfigForRefreshUrl })); wsRequest.oauth2Credentials = { credentials, @@ -138,7 +194,8 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables, } = await getOAuth2TokenUsingClientCredentials({ request: requestCopy, collectionUid: collection.uid, - certsAndProxyConfig + certsAndProxyConfigForTokenUrl, + certsAndProxyConfigForRefreshUrl })); wsRequest.oauth2Credentials = { credentials, @@ -168,7 +225,8 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables, } = await getOAuth2TokenUsingPasswordCredentials({ request: requestCopy, collectionUid: collection.uid, - certsAndProxyConfig + certsAndProxyConfigForTokenUrl, + certsAndProxyConfigForRefreshUrl })); wsRequest.oauth2Credentials = { credentials, diff --git a/packages/bruno-electron/src/ipc/workspace.js b/packages/bruno-electron/src/ipc/workspace.js index 7926c69ef..57914954f 100644 --- a/packages/bruno-electron/src/ipc/workspace.js +++ b/packages/bruno-electron/src/ipc/workspace.js @@ -5,7 +5,7 @@ const archiver = require('archiver'); const extractZip = require('extract-zip'); const { ipcMain, dialog } = require('electron'); const isDev = require('electron-is-dev'); -const { createDirectory, sanitizeName } = require('../utils/filesystem'); +const { createDirectory, sanitizeName, writeFile, DEFAULT_GITIGNORE } = require('../utils/filesystem'); const yaml = require('js-yaml'); const LastOpenedWorkspaces = require('../store/last-opened-workspaces'); const { defaultWorkspaceManager } = require('../store/default-workspace'); @@ -86,6 +86,7 @@ const registerWorkspaceIpc = (mainWindow, workspaceWatcher) => { const workspaceConfig = createWorkspaceConfig(workspaceName); await writeWorkspaceConfig(dirPath, workspaceConfig); + await writeFile(path.join(dirPath, '.gitignore'), DEFAULT_GITIGNORE); lastOpenedWorkspaces.add(dirPath); diff --git a/packages/bruno-electron/src/store/process-env.js b/packages/bruno-electron/src/store/process-env.js index 084187d2d..3dbdd087b 100644 --- a/packages/bruno-electron/src/store/process-env.js +++ b/packages/bruno-electron/src/store/process-env.js @@ -1,28 +1,29 @@ /** - * This file stores all the process.env variables under collection scope + * This file stores all the process.env variables under collection and workspace scope * - * process.env variables are sourced from 2 places: - * 1. .env file in the root of the project - * 2. process.env variables set in the OS + * process.env variables are sourced from 3 places: + * 1. .env file in the workspace root + * 2. .env file in the collection root + * 3. process.env variables set in the OS + * + * Priority (highest to lowest): collection .env > workspace .env > OS process.env * * Multiple collections can be opened in the same electron app. * Each collection's .env file can have different values for the same process.env variable. */ const dotEnvVars = {}; +const workspaceDotEnvVars = {}; +const collectionWorkspaceMap = {}; // collectionUid is a hash based on the collection path const getProcessEnvVars = (collectionUid) => { - // if there are no .env vars for this collection, return the process.env - if (!dotEnvVars[collectionUid]) { - return { - ...process.env - }; - } + const workspacePath = collectionWorkspaceMap[collectionUid]; + const workspaceEnvVars = workspacePath ? workspaceDotEnvVars[workspacePath] : {}; - // if there are .env vars for this collection, return the process.env merged with the .env vars return { ...process.env, + ...workspaceEnvVars, ...dotEnvVars[collectionUid] }; }; @@ -31,7 +32,27 @@ const setDotEnvVars = (collectionUid, envVars) => { dotEnvVars[collectionUid] = envVars; }; +const setWorkspaceDotEnvVars = (workspacePath, envVars) => { + workspaceDotEnvVars[workspacePath] = envVars; +}; + +const clearWorkspaceDotEnvVars = (workspacePath) => { + delete workspaceDotEnvVars[workspacePath]; +}; + +const setCollectionWorkspace = (collectionUid, workspacePath) => { + collectionWorkspaceMap[collectionUid] = workspacePath; +}; + +const clearCollectionWorkspace = (collectionUid) => { + delete collectionWorkspaceMap[collectionUid]; +}; + module.exports = { getProcessEnvVars, - setDotEnvVars + setDotEnvVars, + setWorkspaceDotEnvVars, + clearWorkspaceDotEnvVars, + setCollectionWorkspace, + clearCollectionWorkspace }; diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js index 90406d8dc..0d405419f 100644 --- a/packages/bruno-electron/src/utils/filesystem.js +++ b/packages/bruno-electron/src/utils/filesystem.js @@ -5,6 +5,18 @@ const { dialog } = require('electron'); const isValidPathname = require('is-valid-path'); const os = require('os'); +const DEFAULT_GITIGNORE = [ + '# Secrets', + '.env*', + '', + '# Dependencies', + 'node_modules', + '', + '# OS files', + '.DS_Store', + 'Thumbs.db' +].join('\n'); + const exists = async (p) => { try { await fsPromises.access(p); @@ -456,6 +468,7 @@ const isCollectionRootBruFile = (pathname, collectionPath) => { }; module.exports = { + DEFAULT_GITIGNORE, isValidPathname, exists, isSymbolicLink, diff --git a/packages/bruno-electron/src/utils/oauth2.js b/packages/bruno-electron/src/utils/oauth2.js index 51d21fce2..0a6227df0 100644 --- a/packages/bruno-electron/src/utils/oauth2.js +++ b/packages/bruno-electron/src/utils/oauth2.js @@ -52,7 +52,7 @@ const safeParseJSONBuffer = (data) => { const getCredentialsFromTokenUrl = async ({ requestConfig, certsAndProxyConfig }) => { const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig; const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions }); - let requestDetails, parsedResponseData; + let requestDetails = { request: {}, response: {} }, parsedResponseData; try { const response = await axiosInstance(requestConfig); const { url: responseUrl, headers: responseHeaders, status: responseStatus, statusText: responseStatusText, data: responseData, timeline, config } = response || {}; @@ -112,7 +112,7 @@ const getCredentialsFromTokenUrl = async ({ requestConfig, certsAndProxyConfig } statusText: error?.code, headers: {}, data: safeStringifyJSON(error?.errors), - timeline: error?.response?.timeline + timeline: error?.timeline } }; } @@ -132,7 +132,7 @@ const getCredentialsFromTokenUrl = async ({ requestConfig, certsAndProxyConfig } // AUTHORIZATION CODE -const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => { +const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl }) => { let codeVerifier = generateCodeVerifier(); let codeChallenge = generateCodeChallenge(codeVerifier); @@ -204,7 +204,7 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo if (autoRefreshToken && storedCredentials.refresh_token) { // Try to refresh token try { - const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig }); + const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig: certsAndProxyConfigForRefreshUrl }); return { collectionUid, url, credentials: refreshedCredentialsData.credentials, credentialsId }; } catch (error) { // Refresh failed @@ -254,7 +254,8 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo 'Accept': 'application/json' }; if (credentialsPlacement === 'basic_auth_header') { - axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; + const secret = clientSecret ?? ''; + axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; } const data = { @@ -280,7 +281,7 @@ const getOAuth2TokenUsingAuthorizationCode = async ({ request, collectionUid, fo } axiosRequestConfig.data = qs.stringify(data); try { - const { credentials, requestDetails } = await getCredentialsFromTokenUrl({ requestConfig: axiosRequestConfig, certsAndProxyConfig }); + const { credentials, requestDetails } = await getCredentialsFromTokenUrl({ requestConfig: axiosRequestConfig, certsAndProxyConfig: certsAndProxyConfigForTokenUrl }); // Ensure debugInfo.data is initialized if (!debugInfo) { @@ -365,7 +366,7 @@ const getAdditionalHeaders = (params) => { // CLIENT CREDENTIALS -const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => { +const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl }) => { let requestCopy = cloneDeep(request); const oAuth = get(requestCopy, 'oauth2', {}); const { @@ -413,7 +414,7 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo if (autoRefreshToken && storedCredentials.refresh_token) { // Try to refresh token try { - const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig }); + const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig: certsAndProxyConfigForRefreshUrl }); return { collectionUid, url, credentials: refreshedCredentialsData.credentials, credentialsId }; } catch (error) { clearOauth2Credentials({ collectionUid, url, credentialsId }); @@ -458,8 +459,9 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo 'content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }; - if (credentialsPlacement === 'basic_auth_header' && clientSecret && clientSecret.trim() !== '') { - axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; + if (credentialsPlacement === 'basic_auth_header') { + const secret = clientSecret ?? ''; + axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; } const data = { grant_type: 'client_credentials' @@ -481,7 +483,7 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo axiosRequestConfig.data = qs.stringify(data); let debugInfo = { data: [] }; try { - const { credentials, requestDetails } = await getCredentialsFromTokenUrl({ requestConfig: axiosRequestConfig, certsAndProxyConfig }); + const { credentials, requestDetails } = await getCredentialsFromTokenUrl({ requestConfig: axiosRequestConfig, certsAndProxyConfig: certsAndProxyConfigForTokenUrl }); debugInfo.data.push(requestDetails); credentials && persistOauth2Credentials({ collectionUid, url, credentials, credentialsId }); return { collectionUid, url, credentials, credentialsId, debugInfo }; @@ -492,7 +494,7 @@ const getOAuth2TokenUsingClientCredentials = async ({ request, collectionUid, fo // PASSWORD CREDENTIALS -const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfig }) => { +const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, forceFetch = false, certsAndProxyConfigForTokenUrl, certsAndProxyConfigForRefreshUrl }) => { let requestCopy = cloneDeep(request); const oAuth = get(requestCopy, 'oauth2', {}); const { @@ -559,7 +561,7 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, if (autoRefreshToken && storedCredentials.refresh_token) { // Try to refresh token try { - const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig }); + const refreshedCredentialsData = await refreshOauth2Token({ requestCopy, collectionUid, certsAndProxyConfig: certsAndProxyConfigForRefreshUrl }); return { collectionUid, url, credentials: refreshedCredentialsData.credentials, credentialsId }; } catch (error) { clearOauth2Credentials({ collectionUid, url, credentialsId }); @@ -605,8 +607,9 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, 'content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }; - if (credentialsPlacement === 'basic_auth_header' && clientSecret && clientSecret.trim() !== '') { - axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; + if (credentialsPlacement === 'basic_auth_header') { + const secret = clientSecret ?? ''; + axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; } const data = { grant_type: 'password', @@ -630,7 +633,7 @@ const getOAuth2TokenUsingPasswordCredentials = async ({ request, collectionUid, axiosRequestConfig.data = qs.stringify(data); let debugInfo = { data: [] }; try { - const { credentials, requestDetails } = await getCredentialsFromTokenUrl({ requestConfig: axiosRequestConfig, certsAndProxyConfig }); + const { credentials, requestDetails } = await getCredentialsFromTokenUrl({ requestConfig: axiosRequestConfig, certsAndProxyConfig: certsAndProxyConfigForTokenUrl }); debugInfo.data.push(requestDetails); credentials && persistOauth2Credentials({ collectionUid, url, credentials, credentialsId }); return { collectionUid, url, credentials, credentialsId, debugInfo }; @@ -667,7 +670,8 @@ const refreshOauth2Token = async ({ requestCopy, collectionUid, certsAndProxyCon 'Accept': 'application/json' }; if (credentialsPlacement === 'basic_auth_header') { - axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret || ''}`).toString('base64')}`; + const secret = clientSecret ?? ''; + axiosRequestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; } axiosRequestConfig.url = url; axiosRequestConfig.responseType = 'arraybuffer'; diff --git a/packages/bruno-electron/src/utils/parse.js b/packages/bruno-electron/src/utils/parse.js index d8aacfa5a..415da26a3 100644 --- a/packages/bruno-electron/src/utils/parse.js +++ b/packages/bruno-electron/src/utils/parse.js @@ -4,11 +4,12 @@ const { parseRequestAndRedactBody, parseRequestViaWorker } = require('@usebruno/ * Parses a large BRU request string by redacting body blocks, parsing the remainder, * and then reinserting extracted body content into the parsed structure. * @param {string} bruContent + * @param {string} format - Collection format, defaults to 'bru' * @returns {Promise} parsed request JSON */ -async function parseLargeRequestWithRedaction(bruContent) { - const { bruFileStringWithRedactedBody, extractedBodyContent } = parseRequestAndRedactBody(bruContent); - const parsedData = await parseRequestViaWorker(bruFileStringWithRedactedBody); +async function parseLargeRequestWithRedaction(bruContent, format = 'bru') { + const { bruFileStringWithRedactedBody, extractedBodyContent } = parseRequestAndRedactBody(bruContent, { format }); + const parsedData = await parseRequestViaWorker(bruFileStringWithRedactedBody, { format }); if (!parsedData.request) { parsedData.request = {}; diff --git a/packages/bruno-electron/src/utils/transformBrunoConfig.js b/packages/bruno-electron/src/utils/transformBrunoConfig.js index 43a5e21d3..60b59fd7f 100644 --- a/packages/bruno-electron/src/utils/transformBrunoConfig.js +++ b/packages/bruno-electron/src/utils/transformBrunoConfig.js @@ -1,6 +1,6 @@ const path = require('path'); const { isFile, isDirectory } = require('./filesystem'); -const { get } = require('lodash'); +const { transformProxyConfig } = require('@usebruno/requests'); function transformBrunoConfigBeforeSave(brunoConfig) { // remove exists from importPaths and protoFiles @@ -76,55 +76,7 @@ async function transformBrunoConfigAfterRead(brunoConfig, collectionPathname) { // Migrate proxy configuration from old format to new format if (brunoConfig.proxy) { - const proxy = brunoConfig.proxy || {}; - - // Check if this is an old format (has 'enabled' property) - if (proxy.hasOwnProperty('enabled')) { - const enabled = proxy.enabled; - - let newProxy = { - inherit: true, - config: { - protocol: proxy.protocol || 'http', - hostname: proxy.hostname || '', - port: proxy.port || null, - auth: { - username: get(proxy, 'auth.username', ''), - password: get(proxy, 'auth.password', '') - }, - bypassProxy: proxy.bypassProxy || '' - } - }; - - // Handle old format: enabled (true | false | 'global') - if (enabled === true) { - newProxy.disabled = false; - newProxy.inherit = false; - } else if (enabled === false) { - newProxy.disabled = true; - newProxy.inherit = false; - } else if (enabled === 'global') { - newProxy.disabled = false; - newProxy.inherit = true; - } - - // Migrate auth.enabled to auth.disabled - if (get(proxy, 'auth.enabled') === false) { - newProxy.config.auth.disabled = true; - } - // If auth.enabled is true or undefined, omit disabled (defaults to false) - - // Omit disabled: false at top level (optional field) - if (newProxy.disabled === false) { - delete newProxy.disabled; - } - // Omit auth.disabled: false (optional field) - if (newProxy.config.auth.disabled === false) { - delete newProxy.config.auth.disabled; - } - - brunoConfig.proxy = newProxy; - } + brunoConfig.proxy = transformProxyConfig(brunoConfig.proxy); } return brunoConfig; diff --git a/packages/bruno-electron/src/utils/workspace-config.js b/packages/bruno-electron/src/utils/workspace-config.js index fadab0f08..78093a8a8 100644 --- a/packages/bruno-electron/src/utils/workspace-config.js +++ b/packages/bruno-electron/src/utils/workspace-config.js @@ -217,7 +217,7 @@ const readWorkspaceConfig = (workspacePath) => { const generateYamlContent = (config) => { const yamlLines = []; - const workspaceName = config.info?.name || config.name || 'Unnamed Workspace'; + const workspaceName = config.info?.name || config.name || 'Untitled Workspace'; const workspaceType = config.info?.type || config.type || WORKSPACE_TYPE; yamlLines.push(`opencollection: ${config.opencollection || OPENCOLLECTION_VERSION}`); diff --git a/packages/bruno-js/package.json b/packages/bruno-js/package.json index c1a6eebde..96af47263 100644 --- a/packages/bruno-js/package.json +++ b/packages/bruno-js/package.json @@ -29,7 +29,6 @@ "moment": "^2.29.4", "nanoid": "3.3.8", "node-fetch": "^2.7.0", - "node-vault": "^0.10.2", "path": "^0.12.7", "quickjs-emscripten": "^0.29.2", "tv4": "^1.3.0", diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js index 27c00da33..9f9cfe45e 100644 --- a/packages/bruno-js/src/bru.js +++ b/packages/bruno-js/src/bru.js @@ -7,7 +7,7 @@ const { jar: createCookieJar } = require('@usebruno/requests').cookies; const variableNameRegex = /^[\w-.]*$/; class Bru { - constructor(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables) { + constructor(runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables) { this.envVariables = envVariables || {}; this.runtimeVariables = runtimeVariables || {}; this.promptVariables = promptVariables || {}; @@ -20,6 +20,7 @@ class Bru { this.collectionPath = collectionPath; this.collectionName = collectionName; this.sendRequest = sendRequest; + this.runtime = runtime; this.cookies = { jar: () => { const cookieJar = createCookieJar(); @@ -230,7 +231,7 @@ class Bru { ); } - this.runtimeVariables[key] = this.interpolate(value); + this.runtimeVariables[key] = value; } getVar(key) { @@ -279,6 +280,10 @@ class Bru { getCollectionName() { return this.collectionName; } + + isSafeMode() { + return this.runtime === 'quickjs'; + } } module.exports = Bru; diff --git a/packages/bruno-js/src/bruno-response.js b/packages/bruno-js/src/bruno-response.js index bdd091153..73ca44981 100644 --- a/packages/bruno-js/src/bruno-response.js +++ b/packages/bruno-js/src/bruno-response.js @@ -55,6 +55,20 @@ class BrunoResponse { const clonedData = _.cloneDeep(data); this.res.data = clonedData; this.body = clonedData; + + // Update dataBuffer to match the modified body + if (clonedData === null || clonedData === undefined) { + this.res.dataBuffer = Buffer.from(''); + } else if (typeof clonedData === 'string') { + this.res.dataBuffer = Buffer.from(clonedData); + } else { + // For objects, stringify them + try { + this.res.dataBuffer = Buffer.from(JSON.stringify(clonedData)); + } catch (e) { + this.res.dataBuffer = Buffer.from(''); + } + } } // TODO: Refactor: dataBuffer size calculation should be handled in a shared utility so it can be passed and reused across the application diff --git a/packages/bruno-js/src/runtime/assert-runtime.js b/packages/bruno-js/src/runtime/assert-runtime.js index 8639ee11e..268a1f72e 100644 --- a/packages/bruno-js/src/runtime/assert-runtime.js +++ b/packages/bruno-js/src/runtime/assert-runtime.js @@ -257,6 +257,7 @@ class AssertRuntime { const promptVariables = request?.promptVariables || {}; const bru = new Bru( + this.runtime, envVariables, runtimeVariables, processEnvVars, diff --git a/packages/bruno-js/src/runtime/script-runtime.js b/packages/bruno-js/src/runtime/script-runtime.js index 27aa7d35e..10777947d 100644 --- a/packages/bruno-js/src/runtime/script-runtime.js +++ b/packages/bruno-js/src/runtime/script-runtime.js @@ -33,7 +33,7 @@ class ScriptRuntime { const requestVariables = request?.requestVariables || {}; const promptVariables = request?.promptVariables || {}; const assertionResults = request?.assertionResults || []; - const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables); + const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables); const req = new BrunoRequest(request); // extend bru with result getter methods @@ -128,7 +128,7 @@ class ScriptRuntime { const requestVariables = request?.requestVariables || {}; const promptVariables = request?.promptVariables || {}; const assertionResults = request?.assertionResults || {}; - const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables); + const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, collectionName, promptVariables); const req = new BrunoRequest(request); const res = new BrunoResponse(response); diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js index f2d1764ab..927d9d1db 100644 --- a/packages/bruno-js/src/runtime/test-runtime.js +++ b/packages/bruno-js/src/runtime/test-runtime.js @@ -32,7 +32,7 @@ class TestRuntime { const requestVariables = request?.requestVariables || {}; const promptVariables = request?.promptVariables || {}; const assertionResults = request?.assertionResults || []; - const bru = new Bru(envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, {}, collectionName, promptVariables); + const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, collectionPath, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, {}, collectionName, promptVariables); const req = new BrunoRequest(request); const res = new BrunoResponse(response); diff --git a/packages/bruno-js/src/runtime/vars-runtime.js b/packages/bruno-js/src/runtime/vars-runtime.js index c5ff72925..5eb58c3d2 100644 --- a/packages/bruno-js/src/runtime/vars-runtime.js +++ b/packages/bruno-js/src/runtime/vars-runtime.js @@ -36,7 +36,7 @@ class VarsRuntime { } const promptVariables = request?.promptVariables || {}; - const bru = new Bru(envVariables, runtimeVariables, processEnvVars, undefined, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, undefined, promptVariables); + const bru = new Bru(this.runtime, envVariables, runtimeVariables, processEnvVars, undefined, collectionVariables, folderVariables, requestVariables, globalEnvironmentVariables, oauth2CredentialVariables, undefined, promptVariables); const req = new BrunoRequest(request); const res = createResponseParser(response); diff --git a/packages/bruno-js/src/sandbox/node-vm/index.js b/packages/bruno-js/src/sandbox/node-vm/index.js index efb5e60a2..1646623e3 100644 --- a/packages/bruno-js/src/sandbox/node-vm/index.js +++ b/packages/bruno-js/src/sandbox/node-vm/index.js @@ -36,8 +36,6 @@ async function runScriptInNodeVm({ } try { - const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false); - // Compute additional context roots const additionalContextRoots = get(scriptingConfig, 'additionalContextRoots', []); const additionalContextRootsAbsolute = lodash @@ -88,7 +86,6 @@ async function runScriptInNodeVm({ scriptContext, currentModuleDir: collectionPath, localModuleCache, - allowScriptFilesystemAccess, additionalContextRootsAbsolute }); @@ -116,7 +113,6 @@ async function runScriptInNodeVm({ * @param {Object} options.scriptContext - Script execution context * @param {string} options.currentModuleDir - Current module directory for relative imports * @param {Map} options.localModuleCache - Cache for loaded local modules - * @param {boolean} options.allowScriptFilesystemAccess - Whether to allow fs module access * @param {Array} options.additionalContextRootsAbsolute - Pre-computed absolute context roots * @returns {Function} Custom require function */ @@ -126,7 +122,6 @@ function createCustomRequire({ scriptContext, currentModuleDir = collectionPath, localModuleCache = new Map(), - allowScriptFilesystemAccess = false, additionalContextRootsAbsolute = [] }) { return (moduleName) => { @@ -137,40 +132,11 @@ function createCustomRequire({ return loadLocalModule({ moduleName: normalizedModuleName, collectionPath, scriptContext, localModuleCache, currentModuleDir, additionalContextRootsAbsolute }); } - // Helper function to check if a module is the fs module or a submodule - const isFsModule = (module) => { - if (!module) return false; - const fsModule = require('fs'); - // Check if it's the fs module itself - if (module === fsModule) return true; - // Check if it's fs/promises submodule - if (module === fsModule.promises) return true; - // Check if it's fs/promises by comparing with require('fs/promises') - try { - if (module === require('fs/promises')) return true; - } catch { - // fs/promises might not be available in all Node versions - } - return false; - }; - // First try to require as a native/npm module try { const requiredModulePath = require.resolve(moduleName, { paths: [...additionalContextRootsAbsolute, ...module.paths] }); - const requiredModule = require(requiredModulePath); - - // Block filesystem module access if filesystem access is not allowed - if (!allowScriptFilesystemAccess && isFsModule(requiredModule)) { - throw new Error('Filesystem access is not allowed. Enable "filesystemAccess.allow" in scripting config to use the fs module.'); - } - - return requiredModule; + return require(requiredModulePath); } catch (requireError) { - // Re-throw if it's our filesystem access error - if (requireError.message && requireError.message.includes('Enable "filesystemAccess.allow"')) { - throw requireError; - } - // If that fails, try to resolve from additionalContextRoots throw new Error(`Could not resolve module "${moduleName}": ${requireError.message}\n\nThis most likely means you did not install the module under the collection or the "additionalContextRoots" using a package manager like npm.\n\nThese are your current "additionalContextRoots":\n${additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n') || ' - No "additionalContextRoots" defined'}`); } @@ -251,7 +217,6 @@ function loadLocalModule({ scriptContext, currentModuleDir: moduleDir, localModuleCache, - allowScriptFilesystemAccess: get(scriptContext.scriptingConfig, 'filesystemAccess.allow', false), additionalContextRootsAbsolute }) }; diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js index 0029c3b59..84ca86a54 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js @@ -23,6 +23,12 @@ const addBruShimToContext = (vm, bru) => { vm.setProp(bruObject, 'getCollectionName', getCollectionName); getCollectionName.dispose(); + let isSafeMode = vm.newFunction('isSafeMode', function () { + return marshallToVm(bru.isSafeMode(), vm); + }); + vm.setProp(bruObject, 'isSafeMode', isSafeMode); + isSafeMode.dispose(); + let getProcessEnv = vm.newFunction('getProcessEnv', function (key) { return marshallToVm(bru.getProcessEnv(vm.dump(key)), vm); }); diff --git a/packages/bruno-js/tests/runtime.spec.js b/packages/bruno-js/tests/runtime.spec.js index 352be604f..797e30598 100644 --- a/packages/bruno-js/tests/runtime.spec.js +++ b/packages/bruno-js/tests/runtime.spec.js @@ -248,14 +248,14 @@ describe('runtime', () => { }); describe('bru.setVar random variable', () => { - it('should not be equal to {{$randomFirstName}}', async () => { + it('should be able to set random variables as values', async () => { const script = `bru.setVar('title', '{{$randomFirstName}}')`; const runtime = new ScriptRuntime({ runtime: 'nodevm' }); const result = await runtime.runRequestScript(script, {}, {}, {}, '.', null, process.env); - expect(result.runtimeVariables.title).not.toBe('{{$randomFirstName}}'); + expect(result.runtimeVariables.title).toBe('{{$randomFirstName}}'); }); }); }); diff --git a/packages/bruno-js/tests/setEnvVar.spec.js b/packages/bruno-js/tests/setEnvVar.spec.js index d1929055c..0f32e7e59 100644 --- a/packages/bruno-js/tests/setEnvVar.spec.js +++ b/packages/bruno-js/tests/setEnvVar.spec.js @@ -3,6 +3,7 @@ const Bru = require('../src/bru'); describe('Bru.setEnvVar', () => { const makeBru = () => new Bru( + /* runtime */ 'quickjs', /* envVariables */ {}, /* runtimeVariables */ {}, /* processEnvVars */ {}, diff --git a/packages/bruno-requests/package.json b/packages/bruno-requests/package.json index 76d9749f8..e91f79a08 100644 --- a/packages/bruno-requests/package.json +++ b/packages/bruno-requests/package.json @@ -28,7 +28,10 @@ "debug": "^4.4.3", "google-protobuf": "^4.0.0", "grpc-js-reflection-client": "^1.3.0", + "http-proxy-agent": "~7.0.2", + "https-proxy-agent": "~7.0.6", "is-ip": "^5.0.1", + "socks-proxy-agent": "~8.0.5", "system-ca": "^2.0.1", "tough-cookie": "^6.0.0", "ws": "^8.18.3" diff --git a/packages/bruno-requests/src/auth/oauth2-helper.spec.ts b/packages/bruno-requests/src/auth/oauth2-helper.spec.ts new file mode 100644 index 000000000..7cfd96b47 --- /dev/null +++ b/packages/bruno-requests/src/auth/oauth2-helper.spec.ts @@ -0,0 +1,475 @@ +import axios from 'axios'; +import { getOAuth2Token, TokenStore, OAuth2Config } from './oauth2-helper'; + +/** + * Creates a mock token store for testing purposes. + * + * The token store simulates credential persistence using an in-memory Map. + * Keys are formatted as `${url}:${credentialsId}` to uniquely identify credentials. + */ +const createMockTokenStore = (): TokenStore & { credentials: Map } => { + const credentials = new Map(); + return { + credentials, + async saveCredential({ url, credentialsId, credentials: creds }) { + credentials.set(`${url}:${credentialsId}`, creds); + return true; + }, + async getCredential({ url, credentialsId }) { + return credentials.get(`${url}:${credentialsId}`) || null; + }, + async deleteCredential({ url, credentialsId }) { + return credentials.delete(`${url}:${credentialsId}`); + } + }; +}; + +/** + * Creates a mock axios adapter that intercepts HTTP requests. + * + * This allows tests to: + * 1. Capture the request config (headers, body, URL) for assertion + * 2. Return a controlled response without making actual network calls + * + * @param responseData - The mock response data to return (defaults to a valid token response) + * @returns An object containing the adapter and a getter for the captured request config + */ +const createMockAdapter = (responseData: any = { access_token: 'test-token', expires_in: 3600 }) => { + let capturedConfig: any = null; + + const adapter = async (config: any) => { + capturedConfig = config; + return { + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json' }, + config, + data: Buffer.from(JSON.stringify(responseData)) + }; + }; + + return { adapter, getCapturedConfig: () => capturedConfig }; +}; + +/** + * OAuth2 Client Credentials Grant Tests + * + * These tests verify the behavior of the OAuth2 client credentials flow, + * specifically focusing on how client credentials (clientId and clientSecret) + * are transmitted to the authorization server. + * + * OAuth2 spec allows two methods for sending client credentials: + * 1. HTTP Basic Authentication header (RFC 6749 Section 2.3.1) + * 2. Request body parameters (RFC 6749 Section 2.3.1) + * + * The `credentialsPlacement` config option controls which method is used. + */ +describe('OAuth2 Helper - Client Credentials Grant', () => { + let originalAdapter: any; + + beforeEach(() => { + originalAdapter = axios.defaults.adapter; + }); + + afterEach(() => { + axios.defaults.adapter = originalAdapter; + }); + + /** + * Tests for `credentialsPlacement: 'basic_auth_header'` + * + * When using Basic Auth, credentials are sent as: + * Authorization: Basic base64(clientId:clientSecret) + * + * Per RFC 6749, even if clientSecret is empty, the colon separator + * must still be present: base64(clientId:) + */ + describe('when credentialsPlacement is basic_auth_header', () => { + /** + * Verifies that when clientSecret is undefined, we still send a valid + * Authorization header with an empty secret (clientId:) + * + * This handles cases where a public client doesn't have a secret + * but the server still expects Basic Auth format. + */ + test('should send token request with Authorization header when clientSecret is undefined', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'client_credentials', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: undefined, + credentialsPlacement: 'basic_auth_header' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // Authorization header should contain base64(clientId:) with empty secret + // "my-client-id:" encodes to "bXktY2xpZW50LWlkOg==" + const expectedAuth = `Basic ${Buffer.from('my-client-id:').toString('base64')}`; + expect(capturedConfig.headers['Authorization']).toBe(expectedAuth); + + // grant_type must always be in the request body + expect(capturedConfig.data).toContain('grant_type=client_credentials'); + + // When using basic_auth_header, client_id should NOT be duplicated in the body + expect(capturedConfig.data).not.toContain('client_id='); + }); + + /** + * Verifies that an empty string clientSecret is treated the same as undefined. + * + * The implementation uses nullish coalescing (clientSecret ?? '') so both + * undefined and empty string result in the same Authorization header. + */ + test('should send token request with Authorization header when clientSecret is empty string', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'client_credentials', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: '', + credentialsPlacement: 'basic_auth_header' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // Empty string secret should produce same result as undefined + const expectedAuth = `Basic ${Buffer.from('my-client-id:').toString('base64')}`; + expect(capturedConfig.headers['Authorization']).toBe(expectedAuth); + }); + + /** + * Verifies that when clientSecret is provided, it's properly included + * in the Authorization header. + */ + test('should send token request with Authorization header when clientSecret is present', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'client_credentials', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: 'my-secret', + credentialsPlacement: 'basic_auth_header' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // Authorization header should contain base64(clientId:clientSecret) + // "my-client-id:my-secret" encodes to "bXktY2xpZW50LWlkOm15LXNlY3JldA==" + const expectedAuth = `Basic ${Buffer.from('my-client-id:my-secret').toString('base64')}`; + expect(capturedConfig.headers['Authorization']).toBe(expectedAuth); + + // When using basic_auth_header, client_secret should NOT be in the body + expect(capturedConfig.data).not.toContain('client_secret='); + }); + }); + + /** + * Tests for `credentialsPlacement: 'body'` + * + * When using body placement, credentials are sent as form parameters: + * client_id=xxx&client_secret=yyy + * + * No Authorization header should be present. + */ + describe('when credentialsPlacement is body', () => { + /** + * Verifies that when clientSecret is empty, only client_id is sent in the body. + * + * An empty client_secret should not be sent as it may cause issues with + * some authorization servers that interpret it differently than omitting it. + */ + test('should send client_id in body and no Authorization header when clientSecret is empty', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'client_credentials', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: '', + credentialsPlacement: 'body' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // No Authorization header when using body placement + expect(capturedConfig.headers['Authorization']).toBeUndefined(); + + // client_id must be in the body + expect(capturedConfig.data).toContain('client_id=my-client-id'); + + // Empty client_secret should be omitted entirely, not sent as empty value + expect(capturedConfig.data).not.toContain('client_secret='); + }); + + /** + * Verifies that when clientSecret is provided, both client_id and + * client_secret are sent in the request body. + */ + test('should send both client_id and client_secret in body when clientSecret is present', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'client_credentials', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: 'my-secret', + credentialsPlacement: 'body' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // No Authorization header when using body placement + expect(capturedConfig.headers['Authorization']).toBeUndefined(); + + // Both credentials should be in the body + expect(capturedConfig.data).toContain('client_id=my-client-id'); + expect(capturedConfig.data).toContain('client_secret=my-secret'); + }); + }); +}); + +/** + * OAuth2 Password Grant Tests (Resource Owner Password Credentials) + * + * These tests verify the password grant flow, which includes: + * - User credentials (username, password) always sent in the body + * - Client credentials (clientId, clientSecret) placement configurable + * + * Note: Password grant is considered legacy and not recommended for new apps, + * but many existing systems still require it. + */ +describe('OAuth2 Helper - Password Grant', () => { + let originalAdapter: any; + + beforeEach(() => { + originalAdapter = axios.defaults.adapter; + }); + + afterEach(() => { + axios.defaults.adapter = originalAdapter; + }); + + /** + * Tests for `credentialsPlacement: 'basic_auth_header'` with password grant + * + * Client credentials go in Authorization header, while user credentials + * (username, password) are always in the request body. + */ + describe('when credentialsPlacement is basic_auth_header', () => { + /** + * Verifies password grant with undefined clientSecret sends proper + * Authorization header and includes username/password in body. + */ + test('should send token request with Authorization header when clientSecret is undefined', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'password', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: undefined, + username: 'testuser', + password: 'testpass', + credentialsPlacement: 'basic_auth_header' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // Authorization header with empty secret + const expectedAuth = `Basic ${Buffer.from('my-client-id:').toString('base64')}`; + expect(capturedConfig.headers['Authorization']).toBe(expectedAuth); + + // Password grant specific: grant_type and user credentials in body + expect(capturedConfig.data).toContain('grant_type=password'); + expect(capturedConfig.data).toContain('username=testuser'); + expect(capturedConfig.data).toContain('password=testpass'); + + // client_id should NOT be in body when using basic_auth_header + expect(capturedConfig.data).not.toContain('client_id='); + }); + + /** + * Verifies empty string clientSecret behaves same as undefined. + */ + test('should send token request with Authorization header when clientSecret is empty string', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'password', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: '', + username: 'testuser', + password: 'testpass', + credentialsPlacement: 'basic_auth_header' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // Empty string treated same as undefined + const expectedAuth = `Basic ${Buffer.from('my-client-id:').toString('base64')}`; + expect(capturedConfig.headers['Authorization']).toBe(expectedAuth); + }); + + /** + * Verifies clientSecret is properly included in Authorization header. + */ + test('should send token request with Authorization header when clientSecret is present', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'password', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: 'my-secret', + username: 'testuser', + password: 'testpass', + credentialsPlacement: 'basic_auth_header' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // Full credentials in Authorization header + const expectedAuth = `Basic ${Buffer.from('my-client-id:my-secret').toString('base64')}`; + expect(capturedConfig.headers['Authorization']).toBe(expectedAuth); + + // client_secret should NOT be duplicated in body + expect(capturedConfig.data).not.toContain('client_secret='); + }); + }); + + /** + * Tests for `credentialsPlacement: 'body'` with password grant + * + * Both client credentials and user credentials are sent in the request body. + */ + describe('when credentialsPlacement is body', () => { + /** + * Verifies password grant with empty clientSecret sends client_id + * but omits client_secret from the body. + */ + test('should send client_id in body and no Authorization header when clientSecret is empty', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'password', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: '', + username: 'testuser', + password: 'testpass', + credentialsPlacement: 'body' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // No Authorization header + expect(capturedConfig.headers['Authorization']).toBeUndefined(); + + // client_id in body, but not empty client_secret + expect(capturedConfig.data).toContain('client_id=my-client-id'); + expect(capturedConfig.data).not.toContain('client_secret='); + }); + + /** + * Verifies password grant with clientSecret sends all credentials in body. + */ + test('should send both client_id and client_secret in body when clientSecret is present', async () => { + const { adapter, getCapturedConfig } = createMockAdapter(); + axios.defaults.adapter = adapter; + + const tokenStore = createMockTokenStore(); + const config: OAuth2Config = { + grantType: 'password', + accessTokenUrl: 'https://auth.example.com/token', + clientId: 'my-client-id', + clientSecret: 'my-secret', + username: 'testuser', + password: 'testpass', + credentialsPlacement: 'body' + }; + + const token = await getOAuth2Token(config, tokenStore, ''); + + expect(token).toBe('test-token'); + + const capturedConfig = getCapturedConfig(); + expect(capturedConfig).not.toBeNull(); + + // No Authorization header + expect(capturedConfig.headers['Authorization']).toBeUndefined(); + + // All credentials in body + expect(capturedConfig.data).toContain('client_id=my-client-id'); + expect(capturedConfig.data).toContain('client_secret=my-secret'); + }); + }); +}); diff --git a/packages/bruno-requests/src/auth/oauth2-helper.ts b/packages/bruno-requests/src/auth/oauth2-helper.ts index be3fc1457..d398694ce 100644 --- a/packages/bruno-requests/src/auth/oauth2-helper.ts +++ b/packages/bruno-requests/src/auth/oauth2-helper.ts @@ -1,4 +1,4 @@ -import axios, { AxiosRequestConfig, ResponseType } from 'axios'; +import axios, { AxiosInstance, AxiosRequestConfig, ResponseType } from 'axios'; import qs from 'qs'; import debug from 'debug'; @@ -107,7 +107,7 @@ const safeParseJSONBuffer = (data: any) => { /** * Fetches an OAuth2 token using client credentials grant */ -const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => { +const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config, axiosInstance?: AxiosInstance) => { const { accessTokenUrl, clientId, @@ -145,7 +145,8 @@ const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => { } if (credentialsPlacement === 'basic_auth_header') { - requestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret!}`).toString('base64')}`; + const secret = clientSecret ?? ''; + requestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; } if (credentialsPlacement !== 'basic_auth_header') { @@ -166,7 +167,8 @@ const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => { debug('oauth2')(JSON.stringify(requestConfig, null, 2)); try { - const response = await axios(requestConfig); + const httpClient = axiosInstance || axios; + const response = await httpClient(requestConfig); const parsedData = safeParseJSONBuffer(response.data); if (parsedData && typeof parsedData === 'object') { @@ -196,7 +198,7 @@ const fetchTokenClientCredentials = async (oauth2Config: OAuth2Config) => { /** * Fetches an OAuth2 token using password grant */ -const fetchTokenPassword = async (oauth2Config: OAuth2Config) => { +const fetchTokenPassword = async (oauth2Config: OAuth2Config, axiosInstance?: AxiosInstance) => { const { accessTokenUrl, clientId, @@ -246,7 +248,8 @@ const fetchTokenPassword = async (oauth2Config: OAuth2Config) => { } if (credentialsPlacement === 'basic_auth_header') { - requestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${clientSecret!}`).toString('base64')}`; + const secret = clientSecret ?? ''; + requestConfig.headers['Authorization'] = `Basic ${Buffer.from(`${clientId}:${secret}`).toString('base64')}`; } if (credentialsPlacement !== 'basic_auth_header') { @@ -267,7 +270,8 @@ const fetchTokenPassword = async (oauth2Config: OAuth2Config) => { debug('oauth2')(JSON.stringify(requestConfig, null, 2)); try { - const response = await axios(requestConfig); + const httpClient = axiosInstance || axios; + const response = await httpClient(requestConfig); const parsedData = safeParseJSONBuffer(response.data); if (parsedData && typeof parsedData === 'object') { @@ -311,7 +315,7 @@ const isTokenExpired = (credentials: any): boolean => { /** * Manages OAuth2 token retrieval and storage */ -export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: TokenStore, verbose: string): Promise => { +export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: TokenStore, verbose: string, axiosInstance?: AxiosInstance): Promise => { const { grantType, accessTokenUrl, @@ -365,9 +369,9 @@ export const getOAuth2Token = async (oauth2Config: OAuth2Config, tokenStore: Tok let tokenResponse; if (grantType === 'client_credentials') { - tokenResponse = await fetchTokenClientCredentials(oauth2Config); + tokenResponse = await fetchTokenClientCredentials(oauth2Config, axiosInstance); } else if (grantType === 'password') { - tokenResponse = await fetchTokenPassword(oauth2Config); + tokenResponse = await fetchTokenPassword(oauth2Config, axiosInstance); } else { throw new Error(`Unsupported grant type: ${grantType}`); } diff --git a/packages/bruno-requests/src/grpc/grpc-client.js b/packages/bruno-requests/src/grpc/grpc-client.js index 00a9b8f72..60825f15c 100644 --- a/packages/bruno-requests/src/grpc/grpc-client.js +++ b/packages/bruno-requests/src/grpc/grpc-client.js @@ -528,8 +528,19 @@ class GrpcClient { } } + // Extract user-agent from headers if provided (case-insensitive) + // Set it as grpc.primary_user_agent channel option to prepend to the default user-agent + const userAgentKey = Object.keys(request.headers).find( + (key) => key.toLowerCase() === 'user-agent' + ); + const userAgentValue = userAgentKey ? request.headers[userAgentKey] : null; + + const mergedChannelOptions = userAgentValue + ? { 'grpc.primary_user_agent': userAgentValue, ...channelOptions } + : channelOptions; + const Client = makeGenericClientConstructor({}); - const client = new Client(host, credentials, channelOptions); + const client = new Client(host, credentials, mergedChannelOptions); if (!client) { throw new Error('Failed to create client'); } @@ -612,9 +623,19 @@ class GrpcClient { passphrase, pfx, verifyOptions, - sendEvent + sendEvent, + channelOptions = {} }) { const { host, path } = getParsedGrpcUrlObject(request.url); + + // Extract user-agent from headers if provided (case-insensitive) + // Set it as grpc.primary_user_agent channel option to prepend to the default user-agent + const userAgentKey = Object.keys(request.headers).find( + (key) => key.toLowerCase() === 'user-agent' + ); + const userAgentValue = userAgentKey ? request.headers[userAgentKey] : null; + const mergedChannelOptions = userAgentValue ? { 'grpc.primary_user_agent': userAgentValue, ...channelOptions } : channelOptions; + const metadata = new Metadata(); Object.entries(request.headers).forEach(([name, value]) => { metadata.add(name, value); @@ -630,7 +651,7 @@ class GrpcClient { }); try { - const { client, services, callOptions } = await this.#getReflectionClient(host, credentials, metadata, {}); + const { client, services, callOptions } = await this.#getReflectionClient(host, credentials, metadata, mergedChannelOptions); const methods = []; for (const service of services) { diff --git a/packages/bruno-requests/src/grpc/grpc-client.spec.js b/packages/bruno-requests/src/grpc/grpc-client.spec.js new file mode 100644 index 000000000..72f601c61 --- /dev/null +++ b/packages/bruno-requests/src/grpc/grpc-client.spec.js @@ -0,0 +1,508 @@ +/** + * @jest-environment node + */ + +// Store captured channel options for assertions +let capturedChannelOptions = null; + +// Mock GrpcReflection to capture options +const mockListServices = jest.fn().mockResolvedValue(['test.Service']); +const mockListMethods = jest.fn().mockResolvedValue([ + { + path: '/test.Service/TestMethod', + definition: { + requestStream: false, + responseStream: false + } + } +]); + +jest.mock('grpc-js-reflection-client', () => ({ + GrpcReflection: jest.fn().mockImplementation((host, credentials, options) => { + capturedChannelOptions = options; + return { + listServices: mockListServices, + listMethods: mockListMethods + }; + }) +})); + +// Mock @grpc/grpc-js +jest.mock('@grpc/grpc-js', () => { + const createMockMetadata = () => { + const map = {}; + return { + add: jest.fn((key, value) => { + if (map[key] === undefined) { + map[key] = value; + } else if (Array.isArray(map[key])) { + map[key].push(value); + } else { + map[key] = [map[key], value]; + } + }), + getMap: jest.fn(() => map) + }; + }; + + // Create a mock RPC object with event emitter interface + const createMockRpc = () => { + const handlers = {}; + const mockRpc = { + on: jest.fn((event, handler) => { + handlers[event] = handler; + return mockRpc; // Return the mock object for chaining + }), + write: jest.fn(), + end: jest.fn(), + cancel: jest.fn(), + call: { + channel: { close: jest.fn() } + } + }; + return mockRpc; + }; + + return { + makeGenericClientConstructor: jest.fn(() => { + return jest.fn().mockImplementation((host, credentials, options) => { + capturedChannelOptions = options; + const mockRpc = createMockRpc(); + return { + close: jest.fn(), + makeUnaryRequest: jest.fn().mockReturnValue(mockRpc), + makeClientStreamRequest: jest.fn().mockReturnValue(mockRpc), + makeServerStreamRequest: jest.fn().mockReturnValue(mockRpc), + makeBidiStreamRequest: jest.fn().mockReturnValue(mockRpc) + }; + }); + }), + ChannelCredentials: { + createInsecure: jest.fn().mockReturnValue('insecure-credentials'), + createSsl: jest.fn().mockReturnValue('ssl-credentials'), + createFromSecureContext: jest.fn().mockReturnValue('secure-context-credentials') + }, + Metadata: jest.fn().mockImplementation(() => createMockMetadata()), + status: {}, + credentials: {}, + CallCredentials: { + createFromMetadataGenerator: jest.fn().mockReturnValue('call-credentials') + } + }; +}); + +// Mock proto-loader +jest.mock('@grpc/proto-loader', () => ({ + load: jest.fn().mockResolvedValue({}) +})); + +import { GrpcClient } from './grpc-client'; + +describe('GrpcClient', () => { + let grpcClient; + let mockEventCallback; + + beforeEach(() => { + jest.clearAllMocks(); + capturedChannelOptions = null; + mockEventCallback = jest.fn(); + grpcClient = new GrpcClient(mockEventCallback); + }); + + describe('User-Agent behavior in loadMethodsFromReflection', () => { + const baseRequest = { + url: 'grpc://localhost:50051', + uid: 'test-request-uid', + headers: {} + }; + + const baseParams = { + collectionUid: 'test-collection-uid', + sendEvent: jest.fn() + }; + + describe('case-insensitive header extraction', () => { + test('should extract User-Agent header (capitalized)', async () => { + const request = { + ...baseRequest, + headers: { 'User-Agent': 'Bruno/1.0' } + }; + + await grpcClient.loadMethodsFromReflection({ + request, + ...baseParams + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0'); + }); + + test('should extract user-agent header (lowercase)', async () => { + const request = { + ...baseRequest, + headers: { 'user-agent': 'Bruno/1.0' } + }; + + await grpcClient.loadMethodsFromReflection({ + request, + ...baseParams + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0'); + }); + + test('should extract USER-AGENT header (uppercase)', async () => { + const request = { + ...baseRequest, + headers: { 'USER-AGENT': 'Bruno/1.0' } + }; + + await grpcClient.loadMethodsFromReflection({ + request, + ...baseParams + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0'); + }); + + test('should extract uSeR-aGeNt header (mixed case)', async () => { + const request = { + ...baseRequest, + headers: { 'uSeR-aGeNt': 'Bruno/1.0' } + }; + + await grpcClient.loadMethodsFromReflection({ + request, + ...baseParams + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0'); + }); + }); + + describe('channel options merging', () => { + test('should preserve existing channelOptions when user-agent is set', async () => { + const request = { + ...baseRequest, + headers: { 'User-Agent': 'Bruno/1.0' } + }; + + await grpcClient.loadMethodsFromReflection({ + request, + ...baseParams, + channelOptions: { + 'grpc.max_receive_message_length': 1024 * 1024, + 'grpc.keepalive_time_ms': 30000 + } + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0'); + expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024); + expect(capturedChannelOptions['grpc.keepalive_time_ms']).toBe(30000); + }); + + test('should include grpc.primary_user_agent in merged options alongside other options', async () => { + const request = { + ...baseRequest, + headers: { 'User-Agent': 'Bruno/1.0' } + }; + + await grpcClient.loadMethodsFromReflection({ + request, + ...baseParams, + channelOptions: { + 'grpc.other_option': 'value' + } + }); + + // Use array notation for keys containing dots to avoid Jest interpreting as nested path + expect(capturedChannelOptions).toHaveProperty(['grpc.primary_user_agent'], 'Bruno/1.0'); + expect(capturedChannelOptions).toHaveProperty(['grpc.other_option'], 'value'); + }); + + test('should allow channelOptions to override grpc.primary_user_agent', async () => { + const request = { + ...baseRequest, + headers: { 'User-Agent': 'Bruno/1.0' } + }; + + await grpcClient.loadMethodsFromReflection({ + request, + ...baseParams, + channelOptions: { + 'grpc.primary_user_agent': 'ExistingUA' + } + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('ExistingUA'); + }); + }); + + describe('missing user-agent handling', () => { + test('should pass channelOptions unchanged when no user-agent header', async () => { + const request = { + ...baseRequest, + headers: { 'Content-Type': 'application/grpc' } + }; + + const channelOptions = { + 'grpc.max_receive_message_length': 1024 * 1024 + }; + + await grpcClient.loadMethodsFromReflection({ + request, + ...baseParams, + channelOptions + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined(); + expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024); + }); + + test('should pass empty object when no user-agent and no channelOptions', async () => { + const request = { + ...baseRequest, + headers: {} + }; + + await grpcClient.loadMethodsFromReflection({ + request, + ...baseParams + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined(); + }); + + test('should not add grpc.primary_user_agent when user-agent header is missing', async () => { + const request = { + ...baseRequest, + headers: { Authorization: 'Bearer token' } + }; + + await grpcClient.loadMethodsFromReflection({ + request, + ...baseParams, + channelOptions: {} + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined(); + expect(Object.keys(capturedChannelOptions)).not.toContain('grpc.primary_user_agent'); + }); + }); + + describe('edge cases', () => { + test('should handle empty user-agent value', async () => { + const request = { + ...baseRequest, + headers: { 'User-Agent': '' } + }; + + await grpcClient.loadMethodsFromReflection({ + request, + ...baseParams + }); + + // Empty string is falsy, so grpc.primary_user_agent should not be set + expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined(); + }); + }); + }); + + describe('User-Agent behavior in startConnection', () => { + const baseRequest = { + url: 'grpc://localhost:50051', + uid: 'test-request-uid', + method: '/test.Service/TestMethod', + headers: {}, + body: { + grpc: [{ content: '{}' }] + } + }; + + const baseCollection = { + uid: 'test-collection-uid', + pathname: '/test/path' + }; + + beforeEach(() => { + // Pre-register a method so startConnection can find it + grpcClient.methods.set('/test.Service/TestMethod', { + path: '/test.Service/TestMethod', + requestStream: false, + responseStream: false, + requestSerialize: (val) => Buffer.from(JSON.stringify(val)), + responseDeserialize: (val) => JSON.parse(val.toString()) + }); + }); + + describe('case-insensitive header extraction', () => { + test('should extract User-Agent header (capitalized)', async () => { + const request = { + ...baseRequest, + headers: { 'User-Agent': 'Bruno/1.0' } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0'); + }); + + test('should extract user-agent header (lowercase)', async () => { + const request = { + ...baseRequest, + headers: { 'user-agent': 'Bruno/1.0' } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0'); + }); + + test('should extract USER-AGENT header (uppercase)', async () => { + const request = { + ...baseRequest, + headers: { 'USER-AGENT': 'Bruno/1.0' } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0'); + }); + + test('should extract uSeR-aGeNt header (mixed case)', async () => { + const request = { + ...baseRequest, + headers: { 'uSeR-aGeNt': 'Bruno/1.0' } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0'); + }); + }); + + describe('channel options merging', () => { + test('should preserve existing channelOptions when user-agent is set', async () => { + const request = { + ...baseRequest, + headers: { 'User-Agent': 'Bruno/1.0' } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection, + channelOptions: { + 'grpc.max_receive_message_length': 1024 * 1024, + 'grpc.keepalive_time_ms': 30000 + } + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0'); + expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024); + expect(capturedChannelOptions['grpc.keepalive_time_ms']).toBe(30000); + }); + + test('should include grpc.primary_user_agent in merged options alongside other options', async () => { + const request = { + ...baseRequest, + headers: { 'User-Agent': 'Bruno/1.0' } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection, + channelOptions: { + 'grpc.other_option': 'value' + } + }); + + // Use array notation for keys containing dots to avoid Jest interpreting as nested path + expect(capturedChannelOptions).toHaveProperty(['grpc.primary_user_agent'], 'Bruno/1.0'); + expect(capturedChannelOptions).toHaveProperty(['grpc.other_option'], 'value'); + }); + + test('should allow channelOptions to override grpc.primary_user_agent', async () => { + const request = { + ...baseRequest, + headers: { 'User-Agent': 'Bruno/1.0' } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection, + channelOptions: { + 'grpc.primary_user_agent': 'ExistingUA' + } + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('ExistingUA'); + }); + }); + + describe('missing user-agent handling', () => { + test('should pass channelOptions unchanged when no user-agent header', async () => { + const request = { + ...baseRequest, + headers: { 'Content-Type': 'application/grpc' } + }; + + const channelOptions = { + 'grpc.max_receive_message_length': 1024 * 1024 + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection, + channelOptions + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined(); + expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024); + }); + + test('should not add grpc.primary_user_agent when user-agent header is missing', async () => { + const request = { + ...baseRequest, + headers: { Authorization: 'Bearer token' } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection, + channelOptions: {} + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined(); + expect(Object.keys(capturedChannelOptions)).not.toContain('grpc.primary_user_agent'); + }); + }); + + describe('edge cases', () => { + test('should handle empty user-agent value', async () => { + const request = { + ...baseRequest, + headers: { 'User-Agent': '' } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection + }); + + // Empty string is falsy, so grpc.primary_user_agent should not be set + expect(capturedChannelOptions['grpc.primary_user_agent']).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/bruno-requests/src/index.ts b/packages/bruno-requests/src/index.ts index ad751f0ce..1da7b035a 100644 --- a/packages/bruno-requests/src/index.ts +++ b/packages/bruno-requests/src/index.ts @@ -4,5 +4,11 @@ export { WsClient } from './ws/ws-client'; export { default as cookies } from './cookies'; export { getCACertificates } from './utils/ca-cert'; +export { transformProxyConfig } from './utils/proxy-util'; +export { default as createVaultClient, VaultError } from './utils/node-vault'; +export type { VaultClient, VaultConfig, VaultRequestOptions } from './utils/node-vault'; +export { getHttpHttpsAgents } from './utils/http-https-agents'; export * as scripting from './scripting'; + +export { makeAxiosInstance } from './network/axios-instance'; diff --git a/packages/bruno-requests/src/utils/http-https-agents.ts b/packages/bruno-requests/src/utils/http-https-agents.ts new file mode 100644 index 000000000..a34bb8ea0 --- /dev/null +++ b/packages/bruno-requests/src/utils/http-https-agents.ts @@ -0,0 +1,445 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import https from 'node:https'; +import type { Agent as HttpAgent } from 'node:http'; +import type { Agent as HttpsAgent } from 'node:https'; +import { parse as parseUrl, type Url } from 'url'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { SocksProxyAgent } from 'socks-proxy-agent'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { isEmpty, get, isUndefined, isNull } from 'lodash'; +import { getCACertificates } from './ca-cert'; +import { transformProxyConfig } from './proxy-util'; + +const DEFAULT_PORTS: Record = { + ftp: 21, + gopher: 70, + http: 80, + https: 443, + ws: 80, + wss: 443 +}; + +type ProxyMode = 'on' | 'off' | 'system'; + +type ProxyAuth = { + enabled: boolean; + username?: string; + password?: string; +}; + +type ProxyConfig = { + enabled?: boolean | 'global'; + protocol?: string; + hostname?: string; + port?: number | null; + auth?: ProxyAuth; + bypassProxy?: string; + mode?: ProxyMode; +}; + +type SystemProxyConfig = { + http_proxy?: string; + https_proxy?: string; + no_proxy?: string; +}; + +type ClientCertificate = { + domain?: string; + type?: 'cert' | 'pfx'; + certFilePath?: string; + keyFilePath?: string; + pfxFilePath?: string; + passphrase?: string; +}; + +type CACertificatesCount = { + system: number; + root: number; + custom: number; + extra: number; +}; + +type CertsConfig = { + caCertificatesCount?: CACertificatesCount; + ca?: string | string[]; + cert?: Buffer; + key?: Buffer; + pfx?: Buffer; + passphrase?: string; +}; + +type HttpsAgentRequestFields = { + keepAlive?: boolean; + rejectUnauthorized?: boolean; + caCertificatesCount?: CACertificatesCount; + ca?: string | string[]; +}; + +type TlsOptions = HttpsAgentRequestFields & CertsConfig & { + secureProtocol?: string; + minVersion?: string; + ALPNProtocols?: string[]; +}; + +type AgentResult = { + httpAgent?: HttpAgent; + httpsAgent?: HttpsAgent | HttpsProxyAgent | SocksProxyAgent; +}; + +type ConfigOptions = { + noproxy: boolean; + shouldVerifyTls: boolean; + shouldUseCustomCaCertificate: boolean; + customCaCertificateFilePath?: string; + shouldKeepDefaultCaCertificates: boolean; +}; + +type GetCertsAndProxyConfigParams = { + requestUrl?: string; + collectionPath: string; + options: ConfigOptions; + clientCertificates?: { + certs?: ClientCertificate[]; + }; + collectionLevelProxy?: ProxyConfig; + systemProxyConfig?: SystemProxyConfig; +}; + +type GetCertsAndProxyConfigResult = { + proxyMode: ProxyMode; + proxyConfig: ProxyConfig; + certsConfig: CertsConfig; +}; + +type CreateAgentsParams = { + requestUrl?: string; + proxyMode: ProxyMode; + proxyConfig: ProxyConfig; + certsConfig: CertsConfig; + httpsAgentRequestFields: HttpsAgentRequestFields; + systemProxyConfig?: SystemProxyConfig; +}; + +type GetHttpHttpsAgentsParams = { + requestUrl?: string; + collectionPath: string; + options: ConfigOptions; + clientCertificates?: { + certs?: ClientCertificate[]; + }; + collectionLevelProxy?: ProxyConfig; + systemProxyConfig?: SystemProxyConfig; +}; + +/** + * check for proxy bypass, copied from 'proxy-from-env' + */ +const shouldUseProxy = (url: string | undefined, proxyBypass: string | undefined): boolean => { + if (proxyBypass === '*') { + return false; // Never proxy if wildcard is set. + } + + // use proxy if no proxyBypass is set + if (!proxyBypass || typeof proxyBypass !== 'string' || isEmpty(proxyBypass.trim())) { + return true; + } + + const parsedUrl: Url | {} = typeof url === 'string' ? parseUrl(url) : (url ? (url as unknown as Url) : {}); + const urlObj = parsedUrl as Url; + let proto = urlObj.protocol; + let hostname = urlObj.host; + let port: string | null = urlObj.port; + if (typeof hostname !== 'string' || !hostname || typeof proto !== 'string') { + return false; // Don't proxy URLs without a valid scheme or host. + } + + proto = proto.split(':', 1)[0]; + // Stripping ports in this way instead of using parsedUrl.hostname to make + // sure that the brackets around IPv6 addresses are kept. + hostname = hostname.replace(/:\d*$/, ''); + const portNum = parseInt(port || '', 10) || DEFAULT_PORTS[proto] || 0; + + return proxyBypass.split(/[,;\s]/).every(function (dontProxyFor) { + if (!dontProxyFor) { + return true; // Skip zero-length hosts. + } + const parsedProxy = dontProxyFor.match(/^(.+):(\d+)$/); + let parsedProxyHostname = parsedProxy ? parsedProxy[1] : dontProxyFor; + const parsedProxyPort = parsedProxy ? parseInt(parsedProxy[2], 10) : 0; + if (parsedProxyPort && parsedProxyPort !== portNum) { + return true; // Skip if ports don't match. + } + + if (!/^[.*]/.test(parsedProxyHostname)) { + // No wildcards, so stop proxying if there is an exact match. + return hostname !== parsedProxyHostname; + } + + if (parsedProxyHostname.charAt(0) === '*') { + // Remove leading wildcard. + parsedProxyHostname = parsedProxyHostname.slice(1); + } + // Stop proxying if the hostname ends with the no_proxy host. + return !hostname.endsWith(parsedProxyHostname); + }); +}; + +/** + * Patched version of HttpsProxyAgent to get around a bug that ignores options + * such as ca and rejectUnauthorized when upgrading the proxied socket to TLS: + * https://github.com/TooTallNate/proxy-agents/issues/194 + */ +class PatchedHttpsProxyAgent extends HttpsProxyAgent { + private constructorOpts: any; + + constructor(proxy: string, opts: any) { + super(proxy, opts); + this.constructorOpts = opts; + } + + async connect(req: any, opts: any) { + const combinedOpts = { ...this.constructorOpts, ...opts }; + return super.connect(req, combinedOpts); + } +} + +const getCertsAndProxyConfig = ({ + requestUrl, + collectionPath, + options, + clientCertificates, + collectionLevelProxy, + systemProxyConfig +}: GetCertsAndProxyConfigParams): GetCertsAndProxyConfigResult => { + const certsConfig: CertsConfig = {}; + + const caCertFilePath = options.shouldUseCustomCaCertificate && options.customCaCertificateFilePath ? options.customCaCertificateFilePath : undefined; + const caCertificatesData = getCACertificates({ + caCertFilePath, + shouldKeepDefaultCerts: options.shouldKeepDefaultCaCertificates + }); + + const caCertificates = caCertificatesData.caCertificates; + const caCertificatesCount = caCertificatesData.caCertificatesCount; + + // configure HTTPS agent with aggregated CA certificates + certsConfig.caCertificatesCount = caCertificatesCount; + certsConfig.ca = caCertificates || []; + + // client certificate config + const clientCertConfig = get(clientCertificates, 'certs', []) as ClientCertificate[]; + + for (const clientCert of clientCertConfig) { + const domain = clientCert?.domain; + const type = clientCert?.type || 'cert'; + if (domain) { + const hostRegex = '^(https:\\/\\/|grpc:\\/\\/|grpcs:\\/\\/)?' + domain.replace(/\./g, '\\.').replace(/\*/g, '.*'); + if (requestUrl && requestUrl.match(hostRegex)) { + if (type === 'cert') { + try { + let certFilePath = clientCert?.certFilePath; + if (!certFilePath) { + throw new Error('certFilePath is required for cert type'); + } + certFilePath = path.isAbsolute(certFilePath) ? certFilePath : path.join(collectionPath, certFilePath); + let keyFilePath = clientCert?.keyFilePath; + if (!keyFilePath) { + throw new Error('keyFilePath is required for cert type'); + } + keyFilePath = path.isAbsolute(keyFilePath) ? keyFilePath : path.join(collectionPath, keyFilePath); + + certsConfig.cert = fs.readFileSync(certFilePath); + certsConfig.key = fs.readFileSync(keyFilePath); + } catch (err: any) { + console.error('Error reading cert/key file', err); + throw new Error(`Error reading cert/key file: ${err.message}`); + } + } else if (type === 'pfx') { + try { + let pfxFilePath = clientCert?.pfxFilePath; + if (!pfxFilePath) { + throw new Error('pfxFilePath is required for pfx type'); + } + pfxFilePath = path.isAbsolute(pfxFilePath) ? pfxFilePath : path.join(collectionPath, pfxFilePath); + certsConfig.pfx = fs.readFileSync(pfxFilePath); + } catch (err: any) { + console.error('Error reading pfx file', err); + throw new Error(`Error reading pfx file: ${err.message}`); + } + } + certsConfig.passphrase = clientCert.passphrase; + break; + } + } + } + + /** + * Proxy configuration + * + * Preferences proxyMode has three possible values: on, off, system + * Collection proxyMode has three possible values: true, false, global + * + * When collection proxyMode is true, it overrides the app-level proxy settings + * When collection proxyMode is false, it ignores the app-level proxy settings + * When collection proxyMode is global, it uses the app-level proxy settings + * + * Below logic calculates the proxyMode and proxyConfig to be used for the request + */ + let proxyMode: ProxyMode = 'off'; + let proxyConfig: ProxyConfig = {}; + + const collectionProxyConfig = transformProxyConfig(collectionLevelProxy || {}) as ProxyConfig; + const collectionProxyDisabled = get(collectionProxyConfig, 'disabled', false); + const collectionProxyInherit = get(collectionProxyConfig, 'inherit', true); + const collectionProxyConfigData = get(collectionProxyConfig, 'config', {}); + + if (options.noproxy || collectionProxyDisabled) { + // If noproxy flag is set or collection proxy is disabled, don't use any proxy + proxyMode = 'off'; + } else if (!collectionProxyDisabled && !collectionProxyInherit) { + // Use collection-specific proxy + proxyConfig = collectionProxyConfigData; + proxyMode = 'on'; + } else if (!collectionProxyDisabled && collectionProxyInherit) { + // Inherit from system proxy + const { http_proxy, https_proxy } = systemProxyConfig || {}; + if (http_proxy?.length || https_proxy?.length) { + proxyMode = 'system'; + } + // else: no system proxy available, proxyMode stays 'off' + } + // else: collection proxy is disabled, proxyMode stays 'off' + + return { proxyMode, proxyConfig, certsConfig }; +}; + +function createAgents({ + requestUrl, + proxyMode, + proxyConfig, + systemProxyConfig, + certsConfig, + httpsAgentRequestFields +}: CreateAgentsParams): AgentResult { + // Ensure TLS options are properly set + const tlsOptions: TlsOptions = { + ...httpsAgentRequestFields, + ...certsConfig, + // Enable all secure protocols by default + secureProtocol: undefined, + // Allow Node.js to choose the protocol + minVersion: 'TLSv1', + rejectUnauthorized: httpsAgentRequestFields.rejectUnauthorized !== undefined ? httpsAgentRequestFields.rejectUnauthorized : true + }; + + let httpAgent: HttpAgent | undefined; + let httpsAgent: HttpsAgent | HttpsProxyAgent | SocksProxyAgent | undefined; + + if (proxyMode === 'on') { + const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', '')); + if (shouldProxy) { + const proxyProtocol = get(proxyConfig, 'protocol'); + const proxyHostname = get(proxyConfig, 'hostname'); + const proxyPort = get(proxyConfig, 'port'); + const proxyAuthEnabled = get(proxyConfig, 'auth.enabled', false); + const socksEnabled = proxyProtocol && proxyProtocol.includes('socks'); + + if (!proxyProtocol || !proxyHostname) { + throw new Error('Proxy protocol and hostname are required when proxy is enabled'); + } + + const uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`; + let proxyUri: string; + if (proxyAuthEnabled) { + const proxyAuthUsername = encodeURIComponent(get(proxyConfig, 'auth.username', '')); + const proxyAuthPassword = encodeURIComponent(get(proxyConfig, 'auth.password', '')); + proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`; + } else { + proxyUri = `${proxyProtocol}://${proxyHostname}${uriPort}`; + } + + if (socksEnabled) { + httpAgent = new SocksProxyAgent(proxyUri); + httpsAgent = new SocksProxyAgent(proxyUri, tlsOptions as any); + } else { + httpAgent = new HttpProxyAgent(proxyUri); + httpsAgent = new PatchedHttpsProxyAgent(proxyUri, tlsOptions); + } + } else { + // If proxy should not be used, set default HTTPS agent + httpsAgent = new https.Agent(tlsOptions as any); + } + } else if (proxyMode === 'system') { + const http_proxy = get(systemProxyConfig, 'http_proxy'); + const https_proxy = get(systemProxyConfig, 'https_proxy'); + const no_proxy = get(systemProxyConfig, 'no_proxy'); + const shouldUseSystemProxy = shouldUseProxy(requestUrl, no_proxy || ''); + if (shouldUseSystemProxy) { + try { + if (http_proxy?.length) { + new URL(http_proxy); + httpAgent = new HttpProxyAgent(http_proxy); + } + } catch (error) { + throw new Error('Invalid system http_proxy'); + } + try { + if (https_proxy?.length) { + new URL(https_proxy); + httpsAgent = new PatchedHttpsProxyAgent(https_proxy, tlsOptions as any); + } else { + httpsAgent = new https.Agent(tlsOptions as any); + } + } catch (error) { + throw new Error('Invalid system https_proxy'); + } + } else { + httpsAgent = new https.Agent(tlsOptions as any); + } + } else { + httpsAgent = new https.Agent(tlsOptions as any); + } + + return { httpAgent, httpsAgent }; +} + +const getHttpHttpsAgents = async ({ + requestUrl, + collectionPath, + clientCertificates, + collectionLevelProxy, + systemProxyConfig, + options +}: GetHttpHttpsAgentsParams): Promise => { + const { proxyMode, proxyConfig, certsConfig } = getCertsAndProxyConfig({ + requestUrl, + collectionPath, + clientCertificates, + collectionLevelProxy, + systemProxyConfig, + options + }); + + /** + * @see https://github.com/usebruno/bruno/issues/211 set keepAlive to true, this should fix socket hang up errors + * @see https://github.com/nodejs/node/pull/43522 keepAlive was changed to true globally on Node v19+ + */ + const httpsAgentRequestFields: HttpsAgentRequestFields = { keepAlive: true }; + if (!options.shouldVerifyTls) { + httpsAgentRequestFields.rejectUnauthorized = false; + } + + const { httpAgent, httpsAgent } = createAgents({ + requestUrl, + proxyMode, + proxyConfig, + systemProxyConfig, + certsConfig, + httpsAgentRequestFields + }); + + return { httpAgent, httpsAgent }; +}; + +export { getHttpHttpsAgents }; diff --git a/packages/bruno-requests/src/utils/node-vault.spec.ts b/packages/bruno-requests/src/utils/node-vault.spec.ts new file mode 100644 index 000000000..8a0ee7daa --- /dev/null +++ b/packages/bruno-requests/src/utils/node-vault.spec.ts @@ -0,0 +1,716 @@ +import axios from 'axios'; +import createVaultClient, { VaultError, VaultClient } from './node-vault'; + +// Mock axios +jest.mock('axios', () => { + const mockAxios = jest.fn(); + (mockAxios as any).isAxiosError = jest.fn((error: any) => error.isAxiosError === true); + return mockAxios; +}); + +const mockedAxios = axios as jest.MockedFunction; + +describe('node-vault', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Clear environment variables + delete process.env.VAULT_ADDR; + delete process.env.VAULT_TOKEN; + delete process.env.VAULT_NAMESPACE; + }); + + describe('module', () => { + it('should export a function that returns a new client', () => { + const vault = createVaultClient(); + expect(typeof createVaultClient).toBe('function'); + expect(typeof vault).toBe('object'); + }); + + it('should set default values for endpoint and apiVersion', () => { + const vault = createVaultClient(); + expect(vault.endpoint).toBe('http://127.0.0.1:8200'); + expect(vault.apiVersion).toBe('v1'); + }); + + it('should use environment variables for defaults', () => { + process.env.VAULT_ADDR = 'https://vault.example.com'; + process.env.VAULT_TOKEN = 'env-token'; + process.env.VAULT_NAMESPACE = 'env-namespace'; + + const vault = createVaultClient(); + expect(vault.endpoint).toBe('https://vault.example.com'); + expect(vault.token).toBe('env-token'); + expect(vault.namespace).toBe('env-namespace'); + }); + + it('should allow config to override environment variables', () => { + process.env.VAULT_ADDR = 'https://vault.example.com'; + process.env.VAULT_TOKEN = 'env-token'; + + const vault = createVaultClient({ + endpoint: 'https://custom.vault.com', + token: 'config-token' + }); + expect(vault.endpoint).toBe('https://custom.vault.com'); + expect(vault.token).toBe('config-token'); + }); + }); + + describe('client properties', () => { + it('should allow direct assignment of endpoint', () => { + const vault = createVaultClient(); + vault.endpoint = 'https://new-vault.example.com'; + expect(vault.endpoint).toBe('https://new-vault.example.com'); + }); + + it('should allow direct assignment of token', () => { + const vault = createVaultClient(); + vault.token = 'new-token'; + expect(vault.token).toBe('new-token'); + }); + + it('should allow direct assignment of namespace', () => { + const vault = createVaultClient(); + vault.namespace = 'my-namespace'; + expect(vault.namespace).toBe('my-namespace'); + }); + + it('should allow direct assignment of apiVersion', () => { + const vault = createVaultClient(); + vault.apiVersion = 'v2'; + expect(vault.apiVersion).toBe('v2'); + }); + }); + + describe('read(path, requestOptions)', () => { + let vault: VaultClient; + + beforeEach(() => { + vault = createVaultClient({ + endpoint: 'http://localhost:8200', + token: 'test-token' + }); + }); + + it('should read data from path', async () => { + const responseData = { data: { value: 'secret-value' } }; + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: responseData + }); + + const result = await vault.read('secret/data/hello'); + + expect(mockedAxios).toHaveBeenCalledTimes(1); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: 'http://localhost:8200/v1/secret/data/hello', + headers: expect.objectContaining({ + 'X-Vault-Token': 'test-token' + }) + }) + ); + expect(result).toEqual(responseData); + }); + + it('should include namespace header when set', async () => { + vault.namespace = 'my-namespace'; + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: { data: {} } + }); + + await vault.read('secret/data/hello'); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Vault-Token': 'test-token', + 'X-Vault-Namespace': 'my-namespace' + }) + }) + ); + }); + + it('should use updated endpoint after assignment', async () => { + vault.endpoint = 'https://new-vault.com'; + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: { data: {} } + }); + + await vault.read('secret/data/hello'); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://new-vault.com/v1/secret/data/hello' + }) + ); + }); + + it('should handle 404 errors', async () => { + mockedAxios.mockResolvedValueOnce({ + status: 404, + data: { errors: ['no secrets found'] } + }); + + await expect(vault.read('secret/data/nonexistent')).rejects.toThrow('no secrets found'); + }); + + it('should handle 204 no content response', async () => { + mockedAxios.mockResolvedValueOnce({ + status: 204, + data: null + }); + + const result = await vault.read('secret/data/empty'); + expect(result).toBeNull(); + }); + + it('should handle paths with leading slash without creating double slashes', async () => { + const responseData = { data: { value: 'secret-value' } }; + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: responseData + }); + + const result = await vault.read('/secret/data/hello'); + + expect(mockedAxios).toHaveBeenCalledTimes(1); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: 'http://localhost:8200/v1/secret/data/hello', + headers: expect.objectContaining({ + 'X-Vault-Token': 'test-token' + }) + }) + ); + expect(result).toEqual(responseData); + }); + + it('should handle endpoint with trailing slash', async () => { + vault.endpoint = 'http://localhost:8200/'; + const responseData = { data: { value: 'secret-value' } }; + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: responseData + }); + + const result = await vault.read('secret/data/hello'); + + expect(mockedAxios).toHaveBeenCalledTimes(1); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: 'http://localhost:8200/v1/secret/data/hello' + }) + ); + expect(result).toEqual(responseData); + }); + }); + + describe('write(path, data, requestOptions)', () => { + let vault: VaultClient; + + beforeEach(() => { + vault = createVaultClient({ + endpoint: 'http://localhost:8200', + token: 'test-token' + }); + }); + + it('should write data to path', async () => { + const writeData = { value: 'world' }; + const responseData = { data: { created_time: '2024-01-01T00:00:00Z' } }; + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: responseData + }); + + const result = await vault.write('secret/data/hello', writeData); + + expect(mockedAxios).toHaveBeenCalledTimes(1); + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: 'http://localhost:8200/v1/secret/data/hello', + data: writeData, + headers: expect.objectContaining({ + 'X-Vault-Token': 'test-token', + 'Content-Type': 'application/json' + }) + }) + ); + expect(result).toEqual(responseData); + }); + + it('should handle LDAP login write', async () => { + const loginData = { password: 'my-password' }; + const responseData = { + auth: { + client_token: 'ldap-token', + renewable: true, + lease_duration: 3600 + } + }; + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: responseData + }); + + const result = await vault.write('auth/ldap/login/myuser', loginData); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: 'http://localhost:8200/v1/auth/ldap/login/myuser', + data: loginData + }) + ); + expect(result.auth.client_token).toBe('ldap-token'); + }); + }); + + describe('approleLogin(args)', () => { + let vault: VaultClient; + + beforeEach(() => { + vault = createVaultClient({ + endpoint: 'http://localhost:8200' + }); + }); + + it('should login with role_id and secret_id', async () => { + const responseData = { + auth: { + client_token: 'approle-token', + renewable: true, + lease_duration: 3600, + policies: ['default', 'my-policy'] + } + }; + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: responseData + }); + + const result = await vault.approleLogin({ + role_id: 'my-role-id', + secret_id: 'my-secret-id' + }); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: 'http://localhost:8200/v1/auth/approle/login', + data: { + role_id: 'my-role-id', + secret_id: 'my-secret-id' + } + }) + ); + expect(result.auth.client_token).toBe('approle-token'); + }); + + it('should login with only role_id when secret_id is not required', async () => { + const responseData = { + auth: { client_token: 'approle-token' } + }; + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: responseData + }); + + await vault.approleLogin({ + role_id: 'my-role-id' + }); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + role_id: 'my-role-id' + } + }) + ); + }); + + it('should use custom mount_point', async () => { + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: { auth: { client_token: 'token' } } + }); + + await vault.approleLogin({ + role_id: 'my-role-id', + secret_id: 'my-secret-id', + mount_point: 'custom-approle' + }); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://localhost:8200/v1/auth/custom-approle/login' + }) + ); + }); + + it('should handle authentication errors', async () => { + mockedAxios.mockResolvedValueOnce({ + status: 400, + data: { errors: ['invalid role or secret ID'] } + }); + + await expect(vault.approleLogin({ + role_id: 'bad-role-id', + secret_id: 'bad-secret-id' + })).rejects.toThrow('invalid role or secret ID'); + }); + }); + + describe('tokenLookupSelf()', () => { + let vault: VaultClient; + + beforeEach(() => { + vault = createVaultClient({ + endpoint: 'http://localhost:8200', + token: 'my-token' + }); + }); + + it('should lookup current token', async () => { + const responseData = { + data: { + id: 'my-token', + ttl: 3600, + renewable: true, + policies: ['default'] + } + }; + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: responseData + }); + + const result = await vault.tokenLookupSelf(); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: 'http://localhost:8200/v1/auth/token/lookup-self', + headers: expect.objectContaining({ + 'X-Vault-Token': 'my-token' + }) + }) + ); + expect(result.data.ttl).toBe(3600); + }); + + it('should handle expired token error', async () => { + mockedAxios.mockResolvedValueOnce({ + status: 403, + data: { errors: ['permission denied'] } + }); + + await expect(vault.tokenLookupSelf()).rejects.toThrow('permission denied'); + }); + }); + + describe('tokenRenewSelf(args)', () => { + let vault: VaultClient; + + beforeEach(() => { + vault = createVaultClient({ + endpoint: 'http://localhost:8200', + token: 'my-token' + }); + }); + + it('should renew current token', async () => { + const responseData = { + auth: { + client_token: 'my-token', + renewable: true, + lease_duration: 7200 + } + }; + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: responseData + }); + + const result = await vault.tokenRenewSelf(); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + url: 'http://localhost:8200/v1/auth/token/renew-self', + headers: expect.objectContaining({ + 'X-Vault-Token': 'my-token' + }) + }) + ); + expect(result.auth.lease_duration).toBe(7200); + }); + + it('should pass increment when provided', async () => { + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: { auth: { lease_duration: 3600 } } + }); + + await vault.tokenRenewSelf({ increment: 3600 }); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + data: { increment: 3600 } + }) + ); + }); + + it('should handle non-renewable token error', async () => { + mockedAxios.mockResolvedValueOnce({ + status: 400, + data: { errors: ['lease is not renewable'] } + }); + + await expect(vault.tokenRenewSelf()).rejects.toThrow('lease is not renewable'); + }); + }); + + describe('error handling', () => { + let vault: VaultClient; + + beforeEach(() => { + vault = createVaultClient({ + endpoint: 'http://localhost:8200', + token: 'test-token' + }); + }); + + it('should throw VaultError with response structure', async () => { + mockedAxios.mockResolvedValueOnce({ + status: 500, + data: { errors: ['internal server error'] } + }); + + try { + await vault.read('secret/data/hello'); + } catch (error) { + expect(error).toBeInstanceOf(VaultError); + expect((error as VaultError).message).toBe('internal server error'); + expect((error as VaultError).response).toEqual({ + statusCode: 500, + status: 500, + body: { errors: ['internal server error'] } + }); + } + }); + + it('should handle error without errors array', async () => { + mockedAxios.mockResolvedValueOnce({ + status: 503, + data: {} + }); + + await expect(vault.read('secret/data/hello')).rejects.toThrow('Status 503'); + }); + + it('should handle network errors', async () => { + const networkError = new Error('Network Error'); + (networkError as any).isAxiosError = true; + (networkError as any).code = 'ECONNREFUSED'; + mockedAxios.mockRejectedValueOnce(networkError); + + try { + await vault.read('secret/data/hello'); + } catch (error) { + expect((error as any).message).toBe('Network Error'); + expect((error as any).code).toBe('ECONNREFUSED'); + } + }); + + it('should handle axios error with response', async () => { + const axiosError = new Error('Request failed'); + (axiosError as any).isAxiosError = true; + (axiosError as any).response = { + status: 401, + data: { errors: ['permission denied'] } + }; + mockedAxios.mockRejectedValueOnce(axiosError); + + await expect(vault.read('secret/data/hello')).rejects.toThrow('permission denied'); + }); + + it('should pass through non-axios errors', async () => { + const genericError = new Error('Unknown error'); + mockedAxios.mockRejectedValueOnce(genericError); + + await expect(vault.read('secret/data/hello')).rejects.toThrow('Unknown error'); + }); + }); + + describe('requestOptions', () => { + it('should pass strictSSL to https agent', async () => { + const vault = createVaultClient({ + endpoint: 'https://vault.example.com', + token: 'test-token', + requestOptions: { + strictSSL: false + } + }); + + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: {} + }); + + await vault.read('secret/data/hello'); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + httpsAgent: expect.any(Object) + }) + ); + }); + + it('should not set httpsAgent for http endpoints', async () => { + const vault = createVaultClient({ + endpoint: 'http://localhost:8200', + token: 'test-token', + requestOptions: { + strictSSL: false + } + }); + + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: {} + }); + + await vault.read('secret/data/hello'); + + const callArgs = mockedAxios.mock.calls[0][0] as any; + expect(callArgs.httpsAgent).toBeUndefined(); + }); + + it('should configure proxy when provided', async () => { + const vault = createVaultClient({ + endpoint: 'http://localhost:8200', + token: 'test-token', + requestOptions: { + proxy: 'http://proxy.example.com:8080' + } + }); + + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: {} + }); + + await vault.read('secret/data/hello'); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + proxy: expect.objectContaining({ + host: 'proxy.example.com', + port: 8080 + }) + }) + ); + }); + + it('should configure proxy with authentication', async () => { + const vault = createVaultClient({ + endpoint: 'http://localhost:8200', + token: 'test-token', + requestOptions: { + proxy: 'http://user:pass@proxy.example.com:8080' + } + }); + + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: {} + }); + + await vault.read('secret/data/hello'); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + proxy: expect.objectContaining({ + host: 'proxy.example.com', + port: 8080, + auth: { + username: 'user', + password: 'pass' + } + }) + }) + ); + }); + }); + + describe('URL construction', () => { + it('should construct URL with apiVersion', async () => { + const vault = createVaultClient({ + endpoint: 'http://localhost:8200', + apiVersion: 'v2' + }); + + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: {} + }); + + await vault.read('secret/data/hello'); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://localhost:8200/v2/secret/data/hello' + }) + ); + }); + + it('should handle endpoint without trailing slash', async () => { + const vault = createVaultClient({ + endpoint: 'http://localhost:8200' + }); + + mockedAxios.mockResolvedValueOnce({ + status: 200, + data: {} + }); + + await vault.read('secret/data/hello'); + + expect(mockedAxios).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://localhost:8200/v1/secret/data/hello' + }) + ); + }); + }); + + describe('health endpoint handling', () => { + it('should not throw error for sys/health even with non-200 status', async () => { + const vault = createVaultClient({ + endpoint: 'http://localhost:8200' + }); + + const healthResponse = { + initialized: true, + sealed: true, + standby: true + }; + + mockedAxios.mockResolvedValueOnce({ + status: 503, + data: healthResponse + }); + + const result = await vault.read('sys/health'); + expect(result).toEqual(healthResponse); + }); + }); +}); diff --git a/packages/bruno-requests/src/utils/node-vault.ts b/packages/bruno-requests/src/utils/node-vault.ts new file mode 100644 index 000000000..b4e2d31e9 --- /dev/null +++ b/packages/bruno-requests/src/utils/node-vault.ts @@ -0,0 +1,326 @@ +import axios, { AxiosRequestConfig, AxiosError } from 'axios'; +import * as https from 'node:https'; + +/** + * Configuration options for creating a Vault client + */ +export interface VaultConfig { + apiVersion?: string; + endpoint?: string; + token?: string; + namespace?: string; + requestOptions?: VaultRequestOptions; + debug?: (...args: any[]) => void; +} + +/** + * Request options for Vault HTTP requests + * Compatible with node-vault's requestOptions + */ +export interface VaultRequestOptions { + strictSSL?: boolean; + ca?: string | Buffer | Array; + proxy?: string; + [key: string]: any; +} + +/** + * AppRole login arguments + */ +export interface ApproleLoginArgs { + role?: string; + role_id: string; + secret_id?: string; + mount_point?: string; +} + +/** + * Token renew arguments + */ +export interface TokenRenewArgs { + increment?: number | string; +} + +/** + * Vault API response error structure + * Includes both statusCode (node-vault style) and status (axios style) for compatibility + */ +export class VaultError extends Error { + response?: { + statusCode: number; + status: number; // Alias for axios-style error handling + body: any; + }; + + code?: string; // For network errors + + constructor(message: string, response?: { statusCode: number; body: any }) { + super(message); + this.name = 'VaultError'; + if (response) { + this.response = { + statusCode: response.statusCode, + status: response.statusCode, // Alias for compatibility + body: response.body + }; + } + } +} + +/** + * Vault client interface - matches node-vault API surface + */ +export interface VaultClient { + endpoint: string; + namespace?: string; + token?: string; + apiVersion: string; + + read(path: string, requestOptions?: VaultRequestOptions): Promise; + write(path: string, data: any, requestOptions?: VaultRequestOptions): Promise; + approleLogin(args: ApproleLoginArgs): Promise; + tokenLookupSelf(args?: any): Promise; + tokenRenewSelf(args?: TokenRenewArgs): Promise; +} + +/** + * Creates an HTTPS agent based on request options + */ +function createHttpsAgent(options: VaultRequestOptions): https.Agent | undefined { + const agentOptions: https.AgentOptions = {}; + let needsAgent = false; + + if (options.strictSSL === false) { + agentOptions.rejectUnauthorized = false; + needsAgent = true; + } + + if (options.ca) { + agentOptions.ca = options.ca; + needsAgent = true; + } + + return needsAgent ? new https.Agent(agentOptions) : undefined; +} + +/** + * Handles Vault API response, extracting body or throwing error + */ +function handleVaultResponse(statusCode: number, body: any, path: string): any { + // Success responses + if (statusCode === 200 || statusCode === 204) { + return body; + } + + // Health endpoint special handling (matches node-vault behavior) + if (path.match(/sys\/health/) !== null) { + return body; + } + + // Error responses + let message: string; + if (body && body.errors && body.errors.length > 0) { + message = body.errors[0]; + } else { + message = `Status ${statusCode}`; + } + + throw new VaultError(message, { statusCode, body }); +} + +/** + * Creates a Vault client instance + * + * This is a drop-in replacement for node-vault, implementing only the methods + * used by bruno-electron and bruno-cli. + * + * @param config - Configuration options + * @returns VaultClient instance with mutable properties + * + * @example + * ```javascript + * const vault = createVaultClient({ apiVersion: 'v1' }); + * vault.endpoint = 'https://vault.example.com'; + * vault.token = 'my-token'; + * const secret = await vault.read('secret/data/myapp'); + * ``` + */ +function createVaultClient(config: VaultConfig = {}): VaultClient { + const debug = config.debug || (() => {}); + const defaultRequestOptions = config.requestOptions || {}; + + /** + * Makes an HTTP request to the Vault API + */ + async function request( + method: string, + path: string, + data?: any, + requestOptions?: VaultRequestOptions + ): Promise { + // Merge request options: defaults from config + per-request options + const mergedOptions: VaultRequestOptions = { + ...defaultRequestOptions, + ...requestOptions + }; + + const endpointOrigin = client.endpoint?.endsWith('/') ? client.endpoint : `${client.endpoint}/`; + + // Build URL + const uri = `${endpointOrigin}${client.apiVersion}${path}`; + debug(method, uri); + + // Build headers + const headers: Record = { + 'Content-Type': 'application/json' + }; + + if (typeof client.token === 'string' && client.token.length) { + headers['X-Vault-Token'] = client.token; + } + + if (typeof client.namespace === 'string' && client.namespace.length) { + headers['X-Vault-Namespace'] = client.namespace; + } + + // Build axios config + const axiosConfig: AxiosRequestConfig = { + method: method as any, + url: uri, + headers, + validateStatus: () => true // Don't throw on non-2xx status + }; + + // Add request body for POST/PUT + if (data && (method === 'POST' || method === 'PUT')) { + axiosConfig.data = data; + debug('data:', data); + } + + // Configure HTTPS agent + if (uri.startsWith('https')) { + const agent = createHttpsAgent(mergedOptions); + if (agent) { + axiosConfig.httpsAgent = agent; + } + } + + // Configure proxy + if (mergedOptions.proxy) { + // Parse proxy URL into axios proxy config + try { + const proxyUrl = new URL(mergedOptions.proxy); + axiosConfig.proxy = { + host: proxyUrl.hostname, + port: parseInt(proxyUrl.port, 10) || (proxyUrl.protocol === 'https:' ? 443 : 80), + protocol: proxyUrl.protocol.replace(':', '') + }; + if (proxyUrl.username && proxyUrl.password) { + axiosConfig.proxy.auth = { + username: decodeURIComponent(proxyUrl.username), + password: decodeURIComponent(proxyUrl.password) + }; + } + } catch (e) { + // If proxy URL parsing fails, pass it as-is for backward compatibility + debug('Failed to parse proxy URL:', mergedOptions.proxy); + } + } + + try { + const response = await axios(axiosConfig); + return handleVaultResponse(response.status, response.data, path); + } catch (error) { + // Network errors or other axios errors + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + // Server responded with error status + return handleVaultResponse( + axiosError.response.status, + axiosError.response.data, + path + ); + } + // Network error - preserve original error structure + const vaultError = new VaultError(axiosError.message); + (vaultError as any).code = axiosError.code; + throw vaultError; + } + throw error; + } + } + + // Create client object with mutable properties + const client: VaultClient = { + // Mutable properties (support direct assignment like node-vault) + apiVersion: config.apiVersion || 'v1', + endpoint: config.endpoint || process.env.VAULT_ADDR || 'http://127.0.0.1:8200', + token: config.token || process.env.VAULT_TOKEN, + namespace: config.namespace || process.env.VAULT_NAMESPACE, + + /** + * Read data from a Vault path + * @param path - The path to read from (e.g., 'secret/data/myapp') + * @param requestOptions - Optional request options + */ + async read(path: string, requestOptions?: VaultRequestOptions): Promise { + path = path.startsWith('/') ? path : `/${path}`; + debug('read', path); + return request('GET', path, undefined, requestOptions); + }, + + /** + * Write data to a Vault path + * @param path - The path to write to + * @param data - The data to write + * @param requestOptions - Optional request options + */ + async write(path: string, data: any, requestOptions?: VaultRequestOptions): Promise { + path = path.startsWith('/') ? path : `/${path}`; + debug('write', path, data); + return request('POST', path, data, requestOptions); + }, + + /** + * Authenticate using AppRole + * @param args - AppRole login arguments + */ + async approleLogin(args: ApproleLoginArgs): Promise { + debug('approleLogin', args.role_id); + const mountPoint = args.mount_point || 'approle'; + const body: Record = { + role_id: args.role_id + }; + if (args.secret_id) { + body.secret_id = args.secret_id; + } + return request('POST', `/auth/${mountPoint}/login`, body); + }, + + /** + * Look up the current token's properties + */ + async tokenLookupSelf(args?: any): Promise { + debug('tokenLookupSelf'); + return request('GET', '/auth/token/lookup-self'); + }, + + /** + * Renew the current token + * @param args - Optional arguments including increment + */ + async tokenRenewSelf(args?: TokenRenewArgs): Promise { + debug('tokenRenewSelf'); + const body: Record = {}; + if (args?.increment !== undefined) { + body.increment = args.increment; + } + return request('POST', '/auth/token/renew-self', Object.keys(body).length > 0 ? body : undefined); + } + }; + + return client; +} + +export default createVaultClient; diff --git a/packages/bruno-requests/src/utils/proxy-util.spec.ts b/packages/bruno-requests/src/utils/proxy-util.spec.ts new file mode 100644 index 000000000..27050dac8 --- /dev/null +++ b/packages/bruno-requests/src/utils/proxy-util.spec.ts @@ -0,0 +1,336 @@ +import { transformProxyConfig } from './proxy-util'; + +describe('transformProxyConfig', () => { + describe('Migration from old to new format', () => { + describe('Old Format: enabled (true | false | "global")', () => { + test('should migrate enabled: true to disabled: false, inherit: false', () => { + const oldConfig = { + enabled: true, + protocol: 'http', + hostname: 'proxy.example.com', + port: 8080, + auth: { + enabled: true, + username: 'user', + password: 'pass' + }, + bypassProxy: 'localhost' + }; + + const result = transformProxyConfig(oldConfig); + + expect(result).toEqual({ + inherit: false, + config: { + protocol: 'http', + hostname: 'proxy.example.com', + port: 8080, + auth: { + username: 'user', + password: 'pass' + }, + bypassProxy: 'localhost' + } + }); + expect((result as any).disabled).toBeUndefined(); // disabled: false is omitted + }); + + test('should migrate enabled: false to disabled: true, inherit: false', () => { + const oldConfig = { + enabled: false, + protocol: 'http', + hostname: 'proxy.example.com', + port: 8080, + auth: { + enabled: false, + username: '', + password: '' + }, + bypassProxy: '' + }; + + const result = transformProxyConfig(oldConfig); + + expect((result as any).disabled).toBe(true); + expect((result as any).inherit).toBe(false); + }); + + test('should migrate enabled: "global" to disabled: false, inherit: true', () => { + const oldConfig = { + enabled: 'global' as const, + protocol: 'http', + hostname: '', + port: null, + auth: { + enabled: false, + username: '', + password: '' + }, + bypassProxy: '' + }; + + const result = transformProxyConfig(oldConfig); + + expect((result as any).disabled).toBeUndefined(); // disabled: false is omitted + expect((result as any).inherit).toBe(true); + }); + + test('should migrate auth.enabled: false to auth.disabled: true', () => { + const oldConfig = { + enabled: true, + protocol: 'http', + hostname: 'proxy.example.com', + port: 8080, + auth: { + enabled: false, + username: 'user', + password: 'pass' + }, + bypassProxy: '' + }; + + const result = transformProxyConfig(oldConfig); + + expect((result as any).config.auth.disabled).toBe(true); + expect((result as any).config.auth.username).toBe('user'); + expect((result as any).config.auth.password).toBe('pass'); + }); + + test('should omit auth.disabled when auth.enabled: true', () => { + const oldConfig = { + enabled: true, + protocol: 'http', + hostname: 'proxy.example.com', + port: 8080, + auth: { + enabled: true, + username: 'user', + password: 'pass' + }, + bypassProxy: '' + }; + + const result = transformProxyConfig(oldConfig); + + expect((result as any).config.auth.disabled).toBeUndefined(); + expect((result as any).config.auth.username).toBe('user'); + expect((result as any).config.auth.password).toBe('pass'); + }); + }); + + describe('New Format (no migration)', () => { + test('should not modify new format with inherit: false', () => { + const newConfig = { + inherit: false, + config: { + protocol: 'https', + hostname: 'proxy.example.com', + port: 8443, + auth: { + username: 'user', + password: 'pass' + }, + bypassProxy: '*.local' + } + }; + + const result = transformProxyConfig(newConfig); + + expect(result).toEqual(newConfig); + }); + + test('should not modify new format with inherit: true', () => { + const newConfig = { + inherit: true, + config: { + protocol: 'http', + hostname: '', + port: null, + auth: { + username: '', + password: '' + }, + bypassProxy: '' + } + }; + + const result = transformProxyConfig(newConfig); + + expect(result).toEqual(newConfig); + }); + + test('should not modify new format with disabled: true', () => { + const newConfig = { + disabled: true, + inherit: false, + config: { + protocol: 'http', + hostname: '', + port: null, + auth: { + username: '', + password: '' + }, + bypassProxy: '' + } + }; + + const result = transformProxyConfig(newConfig); + + expect(result).toEqual(newConfig); + }); + + test('should not modify new format with auth.disabled: true', () => { + const newConfig = { + inherit: false, + config: { + protocol: 'http', + hostname: 'proxy.example.com', + port: 8080, + auth: { + disabled: true, + username: 'user', + password: 'pass' + }, + bypassProxy: '' + } + }; + + const result = transformProxyConfig(newConfig); + + expect(result).toEqual(newConfig); + }); + }); + + describe('Edge Cases', () => { + test('should handle missing/null/undefined proxy config', () => { + expect(transformProxyConfig(null)).toEqual({}); + expect(transformProxyConfig(undefined)).toEqual({}); + expect(transformProxyConfig({})).toEqual({}); + }); + + test('should handle null port values', () => { + const oldConfig = { + enabled: true, + protocol: 'http', + hostname: 'proxy.example.com', + port: null, + auth: { + enabled: false, + username: '', + password: '' + }, + bypassProxy: '' + }; + + const result = transformProxyConfig(oldConfig); + + expect((result as any).config.port).toBeNull(); + }); + + test('should handle SOCKS protocols', () => { + const oldConfig = { + enabled: true, + protocol: 'socks5', + hostname: 'socks.example.com', + port: 1080, + auth: { + enabled: true, + username: 'socksuser', + password: 'sockspass' + }, + bypassProxy: '' + }; + + const result = transformProxyConfig(oldConfig); + + expect((result as any).config.protocol).toBe('socks5'); + expect((result as any).config.hostname).toBe('socks.example.com'); + expect((result as any).config.port).toBe(1080); + }); + + test('should handle missing auth object', () => { + const oldConfig = { + enabled: true, + protocol: 'http', + hostname: 'proxy.example.com', + port: 8080, + bypassProxy: '' + }; + + const result = transformProxyConfig(oldConfig); + + expect((result as any).config.auth).toEqual({ + username: '', + password: '' + }); + }); + + test('should handle missing protocol (defaults to http)', () => { + const oldConfig = { + enabled: true, + hostname: 'proxy.example.com', + port: 8080 + }; + + const result = transformProxyConfig(oldConfig); + + expect((result as any).config.protocol).toBe('http'); + }); + + test('should handle missing hostname (defaults to empty string)', () => { + const oldConfig = { + enabled: true, + protocol: 'http', + port: 8080 + }; + + const result = transformProxyConfig(oldConfig); + + expect((result as any).config.hostname).toBe(''); + }); + + test('should handle missing port (defaults to null)', () => { + const oldConfig = { + enabled: true, + protocol: 'http', + hostname: 'proxy.example.com' + }; + + const result = transformProxyConfig(oldConfig); + + expect((result as any).config.port).toBeNull(); + }); + + test('should handle missing bypassProxy (defaults to empty string)', () => { + const oldConfig = { + enabled: true, + protocol: 'http', + hostname: 'proxy.example.com', + port: 8080 + }; + + const result = transformProxyConfig(oldConfig); + + expect((result as any).config.bypassProxy).toBe(''); + }); + + test('should handle auth with missing username/password', () => { + const oldConfig = { + enabled: true, + protocol: 'http', + hostname: 'proxy.example.com', + port: 8080, + auth: { + enabled: true + } + }; + + const result = transformProxyConfig(oldConfig); + + expect((result as any).config.auth.username).toBe(''); + expect((result as any).config.auth.password).toBe(''); + }); + }); + }); +}); diff --git a/packages/bruno-requests/src/utils/proxy-util.ts b/packages/bruno-requests/src/utils/proxy-util.ts new file mode 100644 index 000000000..d2fea9eb8 --- /dev/null +++ b/packages/bruno-requests/src/utils/proxy-util.ts @@ -0,0 +1,92 @@ +/** + * Transform proxy config from old format to new format. + * Old format: { enabled: true | false | 'global', protocol, hostname, port, auth: { enabled, ... }, ... } + * New format: { disabled?, inherit, config: { protocol, hostname, port, auth: { disabled?, ... }, ... } } + */ + +interface OldProxyAuth { + enabled?: boolean; + username?: string; + password?: string; +} + +interface OldProxyConfig { + enabled?: true | false | 'global'; + protocol?: string; + hostname?: string; + port?: number | null; + auth?: OldProxyAuth; + bypassProxy?: string; +} + +interface NewProxyAuth { + disabled?: boolean; + username?: string; + password?: string; +} + +interface NewProxyConfig { + disabled?: boolean; + inherit: boolean; + config: { + protocol: string; + hostname: string; + port: number | null; + auth: NewProxyAuth; + bypassProxy: string; + }; +} + +export const transformProxyConfig = (proxy: OldProxyConfig | NewProxyConfig | null | undefined): NewProxyConfig | OldProxyConfig => { + proxy = proxy || {}; + // Check if this is an old format (has 'enabled' property) + if (proxy.hasOwnProperty('enabled')) { + const oldProxy = proxy as OldProxyConfig; + const enabled = oldProxy.enabled; + + const newProxy: NewProxyConfig = { + inherit: true, + config: { + protocol: oldProxy.protocol || 'http', + hostname: oldProxy.hostname || '', + port: oldProxy.port || null, + auth: { + username: oldProxy.auth?.username || '', + password: oldProxy.auth?.password || '' + }, + bypassProxy: oldProxy.bypassProxy || '' + } + }; + + // Handle old format: enabled (true | false | 'global') + if (enabled === true) { + newProxy.disabled = false; + newProxy.inherit = false; + } else if (enabled === false) { + newProxy.disabled = true; + newProxy.inherit = false; + } else if (enabled === 'global') { + newProxy.disabled = false; + newProxy.inherit = true; + } + + // Migrate auth.enabled to auth.disabled + if (oldProxy.auth?.enabled === false) { + newProxy.config.auth.disabled = true; + } + // If auth.enabled is true or undefined, omit disabled (defaults to false) + + // Omit disabled: false at top level (optional field) + if (newProxy.disabled === false) { + delete newProxy.disabled; + } + // Omit auth.disabled: false (optional field) + if (newProxy.config.auth.disabled === false) { + delete newProxy.config.auth.disabled; + } + + return newProxy; + } + + return proxy; +}; diff --git a/packages/bruno-schema/package.json b/packages/bruno-schema/package.json index 61542cf69..afb4f06bb 100644 --- a/packages/bruno-schema/package.json +++ b/packages/bruno-schema/package.json @@ -10,10 +10,8 @@ "scripts": { "test": "jest" }, - "peerDependencies": { - "yup": "^0.32.11" - }, "dependencies": { - "nanoid": "3.3.8" + "nanoid": "3.3.8", + "yup": "^0.32.11" } } diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 908822fd2..09b40e1ed 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -610,6 +610,7 @@ const collectionSchema = Yup.object({ items: Yup.array() }), runtimeVariables: Yup.object(), + workspaceProcessEnvVariables: Yup.object().default({}), brunoConfig: Yup.object(), root: folderRootSchema }) diff --git a/packages/bruno-tests/collection/bruno.json b/packages/bruno-tests/collection/bruno.json index d2aa0a97a..bf402caf3 100644 --- a/packages/bruno-tests/collection/bruno.json +++ b/packages/bruno-tests/collection/bruno.json @@ -15,10 +15,7 @@ "bypassProxy": "" }, "scripts": { - "moduleWhitelist": ["crypto", "buffer", "form-data"], - "filesystemAccess": { - "allow": true - } + "moduleWhitelist": ["crypto", "buffer", "form-data"] }, "clientCertificates": { "enabled": true, diff --git a/packages/bruno-tests/collection/scripting/api/bru/isSafeMode.bru b/packages/bruno-tests/collection/scripting/api/bru/isSafeMode.bru new file mode 100644 index 000000000..a202e490d --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/bru/isSafeMode.bru @@ -0,0 +1,22 @@ +meta { + name: isSafeMode + type: http + seq: 18 +} + +get { + url: {{host}}/ping + body: none + auth: inherit +} + +script:pre-request { + test("bru.isSafeMode() returns true in safe mode", function() { + expect(bru.isSafeMode()).to.be.false; + }); +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/packages/bruno-tests/collection_level_oauth2/bruno.json b/packages/bruno-tests/collection_level_oauth2/bruno.json index 17f1d8ea0..4771de446 100644 --- a/packages/bruno-tests/collection_level_oauth2/bruno.json +++ b/packages/bruno-tests/collection_level_oauth2/bruno.json @@ -3,10 +3,7 @@ "name": "collection_level_oauth2", "type": "collection", "scripts": { - "moduleWhitelist": ["crypto"], - "filesystemAccess": { - "allow": true - } + "moduleWhitelist": ["crypto"] }, "clientCertificates": { "enabled": true, diff --git a/packages/bruno-tests/collection_oauth2/bruno.json b/packages/bruno-tests/collection_oauth2/bruno.json index 82816b2b5..5b3b41c84 100644 --- a/packages/bruno-tests/collection_oauth2/bruno.json +++ b/packages/bruno-tests/collection_oauth2/bruno.json @@ -5,10 +5,7 @@ "scripts": { "moduleWhitelist": [ "crypto" - ], - "filesystemAccess": { - "allow": true - } + ] }, "clientCertificates": { "enabled": true, diff --git a/readme.md b/readme.md index ea55920ed..2d4199c75 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ ### Bruno - Opensource IDE for exploring and testing APIs. -[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno) +[![GitHub version](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) [![Commit Activity](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) @@ -43,7 +43,8 @@ Bruno is offline-only. There are no plans to add cloud-sync to Bruno, ever. We v 📢 Watch our recent talk at India FOSS 3.0 Conference [here](https://www.youtube.com/watch?v=7bSMFpbcPiY) -![bruno](assets/images/landing-2.png)

      +![bruno](assets/images/landing-2-dark.png#gh-light-mode-only) +![bruno](assets/images/landing-2-light.png#gh-dark-mode-only)

      ## Commercial Versions ✨ diff --git a/scripts/changed-packages.js b/scripts/changed-packages.js new file mode 100755 index 000000000..864de866b --- /dev/null +++ b/scripts/changed-packages.js @@ -0,0 +1,235 @@ +#!/usr/bin/env node +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +/** + * changed-packages.js + * + * Usage: + * node scripts/changed-packages.js + * + * Examples: + * node scripts/changed-packages.js main + * node scripts/changed-packages.js v1.2.3 + * + * Description: + * Prints the top-level package directories under `packages/` that + * have changed since , reads their package names, and prints + * both the dependency tree (internal packages it depends on) and + * the dependent tree (internal packages that depend on it). + * + * Options: + * -h, --help Show this help message + */ + +const USAGE = [ + 'Usage:', + ' node scripts/changed-packages.js ', + '', + 'Examples:', + ' node scripts/changed-packages.js main', + ' node scripts/changed-packages.js v1.2.3', + '', + 'Description:', + ' Print package directories under packages/ that have changed since ,', + ' and show their internal dependency and dependent trees.', + '', + 'Options:', + ' -h, --help Show this help message' +].join('\n'); + +const ref = process.argv.slice(2)[0]; + +if (!ref || ['-h', '--help'].includes(ref)) { + console.log(USAGE); + process.exit(0); +} + +// Validate ref exists +try { + const getRefs = execSync(`git show-ref`); + const refs = getRefs.toString().split('\n').filter((d) => d.includes('refs/heads') || d.includes('refs/tags')).map((d) => { + const [_, refPath] = d.split(/\s+/); + return refPath.replace('refs/heads/', '').replace('refs/tags/', ''); + }); + + if (!refs.includes(ref)) { + console.error('The passed in Ref cannot be found'); + process.exit(1); + } +} catch (err) { + console.error('Error checking git refs:', err.message); + process.exit(1); +} + +// Get changed files since ref and map to top-level package directories +let changedFiles = []; +try { + changedFiles = execSync(`git diff --name-only ${ref}`).toString().split('\n').filter(Boolean); +} catch (err) { + console.error('Error running git diff:', err.message); + process.exit(1); +} + +const changedPackageDirs = Array.from(new Set(changedFiles.map((f) => { + const parts = f.split('/'); + if (parts[0] === 'packages' && parts.length >= 2) { + return `packages/${parts[1]}`; + } + return null; +}).filter(Boolean))).sort(); + +if (changedPackageDirs.length === 0) { + console.log('No changed packages found since', ref); + process.exit(0); +} + +// Build map of all packages in packages/ -> name and their internal dependencies +const packagesRoot = path.join(process.cwd(), 'packages'); +const allPackageDirs = fs.readdirSync(packagesRoot).filter((d) => { + try { + return fs.statSync(path.join(packagesRoot, d)).isDirectory(); + } catch (e) { + return false; + } +}); + +const packageNameByDir = {}; // 'packages/foo' -> '@scope/foo' +const packageDirByName = {}; // '@scope/foo' -> 'packages/foo' +const rawPackageJsonByName = {}; // name -> package.json contents + +allPackageDirs.forEach((d) => { + const pkgJsonPath = path.join(packagesRoot, d, 'package.json'); + try { + const raw = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); + if (raw && raw.name) { + const dir = `packages/${d}`; + packageNameByDir[dir] = raw.name; + packageDirByName[raw.name] = dir; + rawPackageJsonByName[raw.name] = raw; + } + } catch (e) { + // ignore directories without valid package.json + } +}); + +const packageNames = Object.keys(packageDirByName); + +// Build dependency maps (only internal package deps) +const depsMap = {}; // pkgName -> [internal dep names] +const dependentsMap = {}; // pkgName -> Set(internal dependent names) + +packageNames.forEach((name) => { + const pkg = rawPackageJsonByName[name] || {}; + const allDeps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {}, pkg.peerDependencies || {}); + const internalDeps = Object.keys(allDeps).filter((depName) => packageNames.includes(depName)); + depsMap[name] = internalDeps; + internalDeps.forEach((dep) => { + dependentsMap[dep] = dependentsMap[dep] || new Set(); + dependentsMap[dep].add(name); + }); +}); + +function printTree(rootName, map, seen = new Set(), indent = '') { + if (!map[rootName] || map[rootName].length === 0) return ''; // no children + let out = ''; + const children = map[rootName]; + children.forEach((child) => { + if (seen.has(child)) { + out += `${indent}- ${child} (cycle)\n`; + return; + } + out += `${indent}- ${child}\n`; + seen.add(child); + // For dependentsMap value is Set, convert to Array + const nextChildren = Array.isArray(map[child]) ? map[child] : (map[child] ? Array.from(map[child]) : []); + if (nextChildren.length > 0) { + out += printTree(child, map, seen, indent + ' '); + } + }); + return out; +} + +// For dependentsMap, convert sets to arrays for printing +const dependentsMapArr = {}; +Object.keys(dependentsMap).forEach((k) => { + dependentsMapArr[k] = Array.from(dependentsMap[k]); +}); + +// Build bottom-up tree for changed packages +const changedPackageNames = changedPackageDirs.map((d) => packageNameByDir[d]).filter(Boolean); + +function getTransitiveDependents(pkgName, visited = new Set()) { + if (visited.has(pkgName)) return []; + visited.add(pkgName); + + const directDependents = Array.from(dependentsMap[pkgName] || []); + let result = []; + + directDependents.forEach((dependent) => { + if (changedPackageNames.includes(dependent)) { + result.push(dependent); + } + result.push(...getTransitiveDependents(dependent, visited)); + }); + + return result; +} + +function buildUpdateOrder(changedPackages) { + const levels = []; + let remaining = new Set(changedPackages); + + while (remaining.size > 0) { + const currentLevel = []; + const nextLevel = []; + + remaining.forEach((pkg) => { + const deps = depsMap[pkg] || []; + const depsInRemaining = deps.filter((d) => remaining.has(d)); + + if (depsInRemaining.length === 0) { + currentLevel.push(pkg); + } else { + nextLevel.push(pkg); + } + }); + + if (currentLevel.length === 0) { + break; + } + + currentLevel.forEach((pkg) => remaining.delete(pkg)); + levels.push(currentLevel.sort()); + } + + return levels; +} + +console.log('='.repeat(80)); +console.log('Changed packages since', ref); +console.log('='.repeat(80)); +console.log(); + +const updateLevels = buildUpdateOrder(changedPackageNames); + +if (updateLevels.length === 0) { + console.log('No changed packages found.'); + process.exit(0); +} + +updateLevels.forEach((level, idx) => { + console.log(`Level ${idx + 1}:`); + level.forEach((pkgName) => { + const dir = packageDirByName[pkgName]; + console.log(` ${dir || pkgName} -> ${pkgName}`); + const transitiveDependents = getTransitiveDependents(pkgName); + if (transitiveDependents.length > 0) { + console.log(` ├─ Dependent packages: ${transitiveDependents.join(', ')}`); + } + }); + console.log(); +}); + +console.log('='.repeat(80)); diff --git a/tests/import/bruno/fixtures/bruno-missing-required-fields.json b/tests/import/bruno/fixtures/bruno-missing-required-fields.json index 6ecf37a09..07735d8ff 100644 --- a/tests/import/bruno/fixtures/bruno-missing-required-fields.json +++ b/tests/import/bruno/fixtures/bruno-missing-required-fields.json @@ -2916,10 +2916,7 @@ "crypto", "buffer", "form-data" - ], - "filesystemAccess": { - "allow": true - } + ] }, "clientCertificates": { "enabled": true, diff --git a/tests/import/bruno/fixtures/bruno-testbench.json b/tests/import/bruno/fixtures/bruno-testbench.json index 02a9c29fb..86bc74a18 100644 --- a/tests/import/bruno/fixtures/bruno-testbench.json +++ b/tests/import/bruno/fixtures/bruno-testbench.json @@ -2917,10 +2917,7 @@ "crypto", "buffer", "form-data" - ], - "filesystemAccess": { - "allow": true - } + ] }, "clientCertificates": { "enabled": true, diff --git a/tests/runner/cli-env-combined/cli-env-combined.spec.ts b/tests/runner/cli-env-combined/cli-env-combined.spec.ts new file mode 100644 index 000000000..8c9770e88 --- /dev/null +++ b/tests/runner/cli-env-combined/cli-env-combined.spec.ts @@ -0,0 +1,114 @@ +import { test, expect } from '../../../playwright'; +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +test.describe('CLI Combined Environment Support (--env and --env-file)', () => { + const collectionPath = path.resolve(__dirname, 'collection'); + const BRU = 'node ../../../../packages/bruno-cli/bin/bru.js'; + + // Helper: run bru CLI and return exit code + const runFrom = (cwd: string, args: string): number => { + try { + execSync(`cd "${cwd}" && ${BRU} ${args}`, { stdio: 'pipe' }); + return 0; + } catch (error: any) { + return error?.status ?? 1; + } + }; + + test('CLI: Should allow --env and --env-file to be used together', async () => { + const envFilePath = path.join(collectionPath, 'global-env.json'); + const outputPath = path.join(collectionPath, 'combined-out.json'); + + // This should NOT error out anymore - both options should be accepted + runFrom( + collectionPath, + `run request.bru --env CollectionEnv --env-file "${envFilePath}" --reporter-json "${outputPath}"` + ); + + // Check that the output file was created (command ran successfully) + expect(fs.existsSync(outputPath)).toBe(true); + + try { + fs.unlinkSync(outputPath); + } catch (_) {} + }); + + test('CLI: Collection env (--env) should override env-file variables', async () => { + const envFilePath = path.join(collectionPath, 'global-env.json'); + const outputPath = path.join(collectionPath, 'override-out.json'); + + runFrom( + collectionPath, + `run request.bru --env CollectionEnv --env-file "${envFilePath}" --reporter-json "${outputPath}"` + ); + + expect(fs.existsSync(outputPath)).toBe(true); + + const report = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + const result = report.results[0]; + + // baseUrl should be from collection env (https://echo.usebruno.com), not global (https://global.example.com) + expect(result.request.url).toBe('https://echo.usebruno.com'); + + // overrideVar should be from collection env, not global + const body = JSON.parse(result.request.data); + expect(body.overrideVar).toBe('collection-value'); + + // globalOnly should come from env-file since it's not in collection env + expect(body.globalOnly).toBe('from-global'); + + // collectionOnly should come from collection env + expect(body.collectionOnly).toBe('from-collection'); + + try { + fs.unlinkSync(outputPath); + } catch (_) {} + }); + + test('CLI: --env-file only should still work', async () => { + const envFilePath = path.join(collectionPath, 'global-env.json'); + const outputPath = path.join(collectionPath, 'envfile-only-out.json'); + + runFrom(collectionPath, `run request.bru --env-file "${envFilePath}" --reporter-json "${outputPath}"`); + + expect(fs.existsSync(outputPath)).toBe(true); + + const report = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + const result = report.results[0]; + + // Should use env-file values when --env is not provided + // baseUrl would be from global-env.json but the request would fail since it's not a real URL + // We just verify the interpolation happened + expect(result.request.url).toBe('https://global.example.com'); + + try { + fs.unlinkSync(outputPath); + } catch (_) {} + }); + + test('CLI: --env only should still work', async () => { + const outputPath = path.join(collectionPath, 'env-only-out.json'); + + runFrom(collectionPath, `run request.bru --env CollectionEnv --reporter-json "${outputPath}"`); + + expect(fs.existsSync(outputPath)).toBe(true); + + const report = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + const result = report.results[0]; + + // Should use collection env values + expect(result.request.url).toBe('https://echo.usebruno.com'); + + const body = JSON.parse(result.request.data); + expect(body.overrideVar).toBe('collection-value'); + expect(body.collectionOnly).toBe('from-collection'); + // globalOnly is not in collection env, so it should remain as template + expect(body.globalOnly).toBe('{{globalOnly}}'); + + try { + fs.unlinkSync(outputPath); + } catch (_) {} + }); +}); diff --git a/tests/runner/cli-env-combined/collection/bruno.json b/tests/runner/cli-env-combined/collection/bruno.json new file mode 100644 index 000000000..1c0a1b70d --- /dev/null +++ b/tests/runner/cli-env-combined/collection/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "cli-env-combined-test", + "type": "collection" +} diff --git a/tests/runner/cli-env-combined/collection/environments/CollectionEnv.bru b/tests/runner/cli-env-combined/collection/environments/CollectionEnv.bru new file mode 100644 index 000000000..46ada4061 --- /dev/null +++ b/tests/runner/cli-env-combined/collection/environments/CollectionEnv.bru @@ -0,0 +1,5 @@ +vars { + baseUrl: https://echo.usebruno.com + overrideVar: collection-value + collectionOnly: from-collection +} diff --git a/tests/runner/cli-env-combined/collection/global-env.json b/tests/runner/cli-env-combined/collection/global-env.json new file mode 100644 index 000000000..c5d735660 --- /dev/null +++ b/tests/runner/cli-env-combined/collection/global-env.json @@ -0,0 +1,8 @@ +{ + "name": "GlobalEnv", + "variables": [ + { "name": "baseUrl", "value": "https://global.example.com", "enabled": true }, + { "name": "overrideVar", "value": "global-value", "enabled": true }, + { "name": "globalOnly", "value": "from-global", "enabled": true } + ] +} diff --git a/tests/runner/cli-env-combined/collection/request.bru b/tests/runner/cli-env-combined/collection/request.bru new file mode 100644 index 000000000..10bfed39f --- /dev/null +++ b/tests/runner/cli-env-combined/collection/request.bru @@ -0,0 +1,19 @@ +meta { + name: combined-env-test + type: http +} + +http { + method: POST + url: {{baseUrl}} + body: json + auth: none +} + +body:json { + { + "overrideVar": "{{overrideVar}}", + "globalOnly": "{{globalOnly}}", + "collectionOnly": "{{collectionOnly}}" + } +} diff --git a/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test/bruno.json b/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test/bruno.json new file mode 100644 index 000000000..5e9f1df4d --- /dev/null +++ b/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "is-safe-mode-test", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} diff --git a/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test/test-safe-mode-false.bru b/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test/test-safe-mode-false.bru new file mode 100644 index 000000000..31f26f8de --- /dev/null +++ b/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test/test-safe-mode-false.bru @@ -0,0 +1,17 @@ +meta { + name: test-safe-mode-false + type: http + seq: 2 +} + +get { + url: https://echo.usebruno.com + body: none + auth: none +} + +tests { + test("bru.isSafeMode() returns false in developer mode", function() { + expect(bru.isSafeMode()).to.be.false; + }); +} diff --git a/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test/test-safe-mode-true.bru b/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test/test-safe-mode-true.bru new file mode 100644 index 000000000..f53b4d08a --- /dev/null +++ b/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test/test-safe-mode-true.bru @@ -0,0 +1,17 @@ +meta { + name: test-safe-mode-true + type: http + seq: 1 +} + +get { + url: https://echo.usebruno.com + body: none + auth: none +} + +tests { + test("bru.isSafeMode() returns true in safe mode", function() { + expect(bru.isSafeMode()).to.be.true; + }); +} diff --git a/tests/scripting/bru-api/isSafeMode/init-user-data/collection-security.json b/tests/scripting/bru-api/isSafeMode/init-user-data/collection-security.json new file mode 100644 index 000000000..8bfdef8ab --- /dev/null +++ b/tests/scripting/bru-api/isSafeMode/init-user-data/collection-security.json @@ -0,0 +1,10 @@ +{ + "collections": [ + { + "path": "{{projectRoot}}/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test", + "securityConfig": { + "jsSandboxMode": "developer" + } + } + ] +} diff --git a/tests/scripting/bru-api/isSafeMode/init-user-data/preferences.json b/tests/scripting/bru-api/isSafeMode/init-user-data/preferences.json new file mode 100644 index 000000000..156456472 --- /dev/null +++ b/tests/scripting/bru-api/isSafeMode/init-user-data/preferences.json @@ -0,0 +1,6 @@ +{ + "maximized": false, + "lastOpenedCollections": [ + "{{projectRoot}}/tests/scripting/bru-api/isSafeMode/fixtures/collections/is-safe-mode-test" + ] +} diff --git a/tests/scripting/bru-api/isSafeMode/isSafeMode.spec.ts b/tests/scripting/bru-api/isSafeMode/isSafeMode.spec.ts new file mode 100644 index 000000000..863b574ab --- /dev/null +++ b/tests/scripting/bru-api/isSafeMode/isSafeMode.spec.ts @@ -0,0 +1,42 @@ +import { test } from '../../../../playwright'; +import { setSandboxMode, runCollection, validateRunnerResults } from '../../../utils/page'; + +test.describe.parallel('bru.isSafeMode() API', () => { + test('returns false when running in developer mode', async ({ pageWithUserData: page }) => { + // Set up developer mode + await setSandboxMode(page, 'is-safe-mode-test', 'developer'); + + // Run the collection + await runCollection(page, 'is-safe-mode-test'); + + // Validate test results + // In developer mode: + // - test-safe-mode-false should PASS (expects false, gets false) + // - test-safe-mode-true should FAIL (expects true, gets false) + await validateRunnerResults(page, { + totalRequests: 2, + passed: 1, + failed: 1, + skipped: 0 + }); + }); + + test('returns true when running in safe mode', async ({ pageWithUserData: page }) => { + // Set up safe mode + await setSandboxMode(page, 'is-safe-mode-test', 'safe'); + + // Run the collection + await runCollection(page, 'is-safe-mode-test'); + + // Validate test results + // In safe mode: + // - test-safe-mode-false should FAIL (expects false, gets true) + // - test-safe-mode-true should PASS (expects true, gets true) + await validateRunnerResults(page, { + totalRequests: 2, + passed: 1, + failed: 1, + skipped: 0 + }); + }); +}); diff --git a/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_allow_fs/bruno.json b/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_allow_fs/bruno.json index 6130abc78..9cffa1274 100644 --- a/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_allow_fs/bruno.json +++ b/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_allow_fs/bruno.json @@ -5,10 +5,5 @@ "ignore": [ "node_modules", ".git" - ], - "scripts": { - "filesystemAccess": { - "allow": true - } - } + ] } \ No newline at end of file diff --git a/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_disallow_fs/bruno.json b/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_disallow_fs/bruno.json deleted file mode 100644 index e751a8433..000000000 --- a/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_disallow_fs/bruno.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": "1", - "name": "should_disallow_fs", - "type": "collection", - "ignore": [ - "node_modules", - ".git" - ], - "scripts": { - "filesystemAccess": { - "allow": false - } - } -} \ No newline at end of file diff --git a/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_disallow_fs/request.bru b/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_disallow_fs/request.bru deleted file mode 100644 index 2a2fd846a..000000000 --- a/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_disallow_fs/request.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: request - type: http - seq: 1 -} - -post { - url: https://echo.usebruno.com - body: none - auth: none -} - -script:pre-request { - const fs = require('fs'); -} \ No newline at end of file diff --git a/tests/scripting/inbuilt-libraries/fs/fs.spec.ts b/tests/scripting/inbuilt-libraries/fs/fs.spec.ts index 72471fbb7..567107af9 100644 --- a/tests/scripting/inbuilt-libraries/fs/fs.spec.ts +++ b/tests/scripting/inbuilt-libraries/fs/fs.spec.ts @@ -2,79 +2,39 @@ import { test } from '../../../../playwright'; import { setSandboxMode, runCollection, validateRunnerResults } from '../../../utils/page'; test.describe.serial('`fs` library', () => { - test.describe('should allow `fs` library', () => { - test('developer mode', async ({ pageWithUserData: page }) => { - test.setTimeout(2 * 60 * 1000); + test('developer mode allows fs', async ({ pageWithUserData: page }) => { + test.setTimeout(2 * 60 * 1000); - // Set up developer mode - await setSandboxMode(page, 'should_allow_fs', 'developer'); + // Set up developer mode + await setSandboxMode(page, 'should_allow_fs', 'developer'); - // Run the collection - await runCollection(page, 'should_allow_fs'); + // Run the collection + await runCollection(page, 'should_allow_fs'); - // Validate test results - await validateRunnerResults(page, { - totalRequests: 1, - passed: 1, - failed: 0, - skipped: 0 - }); - }); - - test('safe mode', async ({ pageWithUserData: page }) => { - test.setTimeout(2 * 60 * 1000); - - // Set up safe mode - await setSandboxMode(page, 'should_allow_fs', 'safe'); - - // Run the collection - await runCollection(page, 'should_allow_fs'); - - // Validate test results - await validateRunnerResults(page, { - totalRequests: 1, - passed: 0, - failed: 1, - skipped: 0 - }); + // Validate test results + await validateRunnerResults(page, { + totalRequests: 1, + passed: 1, + failed: 0, + skipped: 0 }); }); - test.describe('should disallow `fs` library', () => { - test('developer mode', async ({ pageWithUserData: page }) => { - test.setTimeout(2 * 60 * 1000); + test('safe mode blocks fs', async ({ pageWithUserData: page }) => { + test.setTimeout(2 * 60 * 1000); - // Set up developer mode - await setSandboxMode(page, 'should_disallow_fs', 'developer'); + // Set up safe mode + await setSandboxMode(page, 'should_allow_fs', 'safe'); - // Run the collection - await runCollection(page, 'should_disallow_fs'); + // Run the collection + await runCollection(page, 'should_allow_fs'); - // Validate test results - await validateRunnerResults(page, { - totalRequests: 1, - passed: 0, - failed: 1, - skipped: 0 - }); - }); - - test('safe mode', async ({ pageWithUserData: page }) => { - test.setTimeout(2 * 60 * 1000); - - // Set up safe mode - await setSandboxMode(page, 'should_disallow_fs', 'safe'); - - // Run the collection - await runCollection(page, 'should_disallow_fs'); - - // Validate test results - await validateRunnerResults(page, { - totalRequests: 1, - passed: 0, - failed: 1, - skipped: 0 - }); + // Validate test results + await validateRunnerResults(page, { + totalRequests: 1, + passed: 0, + failed: 1, + skipped: 0 }); }); }); diff --git a/tests/scripting/inbuilt-libraries/fs/init-user-data/preferences.json b/tests/scripting/inbuilt-libraries/fs/init-user-data/preferences.json index f0272aaa3..acc5b8721 100644 --- a/tests/scripting/inbuilt-libraries/fs/init-user-data/preferences.json +++ b/tests/scripting/inbuilt-libraries/fs/init-user-data/preferences.json @@ -1,7 +1,6 @@ { "maximized": false, "lastOpenedCollections": [ - "{{projectRoot}}/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_allow_fs", - "{{projectRoot}}/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_disallow_fs" + "{{projectRoot}}/tests/scripting/inbuilt-libraries/fs/fixtures/collections/should_allow_fs" ] } \ No newline at end of file diff --git a/tests/websockets/connection.spec.ts b/tests/websockets/connection.spec.ts index 88909addd..1efe9bc8d 100644 --- a/tests/websockets/connection.spec.ts +++ b/tests/websockets/connection.spec.ts @@ -40,20 +40,6 @@ test.describe.serial('websockets', () => { await expect(locators.messages().nth(1).getByText('Closed')).toBeAttached(); }); - test('websocket messages sorting can be changed', async ({ pageWithUserData: page, restartApp }) => { - const locators = buildWebsocketCommonLocators(page); - - await locators.toolbar.latestLast().click(); - - await expect(locators.messages().first().getByText('Closed')).toBeAttached(); - await expect(locators.messages().nth(1).getByText('Connected to ws://')).toBeAttached(); - - await locators.toolbar.latestFirst().click(); - - await expect(locators.messages().first().getByText('Connected to ws://')).toBeAttached(); - await expect(locators.messages().nth(1).getByText('Closed')).toBeAttached(); - }); - test('websocket request can send messages', async ({ pageWithUserData: page, restartApp }) => { const locators = buildWebsocketCommonLocators(page); diff --git a/tests/workspace/default-workspace/default-workspace.spec.ts b/tests/workspace/default-workspace/default-workspace.spec.ts index 4d94b1c5f..ad1757568 100644 --- a/tests/workspace/default-workspace/default-workspace.spec.ts +++ b/tests/workspace/default-workspace/default-workspace.spec.ts @@ -12,8 +12,8 @@ test.describe('Default Workspace', () => { await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); // Verify the workspace name is "My Workspace" in the title bar - const workspaceName = page.locator('.workspace-name'); - await expect(workspaceName).toContainText('My Workspace'); + const workspaceName = page.getByTestId('workspace-name'); + await expect(workspaceName).toHaveText('My Workspace'); await app.context().close(); await app.close(); @@ -28,7 +28,7 @@ test.describe('Default Workspace', () => { const app1 = await launchElectronApp({ userDataPath }); const page1 = await app1.firstWindow(); await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page1.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page1.getByTestId('workspace-name')).toHaveText('My Workspace'); await app1.close(); @@ -36,7 +36,7 @@ test.describe('Default Workspace', () => { const app2 = await launchElectronApp({ userDataPath }); const page2 = await app2.firstWindow(); await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page2.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page2.getByTestId('workspace-name')).toHaveText('My Workspace'); await app2.context().close(); await app2.close(); @@ -69,7 +69,7 @@ test.describe('Default Workspace', () => { await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); // Should show "My Workspace" - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); // Old directory should still exist (never deleted) expect(fs.existsSync(defaultWorkspacePath)).toBe(true); @@ -106,7 +106,7 @@ test.describe('Default Workspace', () => { const page = await app.firstWindow(); await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); // Old corrupted file should still exist (never deleted) const oldContent = fs.readFileSync(path.join(defaultWorkspacePath, 'workspace.yml'), 'utf8'); @@ -150,7 +150,7 @@ docs: '' const page = await app.firstWindow(); await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); // New workspace should have been created const newWorkspacePath = path.join(userDataPath, 'default-workspace-1'); @@ -179,7 +179,7 @@ docs: '' const page = await app.firstWindow(); await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); // New workspace should have been created (default-workspace since non-existent doesn't block) const newWorkspacePath = path.join(userDataPath, 'default-workspace'); diff --git a/tests/workspace/default-workspace/migration.spec.ts b/tests/workspace/default-workspace/migration.spec.ts index b6ec9e34e..07bf9c906 100644 --- a/tests/workspace/default-workspace/migration.spec.ts +++ b/tests/workspace/default-workspace/migration.spec.ts @@ -35,7 +35,7 @@ test.describe('Default Workspace Migration', () => { await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); await test.step('Verify workspace UI', async () => { - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); }); await test.step('Verify workspace filesystem artifacts', async () => { @@ -87,7 +87,7 @@ test.describe('Default Workspace Migration', () => { const page = await app.firstWindow(); await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); // Verify workspace.yml has both collections const workspacePath = path.join(userDataPath, 'default-workspace'); @@ -132,7 +132,7 @@ test.describe('Default Workspace Migration', () => { await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); // Verify default workspace is created - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); // Sample collection should NOT be created (because user has existing collections) const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection'); @@ -151,7 +151,7 @@ test.describe('Default Workspace Migration', () => { const app1 = await launchElectronApp({ userDataPath }); const page1 = await app1.firstWindow(); await page1.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page1.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page1.getByTestId('workspace-name')).toHaveText('My Workspace'); // Verify initial workspace was created const workspacePath = path.join(userDataPath, 'default-workspace'); @@ -165,7 +165,7 @@ test.describe('Default Workspace Migration', () => { const app2 = await launchElectronApp({ userDataPath }); const page2 = await app2.firstWindow(); await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page2.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page2.getByTestId('workspace-name')).toHaveText('My Workspace'); // workspace.yml should NOT have been modified const currentYmlContent = fs.readFileSync(path.join(workspacePath, 'workspace.yml'), 'utf8'); @@ -188,7 +188,7 @@ test.describe('Default Workspace Migration', () => { const page = await app.firstWindow(); await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); // Verify workspace was created const workspacePath = path.join(userDataPath, 'default-workspace'); diff --git a/tests/workspace/default-workspace/recovery-and-backup.spec.ts b/tests/workspace/default-workspace/recovery-and-backup.spec.ts index 123f59ffc..2fefc076f 100644 --- a/tests/workspace/default-workspace/recovery-and-backup.spec.ts +++ b/tests/workspace/default-workspace/recovery-and-backup.spec.ts @@ -183,7 +183,7 @@ docs: '' await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); // UI always shows "My Workspace" - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); // Should NOT create a new workspace expect(fs.existsSync(path.join(userDataPath, 'default-workspace-1'))).toBe(false); @@ -231,7 +231,7 @@ docs: '' const page = await app.firstWindow(); await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); // Verify the correct workspace was selected (workspace-2) const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8')); @@ -295,7 +295,7 @@ docs: '' const page = await app.firstWindow(); await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); // Verify workspace-1 was selected (not workspace-2 which is broken) const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8')); @@ -521,7 +521,7 @@ docs: '' const page = await app.firstWindow(); await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); // Since path doesn't exist but we have a valid workspace, it should use it // OR create a new one recovering from the existing one @@ -603,7 +603,7 @@ docs: '' const page = await app.firstWindow(); await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); - await expect(page.locator('.workspace-name')).toContainText('My Workspace'); + await expect(page.getByTestId('workspace-name')).toHaveText('My Workspace'); // Verify workspace-1 was used (or workspace-2 was created recovering from workspace-1) const prefs = JSON.parse(fs.readFileSync(path.join(userDataPath, 'preferences.json'), 'utf8'));