Merge remote-tracking branch 'upstream/main' into feature/js-api-supports-get-path-params-5235

This commit is contained in:
Sid
2026-01-21 18:18:13 +05:30
179 changed files with 7456 additions and 1406 deletions

View File

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

1
.gitignore vendored
View File

@@ -48,6 +48,7 @@ yarn-error.log*
bruno.iml
.idea
.vscode
.cursor
# Playwright
/blob-report/

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

511
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -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 (
<div ref={ref} className="workspace-name-container" {...props}>
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
<span data-testid="workspace-name" className={classNames('workspace-name', { 'italic text-muted': !activeWorkspace?.name })}>{getWorkspaceDisplayName(activeWorkspace?.name)}</span>
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
</div>
);
@@ -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 ? (
<IconPinned size={14} stroke={1.5} />
) : (
<IconPin size={14} stroke={1.5} />
)}
{isPinned ? <IconPinned size={14} stroke={1.5} /> : <IconPin size={14} stroke={1.5} />}
</ActionIcon>
)}
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
@@ -247,12 +249,7 @@ const AppTitleBar = () => {
<div className="titlebar-content">
{/* Left section: Home + Workspace */}
<div className="titlebar-left">
<ActionIcon
onClick={handleHomeClick}
label="Home"
size="lg"
className="home-button"
>
<ActionIcon onClick={handleHomeClick} label="Home" size="lg" className="home-button">
<IconHome size={16} stroke={1.5} />
</ActionIcon>

View File

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

View File

@@ -59,7 +59,7 @@ const CreateEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
return (
<Portal>
<Modal
size="sm"
size="md"
title="Create Environment"
confirmText="Create"
handleConfirm={onSubmit}

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useRef, useMemo, useEffect } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { get } from 'lodash';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
@@ -18,14 +19,28 @@ import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'uti
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { sensitiveFields } from './constants';
const TableRow = React.memo(({ children, item }) => <tr key={item.uid} data-testid={`env-var-row-${item.name}`}>{children}</tr>, (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 (
<StyledWrapper>
<div className="table-container">
<table>
<thead>
<tr>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
</thead>
<tbody>
{formik.values.map((variable, index) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
components={{ TableRow }}
data={formik.values}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
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 (
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{!variable.secret && hasSensitiveUsage(variable.name) && (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{!variable.secret && hasSensitiveUsage(variable.name) && (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
<div className="button-container">
<div className="flex items-center">

View File

@@ -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}
/>
<GrantTypeComponentMap collection={collection} folder={folder} />
<GrantTypeComponentMap collection={collection} folder={folder} updateFolderAuth={updateFolderAuth} />
</>
);
}

View File

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

View File

@@ -103,14 +103,9 @@ const RequestTabs = () => {
});
};
const getRootClassname = () => {
return classnames({
'has-chevrons': showChevrons
});
};
// Todo: Must support ephemeral requests
return (
<StyledWrapper className={getRootClassname()}>
<StyledWrapper>
{newRequestModalOpen && (
<NewRequest collectionUid={activeCollection?.uid} onClose={() => setNewRequestModalOpen(false)} />
)}
@@ -118,12 +113,11 @@ const RequestTabs = () => {
<>
<CollectionToolBar collection={activeCollection} />
<div className="flex items-center gap-2 pl-2" ref={collectionTabsRef}>
{showChevrons ? (
<div className={classnames('scroll-chevrons', { hidden: !showChevrons })}>
<ActionIcon size="lg" onClick={leftSlide} aria-label="Left Chevron" style={{ marginBottom: '3px' }}>
<IconChevronLeft size={18} strokeWidth={1.5} />
</ActionIcon>
) : null}
</div>
{/* Moved to post mvp */}
{/* <li className="select-none new-tab mr-1" onClick={createNewTab}>
<div className="flex items-center home-icon-container">
@@ -175,11 +169,11 @@ const RequestTabs = () => {
</ActionIcon>
)}
{showChevrons ? (
<div className={classnames('scroll-chevrons', { hidden: !showChevrons })}>
<ActionIcon size="lg" onClick={rightSlide} aria-label="Right Chevron" style={{ marginBottom: '3px' }}>
<IconChevronRight size={18} strokeWidth={1.5} />
</ActionIcon>
) : null}
</div>
{/* Moved to post mvp */}
{/* <li className="select-none new-tab choose-request">
<div className="flex items-center">

View File

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

View File

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

View File

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

View File

@@ -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 (
<div
ref={(node) => {
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}
>
<div className="flex min-w-0 shrink">
<span className="message-type-icon">
@@ -176,23 +172,87 @@ const WSMessageItem = ({ message, inFocus }) => {
)}
</div>
);
};
});
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 <WSMessageItem message={msg} isOpen={isOpen} onToggle={handleMessageToggle} />;
}, [openMessages, handleMessageToggle]);
const computeItemKey = useCallback((_, msg) => {
return msg.seq ?? msg.timestamp;
}, []);
const WSMessagesList = ({ order = -1, messages = [] }) => {
if (!messages.length) {
return <StyledWrapper><div className="empty-state">No messages yet.</div></StyledWrapper>;
}
// 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 (
<StyledWrapper className="ws-messages-list flex flex-col">
{ordered.map((msg, idx, src) => {
const inFocus = order === -1 ? src.length - 1 === idx : idx === 0;
return <WSMessageItem key={msg.seq ? msg.seq : msg.timestamp} inFocus={inFocus} id={idx} message={msg} />;
})}
<Virtuoso
ref={virtuosoRef}
scrollerRef={setScrollerElement}
data={messages}
itemContent={renderItem}
computeItemKey={computeItemKey}
followOutput={followOutput}
initialTopMostItemIndex={messages.length - 1}
atBottomStateChange={handleAtBottomStateChange}
/>
</StyledWrapper>
);
};

View File

@@ -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 <WSMessagesList order={response?.sortOrder} messages={response.responses || []} />;
return <WSMessagesList messages={response.responses || []} />;
};
const WSResponsePane = ({ item, collection }) => {
@@ -116,7 +115,6 @@ const WSResponsePane = ({ item, collection }) => {
<>
<ResponseLayoutToggle />
<ResponseClear item={item} collection={collection} />
<WSResponseSortOrder item={item} collection={collection} />
<WSStatusCode
status={response.statusCode}
text={response.statusText}

View File

@@ -42,9 +42,12 @@ const ResponsePane = ({ 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 }));

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }) => <tr key={item.uid} data-testid={`env-var-row-${item.name}`}>{children}</tr>, (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 (
<StyledWrapper>
<div className="table-container">
<table>
<thead>
<tr>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
</thead>
<tbody>
{formik.values.map((variable, index) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
totalListHeightChanged={handleTotalHeightChanged}
data={formik.values}
fixedItemHeight={35}
components={{ TableRow }}
computeItemKey={(index, variable) => variable.uid}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
itemContent={(index, variable) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<tr key={variable.uid}>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle
id={`${variable.uid}-disabled-info-icon`}
className="text-muted"
size={16}
/>
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
<div className="button-container mt-5">
<div className="flex items-center gap-2">

View File

@@ -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 = () => {
</div>
</div>
) : (
<span>{activeWorkspace.name}</span>
<span className={classNames('workspace-name', { 'italic text-muted': !activeWorkspace?.name })}>{getWorkspaceDisplayName(activeWorkspace.name)}</span>
)}
</div>

View File

@@ -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 }) => {
<ul className="mt-4">
{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 (
<li key={`${item.type}-${item.collectionUid || item.uid}-${index}`} className="mt-1 text-xs">
{prefix}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = '<access_token>';
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 [];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -99,6 +99,7 @@ export const exportCollection = (collection, version) => {
// delete process variables
delete collection.processEnvVariables;
delete collection.workspaceProcessEnvVariables;
deleteUidsInItems(collection.items);
deleteUidsInEnvs(collection.environments);

View File

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

View File

@@ -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 <svg tag
* @param {Buffer} buffer - The data buffer to analyze
* @returns {boolean} - true if buffer contains SVG content
*/
const isSvgContent = (buffer) => {
const length = buffer.length;
if (length < 4 || buffer[0] !== 0x3C) return false;
// Fast path: <svg
if (buffer[1] === 0x73 && buffer[2] === 0x76 && buffer[3] === 0x67) {
return true;
}
// Slow path: <?xml or <!DOCTYPE or <!--
if (buffer[1] !== 0x3F && buffer[1] !== 0x21) return false;
// Search for <svg in first 512 bytes
const limit = Math.min(512, length - 3);
for (let i = 2; i < limit; i++) {
if (buffer[i] === 0x3C && buffer[i + 1] === 0x73
&& buffer[i + 2] === 0x76 && buffer[i + 3] === 0x67) {
return true;
}
}
return false;
};
/**
* Decode only the first N bytes from a Base64 string
* Returns an empty buffer for invalid/missing input
@@ -159,6 +189,10 @@ export const detectContentTypeFromBuffer = (buffer) => {
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,6 @@ describe('create collection json from pathname', () => {
expect(c).toHaveProperty('brunoConfig.proxy.auth.password', '<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', []);

View File

@@ -19,10 +19,7 @@
"bypassProxy": ""
},
"scripts": {
"moduleWhitelist": ["crypto", "buffer"],
"filesystemAccess": {
"allow": true
}
"moduleWhitelist": ["crypto", "buffer"]
},
"clientCertificates": {
"enabled": true,

View File

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

View File

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

View File

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

View File

@@ -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<string, any>,
escapeJSONStrings: boolean
): Record<string, any> => {
const processed: Record<string, any> = {};
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<string, any>): unknown => {
const seen = new WeakSet<object>();
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<string, unknown>, walk);
}
return value;
} finally {
seen.delete(value as object);
}
}
return value;
};
return walk(obj);
};
export default interpolate;

View File

@@ -310,7 +310,7 @@ export const htmlTemplateString = (resutsJsonString: string) => `<!DOCTYPE html>
:bordered="false"
>
<template #header>
{{result.path}} - {{result.response.status === 'skipped' ? 'Request Skipped' : (totalPassed + '/' + total + ' Passed')}} {{hasError ? " - (request failed)" : "" }}
{{result.path}} - {{result.response.status === 'skipped' ? 'Request Skipped' : (totalPassed + '/' + total + ' Passed')}} {{hasError && result.response.status !== 'skipped' ? " - (request failed)" : "" }}
</template>
</n-alert>
</template>
@@ -365,7 +365,7 @@ export const htmlTemplateString = (resutsJsonString: string) => `<!DOCTYPE html>
</n-card>
</n-gi>
</n-grid>
<n-alert v-if="hasError" title="Error" type="error">
<n-alert v-if="hasError || (result.response.status === 'skipped' && result.error)" title="Error" type="error">
{{result.error}}
</n-alert>
<n-card title="REQUEST HEADERS">
@@ -757,7 +757,7 @@ export const htmlTemplateString = (resutsJsonString: string) => `<!DOCTYPE html>
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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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