Compare commits

...

102 Commits

Author SHA1 Message Date
Anoop M D
942c0ee113 Merge pull request #4671 from lohxt1/lint_gh_workflow_step
add lint step to the unit-tests gh workflow
2025-05-14 17:58:54 +05:30
lohit
dbf8af1146 Merge branch 'main' into lint_gh_workflow_step 2025-05-14 15:41:35 +05:30
lohit
d7ccf1454e revert lint-staged step, make lint check as part of gh unit-tests workflow 2025-05-14 14:05:49 +05:30
Anoop M D
652d447f8b Merge pull request #4667 from usebruno/feature/playwright
Customize Playwright reporter and retries for dev and CI env
2025-05-14 13:28:03 +05:30
ramki-bruno
2f58379feb Customize Playwright reporter and retries for dev and CI env 2025-05-14 11:54:56 +05:30
Anoop M D
d4673a2f07 Merge pull request #4665 from lohxt1/eslint_node_space_issue
fix: eslint - javascript heap out of memory
2025-05-14 00:34:03 +05:30
Anoop M D
3a0c94577f Merge pull request #4666 from sreelakshmi-bruno/update_contributing_guidelines
Updating contributing.md
2025-05-13 23:59:59 +05:30
sreelakshmi-bruno
c407b73c22 Updating contributing.md 2025-05-13 19:42:00 +05:30
lohit
361add3385 handle folder run action while a collection run is in progress (#4658)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-13 19:41:39 +05:30
lohit
9d6ab69d37 eslint - node out of memeory issue 2025-05-13 19:21:30 +05:30
ramki-bruno
b699088dd6 Create/Import collection UX improvements (#4540)
* Fix: Improve UX for selecting location when create/import collection

Allow editing the input path where previously the `<input>` is marked
`readonly`.
Also this will allow automating test using Playwright.

* Fix: Import-collection select-location Modal closes on error

* Improved error-toast for creating and importing collections

- Added a util for formatting the error form IPC
- Updated Toast global styles to prevent text overflow.
  Whenever long file paths are shown, it overflows the Toast container.
2025-05-13 14:25:35 +05:30
ganesh
458c070004 Fix: Specify Node.js version in Contributing Guide (#4656)
* fix node version on contributing file

* updated to node 22 version
2025-05-13 14:10:10 +05:30
Anoop M D
babac6df3c Merge pull request #4651 from usebruno/feature/playwright
Playwright Codegen and CI setup
2025-05-12 22:08:31 +05:30
Pooja
f58477931f feat: add support for oauth2 in cli (#4578)
Co-authored-by: Pooja Belaramani <109731557+poojabela@users.noreply.github.com>
2025-05-12 21:37:42 +05:30
lohit
2171d491a6 Merge pull request #4641 from lohxt1/folder_sequencing_cli
refactor: `bruno-cli` - follow folder/request sequences during collection runs
2025-05-12 21:19:58 +05:30
ramki-bruno
aa911f88f2 Playwright Codegen and CI setup
- Improved the Codegen setup
  - Removed the app-launch related boilerplate from tests
  - Enable recording mode by default
  - Option to provide the test file name to save the recording
- Added GitHub workflow to run Playwright tests with Electron in
  Headless mode(mocking display using `xvfb`).
2025-05-12 20:35:05 +05:30
lohit
bdbcaeff67 updates 2025-05-12 20:06:26 +05:30
lohit
b2756b3c63 updates 2025-05-12 17:37:45 +05:30
lohit
27f11ab583 updates 2025-05-12 17:19:13 +05:30
lohit
2776970317 revert collection-pathname param 2025-05-12 16:57:27 +05:30
Anoop M D
9d28bf7e82 Merge pull request #4571 from pooja-bruno/feat/extend-cli-auth-for-Inherit-and-req
feat: extend cli auth for inherit and request
2025-05-12 16:15:17 +05:30
ramki-bruno
6455b00742 Removed old Playwright setup 2025-05-12 12:12:21 +05:30
lohit
16179a3b50 refactored getCollectionJsonFromPathname function and added tests 2025-05-11 23:08:44 +05:30
Anoop M D
6a37c9d076 Merge pull request #4636 from sreelakshmi-bruno/add-build-commands
Adding build instructions for new packages
2025-05-10 17:03:14 +05:30
Anoop M D
1915b1c00a Fix/add missing translations (#4637)
* fix: add missing deps
* feat: add missing translations
* fix: regex tranasaltion for to.have.headers
2025-05-10 16:59:21 +05:30
lohit
a9982d6e28 removed unused collectionPathnmae prop to components (#4640)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-10 15:11:26 +05:30
sanish-bruno
1daeb8fe93 fix: regex tranasaltion for to.have.headers 2025-05-09 19:12:52 +05:30
sanish-bruno
3dfb158382 feat: add missing translations 2025-05-09 17:56:32 +05:30
sanish-bruno
fb7d247fa7 fix: add missing deps 2025-05-09 17:37:16 +05:30
lohit
6bf2312a94 Merge branch 'main' into folder_sequencing_cli 2025-05-09 16:34:50 +05:30
sreelakshmi-bruno
0cdcb83a7a Adding build instructions for new packages 2025-05-09 15:48:49 +05:30
sanish chirayath
e4f48e81fc feat: add setBody test script to bruno-tests collection (#4415) 2025-05-09 14:16:29 +05:30
lohit
1d32a95a09 Merge pull request #4628 from lohxt1/fix_tests_converters_package
fix: tests for postmanToBrunoEnvironment function
2025-05-09 01:45:56 +05:30
lohit
4c934a78a6 fix tests for postmanToBrunoEnvironment function 2025-05-09 00:07:58 +05:30
Pooja
c47bc86d37 fix: Improve Variable Highlighting in Code Generation and Environment Views (#4593) 2025-05-08 21:53:14 +05:30
Sanjai Kumar
a125781312 feat/replace unsupported characters in env key during pm import (#4618)
---------

Co-authored-by: sanjai0py <sanjailucifer666@gmail.com>
2025-05-08 21:52:58 +05:30
sanish chirayath
dfa951e574 Feature: postman to bru translator (#4534) 2025-05-08 21:51:21 +05:30
naman-bruno
76779e6f95 Merge pull request #4615 from pooja-bruno/feat/add-openapi-to-bruno-import-in-cli
Feat: add openapi to bruno import in cli
2025-05-08 19:53:00 +05:30
lohit
e9a79a32da lint error fixes (#4623)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-08 18:12:16 +05:30
lohit
967170a7b2 eslint for bruno-app and bruno-electron packages (#4622)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-08 18:09:55 +05:30
Anoop M D
3326784315 Merge pull request #4620 from lohxt1/fix_regex_tests
fix: tests for sanitizeName function
2025-05-08 18:07:41 +05:30
lohit
fc553e1009 fix sanitize name function tests in bruno-electron 2025-05-07 22:24:52 +05:30
lohit
da172ff9b5 fix sanitize name function tests 2025-05-07 22:12:57 +05:30
lohit
fc422853ef update T_RunnerResults type to include summary prop (#4617)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-07 19:19:40 +05:30
Pooja Belaramani
2852c07ec7 feat: support tv4 as a inbuilt lib (#4589) 2025-05-07 17:44:29 +05:30
Anoop M D
ead1c9ecab Merge pull request #4542 from pooja-bruno/fix/app-crash-when-we-rename-folder-in-fs
fix: app crash when we rename folder in fs
2025-05-07 17:42:09 +05:30
Anoop M D
5b5066577f Merge pull request #4373 from vishnuprasanth-j/bugfix/regression-4350
Fix: Leading dot is not allowed for collections, folders and requests
2025-05-07 17:39:25 +05:30
sreelakshmi-bruno
4af0bb3943 Fix: ResponseSize component logic to handle size when it's undefined (#4613)
* Fix: ResponseSize component logic to handle size when it's undefined
* Improved check for valid response size

---------

Co-authored-by: Sreelakshmi Jayarajan <sreelakshmi@Sreelakshmis-MacBook-Air.local>
2025-05-07 16:59:35 +05:30
pooja-bruno
f2eaa79318 Feat: add openapi to bruno import in cli 2025-05-07 15:36:21 +05:30
lohit
2ee7ce5829 persist request/folder uids after request/folder resequencing and ui updates (#4611)
* move file/folder uids to new paths

* drag file/folder preview ui updates, can item be dropped ui hint check

---------

Co-authored-by: lohit <lohit@usebruno.com>
2025-05-06 22:20:59 +05:30
lohit
38c307d6f1 feat: folder sequencing (#4595)
Co-authored-by: Pooja Belaramani <pooja@usebruno.com>
Co-authored-by: naman-bruno <naman@usebruno.com>
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-05 16:52:00 +05:30
lohit
520567793a feat(cli): Refactor request runner and improve folder sorting
~ remove duplicate code by consolidating request collection functionality
~ add proper sorting of folder and request items based on sequence numbers
~ add error request count to run summary output
~ update dev dependencies with proper markings in package-lock.json and removed previously added currently reverted entries
2025-05-03 23:50:12 +05:30
Pooja Belaramani
126186041e add: tests for request level auth 2025-04-28 15:18:18 +05:30
Pooja Belaramani
6379e1703c feat: extend cli auth for inherit and request 2025-04-28 12:52:52 +05:30
Pooja Belaramani
2b246e431b change: no found folder tab 2025-04-25 15:58:53 +05:30
Anoop M D
526fcabffe Support Importing Collection Level Auth from Postman (Merge pull request #4475) 2025-04-22 21:27:06 +05:30
Anoop M D
75ff31f0cf Include globalEnvironmentVariables in runPostResponseVars result (Merge pull request #4520)
Include globalEnvironmentVariables in runPostResponseVars result
2025-04-22 20:30:43 +05:30
Pragadesh-45
46dab6e474 test: add request authentication tests for basic and bearer auth handling 2025-04-22 20:25:27 +05:45
Pragadesh-45
c7e8c07d40 test: add folder authentication tests for basic, bearer, API key, and digest auth 2025-04-22 19:56:03 +05:45
Pragadesh-45
932d2b77dc test: add collection authentication tests for basic, bearer, API key, and digest auth 2025-04-22 19:10:28 +05:45
Pragadesh-45
049de84cbb test: refactor postman-to-bruno.spec.js by adding a simple collection 2025-04-22 18:00:49 +05:45
pooja-bruno
3bd44ea7ef Refactor: Reorganize Postman translation tests into modules (#4524) 2025-04-22 16:42:09 +05:30
pooja-bruno
317ccccfc6 fix: Add null checks and fallbacks to Bruno-to-Postman converter to… (#4525) 2025-04-22 16:40:15 +05:30
lohit
220da6b58e feat: Moved logic related to html report generation to bruno-common package (#4536)
---------
Co-authored-by: lohit jiddimani <lohitjiddimani@lohits-MacBook-Air.local>
2025-04-22 16:35:54 +05:30
Pragadesh-45
6a7750d354 refactor: rename test files and update import paths for postman-to-bruno tests 2025-04-22 15:19:04 +05:45
Pooja Belaramani
529803f791 fix: app crash when we rename folder in fs 2025-04-22 13:18:25 +05:30
Pragadesh-45
4c23ab5664 Merge remote-tracking branch 'origin/main' into fix/import-pm-collection-improvements 2025-04-21 19:03:34 +05:45
Tim Nikischin
e3c28fd0ec feat: style skipped requests in runner and show skipped count (#3853)
Mostly taken from @JorgeTrovisco 's implementation #2397
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-04-19 18:14:45 +05:30
Anoop M D
56ab61c29c Fixed issue related to postman environment imports failure (#4523) 2025-04-18 21:33:12 +05:30
pooja-bruno
d3056ba843 Fix: Folder drag-and-drop crash (#3944) 2025-04-18 02:48:37 +05:30
lohit
e34e2ec1f1 feat: support object and array interpolation in bruno-common interpolate fn (#4519)
---------
Co-authored-by: Pooja Belaramani <pooja@usebruno.com>
Co-authored-by: lohit jiddimani <lohitjiddimani@lohits-MacBook-Air.local>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-04-18 00:47:02 +05:30
lohit jiddimani
524bb5e4b7 fix: console errors if any while importing postman env collections 2025-04-17 20:56:22 +05:30
lohit jiddimani
3f8ea7764e fix: add JSON parsing and error handling for Postman environment imports
~ return parsed JSON object instead of raw file string
2025-04-17 20:41:14 +05:30
anusree-bruno
f0d1e6936e Include globalEnvironmentVariables in runPostResponseVars result 2025-04-17 16:18:08 +05:30
Andreas Wirth
9a21eec1b9 Bugfix: Add cheerio and xml2js modules to post-response scripts (#4516)
* Bugfix: Add cheerio and xml2js modules to post-response scripts
* chore: improved cheerio and xml2js test
---------

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-04-17 01:31:53 +05:30
pooja-bruno
1703346bb6 feat: improve postman translations: pm.request and pm.response (#4517) 2025-04-16 19:19:31 +05:30
ramki-bruno
b93d8e73a2 Allow leading dot for file and folder names
Co-authored-by: vishnuprasanth-j <jvpvis6@gmail.com>
2025-04-16 03:26:38 +05:30
ramki-bruno
17c9813c98 Regularize RegEx patterns for validating filenames
- Fixed inconsistency in validating last character between
  bruno-electron and bruno-app.
- Fixed inconsistency in sanitizing first character between
  bruno-electron and bruno-app.
- Updated the comments to accurately reflect the patterns
- Fixed inconsistencies in escaping certain characters in the patterns
  itself.
2025-04-16 03:20:58 +05:30
pooja-bruno
e5ebe20a20 feat: add insomnia v5 import (#4468) 2025-04-16 02:37:48 +05:30
pooja-bruno
54a03fd0d3 fix: lint errors for atob/btoa redefinition (#4509)
* fix: lint errors for atob/btoa redefinition
2025-04-15 20:35:55 +05:30
Sanjai Kumar
e8affcfde9 feat: add tests for mock variable interpolation in interpolate function (#4507)
* feat: add tests for mock variable interpolation in interpolate function
* test: enhance mock variable interpolation tests for additional types and JSON validation

---------
Co-authored-by: sanjai0py <sanjailucifer666@gmail.com>
2025-04-15 17:24:24 +05:30
Sanjai Kumar
d376947a91 Move mock builtin vars interpolation to bruno-common for CLI support (#4497)
* move interpolateMockVars function inside the main interpolate logic inside bruno-common.
* improve comments for JSON escaping logic in interpolate function
* update faker-functions to use CommonJS module syntax to satisfy jest and add regex validation tests
---------

Co-authored-by: sanjai0py <sanjailucifer666@gmail.com>
Co-authored-by: ramki-bruno <ramki@usebruno.com>
2025-04-15 00:14:13 +05:30
Anoop M D
59e38fbdb0 Merge pull request #4487 from Skrivoo/bugfix/local_dev_requests_build
Fix: Missing bruno-requests module in `setup.js` and `package-lock.json`
2025-04-14 13:07:58 +05:30
Anoop M D
492449b7c5 Merge pull request #4483 from usebruno/fix/mock-builtin-bugs
Fix: Mock/Random built-in variables related issues
2025-04-14 12:11:44 +05:30
david.skrivanek
7cd21636d6 fix: setup file to build bruno-requests package 2025-04-11 23:42:57 +02:00
ramki-bruno
6ff49589be Fix: Line-breaks in $<mock> built-ins are breaking JSON req body 2025-04-11 17:03:19 +05:30
ramki-bruno
c950806541 Fix: Falsy values from $<mock> built-ins are not getting interpolated. 2025-04-11 12:50:21 +05:30
Pragadesh-45
3dcc12042f fix: add watch script for bruno-converters workspace 2025-04-10 21:14:22 +05:45
Pragadesh-45
92925648e6 fix: OAuth2 key value retrieval 2025-04-10 21:14:22 +05:45
Pragadesh-45
811c492bce fix: improve URL construction by handling empty input and filtering invalid query parameters 2025-04-10 21:14:22 +05:45
Pragadesh-45
73fa2e19e4 fix: enhance error handling for unsupported Postman schema versions and invalid JSON format 2025-04-10 21:13:49 +05:45
Anoop M D
921e1af72b Merge pull request #2958 from lzl0304/fix-2508
bugfix/chokidar disables globbing
2025-04-10 20:10:42 +05:30
pooja-bruno
cc905da630 Fix: Prevent --bail option from treating skipped requests as failures (#4166) 2025-04-10 19:58:08 +05:30
pooja-bruno
74bbfce8a0 fix: update global env in collection runner (#4135)
* fix: update global env in collection runner
2025-04-10 19:54:44 +05:30
pooja-bruno
8b67a0423d fix: header key variables not interpolating with capital letters (#4264) 2025-04-10 19:50:17 +05:30
Pragadesh-45
f1d527aa9c feat: implement support for various authentication types in Postman to Bruno converter 2025-04-10 18:55:03 +05:45
0xflotus
9e45d4d227 chore: updated required node version in german contributing file (#3875)
* chore: updated required node version in german contributing file
---------

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-04-10 14:59:44 +05:30
Sanjai Kumar
2dd0424d8f Add @usebruno/requests package with digest authentication support (#4417)
* Add @usebruno/requests package with digest authentication support
---------

Co-authored-by: sanjai0py <sanjailucifer666@gmail.com>
Co-authored-by: ramki-bruno <ramki@usebruno.com>
2025-04-10 14:49:21 +05:30
ganesh
838f25b9db added example to readme file (#4471)
* added example to readme file
* modified example
2025-04-10 03:55:30 +05:30
Anoop M D
8809469f8e Merge pull request #4465 from pooja-bruno/add/bruno-converters-paclage-in-workflow-file 2025-04-09 14:59:23 +05:30
Pooja Belaramani
289f138c2a add: bruno converters package in workfow file 2025-04-09 11:35:14 +05:30
lzl0304
f972733426 bugfix/chokidar disables globbing 2024-08-29 10:30:38 +08:00
232 changed files with 14378 additions and 2870 deletions

44
.github/workflows/playwright.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Playwright E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e-test:
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: v22.11.x
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

View File

@@ -29,6 +29,10 @@ jobs:
npm run build --workspace=packages/bruno-query
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
- name: Lint Check
run: npm run lint
# tests
- name: Test Package bruno-js
@@ -46,6 +50,8 @@ jobs:
run: npm run test --workspace=packages/bruno-app
- name: Test Package bruno-common
run: npm run test --workspace=packages/bruno-common
- name: Test Package bruno-converters
run: npm run test --workspace=packages/bruno-converters
- name: Test Package bruno-electron
run: npm run test --workspace=packages/bruno-electron
@@ -73,6 +79,7 @@ jobs:
npm run build --workspace=packages/bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
- name: Run tests
run: |

6
.gitignore vendored
View File

@@ -46,4 +46,8 @@ yarn-error.log*
#dev editor
bruno.iml
.idea
.idea
.vscode
# Playwright
/blob-report/

View File

@@ -15,15 +15,15 @@
| [正體中文](docs/contributing/contributing_zhtw.md)
| [日本語](docs/contributing/contributing_ja.md)
| [हिंदी](docs/contributing/contributing_hi.md)
| [Nederlands](docs/contributing/contributing_nl.md)
| [Dutch](docs/contributing/contributing_nl.md)
## Let's make Bruno better, together!!
We are happy that you are looking to improve Bruno. Below are the guidelines to get started bringing up Bruno on your computer.
We are happy that you are looking to improve Bruno. Below are the guidelines to run Bruno on your computer.
### Technology Stack
Bruno is built using Next.js and React. We also use electron to ship a desktop version (that supports local collections)
Bruno is built using React and Electron.
Libraries we use
@@ -42,30 +42,49 @@ Libraries we use
## Development
Bruno is being developed as a desktop app. You need to load the app by running the Next.js app in one terminal and then run the electron app in another terminal.
Bruno is a desktop app. Below are the instructions to run Bruno.
> Note: We use React for the frontend and rsbuild for build and dev server.
## Install Dependencies
```bash
# use nodejs 20 version
# use nodejs 22 version
nvm use
# install deps
npm i --legacy-peer-deps
```
### Local Development (Option 1)
### Local Development
```bash
#### Build packages
##### Option 1
```bash
# build packages
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# bundle js sandbox libraries
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
```
##### Option 2
```bash
# install dependencies and setup
npm run setup
```
#### Run the app
##### Option 1
```bash
# run react app (terminal 1)
npm run dev:web
@@ -73,12 +92,8 @@ npm run dev:web
npm run dev:electron
```
### Local Development (Option 2)
##### Option 2
```bash
# install dependencies and setup
npm run setup
# run electron and react app concurrently
npm run dev
```
@@ -101,7 +116,28 @@ find . -type f -name "package-lock.json" -delete
```bash
# run bruno-schema tests
npm test --workspace=packages/bruno-schema
npm run test --workspace=packages/bruno-schema
# run bruno-query tests
npm run test --workspace=packages/bruno-query
# run bruno-common tests
npm run test --workspace=packages/bruno-common
# run bruno-converters tests
npm run test --workspace=packages/bruno-converters
# run bruno-app tests
npm run test --workspace=packages/bruno-app
# run bruno-electron tests
npm run test --workspace=packages/bruno-electron
# run bruno-lang tests
npm run test --workspace=packages/bruno-lang
# run bruno-toml tests
npm run test --workspace=packages/bruno-toml
# run tests over all workspaces
npm test --workspaces --if-present

View File

@@ -21,7 +21,7 @@ Bibliotheken die wir benutzen
### Abhängigkeiten
Du benötigst [Node v20.x oder die neuste LTS Version](https://nodejs.org/en/) und npm 8.x. Wir benutzen npm workspaces in dem Projekt.
Du benötigst [Node v22.x oder die neuste LTS Version](https://nodejs.org/en/) und npm 8.x. Wir benutzen npm workspaces in dem Projekt.
### Lass uns coden
@@ -42,12 +42,12 @@ Bruno wird als Desktop-Anwendung entwickelt. Um die App zu starten, musst Du zue
### Abhängigkeiten
- NodeJS v18
- NodeJS v22
### Lokales Entwickeln
```bash
# use nodejs 18 version
# use nodejs 22 version
nvm use
# install deps

View File

@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# Next.js ऐप चलाएँ (टर्मिनल 1 पर)
npm run dev:web

View File

@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# run next app (terminal 1)
npm run dev:web

View File

@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# next 앱 실행 (1번 터미널)
npm run dev:web

View File

@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# draai next app (terminal 1)
npm run dev:web

View File

@@ -42,6 +42,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# spustite ďalšiu aplikáciu (terminál 1)
npm run dev:web

View File

@@ -0,0 +1,5 @@
import { test, expect } from '../playwright';
test('test-app-start', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});

41
eslint.config.js Normal file
View File

@@ -0,0 +1,41 @@
// eslint.config.js
const { defineConfig } = require("eslint/config");
const globals = require("globals");
module.exports = defineConfig([
{
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
ignores: ["**/*.config.js"],
languageOptions: {
globals: {
...globals.browser,
...globals.jest,
global: false,
require: false,
Buffer: false,
process: false
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-electron/**/*.{js}"],
ignores: ["**/*.config.js"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
},
rules: {
"no-undef": "error",
},
}
]);

2326
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,18 +13,24 @@
"packages/bruno-lang",
"packages/bruno-tests",
"packages/bruno-toml",
"packages/bruno-graphql-docs"
"packages/bruno-graphql-docs",
"packages/bruno-requests"
],
"homepage": "https://usebruno.com",
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.27.1",
"@playwright/test": "^1.51.1",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"concurrently": "^8.2.2",
"eslint": "^9.26.0",
"fs-extra": "^11.1.1",
"husky": "^8.0.3",
"globals": "^16.1.0",
"jest": "^29.2.0",
"lodash-es": "^4.17.21",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
"rimraf": "^6.0.1",
@@ -32,6 +38,7 @@
},
"scripts": {
"setup": "node ./scripts/setup.js",
"watch:converters": "npm run watch --workspace=packages/bruno-converters",
"dev": "concurrently --kill-others \"npm run dev:web\" \"npm run dev:electron\"",
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
@@ -39,6 +46,7 @@
"dev:electron": "npm run dev --workspace=packages/bruno-electron",
"dev:electron:debug": "npm run debug --workspace=packages/bruno-electron",
"build:bruno-common": "npm run build --workspace=packages/bruno-common",
"build:bruno-requests": "npm run build --workspace=packages/bruno-requests",
"build:bruno-converters": "npm run build --workspace=packages/bruno-converters",
"build:bruno-query": "npm run build --workspace=packages/bruno-query",
"build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs",
@@ -49,11 +57,11 @@
"build:electron:deb": "./scripts/build-electron.sh deb",
"build:electron:rpm": "./scripts/build-electron.sh rpm",
"build:electron:snap": "./scripts/build-electron.sh snap",
"test:codegen": "npm run dev:web & node ./scripts/playwright-codegen.js",
"test:e2e": "npx playwright test",
"test:report": "npx playwright show-report",
"watch:common": "npm run watch --workspace=packages/bruno-common",
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"prepare": "husky install"
"lint": "node --max_old_space_size=4096 $(npx which eslint)"
},
"overrides": {
"rollup": "3.29.5",

View File

@@ -19,7 +19,7 @@ export default defineConfig({
})
],
source: {
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file,
},
html: {
title: 'Bruno'
@@ -34,6 +34,16 @@ export default defineConfig({
},
},
},
ignoreWarnings: [
(warning) => warning.message.includes('Critical dependency: the request of a dependency is an expression') && warning?.moduleDescriptor?.name?.includes('flow-parser')
],
// Add externals configuration to exclude Node.js libraries
externals: {
// List specific Node.js modules you want to exclude
// Format: 'module-name': 'commonjs module-name'
'worker_threads': 'commonjs worker_threads',
// 'path': 'commonjs path'
}
},
}
});

View File

@@ -72,7 +72,7 @@ const Info = ({ collection }) => {
</div>
</div>
</div>
{showShareCollectionModal && <ShareCollection collection={collection} onClose={handleToggleShowShareCollectionModal(false)} />}
{showShareCollectionModal && <ShareCollection collectionUid={collection.uid} onClose={handleToggleShowShareCollectionModal(false)} />}
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
@@ -13,11 +13,18 @@ import { variableNameRegex } from 'utils/common/regex';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
let _collection = cloneDeep(collection);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
const formik = useFormik({
enableReinitialize: true,
@@ -160,7 +167,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
theme={storedTheme}
collection={collection}
collection={_collection}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}

View File

@@ -28,7 +28,10 @@ const ImportEnvironment = ({ collection, onClose }) => {
.then(() => {
toast.success('Environment imported successfully');
})
.catch(() => toast.error('An error occurred while importing the environment'));
.catch((error) => {
toast.error('An error occurred while importing the environment');
console.error(error);
});
});
})
.then(() => {

View File

@@ -28,7 +28,7 @@ const FolderSettings = ({ collection, folder }) => {
tab = folderLevelSettingsSelectedTab[folder?.uid];
}
const folderRoot = collection?.items.find((item) => item.uid === folder?.uid)?.root;
const folderRoot = folder?.root;
const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req;
const hasTests = folderRoot?.request?.tests;

View File

@@ -34,7 +34,10 @@ const ImportEnvironment = ({ onClose }) => {
.then(() => {
toast.success('Global Environment imported successfully');
})
.catch(() => toast.error('An error occurred while importing the environment'));
.catch((error) => {
toast.error('An error occurred while importing the environment');
console.error(error);
});
});
})
.then(() => {

View File

@@ -3,8 +3,7 @@ import { useDispatch } from "react-redux";
import toast from 'react-hot-toast';
import { cloneDeep, find } from 'lodash';
import { IconLoader2 } from '@tabler/icons';
import brunoCommon from '@usebruno/common';
const { interpolate } = brunoCommon;
import { interpolate } from '@usebruno/common';
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
import { getAllVariables } from "utils/collections/index";

View File

@@ -1,10 +1,9 @@
import { useState, useEffect, useMemo } from "react";
import { find } from "lodash";
import StyledWrapper from "./StyledWrapper";
import { useState, useEffect } from "react";
import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/icons';
import { getAllVariables } from 'utils/collections/index';
import brunoCommon from '@usebruno/common';
const { interpolate } = brunoCommon;
import { interpolate } from '@usebruno/common';
const TokenSection = ({ title, token }) => {
if (!token) return null;

View File

@@ -140,7 +140,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
</div>
</div>
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
<GenerateCodeItem collectionUid={collection.uid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
</StyledWrapper>
);

View File

@@ -0,0 +1,42 @@
import React, { useEffect, useState, useCallback } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
const FolderNotFound = ({ folderUid }) => {
const dispatch = useDispatch();
const [showErrorMessage, setShowErrorMessage] = useState(false);
const closeTab = useCallback(() => {
dispatch(
closeTabs({
tabUids: [folderUid]
})
);
}, [dispatch, folderUid]);
useEffect(() => {
setTimeout(() => {
setShowErrorMessage(true);
}, 300);
}, []);
if (!showErrorMessage) {
return null;
}
return (
<div className="mt-6 px-6">
<div className="p-4 bg-orange-100 border-l-4 border-yellow-500 text-yellow-700">
<div>Folder no longer exists.</div>
<div className="mt-2">
This can happen when the folder was renamed or deleted on your filesystem.
</div>
</div>
<button className="btn btn-md btn-secondary mt-6" onClick={closeTab}>
Close Tab
</button>
</div>
);
};
export default FolderNotFound;

View File

@@ -25,6 +25,7 @@ import { produce } from 'immer';
import CollectionOverview from 'components/CollectionSettings/Overview';
import RequestNotLoaded from './RequestNotLoaded';
import RequestIsLoading from './RequestIsLoading';
import FolderNotFound from './FolderNotFound';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@@ -163,6 +164,10 @@ const RequestTabPanel = () => {
if (focusedTab.type === 'folder-settings') {
const folder = findItemInCollection(collection, focusedTab.folderUid);
if (!folder) {
return <FolderNotFound folderUid={focusedTab.folderUid} />;
}
return <FolderSettings collection={collection} folder={folder} />;
}

View File

@@ -76,7 +76,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? "italic" : ""}`}
onMouseUp={handleMouseUp} // Add middle-click behavior here
>
{tab.type === 'folder-settings' ? (
{tab.type === 'folder-settings' && !folder ? (
<RequestTabNotFound handleCloseClick={handleCloseClick} />
) : tab.type === 'folder-settings' ? (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
@@ -261,13 +263,13 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
return (
<Fragment>
{showAddNewRequestModal && (
<NewRequest collection={collection} onClose={() => setShowAddNewRequestModal(false)} />
<NewRequest collectionUid={collection.uid} onClose={() => setShowAddNewRequestModal(false)} />
)}
{showCloneRequestModal && (
<CloneCollectionItem
item={currentTabItem}
collection={collection}
collectionUid={collection.uid}
onClose={() => setShowCloneRequestModal(false)}
/>
)}

View File

@@ -79,7 +79,7 @@ const RequestTabs = () => {
return (
<StyledWrapper className={getRootClassname()}>
{newRequestModalOpen && (
<NewRequest collection={activeCollection} onClose={() => setNewRequestModalOpen(false)} />
<NewRequest collectionUid={activeCollection?.uid} onClose={() => setNewRequestModalOpen(false)} />
)}
{collectionRequestTabs && collectionRequestTabs.length ? (
<>

View File

@@ -2,14 +2,20 @@ import React from 'react';
import StyledWrapper from './StyledWrapper';
const ResponseSize = ({ size }) => {
if (!Number.isFinite(size)) {
return null;
}
let sizeToDisplay = '';
// If size is greater than 1024 bytes, format as KB
if (size > 1024) {
// size is greater than 1kb
let kb = Math.floor(size / 1024);
let decimal = Math.round(((size % 1024) / 1024).toFixed(2) * 100);
sizeToDisplay = kb + '.' + decimal + 'KB';
} else {
// If size is less than or equal to 1024 bytes, display as bytes (B)
sizeToDisplay = size + 'B';
}

View File

@@ -0,0 +1,11 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
padding-top: 20%;
width: 100%;
.send-icon {
color: ${(props) => props.theme.requestTabPanel.responseSendIcon};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { IconCircleOff } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const SkippedRequest = () => {
return (
<StyledWrapper>
<div className="send-icon flex justify-center" style={{ fontSize: 200 }}>
<IconCircleOff size={150} strokeWidth={1} />
</div>
<div className="flex mt-4 justify-center" style={{ fontSize: 25 }}>
Request skipped
</div>
</StyledWrapper>
);
};
export default SkippedRequest;

View File

@@ -12,6 +12,10 @@ const StyledWrapper = styled.div`
.error-message {
color: ${(props) => props.theme.colors.text.muted};
}
.skipped-request {
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@@ -18,6 +18,7 @@ import ScriptErrorIcon from './ScriptErrorIcon';
import StyledWrapper from './StyledWrapper';
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
import SkippedRequest from './SkippedRequest';
import ClearTimeline from './ClearTimeline/index';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
@@ -47,6 +48,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
};
const response = item.response || {};
const responseSize = response.size || 0;
const getTabPanel = (tab) => {
switch (tab) {
@@ -80,6 +82,14 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
}
};
if (item.response && item.status === 'skipped') {
return (
<StyledWrapper className="flex h-full relative">
<SkippedRequest />
</StyledWrapper>
);
}
if (isLoading && !item.response) {
return (
<StyledWrapper className="flex flex-col h-full relative">
@@ -147,7 +157,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
<ResponseSave item={item} />
<StatusCode status={response.status} />
<ResponseTime duration={response.duration} />
<ResponseSize size={response.size} />
<ResponseSize size={responseSize} />
</>
) : null}
</div>

View File

@@ -33,6 +33,10 @@ const StyledWrapper = styled.div`
.all-tests-passed {
color: ${(props) => props.theme.colors.text.green} !important;
}
.skipped-request {
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@@ -10,6 +10,7 @@ import ResponseSize from 'components/ResponsePane/ResponseSize';
import TestResults from 'components/ResponsePane/TestResults';
import TestResultsLabel from 'components/ResponsePane/TestResultsLabel';
import StyledWrapper from './StyledWrapper';
import SkippedRequest from 'components/ResponsePane/SkippedRequest';
import RunnerTimeline from 'components/ResponsePane/RunnerTimeline';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
@@ -63,6 +64,14 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
});
};
if (item.status === 'skipped') {
return (
<StyledWrapper className="flex h-full relative">
<SkippedRequest />
</StyledWrapper>
);
}
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex items-center px-3 tabs" role="tablist">

View File

@@ -39,6 +39,10 @@ const Wrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
}
.skipped-request {
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default Wrapper;

View File

@@ -5,7 +5,7 @@ import { get, cloneDeep } from 'lodash';
import { runCollectionFolder, cancelRunnerExecution } from 'providers/ReduxStore/slices/collections/actions';
import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections';
import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun } from '@tabler/icons';
import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun } from '@tabler/icons';
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
@@ -102,6 +102,9 @@ export default function RunnerResults({ collection }) {
return (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail';
});
const skippedRequests = items.filter((item) => {
return item.status === 'skipped';
});
let isCollectionLoading = areItemsLoading(collection);
if (!items || !items.length) {
@@ -159,7 +162,8 @@ export default function RunnerResults({ collection }) {
ref={runnerBodyRef}
>
<div className="pb-2 font-medium test-summary">
Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}
Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}, Skipped:{' '}
{skippedRequests.length}
</div>
{runnerInfo?.statusText ?
<div className="pb-2 font-medium danger">
@@ -172,14 +176,18 @@ export default function RunnerResults({ collection }) {
<div className="item-path mt-2">
<div className="flex items-center">
<span>
{item.status !== 'error' && item.testStatus === 'pass' && item.status !== 'skipped' ? (
{item.testStatus === 'pass' && item.assertionStatus === 'pass' ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
) : (
: null}
{item.status === 'skipped' ?
<IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
:null}
{item.status === 'error' || item.testStatus === 'fail' || item.assertionStatus === 'fail' ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
)}
:null}
</span>
<span
className={`mr-1 ml-2 ${item.status == 'error' || item.status == 'skipped' || item.testStatus == 'fail' ? 'danger' : ''}`}
className={`mr-1 ml-2 ${item.status == 'skipped' ? 'skipped-request' : item.status === 'error' || item.testStatus === 'fail' || item.assertionStatus === 'fail' ? 'danger' : ''}`}
>
{item.displayName}
</span>
@@ -263,11 +271,15 @@ export default function RunnerResults({ collection }) {
<div className="flex items-center px-3 mb-4 font-medium">
<span className="mr-2">{selectedItem.displayName}</span>
<span>
{selectedItem.testStatus === 'pass' ? (
{selectedItem.testStatus === 'pass' && selectedItem.assertionStatus === 'pass' ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
) : (
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
)}
: null}
{selectedItem.status === 'error' || selectedItem.testStatus === 'fail' || selectedItem.assertionStatus === 'fail' ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
: null}
{selectedItem.status === 'skipped' ?
<IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
: null}
</span>
</div>
<ResponsePane item={selectedItem} collection={collection} />

View File

@@ -7,8 +7,11 @@ import exportBrunoCollection from 'utils/collections/export';
import exportPostmanCollection from 'utils/exporters/postman-collection';
import { cloneDeep } from 'lodash';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
import { useSelector } from 'react-redux';
import { findCollectionByUid } from 'utils/collections/index';
const ShareCollection = ({ onClose, collection }) => {
const ShareCollection = ({ onClose, collectionUid }) => {
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const handleExportBrunoCollection = () => {
const collectionCopy = cloneDeep(collection);
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));

View File

@@ -1,5 +1,5 @@
import React, { useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
@@ -11,11 +11,13 @@ import Help from 'components/Help';
import PathDisplay from 'components/PathDisplay';
import { useState } from 'react';
import { IconArrowBackUp, IconEdit } from "@tabler/icons";
import { findCollectionByUid } from 'utils/collections/index';
const CloneCollection = ({ onClose, collection }) => {
const CloneCollection = ({ onClose, collectionUid }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const [isEditing, toggleEditing] = useState(false);
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const { name } = collection;
const formik = useFormik({
@@ -46,7 +48,7 @@ const CloneCollection = ({ onClose, collection }) => {
values.collectionName,
values.collectionFolderName,
values.collectionLocation,
collection.pathname
collection?.pathname
)
)
.then(() => {

View File

@@ -15,7 +15,7 @@ import Portal from 'components/Portal';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
const CloneCollectionItem = ({ collection, item, onClose }) => {
const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
@@ -49,7 +49,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
.test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
}),
onSubmit: (values) => {
dispatch(cloneItem(values.name, values.filename, item.uid, collection.uid))
dispatch(cloneItem(values.name, values.filename, item.uid, collectionUid))
.then(() => {
toast.success('Request cloned!');
onClose();
@@ -172,8 +172,6 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
baseName={formik.values.filename}
/>
</div>

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.drag-preview {
background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,49 @@
import { useDragLayer } from 'react-dnd';
import {
IconFile,
IconFolder,
} from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
function getItemStyles({ x, y }) {
if (Number.isNaN(x) || Number.isNaN(y)) return { display: 'none' };
const transform = `translate(${x}px, ${y}px)`;
return {
position: 'fixed',
pointerEvents: 'none',
top: 0,
transform,
WebkitTransform: transform,
zIndex: 100,
};
}
export const CollectionItemDragPreview = () => {
const {
item,
isDragging,
clientOffset
} = useDragLayer((monitor) => ({
item: monitor.getItem(),
isDragging: monitor.isDragging(),
clientOffset: monitor.getClientOffset(),
}));
if (!isDragging) return null;
const { x, y } = clientOffset || {};
const shouldShowFolderIcon = !item.type || item.type === 'folder';
return (
<StyledWrapper>
<div style={getItemStyles({ x, y })} className='p-2'>
<div className='flex items-center gap-2 border border-gray-500/10 rounded-md px-2 py-1 drag-preview'>
{shouldShowFolderIcon ? (
<IconFolder size={16} />
) : (
<IconFile size={16} />
)}
{item.name}
</div>
</div>
</StyledWrapper>
);
};

View File

@@ -7,11 +7,11 @@ import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
import { recursivelyGetAllItemUids } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const DeleteCollectionItem = ({ onClose, item, collection }) => {
const DeleteCollectionItem = ({ onClose, item, collectionUid }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const onConfirm = () => {
dispatch(deleteItem(item.uid, collection.uid)).then(() => {
dispatch(deleteItem(item.uid, collectionUid)).then(() => {
if (isFolder) {
// close all tabs that belong to the folder

View File

@@ -62,6 +62,7 @@ const CodeView = ({ language, item }) => {
<CodeEditor
readOnly
collection={collection}
item={item}
value={snippet}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}

View File

@@ -10,9 +10,11 @@ import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
const GenerateCodeItem = ({ collection, item, onClose }) => {
const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
const languages = getLanguages();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });

View File

@@ -16,7 +16,7 @@ import Portal from 'components/Portal';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
const RenameCollectionItem = ({ collection, item, onClose }) => {
const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
@@ -57,13 +57,13 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
return;
}
if (!isFolder && item.draft) {
await dispatch(saveRequest(item.uid, collection.uid, true));
await dispatch(saveRequest(item.uid, collectionUid, true));
}
const { name: newName, filename: newFilename } = values;
try {
let renameConfig = {
itemUid: item.uid,
collectionUid: collection.uid,
collectionUid,
};
renameConfig['newName'] = newName;
if (itemFilename !== newFilename) {
@@ -191,8 +191,6 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
baseName={formik.values.filename}
/>
</div>

View File

@@ -2,16 +2,19 @@ import React from 'react';
import get from 'lodash/get';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions';
import { flattenItems } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
const RunCollectionItem = ({ collection, item, onClose }) => {
const RunCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended');
const onSubmit = (recursive) => {
dispatch(
addTab({
@@ -20,10 +23,24 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
type: 'collection-runner'
})
);
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
if (!isCollectionRunInProgress) {
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
}
onClose();
};
const handleViewRunner = (e) => {
e.preventDefault();
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'collection-runner'
})
);
onClose();
}
const getRequestsCount = (items) => {
const requestTypes = ['http-request', 'graphql-request']
return items.filter(req => requestTypes.includes(req.type)).length;
@@ -34,8 +51,6 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
const recursiveRunLength = getRequestsCount(flattenedItems);
const isFolderLoading = areItemsLoading(item);
console.log(item);
console.log(isFolderLoading);
return (
<StyledWrapper>
@@ -55,22 +70,34 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
</div>
<div className={isFolderLoading ? "mb-2" : "mb-8"}>This will run all the requests in this folder and all its subfolders.</div>
{isFolderLoading ? <div className='mb-8 warning'>Requests in this folder are still loading.</div> : null}
{isCollectionRunInProgress ? <div className='mb-6 warning'>A Collection Run is already in progress.</div> : null}
<div className="flex justify-end bruno-modal-footer">
<span className="mr-3">
<button type="button" onClick={onClose} className="btn btn-md btn-close">
Cancel
</button>
</span>
<span>
<button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
Recursive Run
</button>
</span>
<span>
<button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
Run
</button>
</span>
{
isCollectionRunInProgress ?
<span>
<button type="submit" className="submit btn btn-md btn-secondary mr-3" onClick={handleViewRunner}>
View Run
</button>
</span>
:
<>
<span>
<button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
Recursive Run
</button>
</span>
<span>
<button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
Run
</button>
</span>
</>
}
</div>
</div>
)}

View File

@@ -1,6 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: relative;
.menu-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
@@ -22,6 +23,65 @@ const Wrapper = styled.div`
height: 1.875rem;
cursor: pointer;
user-select: none;
position: relative;
/* Common styles for drop indicators */
&::before,
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: ${(props) => props.theme.dragAndDrop.border};
opacity: 0;
pointer-events: none;
}
&::before {
top: 0;
}
&::after {
bottom: 0;
}
/* Drop target styles */
&.drop-target {
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
&::before,
&::after {
opacity: 0;
}
}
&.drop-target-above {
&::before {
opacity: 1;
height: 2px;
}
}
&.drop-target-below {
&::after {
opacity: 1;
height: 2px;
}
}
/* Inside drop target style */
&.drop-target {
&::before {
top: 0;
bottom: 0;
height: 100%;
opacity: 1;
background: ${(props) => props.theme.dragAndDrop.hoverBg};
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
// border-radius: 4px;
}
}
.rotate-90 {
transform: rotateZ(90deg);
@@ -45,6 +105,20 @@ const Wrapper = styled.div`
}
}
&.item-target {
background: #ccc3;
}
&.item-seperator {
.seperator {
bottom: 0px;
position: absolute;
height: 3px;
width: 100%;
background: #ccc3;
}
}
&.item-focused-in-tab {
background: ${(props) => props.theme.sidebar.collection.item.bg};

View File

@@ -1,4 +1,5 @@
import React, { useState, useRef, forwardRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import range from 'lodash/range';
import filter from 'lodash/filter';
import classnames from 'classnames';
@@ -6,7 +7,7 @@ import { useDrag, useDrop } from 'react-dnd';
import { IconChevronRight, IconDots } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { moveItem, showInFolder, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { handleCollectionItemDrop, sendRequest, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
import Dropdown from 'components/Dropdown';
import NewRequest from 'components/Sidebar/NewRequest';
@@ -16,7 +17,7 @@ import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
import RunCollectionItem from './RunCollectionItem';
import GenerateCodeItem from './GenerateCodeItem';
import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
import { isItemARequest, isItemAFolder } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
@@ -26,13 +27,22 @@ import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemInfo from './CollectionItemInfo/index';
import CollectionItemIcon from './CollectionItemIcon';
import { scrollToTheActiveTab } from 'utils/tabs';
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
import { isEqual } from 'lodash';
import { calculateDraggedItemNewPathname } from 'utils/collections/index';
const CollectionItem = ({ item, collection, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual);
const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid });
const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual);
const isSidebarDragging = useSelector((state) => state.app.isDragging);
const dispatch = useDispatch();
const collectionItemRef = useRef(null);
// We use a single ref for drag and drop.
const ref = useRef(null);
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
@@ -44,10 +54,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
const hasSearchText = searchText && searchText?.trim()?.length;
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
const isFolder = isItemAFolder(item);
const [{ isDragging }, drag] = useDrag({
type: `collection-item-${collection.uid}`,
item: item,
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
const [{ isDragging }, drag, dragPreview] = useDrag({
type: `collection-item-${collectionUid}`,
item,
collect: (monitor) => ({
isDragging: monitor.isDragging()
}),
@@ -56,21 +69,72 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
});
const [{ isOver }, drop] = useDrop({
accept: `collection-item-${collection.uid}`,
drop: (draggedItem) => {
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
useEffect(() => {
dragPreview(getEmptyImage(), { captureDraggingState: true });
}, []);
const determineDropType = (monitor) => {
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!hoverBoundingRect || !clientOffset) return null;
const clientY = clientOffset.y - hoverBoundingRect.top;
const folderUpperThreshold = hoverBoundingRect.height * 0.35;
const fileUpperThreshold = hoverBoundingRect.height * 0.5;
if (isItemAFolder(item)) {
return clientY < folderUpperThreshold ? 'adjacent' : 'inside';
} else {
return clientY < fileUpperThreshold ? 'adjacent' : null;
}
};
const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => {
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
if (draggedItemUid === targetItemUid) return false;
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
if (!newPathname) return false;
if (targetItemPathname?.startsWith(draggedItemPathname)) return false;
return true;
};
const [{ isOver, canDrop }, drop] = useDrop({
accept: `collection-item-${collectionUid}`,
hover: (draggedItem, monitor) => {
const { uid: targetItemUid } = item;
const { uid: draggedItemUid } = draggedItem;
if (draggedItemUid === targetItemUid) return;
const dropType = determineDropType(monitor);
const _canItemBeDropped = canItemBeDropped({ draggedItem, targetItem: item, dropType });
setDropType(_canItemBeDropped ? dropType : null);
},
canDrop: (draggedItem) => {
return draggedItem.uid !== item.uid;
drop: async (draggedItem, monitor) => {
const { uid: targetItemUid } = item;
const { uid: draggedItemUid } = draggedItem;
if (draggedItemUid === targetItemUid) return;
const dropType = determineDropType(monitor);
if (!dropType) return;
await dispatch(handleCollectionItemDrop({ targetItem: item, draggedItem, dropType, collectionUid }))
setDropType(null);
},
canDrop: (draggedItem) => draggedItem.uid !== item.uid,
collect: (monitor) => ({
isOver: monitor.isOver(),
isOver: monitor.isOver()
}),
});
drag(drop(collectionItemRef));
const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => {
return (
@@ -84,13 +148,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
'rotate-90': !itemIsCollapsed
});
const itemRowClassName = classnames('flex collection-item-name items-center', {
'item-focused-in-tab': item.uid == activeTabUid,
'item-hovered': isOver
const itemRowClassName = classnames('flex collection-item-name relative items-center', {
'item-focused-in-tab': isTabForItemActive,
'item-hovered': isOver && canDrop,
'drop-target': isOver && dropType === 'inside',
'drop-target-above': isOver && dropType === 'adjacent'
});
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
dispatch(sendRequest(item, collectionUid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
})
@@ -101,12 +167,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
if (event && event.detail != 1) return;
//scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);
const isRequest = isItemARequest(item);
if (isRequest) {
dispatch(hideHomePage());
if (itemIsOpenedInTabs(item, tabs)) {
if (isTabForItemPresent) {
dispatch(
focusTab({
uid: item.uid
@@ -114,11 +178,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab(item),
type: 'request',
})
@@ -127,14 +190,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
collectionUid: collectionUid,
type: 'folder-settings',
})
);
dispatch(
collectionFolderClicked({
itemUid: item.uid,
collectionUid: collection.uid
collectionUid: collectionUid
})
);
}
@@ -146,10 +209,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch(
collectionFolderClicked({
itemUid: item.uid,
collectionUid: collection.uid
collectionUid: collectionUid
})
);
}
};
const handleRightClick = (event) => {
const _menuDropdown = dropdownTippyRef.current;
@@ -164,7 +227,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const isFolder = isItemAFolder(item);
const className = classnames('flex flex-col w-full', {
'is-sidebar-dragging': isSidebarDragging
@@ -183,49 +245,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
const handleDoubleClick = (event) => {
dispatch(makeTabPermanent({ uid: item.uid }))
dispatch(makeTabPermanent({ uid: item.uid }));
};
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
// Sort items by their "seq" property.
const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
};
// we need to sort folder items by name alphabetically
const sortFolderItems = (items = []) => {
return items.sort((a, b) => a.name.localeCompare(b.name));
};
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
if (item?.request?.url !== '' || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
const viewFolderSettings = () => {
if (isItemAFolder(item)) {
if (itemIsOpenedInTabs(item, tabs)) {
dispatch(
focusTab({
uid: item.uid
})
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
type: 'folder-settings'
})
);
return;
}
};
const handleShowInFolder = () => {
dispatch(showInFolder(item.pathname)).catch((error) => {
console.error('Error opening the folder', error);
@@ -233,62 +260,89 @@ const CollectionItem = ({ item, collection, searchText }) => {
});
};
const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
const folderItems = sortItemsBySequence(filter(item.items, (i) => isItemAFolder(i)));
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
if (
(item?.request?.url !== '') ||
(item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
const viewFolderSettings = () => {
if (isItemAFolder(item)) {
if (isTabForItemPresent) {
dispatch(focusTab({ uid: item.uid }));
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid,
type: 'folder-settings'
})
);
}
};
return (
<StyledWrapper className={className}>
{renameItemModalOpen && (
<RenameCollectionItem item={item} collection={collection} onClose={() => setRenameItemModalOpen(false)} />
<RenameCollectionItem item={item} collectionUid={collectionUid} onClose={() => setRenameItemModalOpen(false)} />
)}
{cloneItemModalOpen && (
<CloneCollectionItem item={item} collection={collection} onClose={() => setCloneItemModalOpen(false)} />
<CloneCollectionItem item={item} collectionUid={collectionUid} onClose={() => setCloneItemModalOpen(false)} />
)}
{deleteItemModalOpen && (
<DeleteCollectionItem item={item} collection={collection} onClose={() => setDeleteItemModalOpen(false)} />
<DeleteCollectionItem item={item} collectionUid={collectionUid} onClose={() => setDeleteItemModalOpen(false)} />
)}
{newRequestModalOpen && (
<NewRequest item={item} collection={collection} onClose={() => setNewRequestModalOpen(false)} />
<NewRequest item={item} collectionUid={collectionUid} onClose={() => setNewRequestModalOpen(false)} />
)}
{newFolderModalOpen && (
<NewFolder item={item} collection={collection} onClose={() => setNewFolderModalOpen(false)} />
<NewFolder item={item} collectionUid={collectionUid} onClose={() => setNewFolderModalOpen(false)} />
)}
{runCollectionModalOpen && (
<RunCollectionItem collection={collection} item={item} onClose={() => setRunCollectionModalOpen(false)} />
<RunCollectionItem collectionUid={collectionUid} item={item} onClose={() => setRunCollectionModalOpen(false)} />
)}
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
<GenerateCodeItem collectionUid={collectionUid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
{itemInfoModalOpen && (
<CollectionItemInfo item={item} collection={collection} onClose={() => setItemInfoModalOpen(false)} />
<CollectionItemInfo item={item} onClose={() => setItemInfoModalOpen(false)} />
)}
<div className={itemRowClassName} ref={collectionItemRef}>
<div
className={itemRowClassName}
ref={(node) => {
ref.current = node;
drag(drop(node));
}}
>
<div className="flex items-center h-full w-full">
{indents && indents.length
? indents.map((i) => {
return (
<div
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
className="indent-block"
key={i}
style={{
width: 16,
minWidth: 16,
height: '100%'
}}
>
&nbsp;{/* Indent */}
</div>
);
})
? indents.map((i) => (
<div
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
className="indent-block"
key={i}
style={{ width: 16, minWidth: 16, height: '100%' }}
>
&nbsp;{/* Indent */}
</div>
))
: null}
<div
className="flex flex-grow items-center h-full overflow-hidden"
style={{
paddingLeft: 8
}}
style={{ paddingLeft: 8 }}
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
@@ -304,10 +358,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
/>
) : null}
</div>
<div
className="ml-1 flex w-full h-full items-center overflow-hidden"
>
<div className="ml-1 flex w-full h-full items-center overflow-hidden">
<CollectionItemIcon item={item} />
<span className="item-name" title={item.name}>
{item.name}
@@ -429,17 +480,16 @@ const CollectionItem = ({ item, collection, searchText }) => {
</div>
</div>
</div>
{!itemIsCollapsed ? (
<div>
{folderItems && folderItems.length
? folderItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
})
: null}
{requestItems && requestItems.length
? requestItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
})
: null}
</div>
@@ -448,4 +498,4 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
};
export default CollectionItem;
export default React.memo(CollectionItem);

View File

@@ -1,12 +1,14 @@
import React from 'react';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { IconFiles } from '@tabler/icons';
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid } from 'utils/collections/index';
const RemoveCollection = ({ onClose, collection }) => {
const RemoveCollection = ({ onClose, collectionUid }) => {
const dispatch = useDispatch();
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const onConfirm = () => {
dispatch(removeCollection(collection.uid))

View File

@@ -2,13 +2,15 @@ import React, { useRef, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { renameCollection } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid } from 'utils/collections/index';
const RenameCollection = ({ collection, onClose }) => {
const RenameCollection = ({ collectionUid, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const formik = useFormik({
enableReinitialize: true,
initialValues: {

View File

@@ -13,7 +13,8 @@ const Wrapper = styled.div`
}
&.item-hovered {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
border-bottom: 2px solid transparent;
.collection-actions {
.dropdown {
div[aria-expanded='false'] {
@@ -62,6 +63,36 @@ const Wrapper = styled.div`
color: white;
}
}
&.drop-target {
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
transition: ${(props) => props.theme.dragAndDrop.transition};
}
&.drop-target-above {
border: none;
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
margin-top: -2px;
background: transparent;
transition: ${(props) => props.theme.dragAndDrop.transition};
}
&.drop-target-below {
border: none;
border-bottom: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
margin-bottom: -2px;
background: transparent;
transition: ${(props) => props.theme.dragAndDrop.transition};
}
}
.collection-name.drop-target {
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
border-radius: 4px;
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
margin: -2px;
transition: ${(props) => props.theme.dragAndDrop.transition};
box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg};
}
#sidebar-collection-name {

View File

@@ -1,4 +1,5 @@
import React, { useState, forwardRef, useRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import classnames from 'classnames';
import { uuid } from 'utils/common';
import filter from 'lodash/filter';
@@ -6,8 +7,8 @@ import { useDrop, useDrag } from 'react-dnd';
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { collapseCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveItemToRootOfCollection, moveCollectionAndPersist } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
@@ -19,9 +20,10 @@ import { isItemAFolder, isItemARequest } from 'utils/collections';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
import CloneCollection from './CloneCollection';
import { areItemsLoading, findItemInCollection } from 'utils/collections';
import { areItemsLoading } from 'utils/collections';
import { scrollToTheActiveTab } from 'utils/tabs';
import ShareCollection from 'components/ShareCollection/index';
import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
const Collection = ({ collection, searchText }) => {
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
@@ -33,7 +35,7 @@ const Collection = ({ collection, searchText }) => {
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const collectionRef = useRef(null);
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
const MenuIcon = forwardRef((props, ref) => {
@@ -127,8 +129,8 @@ const Collection = ({ collection, searchText }) => {
const isCollectionItem = (itemType) => {
return itemType.startsWith('collection-item');
};
const [{ isDragging }, drag] = useDrag({
const [{ isDragging }, drag, dragPreview] = useDrag({
type: "collection",
item: collection,
collect: (monitor) => ({
@@ -144,7 +146,7 @@ const Collection = ({ collection, searchText }) => {
drop: (draggedItem, monitor) => {
const itemType = monitor.getItemType();
if (isCollectionItem(itemType)) {
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid))
dispatch(handleCollectionItemDrop({ targetItem: collection, draggedItem, dropType: 'inside', collectionUid: collection.uid }))
} else {
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
}
@@ -157,7 +159,9 @@ const Collection = ({ collection, searchText }) => {
}),
});
drag(drop(collectionRef));
useEffect(() => {
dragPreview(getEmptyImage(), { captureDraggingState: true });
}, []);
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
@@ -170,36 +174,35 @@ const Collection = ({ collection, searchText }) => {
});
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
};
// we need to sort folder items by name alphabetically
const sortFolderItems = (items = []) => {
return items.sort((a, b) => a.name.localeCompare(b.name));
};
const requestItems = sortRequestItems(filter(collection.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(collection.items, (i) => isItemAFolder(i)));
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
const folderItems = sortItemsBySequence(filter(collection.items, (i) => isItemAFolder(i)));
return (
<StyledWrapper className="flex flex-col">
{showNewRequestModal && <NewRequest collection={collection} onClose={() => setShowNewRequestModal(false)} />}
{showNewFolderModal && <NewFolder collection={collection} onClose={() => setShowNewFolderModal(false)} />}
{showNewRequestModal && <NewRequest collectionUid={collection.uid} onClose={() => setShowNewRequestModal(false)} />}
{showNewFolderModal && <NewFolder collectionUid={collection.uid} onClose={() => setShowNewFolderModal(false)} />}
{showRenameCollectionModal && (
<RenameCollection collection={collection} onClose={() => setShowRenameCollectionModal(false)} />
<RenameCollection collectionUid={collection.uid} onClose={() => setShowRenameCollectionModal(false)} />
)}
{showRemoveCollectionModal && (
<RemoveCollection collection={collection} onClose={() => setShowRemoveCollectionModal(false)} />
<RemoveCollection collectionUid={collection.uid} onClose={() => setShowRemoveCollectionModal(false)} />
)}
{showShareCollectionModal && (
<ShareCollection collection={collection} onClose={() => setShowShareCollectionModal(false)} />
<ShareCollection collectionUid={collection.uid} onClose={() => setShowShareCollectionModal(false)} />
)}
{showCloneCollectionModalOpen && (
<CloneCollection collection={collection} onClose={() => setShowCloneCollectionModalOpen(false)} />
<CloneCollection collectionUid={collection.uid} onClose={() => setShowCloneCollectionModalOpen(false)} />
)}
<CollectionItemDragPreview />
<div className={collectionRowClassName}
ref={collectionRef}
ref={(node) => {
collectionRef.current = node;
drag(drop(node));
}}
>
<div
className="flex flex-grow items-center overflow-hidden"
@@ -296,20 +299,15 @@ const Collection = ({ collection, searchText }) => {
</Dropdown>
</div>
</div>
<div>
{!collectionIsCollapsed ? (
<div>
{folderItems && folderItems.length
? folderItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}
{requestItems && requestItems.length
? requestItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}
{folderItems?.map?.((i) => {
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
})}
{requestItems?.map?.((i) => {
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
})}
</div>
) : null}
</div>

View File

@@ -11,6 +11,8 @@ import PathDisplay from 'components/PathDisplay/index';
import { useState } from 'react';
import { IconArrowBackUp, IconEdit } from '@tabler/icons';
import Help from 'components/Help';
import { multiLineMsg } from "utils/common";
import { formatIpcError } from "utils/common/error";
const CreateCollection = ({ onClose }) => {
const inputRef = useRef();
@@ -45,7 +47,7 @@ const CreateCollection = ({ onClose }) => {
toast.success('Collection created!');
onClose();
})
.catch((e) => toast.error('An error occurred while creating the collection - ' + e));
.catch((e) => toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))));
}
});
@@ -113,7 +115,6 @@ const CreateCollection = ({ onClose }) => {
id="collection-location"
type="text"
name="collectionLocation"
readOnly={true}
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
@@ -121,6 +122,9 @@ const CreateCollection = ({ onClose }) => {
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
onChange={e => {
formik.setFieldValue('collectionLocation', e.target.value);
}}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
<div className="text-red-500">{formik.errors.collectionLocation}</div>

View File

@@ -1,34 +1,43 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { IconLoader2 } from '@tabler/icons';
import importBrunoCollection from 'utils/importers/bruno-collection';
import importPostmanCollection from 'utils/importers/postman-collection';
import { postmanToBruno, readFile } from 'utils/importers/postman-collection';
import importInsomniaCollection from 'utils/importers/insomnia-collection';
import importOpenapiCollection from 'utils/importers/openapi-collection';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
import fileDialog from 'file-dialog';
const ImportCollection = ({ onClose, handleSubmit }) => {
const [isLoading, setIsLoading] = useState(false)
const handleImportBrunoCollection = () => {
importBrunoCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Import collection failed'));
.catch((err) => toastError(err, 'Import collection failed'))
};
const handleImportPostmanCollection = () => {
importPostmanCollection()
.then(({ collection }) => {
handleSubmit({ collection });
fileDialog({ accept: 'application/json' })
.then((...args) => {
setIsLoading(true);
return readFile(...args);
})
.catch((err) => toastError(err, 'Postman Import collection failed'));
};
.then((collection) => postmanToBruno(collection))
.then((collection) => handleSubmit({ collection }))
.catch((err) => toastError(err, 'Postman Import collection failed'))
.finally(() => setIsLoading(false));
}
const handleImportInsomniaCollection = () => {
importInsomniaCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Insomnia Import collection failed'));
.catch((err) => toastError(err, 'Insomnia Import collection failed'))
};
const handleImportOpenapiCollection = () => {
@@ -36,8 +45,9 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'));
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'))
};
const CollectionButton = ({ children, className, onClick }) => {
return (
<button
@@ -50,18 +60,67 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
</button>
);
};
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
<div className="flex flex-col">
<h3 className="text-sm">Select the type of your existing collection :</h3>
<div className="mt-4 grid grid-rows-2 grid-flow-col gap-2">
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
const FullscreenLoader = () => {
const [loadingMessage, setLoadingMessage] = useState('');
// Messages to cycle through while loading
const loadingMessages = [
'Processing collection...',
'Analyzing requests...',
'Translating scripts...',
'Preparing collection...',
'Almost done...'
];
// Cycle through loading messages for better UX
useEffect(() => {
if (!isLoading) return;
let messageIndex = 0;
const interval = setInterval(() => {
messageIndex = (messageIndex + 1) % loadingMessages.length;
setLoadingMessage(loadingMessages[messageIndex]);
}, 2000);
setLoadingMessage(loadingMessages[0]);
return () => clearInterval(interval);
}, [isLoading]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
{loadingMessage}
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
This may take a moment depending on the collection size
</p>
</div>
</div>
</Modal>
);
};
return (
<>
{isLoading && <FullscreenLoader />}
{!isLoading && (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
<div className="flex flex-col">
<h3 className="text-sm">Select the type of your existing collection :</h3>
<div className="mt-4 grid grid-rows-2 grid-flow-col gap-2">
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
</div>
</div>
</Modal>
)}
</>
);
};

View File

@@ -61,7 +61,6 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
id="collection-location"
type="text"
name="collectionLocation"
readOnly={true}
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
@@ -69,6 +68,9 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
onChange={e => {
formik.setFieldValue('collectionLocation', e.target.value);
}}
/>
</>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (

View File

@@ -14,7 +14,7 @@ import Dropdown from "components/Dropdown";
import { IconCaretDown } from "@tabler/icons";
import StyledWrapper from './StyledWrapper';
const NewFolder = ({ collection, item, onClose }) => {
const NewFolder = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const [isEditing, toggleEditing] = useState(false);
@@ -52,7 +52,7 @@ const NewFolder = ({ collection, item, onClose }) => {
})
}),
onSubmit: (values) => {
dispatch(newFolder(values.folderName, values.directoryName, collection.uid, item ? item.uid : null))
dispatch(newFolder(values.folderName, values.directoryName, collectionUid, item ? item.uid : null))
.then(() => {
toast.success('New folder created!');
onClose();

View File

@@ -5,7 +5,7 @@ import toast from 'react-hot-toast';
import path from 'utils/common/path';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections';
import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
import { addTab } from 'providers/ReduxStore/slices/tabs';
@@ -20,9 +20,11 @@ import Portal from 'components/Portal';
import Help from 'components/Help';
import StyledWrapper from './StyledWrapper';
const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const {
brunoConfig: { presets: collectionPresets = {} }
} = collection;
@@ -135,14 +137,14 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
collectionUid: collection.uid
collectionUid: collectionUid
})
)
.then(() => {
dispatch(
addTab({
uid: uid,
collectionUid: collection.uid,
collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType })
})
);
@@ -158,7 +160,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: curlRequestTypeDetected,
requestUrl: request.url,
requestMethod: request.method,
collectionUid: collection.uid,
collectionUid: collectionUid,
itemUid: item ? item.uid : null,
headers: request.headers,
body: request.body,
@@ -178,7 +180,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
collectionUid: collection.uid,
collectionUid: collectionUid,
itemUid: item ? item.uid : null
})
)
@@ -389,8 +391,6 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, item?.pathname || collection?.pathname)}
baseName={formik.values.filename? `${formik.values.filename}.bru` : ''}
/>
</div>

View File

@@ -11,6 +11,8 @@ import { useDispatch } from 'react-redux';
import { showHomePage } from 'providers/ReduxStore/slices/app';
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { multiLineMsg } from "utils/common";
import { formatIpcError } from "utils/common/error";
const TitleBar = () => {
const [importedCollection, setImportedCollection] = useState(null);
@@ -34,9 +36,8 @@ const TitleBar = () => {
toast.success('Collection imported successfully');
})
.catch((err) => {
setImportCollectionLocationModalOpen(false);
console.error(err);
toast.error('An error occurred while importing the collection. Check the logs for more information.');
toast.error(multiLineMsg('An error occurred while importing the collection.', formatIpcError(err)));
});
};

View File

@@ -13,12 +13,9 @@ import {
findEnvironmentInCollection,
findItemInCollection,
findParentItemInCollection,
getItemsToResequence,
isItemAFolder,
refreshUidsInItem,
isItemARequest,
moveCollectionItem,
moveCollectionItemToRootOfCollection,
transformRequestToSaveToFilesystem
} from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common';
@@ -47,8 +44,7 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
import { getGlobalEnvironmentVariables, findCollectionByPathname, findEnvironmentInCollectionByName, getReorderedItemsInTargetDirectory, resetSequencesInFolder, getReorderedItemsInSourceDirectory, calculateDraggedItemNewPathname } from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
@@ -60,7 +56,7 @@ export const renameCollection = (newName, collectionUid) => (dispatch, getState)
if (!collection) {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:rename-collection', newName, collection.pathname).then(resolve).catch(reject);
});
};
@@ -337,6 +333,7 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
})
);
const { ipcRenderer } = window;
ipcRenderer
.invoke(
'renderer:run-collection-folder',
@@ -358,6 +355,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
export const newFolder = (folderName, directoryName, collectionUid, itemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
return new Promise((resolve, reject) => {
if (!collection) {
@@ -372,10 +371,27 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
if (!folderWithSameNameExists) {
const fullName = path.join(collection.pathname, directoryName);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve())
.invoke('renderer:new-folder', fullName)
.then(async () => {
const folderData = {
name: folderName,
pathname: fullName,
root: {
meta: {
name: folderName,
seq: items?.length + 1
}
}
};
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
reject(err);
});
})
.catch((error) => reject(error));
} else {
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
@@ -392,8 +408,26 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve())
.invoke('renderer:new-folder', fullName)
.then(async () => {
const folderData = {
name: folderName,
pathname: fullName,
root: {
meta: {
name: folderName,
seq: items?.length + 1
}
}
};
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
reject(err);
});
})
.catch((error) => reject(error));
} else {
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
@@ -495,8 +529,11 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (disp
set(item, 'name', newName);
set(item, 'filename', newFilename);
set(item, 'root.meta.name', newName);
set(item, 'root.meta.seq', parentFolder?.items?.length + 1);
const collectionPath = path.join(parentFolder.pathname, newFilename);
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject);
return;
}
@@ -594,176 +631,114 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
export const sortCollections = (payload) => (dispatch) => {
dispatch(_sortCollections(payload));
};
export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => {
export const moveItem = ({ targetDirname, sourcePathname }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:move-item', { targetDirname, sourcePathname })
.then(resolve)
.catch(reject);
});
}
export const handleCollectionItemDrop = ({ targetItem, draggedItem, dropType, collectionUid }) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection;
const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items);
const draggedItemDirectory = findParentItemInCollection(collection, draggedItemUid) || collection;
const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items);
const handleMoveToNewLocation = async ({ draggedItem, draggedItemDirectoryItems, targetItem, targetItemDirectoryItems, newPathname, dropType }) => {
const { uid: targetItemUid } = targetItem;
const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem;
const newDirname = path.dirname(newPathname);
await dispatch(moveItem({
targetDirname: newDirname,
sourcePathname: draggedItemPathname
}));
// Update sequences in the source directory
if (draggedItemDirectoryItems?.length) {
// reorder items in the source directory
const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter(i => i.uid !== draggedItemUid);
const reorderedSourceItems = getReorderedItemsInSourceDirectory({ items: draggedItemDirectoryItemsWithoutDraggedItem });
if (reorderedSourceItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedSourceItems }));
}
}
// Update sequences in the target directory (if dropping adjacent)
if (dropType === 'adjacent') {
const targetItemSequence = targetItemDirectoryItems.findIndex(i => i.uid === targetItemUid)?.seq;
const draggedItemWithNewPathAndSequence = {
...draggedItem,
pathname: newPathname,
seq: targetItemSequence
};
// draggedItem is added to the targetItem's directory
const reorderedTargetItems = getReorderedItemsInTargetDirectory({
items: [ ...targetItemDirectoryItems, draggedItemWithNewPathAndSequence ],
targetItemUid,
draggedItemUid
});
if (reorderedTargetItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedTargetItems }));
}
}
};
const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems }) => {
const { uid: targetItemUid } = targetItem;
const { uid: draggedItemUid } = draggedItem;
// reorder items in the targetItem's directory
const reorderedItems = getReorderedItemsInTargetDirectory({
items: targetItemDirectoryItems,
targetItemUid,
draggedItemUid
});
if (reorderedItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedItems }));
}
};
return new Promise(async (resolve, reject) => {
try {
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname: collection.pathname });
if (!newPathname) return;
if (targetItemPathname?.startsWith(draggedItemPathname)) return;
if (newPathname !== draggedItemPathname) {
await handleMoveToNewLocation({ targetItem, targetItemDirectoryItems, draggedItem, draggedItemDirectoryItems, newPathname, dropType });
} else {
await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem });
}
resolve();
} catch (error) {
console.error(error);
toast.error(error?.message);
reject(error);
}
})
}
export const updateItemsSequences = ({ itemsToResequence }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
const collectionCopy = cloneDeep(collection);
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
const targetItem = findItemInCollection(collectionCopy, targetItemUid);
if (!draggedItem) {
return reject(new Error('Dragged item not found'));
}
if (!targetItem) {
return reject(new Error('Target item not found'));
}
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
const targetItemParent = findParentItemInCollection(collectionCopy, targetItemUid);
const sameParent = draggedItemParent === targetItemParent;
// file item dragged onto another file item and both are in the same folder
// this is also true when both items are at the root level
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && sameParent) {
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:resequence-items', itemsToResequence)
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged onto another file item which is at the root level
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged onto another file item and both are in different folders
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !sameParent) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, targetItemParent.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged into its own folder
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
return resolve();
}
// file item dragged into another folder
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItem, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, targetItem.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// end of the file drags, now let's handle folder drags
// folder drags are simpler since we don't allow ordering of folders
// folder dragged into its own folder
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
return resolve();
}
// folder dragged into a file which is at the same level
// this is also true when both items are at the root level
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && sameParent) {
return resolve();
}
// folder dragged into a file which is a child of the folder
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && draggedItem === targetItemParent) {
return resolve();
}
// folder dragged into a file which is at the root level
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
const draggedItemPathname = draggedItem.pathname;
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
.then(resolve)
.catch((error) => reject(error));
}
// folder dragged into another folder
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
const draggedItemPathname = draggedItem.pathname;
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, targetItem.pathname)
.then(resolve)
.catch((error) => reject(error));
}
ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)
.then(resolve)
.catch(reject);
});
};
export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
if (!draggedItem) {
return reject(new Error('Dragged item not found'));
}
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
// file item is already at the root level
if (!draggedItemParent) {
return resolve();
}
const draggedItemPathname = draggedItem.pathname;
moveCollectionItemToRootOfCollection(collectionCopy, draggedItem);
if (isItemAFolder(draggedItem)) {
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
.then(resolve)
.catch((error) => reject(error));
} else {
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(collectionCopy, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
});
};
}
export const newHttpRequest = (params) => (dispatch, getState) => {
const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
@@ -823,8 +798,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
collection.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
);
const requestItems = filter(collection.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1;
const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
item.seq = items.length + 1;
if (!reqWithSameNameExists) {
const fullName = path.join(collection.pathname, resolvedFilename);
@@ -852,8 +827,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
currentItem.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
);
const requestItems = filter(currentItem.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1;
const items = filter(currentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
item.seq = items.length + 1;
if (!reqWithSameNameExists) {
const fullName = path.join(currentItem.pathname, resolvedFilename);
const { ipcRenderer } = window;
@@ -885,6 +860,7 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, name)
.then(
@@ -913,6 +889,7 @@ export const importEnvironment = (name, variables, collectionUid) => (dispatch,
const sanitizedName = sanitizeName(name);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
.then(
@@ -946,6 +923,7 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
const sanitizedName = sanitizeName(name);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
.then(
@@ -982,6 +960,7 @@ export const renameEnvironment = (newName, environmentUid, collectionUid) => (di
const oldName = environment.name;
environment.name = sanitizedName;
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, sanitizedName))
@@ -1005,6 +984,7 @@ export const deleteEnvironment = (environmentUid, collectionUid) => (dispatch, g
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:delete-environment', collection.pathname, environment.name)
.then(resolve)
@@ -1028,6 +1008,7 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
environment.variables = variables;
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
@@ -1053,7 +1034,8 @@ export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, g
if (environmentUid && !environmentName) {
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:update-ui-state-snapshot', { type: 'COLLECTION_ENVIRONMENT', data: { collectionPath: collection?.pathname, environmentName }});
dispatch(_selectEnvironment({ environmentUid, collectionUid }));
@@ -1112,11 +1094,13 @@ export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getS
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:update-bruno-config', brunoConfig, collection.pathname, collectionUid)
.then(resolve)
@@ -1135,6 +1119,8 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
brunoConfig: brunoConfig
};
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
ipcRenderer.invoke('renderer:get-collection-security-config', pathname).then((securityConfig) => {
collectionSchema

View File

@@ -1719,6 +1719,9 @@ export const collectionsSlice = createSlice({
folderItem.name = file?.data?.meta?.name;
}
folderItem.root = file.data;
if (file?.data?.meta?.seq) {
folderItem.seq = file.data?.meta?.seq;
}
}
return;
}
@@ -1795,9 +1798,10 @@ export const collectionsSlice = createSlice({
currentPath = path.join(currentPath, directoryName);
if (!childItem) {
childItem = {
uid: uuid(),
uid: dir?.meta?.uid || uuid(),
pathname: currentPath,
name: dir?.meta?.name || directoryName,
seq: dir?.meta?.seq || 1,
filename: directoryName,
collapsed: true,
type: 'folder',
@@ -1829,6 +1833,9 @@ export const collectionsSlice = createSlice({
if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name;
}
if (file?.data?.meta?.seq) {
folderItem.seq = file?.data?.meta?.seq;
}
folderItem.root = file.data;
}
return;

View File

@@ -1,5 +1,5 @@
import { createSlice } from '@reduxjs/toolkit';
import { stringifyIfNot, uuid } from 'utils/common/index';
import { uuid } from 'utils/common/index';
import { environmentSchema } from '@usebruno/schema';
import { cloneDeep } from 'lodash';
@@ -90,6 +90,7 @@ export const {
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const uid = uuid();
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-global-environment', { name, uid, variables })
.then(() => dispatch(_addGlobalEnvironment({ name, uid, variables })))
@@ -104,6 +105,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const baseEnv = globalEnvironments?.find(env => env?.uid == baseEnvUid)
const uid = uuid();
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables })
.then(() => dispatch(_copyGlobalEnvironment({ name, uid, variables: baseEnv.variables })))
@@ -114,6 +116,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
const state = getState();
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const environment = globalEnvironments?.find(env => env?.uid == environmentUid)
@@ -139,6 +142,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-global-environment', {
@@ -155,6 +159,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:select-global-environment', { environmentUid })
.then(() => dispatch(_selectGlobalEnvironment({ environmentUid })))
@@ -165,6 +170,7 @@ export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:delete-global-environment', { environmentUid })
.then(() => dispatch(_deleteGlobalEnvironment({ environmentUid })))
@@ -175,6 +181,7 @@ export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
if (!globalEnvironmentVariables) resolve();
const state = getState();

View File

@@ -7,9 +7,17 @@ export const ToastContext = React.createContext();
export const ToastProvider = (props) => {
const { storedTheme } = useTheme();
const toastOptions = { duration: 2000 };
const toastOptions = {
duration: 2000,
style: {
// Break long word like file-path, URL etc. to prevent overflow
overflowWrap: 'anywhere'
}
};
if (storedTheme === 'dark') {
toastOptions.style = {
...toastOptions.style,
borderRadius: '10px',
background: '#3d3d3d',
color: '#fff'

View File

@@ -0,0 +1,9 @@
import { createSelector } from '@reduxjs/toolkit';
export const isTabForItemActive = ({ itemUid }) => createSelector([
(state) => state.tabs?.activeTabUid
], (activeTabUid) => activeTabUid === itemUid);
export const isTabForItemPresent = ({ itemUid }) => createSelector([
(state) => state.tabs.tabs,
], (tabs) => tabs.some((tab) => tab.uid === itemUid));

View File

@@ -281,6 +281,12 @@ const darkTheme = {
color: 'rgb(52 51 49)'
},
dragAndDrop: {
border: '#666666',
borderStyle: '2px solid',
hoverBg: 'rgba(102, 102, 102, 0.08)',
transition: 'all 0.1s ease'
},
infoTip: {
bg: '#1f1f1f',
border: '#333333',

View File

@@ -282,6 +282,12 @@ const lightTheme = {
color: 'rgb(152 151 149)'
},
dragAndDrop: {
border: '#8b8b8b', // Using the same gray as focusBorder from input
borderStyle: '2px solid',
hoverBg: 'rgba(139, 139, 139, 0.05)', // Matching the border color with reduced opacity
transition: 'all 0.1s ease'
},
infoTip: {
bg: 'white',
border: '#e0e0e0',

View File

@@ -6,10 +6,7 @@
* LICENSE file at https://github.com/graphql/codemirror-graphql/tree/v0.8.3
*/
// Todo: Fix this
// import { interpolate } from '@usebruno/common';
import brunoCommon from '@usebruno/common';
const { interpolate } = brunoCommon;
import { interpolate } from '@usebruno/common';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;

View File

@@ -5,6 +5,8 @@
* Copyright (C) 2017 by Marijn Haverbeke <marijnh@gmail.com> and others
*/
import { JSHINT } from 'jshint';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
@@ -76,6 +78,18 @@ if (!SERVER_RENDERED) {
return true;
}
/*
* Filter out errors due to atob/btoa redefinition
*
* - W079: Redefinition of '{a}'
* This JSHint warning triggers when a variable name conflicts with a built-in global.
* We filter this for atob/btoa to allow explicit requires in Node.js environments
* where these browser functions might not be available.
*/
if (error.code === 'W079' && (error.a === 'atob' || error.a === 'btoa')) {
return false;
}
return true;
});

View File

@@ -1,8 +1,6 @@
import {cloneDeep, isEqual, sortBy, filter, map, isString, findIndex, find, each, get } from 'lodash';
import { uuid } from 'utils/common';
import path from 'utils/common/path';
import brunoCommon from '@usebruno/common';
const { interpolate } = brunoCommon;
const replaceTabsWithSpaces = (str, numSpaces = 2) => {
if (!str || !str.length || !isString(str)) {
@@ -100,6 +98,14 @@ export const findItemInCollectionByPathname = (collection, pathname) => {
return findItemByPathname(flattenedItems, pathname);
};
export const findParentItemInCollectionByPathname = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return find(flattenedItems, (item) => {
return item.items && find(item.items, (i) => i.pathname === pathname);
});
};
export const findItemInCollection = (collection, itemUid) => {
let flattenedItems = flattenItems(collection.items);
@@ -152,90 +158,6 @@ export const getItemsLoadStats = (folder) => {
};
}
export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
if (draggedItemParent) {
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
}
if (targetItem.type === 'folder') {
targetItem.items = sortBy(targetItem.items || [], (item) => item.seq);
targetItem.items.push(draggedItem);
draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename);
} else {
let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
if (targetItemParent) {
targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
collection.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
}
}
};
export const moveCollectionItemToRootOfCollection = (collection, draggedItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
// If the dragged item is already at the root of the collection, do nothing
if (!draggedItemParent) {
return;
}
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items.push(draggedItem);
if (draggedItem.type == 'folder') {
draggedItem.pathname = path.join(collection.pathname, draggedItem.name);
} else {
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
}
};
export const getItemsToResequence = (parent, collection) => {
let itemsToResequence = [];
if (!parent) {
let index = 1;
each(collection.items, (item) => {
if (isItemARequest(item)) {
itemsToResequence.push({
pathname: item.pathname,
seq: index++
});
}
});
return itemsToResequence;
}
if (parent.items && parent.items.length) {
let index = 1;
each(parent.items, (item) => {
if (isItemARequest(item)) {
itemsToResequence.push({
pathname: item.pathname,
seq: index++
});
}
});
return itemsToResequence;
}
return itemsToResequence;
};
export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => {
const copyHeaders = (headers) => {
return map(headers, (header) => {
@@ -504,6 +426,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
if (meta?.name) {
di.root.meta = {};
di.root.meta.name = meta?.name;
di.root.meta.seq = meta?.seq;
}
if (!Object.keys(di.root.request)?.length) {
delete di.root.request;
@@ -1088,3 +1011,77 @@ export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = []
});
return credentialsVariables;
};
// item sequence utils - START
export const resetSequencesInFolder = (folderItems) => {
const items = folderItems;
const sortedItems = items.sort((a, b) => a.seq - b.seq);
return sortedItems.map((item, index) => {
item.seq = index + 1;
return item;
});
};
export const isItemBetweenSequences = (itemSequence, sourceItemSequence, targetItemSequence) => {
if (targetItemSequence > sourceItemSequence) {
return itemSequence > sourceItemSequence && itemSequence < targetItemSequence;
}
return itemSequence < sourceItemSequence && itemSequence >= targetItemSequence;
};
export const calculateNewSequence = (isDraggedItem, targetSequence, draggedSequence) => {
if (!isDraggedItem) {
return null;
}
return targetSequence > draggedSequence ? targetSequence - 1 : targetSequence;
};
export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, draggedItemUid }) => {
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
const targetItem = findItem(itemsWithFixedSequences, targetItemUid);
const draggedItem = findItem(itemsWithFixedSequences, draggedItemUid);
const targetSequence = targetItem?.seq;
const draggedSequence = draggedItem?.seq;
itemsWithFixedSequences?.forEach(item => {
const isDraggedItem = item?.uid === draggedItemUid;
const isBetween = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
if (isBetween) {
item.seq += targetSequence > draggedSequence ? -1 : 1;
}
const newSequence = calculateNewSequence(isDraggedItem, targetSequence, draggedSequence);
if (newSequence !== null) {
item.seq = newSequence;
}
});
// only return items that have been reordered
return itemsWithFixedSequences.filter(item =>
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
);
};
export const getReorderedItemsInSourceDirectory = ({ items }) => {
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
return itemsWithFixedSequences.filter(item =>
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
);
};
export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropType, collectionPathname }) => {
const { pathname: targetItemPathname } = targetItem;
const { filename: draggedItemFilename } = draggedItem;
const targetItemDirname = path.dirname(targetItemPathname);
const isTargetTheCollection = targetItemPathname === collectionPathname;
const isTargetItemAFolder = isItemAFolder(targetItem);
if (dropType === 'inside' && (isTargetItemAFolder || isTargetTheCollection)) {
return path.join(targetItemPathname, draggedItemFilename)
} else if (dropType === 'adjacent') {
return path.join(targetItemDirname, draggedItemFilename)
}
return null;
};
// item sequence utils - END

View File

@@ -1,10 +0,0 @@
class Cache {
get(key) {
return window.localStorage.getItem(key);
}
set(key, val) {
window.localStorage.setItem(key, val);
}
}
module.exports = new Cache();

View File

@@ -34,3 +34,11 @@ export const toastError = (error, defaultErrorMsg = 'An error occurred') => {
return toast.error(errorMsg);
};
export function formatIpcError(error) {
if (!(error instanceof Error)) return error;
if (!error?.message) return ''; // Avoid returning `null` or `undefined`
// https://github.com/electron/electron/blob/659e79fc08c6ffc2f7506dd1358918d97d240147/lib/renderer/api/ipc-renderer.ts#L24-L30
// There is no other way to get rid of this error prefix as of now.
return error.message.replace(/^Error invoking remote method '.+?': (Error: )?/, '');
}

View File

@@ -53,7 +53,7 @@ export const safeStringifyJSON = (obj, indent = false) => {
export const convertToCodeMirrorJson = (obj) => {
try {
return JSON5.stringify(obj).slice(1, -1);
return JSON.stringify(obj, null, 2).slice(1, -1);
} catch (e) {
return obj;
}
@@ -181,4 +181,8 @@ export const getEncoding = (headers) => {
// Parse the charset from content type: https://stackoverflow.com/a/33192813
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(headers?.['content-type'] || '');
return charsetMatch?.[1];
}
export const multiLineMsg = (...messages) => {
return messages.filter(m => m !== undefined && m !== null && m !== '').join('\n');
}

View File

@@ -1,16 +1,16 @@
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // replace invalid characters with hyphens
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
const lastCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot or space at end, hyphen allowed
const firstCharacter = /^[^\s\-<>:"/\\|?*\x00-\x1F]/; // no space, hyphen and `invalidCharacters`
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no `invalidCharacters`
const lastCharacter = /[^.\s<>:"/\\|?*\x00-\x1F]$/; // no dot, space and `invalidCharacters`
export const variableNameRegex = /^[\w-.]*$/;
export const sanitizeName = (name) => {
name = name
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[.\s-]+/, '') // remove leading dots, hyphens and spaces
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[\s\-]+/, '') // remove leading spaces and hyphens
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces
return name;
};

View File

@@ -23,8 +23,8 @@ describe('regex validators', () => {
});
it('should remove trailing periods', () => {
expect(sanitizeName('.file')).toBe('file');
expect(sanitizeName('.file.')).toBe('file');
expect(sanitizeName('.file')).toBe('.file');
expect(sanitizeName('.file.')).toBe('.file');
expect(sanitizeName('file.')).toBe('file');
expect(sanitizeName('file.name.')).toBe('file.name');
expect(sanitizeName('hello world.')).toBe('hello world');
@@ -83,11 +83,11 @@ describe('regex validators', () => {
it('should handle filenames with multiple consecutive periods (only remove trailing)', () => {
expect(sanitizeName('file.name...')).toBe('file.name');
expect(sanitizeName('...file')).toBe('file');
expect(sanitizeName('...file')).toBe('...file');
expect(sanitizeName('file.name... ')).toBe('file.name');
expect(sanitizeName(' ...file')).toBe('file');
expect(sanitizeName(' ...file ')).toBe('file');
expect(sanitizeName(' ...file.... ')).toBe('file');
expect(sanitizeName(' ...file')).toBe('...file');
expect(sanitizeName(' ...file ')).toBe('...file');
expect(sanitizeName(' ...file.... ')).toBe('...file');
});
it('should handle very long filenames', () => {

View File

@@ -1,6 +1,5 @@
import * as FileSaver from 'file-saver';
import brunoConverters from '@usebruno/converters';
const { brunoToPostman } = brunoConverters;
import { brunoToPostman } from '@usebruno/converters';
export const exportCollection = (collection) => {

View File

@@ -1,8 +1,7 @@
import jsyaml from 'js-yaml';
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import brunoConverters from '@usebruno/converters';
const { insomniaToBruno } = brunoConverters;
import { insomniaToBruno } from '@usebruno/converters';
const readFile = (files) => {
return new Promise((resolve, reject) => {

View File

@@ -1,8 +1,7 @@
import jsyaml from 'js-yaml';
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import brunoConverters from '@usebruno/converters';
const { openApiToBruno } = brunoConverters;
import { openApiToBruno } from '@usebruno/converters';
const readFile = (files) => {
return new Promise((resolve, reject) => {

View File

@@ -1,8 +1,6 @@
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import brunoConverters from '@usebruno/converters';
import { safeParseJSON } from 'utils/common/index';
const { postmanToBruno } = brunoConverters;
const readFile = (files) => {
return new Promise((resolve, reject) => {
@@ -13,18 +11,15 @@ const readFile = (files) => {
});
};
const importCollection = () => {
const postmanToBruno = (collection) => {
return new Promise((resolve, reject) => {
fileDialog({ accept: 'application/json' })
.then(readFile)
.then((collection) => postmanToBruno(collection))
.then((collection) => resolve({ collection }))
.catch((err) => {
console.log(err);
reject(new BrunoError('Import collection failed'));
})
window.ipcRenderer.invoke('renderer:convert-postman-to-bruno', collection)
.then(result => resolve(result))
.catch(err => {
console.error('Error converting Postman to Bruno via Electron:', err);
reject(new BrunoError('Conversion failed'));
});
});
};
export default importCollection;
export { postmanToBruno, readFile };

View File

@@ -1,12 +1,19 @@
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import brunoConverters from '@usebruno/converters';
const { postmanToBrunoEnvironment } = brunoConverters;
import { postmanToBrunoEnvironment } from '@usebruno/converters';
const readFile = (files) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = (e) => resolve(e.target.result);
fileReader.onload = (e) => {
try {
let parsedPostmanEnvironment = JSON.parse(e.target.result);
resolve(parsedPostmanEnvironment);
} catch (err) {
console.error(err);
reject(new BrunoError('Unable to parse the postman environment json file'));
}
}
fileReader.onerror = (err) => reject(err);
fileReader.readAsText(files[0]);
});

View File

@@ -0,0 +1,126 @@
import { resetSequencesInFolder, isItemBetweenSequences } from 'utils/collections/index';
describe('resetSequencesInFolder', () => {
it('should fix the sequences in the folder 1', () => {
const folder = {
items: [
{ uid: '1', seq: 1 },
{ uid: '2', seq: 3 },
{ uid: '3', seq: 6 },
],
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '1', seq: 1 },
{ uid: '2', seq: 2 },
{ uid: '3', seq: 3 },
]);
});
it('should fix the sequences in the folder 2', () => {
const folder = {
items: [
{ uid: '1', seq: 3 },
{ uid: '2', seq: 1 },
{ uid: '3', seq: 2 },
],
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '2', seq: 1 },
{ uid: '3', seq: 2 },
{ uid: '1', seq: 3 },
]);
});
it('should fix the sequences in the folder with missing sequences', () => {
const folder = {
items: [
{ uid: '1', seq: 1 },
{ uid: '2', type: 'folder' },
{ uid: '3', type: 'folder' },
{ uid: '4', seq: 7 },
]
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '1', seq: 1 },
{ uid: '2', seq: 2, type: 'folder' },
{ uid: '3', seq: 3, type: 'folder' },
{ uid: '4', seq: 4 },
]);
});
it('should fix the sequences in the folder with same sequences', () => {
const folder = {
items: [
{ uid: '1', seq: 2 },
{ uid: '2', seq: 2 },
{ uid: '3', seq: 3 },
{ uid: '4', seq: 1 },
],
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '4', seq: 1 },
{ uid: '1', seq: 2 },
{ uid: '2', seq: 3 },
{ uid: '3', seq: 4 },
]);
});
});
describe('isItemBetweenSequences', () => {
it('should return true if the item is between the sequences 1', () => {
const item = { uid: '1', seq: 2 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return true if the item is between the sequences 2', () => {
const item = { uid: '1', seq: 2 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return true if the item is between the sequences 3', () => {
const item = { uid: '1', seq: 4 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return true if the item is between the sequences 4', () => {
const item = { uid: '1', seq: 1 };
const draggedSequence = 5;
const targetSequence = 1;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return false if the item is between the sequences 1', () => {
const item = { uid: '1', seq: 1 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(false);
});
it('should return false if the item is between the sequences 2', () => {
const item = { uid: '1', seq: 5 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(false);
});
});

View File

@@ -1,11 +1,9 @@
import isEmpty from 'lodash/isEmpty';
import trim from 'lodash/trim';
import each from 'lodash/each';
import filter from 'lodash/filter';
import find from 'lodash/find';
import brunoCommon from '@usebruno/common';
const { interpolate } = brunoCommon;
import { interpolate } from '@usebruno/common';
const hasLength = (str) => {
if (!str || !str.length) {

View File

@@ -51,6 +51,8 @@
"@usebruno/js": "0.12.0",
"@usebruno/lang": "0.12.0",
"@usebruno/vm2": "^3.9.13",
"@usebruno/requests": "^0.1.0",
"@usebruno/converters": "^0.1.0",
"aws4-axios": "^3.3.0",
"axios": "^1.8.3",
"axios-ntlm": "^1.4.2",
@@ -62,6 +64,7 @@
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"iconv-lite": "^0.6.3",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",

View File

@@ -58,6 +58,44 @@ If you need to limit the trusted CA to a specified set when validating the reque
bru run request.bru --cacert myCustomCA.pem --ignore-truststore
```
## Importing Collections
You can import collections from other formats, such as OpenAPI, using the import command:
```bash
bru import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"
```
You can also use the shorter form with aliases:
```bash
bru import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"
```
This creates a Bruno collection directory that can be opened in Bruno.
You can also import directly from a URL:
```bash
bru import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"
```
You can also export the collection as a JSON file:
```bash
bru import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"
```
Import Options:
| Option | Details |
| ------------------------- | -------------------------------------------------- |
| --source, -s | Path to the source file or URL (required) |
| --output, -o | Path to the output directory |
| --output-file, -f | Path to the output JSON file |
| --collection-name, -n | Name for the imported collection |
| --insecure | Skip SSL certificate validation when fetching from URLs |
## Command Line Options
| Option | Details |

View File

@@ -0,0 +1,230 @@
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const jsyaml = require('js-yaml');
const axios = require('axios');
const { openApiToBruno } = require('@usebruno/converters');
const { exists, isDirectory, sanitizeName } = require('../utils/filesystem');
const { createCollectionFromBrunoObject } = require('../utils/collection');
const command = 'import <type>';
const desc = 'Import a collection from other formats';
const builder = (yargs) => {
yargs
.positional('type', {
describe: 'Type of collection to import',
type: 'string',
choices: ['openapi']
})
.option('source', {
alias: 's',
describe: 'Path to the source file or URL',
type: 'string',
demandOption: true
})
.option('output', {
alias: 'o',
describe: 'Path to the output directory',
type: 'string',
conflicts: 'output-file'
})
.option('output-file', {
alias: 'f',
describe: 'Path to the output JSON file',
type: 'string',
conflicts: 'output'
})
.option('collection-name', {
alias: 'n',
describe: 'Name for the imported collection',
type: 'string'
})
.option('insecure', {
type: 'boolean',
describe: 'Skip SSL certificate verification when fetching from URLs',
default: false
})
.example('$0 import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"')
.example('$0 import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"')
.example('$0 import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"')
.example('$0 import openapi --source https://self-signed.example.com/api.json --insecure --output ~/Desktop')
.example('$0 import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"')
.example('$0 import openapi -s api.yml -f ~/Desktop/my-collection.json -n "My API"');
};
const isUrl = (str) => {
try {
return Boolean(new URL(str));
} catch (error) {
return false;
}
};
const readOpenApiFile = async (source, options = {}) => {
try {
let content;
if (isUrl(source)) {
// Handle URL input
console.log(chalk.yellow(`Fetching specification from URL: ${source}`));
try {
const axiosOptions = {
timeout: 30000, // 30 second timeout
maxContentLength: 10 * 1024 * 1024,
validateStatus: status => status >= 200 && status < 300
};
// Skip SSL certificate validation if insecure flag is set
if (options.insecure) {
console.log(chalk.yellow('Warning: SSL certificate verification is disabled. Use with caution.'));
axiosOptions.httpsAgent = new (require('https')).Agent({ rejectUnauthorized: false });
}
const response = await axios.get(source, axiosOptions);
content = response.data;
} catch (error) {
if (error.code === 'ECONNABORTED') {
throw new Error('Request timed out. The server took too long to respond.');
} else if (error.code === 'CERT_HAS_EXPIRED' || error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
error.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
throw new Error(`SSL Certificate error: ${error.code}. Try using --insecure if you trust this source.`);
} else if (error.response) {
throw new Error(`Failed to fetch from URL: ${error.response.status} ${error.response.statusText}`);
} else if (error.request) {
throw new Error(`No response received from server. Check the URL and your network connection.`);
} else {
throw new Error(`Error fetching URL: ${error.message}`);
}
}
// If response is already an object, return it directly
if (typeof content === 'object' && content !== null) {
return content;
}
} else {
// Handle file input
if (!await exists(source)) {
throw new Error(`File does not exist: ${source}`);
}
content = fs.readFileSync(source, 'utf8');
}
// If content is a string, try to parse as JSON or YAML
if (typeof content === 'string') {
try {
return JSON.parse(content);
} catch (jsonError) {
try {
return jsyaml.load(content);
} catch (yamlError) {
throw new Error('Failed to parse content as JSON or YAML');
}
}
}
return content;
} catch (error) {
// Let the specific error handling from above propagate
throw error;
}
};
const handler = async (argv) => {
try {
const { type, source, output, outputFile, collectionName, insecure } = argv;
if (!type || type !== 'openapi') {
console.error(chalk.red('Only OpenAPI import is supported currently'));
process.exit(1);
}
if (!source) {
console.error(chalk.red('Source file or URL is required'));
process.exit(1);
}
if (!output && !outputFile) {
console.error(chalk.red('Either --output or --output-file is required'));
process.exit(1);
}
console.log(chalk.yellow(`Reading OpenAPI specification from ${source}...`));
const openApiSpec = await readOpenApiFile(source, { insecure });
if (!openApiSpec) {
console.error(chalk.red('Failed to parse OpenAPI specification'));
process.exit(1);
}
console.log(chalk.yellow('Converting OpenAPI specification to Bruno format...'));
// Convert OpenAPI to Bruno format
let brunoCollection = openApiToBruno(openApiSpec);
// Override collection name if provided
if (collectionName) {
brunoCollection.name = collectionName;
}
if (outputFile) {
// Save as JSON file
const outputPath = path.resolve(outputFile);
fs.writeFileSync(outputPath, JSON.stringify(brunoCollection, null, 2));
console.log(chalk.green(`Bruno collection saved as JSON to ${outputPath}`));
} else if (output) {
const resolvedOutput = path.resolve(output);
// Check if output is an existing directory
const isOutputDirectory = await exists(resolvedOutput) && isDirectory(resolvedOutput);
// Determine the final output directory
let outputDir;
if (isOutputDirectory) {
// If output is an existing directory, use collection name to create a subdirectory
const dirName = sanitizeName(brunoCollection.name);
outputDir = path.join(resolvedOutput, dirName);
// Check if this subfolder already exists
if (await exists(outputDir)) {
const dirContents = fs.readdirSync(outputDir);
if (dirContents.length > 0) {
console.error(chalk.red(`Output directory is not empty: ${outputDir}`));
process.exit(1);
}
} else {
// Create the subfolder
fs.mkdirSync(outputDir, { recursive: true });
}
} else {
// If output doesn't exist or is not a directory, use it directly
outputDir = resolvedOutput;
// Check if parent directory exists
const parentDir = path.dirname(outputDir);
if (!await exists(parentDir)) {
console.error(chalk.red(`Parent directory does not exist: ${parentDir}`));
process.exit(1);
}
fs.mkdirSync(outputDir, { recursive: true });
}
await createCollectionFromBrunoObject(brunoCollection, outputDir);
console.log(chalk.green(`Bruno collection created at ${outputDir}`));
}
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
process.exit(1);
}
};
module.exports = {
command,
desc,
builder,
handler,
isUrl,
readOpenApiFile
};

View File

@@ -2,6 +2,7 @@ const fs = require('fs');
const chalk = require('chalk');
const path = require('path');
const { forOwn, cloneDeep } = require('lodash');
const { getRunnerSummary } = require('@usebruno/common/runner');
const { exists, isFile, isDirectory } = require('../utils/filesystem');
const { runSingleRequest } = require('../runner/run-single-request');
const { bruToEnvJson, getEnvVars } = require('../utils/bru');
@@ -11,55 +12,24 @@ const { rpad } = require('../utils/common');
const { bruToJson, getOptions, collectionBruToJson } = require('../utils/bru');
const { dotenvToJson } = require('@usebruno/lang');
const constants = require('../constants');
const { findItemInCollection } = require('../utils/collection');
const { findItemInCollection, getAllRequestsInFolder, createCollectionJsonFromPathname } = require('../utils/collection');
const command = 'run [filename]';
const desc = 'Run a request';
const printRunSummary = (results) => {
let totalRequests = 0;
let passedRequests = 0;
let failedRequests = 0;
let skippedRequests = 0;
let totalAssertions = 0;
let passedAssertions = 0;
let failedAssertions = 0;
let totalTests = 0;
let passedTests = 0;
let failedTests = 0;
for (const result of results) {
totalRequests += 1;
totalTests += result.testResults.length;
totalAssertions += result.assertionResults.length;
let anyFailed = false;
let hasAnyTestsOrAssertions = false;
for (const testResult of result.testResults) {
hasAnyTestsOrAssertions = true;
if (testResult.status === 'pass') {
passedTests += 1;
} else {
anyFailed = true;
failedTests += 1;
}
}
for (const assertionResult of result.assertionResults) {
hasAnyTestsOrAssertions = true;
if (assertionResult.status === 'pass') {
passedAssertions += 1;
} else {
anyFailed = true;
failedAssertions += 1;
}
}
if (!hasAnyTestsOrAssertions && result.skipped) {
skippedRequests += 1;
}
else if (!hasAnyTestsOrAssertions && result.error) {
failedRequests += 1;
} else {
passedRequests += 1;
}
}
const {
totalRequests,
passedRequests,
failedRequests,
skippedRequests,
errorRequests,
totalAssertions,
passedAssertions,
failedAssertions,
totalTests,
passedTests,
failedTests
} = getRunnerSummary(results);
const maxLength = 12;
@@ -67,6 +37,9 @@ const printRunSummary = (results) => {
if (failedRequests > 0) {
requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`;
}
if (errorRequests > 0) {
requestSummary += `, ${chalk.red(`${errorRequests} error`)}`;
}
if (skippedRequests > 0) {
requestSummary += `, ${chalk.magenta(`${skippedRequests} skipped`)}`;
}
@@ -93,170 +66,14 @@ const printRunSummary = (results) => {
passedRequests,
failedRequests,
skippedRequests,
errorRequests,
totalAssertions,
passedAssertions,
failedAssertions,
totalTests,
passedTests,
failedTests
};
};
const createCollectionFromPath = (collectionPath) => {
const environmentsPath = path.join(collectionPath, `environments`);
const getFilesInOrder = (collectionPath) => {
let collection = {
pathname: collectionPath
};
const traverse = (currentPath) => {
const filesInCurrentDir = fs.readdirSync(currentPath);
if (currentPath.includes('node_modules')) {
return;
}
const currentDirItems = [];
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (
stats.isDirectory() &&
filePath !== environmentsPath &&
!filePath.startsWith('.git') &&
!filePath.startsWith('node_modules')
) {
let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) }
const folderBruFilePath = path.join(filePath, 'folder.bru');
const folderBruFileExists = fs.existsSync(folderBruFilePath);
if(folderBruFileExists) {
const folderBruContent = fs.readFileSync(folderBruFilePath, 'utf8');
let folderBruJson = collectionBruToJson(folderBruContent);
folderItem.root = folderBruJson;
}
currentDirItems.push(folderItem);
}
}
for (const file of filesInCurrentDir) {
if (['collection.bru', 'folder.bru'].includes(file)) {
continue;
}
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (!stats.isDirectory() && path.extname(filePath) === '.bru') {
const bruContent = fs.readFileSync(filePath, 'utf8');
const bruJson = bruToJson(bruContent);
currentDirItems.push({
name: file,
pathname: filePath,
...bruJson
});
}
}
return currentDirItems;
};
collection.items = traverse(collectionPath);
return collection;
};
return getFilesInOrder(collectionPath);
};
const getBruFilesRecursively = (dir, testsOnly) => {
const environmentsPath = 'environments';
const collection = {};
const getFilesInOrder = (dir) => {
let bruJsons = [];
const traverse = (currentPath) => {
const filesInCurrentDir = fs.readdirSync(currentPath);
if (currentPath.includes('node_modules')) {
return;
}
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.statSync(filePath);
// todo: we might need a ignore config inside bruno.json
if (
stats.isDirectory() &&
filePath !== environmentsPath &&
!filePath.startsWith('.git') &&
!filePath.startsWith('node_modules')
) {
traverse(filePath);
}
}
const currentDirBruJsons = [];
for (const file of filesInCurrentDir) {
if (['collection.bru', 'folder.bru'].includes(file)) {
continue;
}
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (!stats.isDirectory() && path.extname(filePath) === '.bru') {
const bruContent = fs.readFileSync(filePath, 'utf8');
const bruJson = bruToJson(bruContent);
const requestHasTests = bruJson.request?.tests;
const requestHasActiveAsserts = bruJson.request?.assertions.some((x) => x.enabled) || false;
if (testsOnly) {
if (requestHasTests || requestHasActiveAsserts) {
currentDirBruJsons.push({
bruFilepath: filePath,
bruJson
});
}
} else {
currentDirBruJsons.push({
bruFilepath: filePath,
bruJson
});
}
}
}
// order requests by sequence
currentDirBruJsons.sort((a, b) => {
const aSequence = a.bruJson.seq || 0;
const bSequence = b.bruJson.seq || 0;
return aSequence - bSequence;
});
bruJsons = bruJsons.concat(currentDirBruJsons);
};
traverse(dir);
return bruJsons;
};
return getFilesInOrder(dir);
};
const getCollectionRoot = (dir) => {
const collectionRootPath = path.join(dir, 'collection.bru');
const exists = fs.existsSync(collectionRootPath);
if (!exists) {
return {};
}
const content = fs.readFileSync(collectionRootPath, 'utf8');
return collectionBruToJson(content);
};
const getFolderRoot = (dir) => {
const folderRootPath = path.join(dir, 'folder.bru');
const exists = fs.existsSync(folderRootPath);
if (!exists) {
return {};
}
const content = fs.readFileSync(folderRootPath, 'utf8');
return collectionBruToJson(content);
};
const getJsSandboxRuntime = (sandbox) => {
@@ -351,7 +168,6 @@ const builder = async (yargs) => {
type:"number",
description: "Delay between each requests (in miliseconds)"
})
.example('$0 run request.bru', 'Run a request')
.example('$0 run request.bru --env local', 'Run a request with the environment set to local')
.example('$0 run folder', 'Run all requests in a folder')
@@ -421,25 +237,8 @@ const handler = async function (argv) {
} = argv;
const collectionPath = process.cwd();
// todo
// right now, bru must be run from the root of the collection
// will add support in the future to run it from anywhere inside the collection
const brunoJsonPath = path.join(collectionPath, 'bruno.json');
const brunoJsonExists = await exists(brunoJsonPath);
if (!brunoJsonExists) {
console.error(chalk.red(`You can run only at the root of a collection`));
process.exit(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
}
const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
const brunoConfig = JSON.parse(brunoConfigFile);
const collectionRoot = getCollectionRoot(collectionPath);
let collection = createCollectionFromPath(collectionPath);
collection = {
brunoConfig,
root: collectionRoot,
...collection
}
let collection = createCollectionJsonFromPathname(collectionPath);
const { root: collectionRoot, brunoConfig } = collection;
if (clientCertConfig) {
try {
@@ -475,7 +274,6 @@ const handler = async function (argv) {
}
}
if (filename && filename.length) {
const pathExists = await exists(filename);
if (!pathExists) {
@@ -597,54 +395,39 @@ const handler = async function (argv) {
const _isFile = isFile(filename);
let results = [];
let bruJsons = [];
let requestItems = [];
if (_isFile) {
console.log(chalk.yellow('Running Request \n'));
const bruContent = fs.readFileSync(filename, 'utf8');
const bruJson = bruToJson(bruContent);
bruJsons.push({
bruFilepath: filename,
bruJson
});
const requestItem = bruToJson(bruContent);
requestItem.pathname = path.resolve(collectionPath, filename);
requestItems.push(requestItem);
}
const _isDirectory = isDirectory(filename);
if (_isDirectory) {
if (!recursive) {
console.log(chalk.yellow('Running Folder \n'));
const files = fs.readdirSync(filename);
const bruFiles = files.filter((file) => !['folder.bru'].includes(file) && file.endsWith('.bru'));
for (const bruFile of bruFiles) {
const bruFilepath = path.join(filename, bruFile);
const bruContent = fs.readFileSync(bruFilepath, 'utf8');
const bruJson = bruToJson(bruContent);
const requestHasTests = bruJson.request?.tests;
const requestHasActiveAsserts = bruJson.request?.assertions.some((x) => x.enabled) || false;
if (testsOnly) {
if (requestHasTests || requestHasActiveAsserts) {
bruJsons.push({
bruFilepath,
bruJson
});
}
} else {
bruJsons.push({
bruFilepath,
bruJson
});
}
}
bruJsons.sort((a, b) => {
const aSequence = a.bruJson.seq || 0;
const bSequence = b.bruJson.seq || 0;
return aSequence - bSequence;
});
} else {
console.log(chalk.yellow('Running Folder Recursively \n'));
}
const resolvedFilepath = path.resolve(filename);
if (resolvedFilepath === collectionPath) {
requestItems = getAllRequestsInFolder(collection?.items, recursive);
} else {
const folderItem = findItemInCollection(collection, resolvedFilepath);
if (folderItem) {
requestItems = getAllRequestsInFolder(folderItem.items, recursive);
}
}
bruJsons = getBruFilesRecursively(filename, testsOnly);
if (testsOnly) {
requestItems = requestItems.filter((iter) => {
const requestHasTests = iter.request?.tests;
const requestHasActiveAsserts = iter.request?.assertions.some((x) => x.enabled) || false;
return requestHasTests || requestHasActiveAsserts;
});
}
}
@@ -656,11 +439,10 @@ const handler = async function (argv) {
if (itemPathname && !itemPathname?.endsWith('.bru')) {
itemPathname = `${itemPathname}.bru`;
}
const bruJson = cloneDeep(findItemInCollection(collection, itemPathname));
if (bruJson) {
const requestItem = cloneDeep(findItemInCollection(collection, itemPathname));
if (requestItem) {
const res = await runSingleRequest(
itemPathname,
bruJson,
requestItem,
collectionPath,
runtimeVariables,
envVars,
@@ -679,14 +461,13 @@ const handler = async function (argv) {
let currentRequestIndex = 0;
let nJumps = 0; // count the number of jumps to avoid infinite loops
while (currentRequestIndex < bruJsons.length) {
const iter = cloneDeep(bruJsons[currentRequestIndex]);
const { bruFilepath, bruJson } = iter;
while (currentRequestIndex < requestItems.length) {
const requestItem = cloneDeep(requestItems[currentRequestIndex]);
const { pathname } = requestItem;
const start = process.hrtime();
const result = await runSingleRequest(
bruFilepath,
bruJson,
requestItem,
collectionPath,
runtimeVariables,
envVars,
@@ -698,7 +479,7 @@ const handler = async function (argv) {
runSingleRequestByPathname
);
const isLastRun = currentRequestIndex === bruJsons.length - 1;
const isLastRun = currentRequestIndex === requestItems.length - 1;
const isValidDelay = !Number.isNaN(delay) && delay > 0;
if(isValidDelay && !isLastRun){
console.log(chalk.yellow(`Waiting for ${delay}ms or ${(delay/1000).toFixed(3)}s before next request.`));
@@ -712,7 +493,7 @@ const handler = async function (argv) {
results.push({
...result,
runtime: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
suitename: bruFilepath.replace('.bru', '')
suitename: pathname.replace('.bru', '')
});
if (reporterSkipAllHeaders) {
@@ -746,7 +527,7 @@ const handler = async function (argv) {
// bail if option is set and there is a failure
if (bail) {
const requestFailure = result?.error;
const requestFailure = result?.error && !result?.skipped;
const testFailure = result?.testResults?.find((iter) => iter.status === 'fail');
const assertionFailure = result?.assertionResults?.find((iter) => iter.status === 'fail');
if (requestFailure || testFailure || assertionFailure) {
@@ -770,7 +551,7 @@ const handler = async function (argv) {
if (nextRequestName === null) {
break;
}
const nextRequestIdx = bruJsons.findIndex((iter) => iter.bruJson.name === nextRequestName);
const nextRequestIdx = requestItems.findIndex((iter) => iter.name === nextRequestName);
if (nextRequestIdx >= 0) {
currentRequestIndex = nextRequestIdx;
} else {

View File

@@ -32,7 +32,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
});
});
const _interpolate = (str) => {
const _interpolate = (str, { escapeJSONStrings } = {}) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
@@ -51,7 +51,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
}
};
return interpolate(str, combinedVars);
return interpolate(str, combinedVars, { escapeJSONStrings });
};
request.url = _interpolate(request.url);
@@ -67,14 +67,14 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
if (typeof request.data === 'object') {
try {
let parsed = JSON.stringify(request.data);
parsed = _interpolate(parsed);
parsed = _interpolate(parsed, { escapeJSONStrings: true });
request.data = JSON.parse(parsed);
} catch (err) {}
}
if (typeof request.data === 'string') {
if (request?.data?.length) {
request.data = _interpolate(request.data);
request.data = _interpolate(request.data, { escapeJSONStrings: true });
}
}
} else if (contentType === 'application/x-www-form-urlencoded') {
@@ -156,6 +156,37 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
delete request.basicAuth;
}
if (request?.oauth2?.grantType) {
switch (request.oauth2.grantType) {
case 'password':
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
request.oauth2.username = _interpolate(request.oauth2.username) || '';
request.oauth2.password = _interpolate(request.oauth2.password) || '';
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
break;
case 'client_credentials':
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
break;
default:
break;
}
}
if (request.awsv4config) {
request.awsv4config.accessKeyId = _interpolate(request.awsv4config.accessKeyId) || '';
request.awsv4config.secretAccessKey = _interpolate(request.awsv4config.secretAccessKey) || '';

View File

@@ -0,0 +1,6 @@
const { getOAuth2Token } = require('@usebruno/requests');
const tokenStore = require('./tokenStore');
module.exports = {
getOAuth2Token: (oauth2Config) => getOAuth2Token(oauth2Config, tokenStore)
};

View File

@@ -1,7 +1,7 @@
const { get, each, filter } = require('lodash');
const decomment = require('decomment');
const crypto = require('node:crypto');
const { mergeHeaders, mergeScripts, mergeVars, getTreePathFromCollectionToItem } = require('../utils/collection');
const { mergeHeaders, mergeScripts, mergeVars, mergeAuth, getTreePathFromCollectionToItem } = require('../utils/collection');
const { createFormData } = require('../utils/form-data');
const prepareRequest = (item = {}, collection = {}) => {
@@ -16,6 +16,7 @@ const prepareRequest = (item = {}, collection = {}) => {
mergeHeaders(collection, request, requestTreePath);
mergeScripts(collection, request, requestTreePath, scriptFlow);
mergeVars(collection, request, requestTreePath);
mergeAuth(collection, request, requestTreePath);
}
each(get(request, 'headers', []), (h) => {
@@ -65,6 +66,83 @@ const prepareRequest = (item = {}, collection = {}) => {
}
}
}
if (collectionAuth.mode === 'digest') {
axiosRequest.digestConfig = {
username: get(collectionAuth, 'digest.username'),
password: get(collectionAuth, 'digest.password')
};
}
if (collectionAuth.mode === 'oauth2') {
const grantType = get(collectionAuth, 'oauth2.grantType');
if (grantType === 'client_credentials') {
axiosRequest.oauth2 = {
grantType,
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
clientId: get(collectionAuth, 'oauth2.clientId'),
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
scope: get(collectionAuth, 'oauth2.scope'),
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
};
} else if (grantType === 'password') {
axiosRequest.oauth2 = {
grantType,
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
username: get(collectionAuth, 'oauth2.username'),
password: get(collectionAuth, 'oauth2.password'),
clientId: get(collectionAuth, 'oauth2.clientId'),
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
scope: get(collectionAuth, 'oauth2.scope'),
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
};
}
}
if (collectionAuth.mode === 'awsv4') {
axiosRequest.awsv4config = {
accessKeyId: get(collectionAuth, 'awsv4.accessKeyId'),
secretAccessKey: get(collectionAuth, 'awsv4.secretAccessKey'),
sessionToken: get(collectionAuth, 'awsv4.sessionToken'),
service: get(collectionAuth, 'awsv4.service'),
region: get(collectionAuth, 'awsv4.region'),
profileName: get(collectionAuth, 'awsv4.profileName')
};
}
if (collectionAuth.mode === 'ntlm') {
axiosRequest.ntlmConfig = {
username: get(collectionAuth, 'ntlm.username'),
password: get(collectionAuth, 'ntlm.password'),
domain: get(collectionAuth, 'ntlm.domain')
};
}
if (collectionAuth.mode === 'wsse') {
const username = get(collectionAuth, 'wsse.username', '');
const password = get(collectionAuth, 'wsse.password', '');
const ts = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('hex');
// Create the password digest using SHA-1 as required for WSSE
const hash = crypto.createHash('sha1');
hash.update(nonce + ts + password);
const digest = Buffer.from(hash.digest('hex').toString('utf8')).toString('base64');
// Construct the WSSE header
axiosRequest.headers[
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Nonce="${nonce}", Created="${ts}"`;
}
console.log('axiosRequest', axiosRequest);
}
if (request.auth && request.auth.mode !== 'inherit') {
@@ -115,6 +193,63 @@ const prepareRequest = (item = {}, collection = {}) => {
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Nonce="${nonce}", Created="${ts}"`;
}
if (request.auth.mode === 'digest') {
axiosRequest.digestConfig = {
username: get(request, 'auth.digest.username'),
password: get(request, 'auth.digest.password')
};
}
if (request.auth.mode === 'oauth2') {
const grantType = get(request, 'auth.oauth2.grantType');
if (grantType === 'client_credentials') {
axiosRequest.oauth2 = {
grantType,
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
scope: get(request, 'auth.oauth2.scope'),
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
};
} else if (grantType === 'password') {
axiosRequest.oauth2 = {
grantType,
username: get(request, 'auth.oauth2.username'),
password: get(request, 'auth.oauth2.password'),
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
scope: get(request, 'auth.oauth2.scope'),
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
};
}
}
if (request.auth.mode === 'apikey') {
if (request.auth.apikey?.placement === 'header') {
axiosRequest.headers[request.auth.apikey?.key] = request.auth.apikey?.value;
}
if (request.auth.apikey?.placement === 'queryparams') {
if (axiosRequest.url && request.auth.apikey?.key) {
try {
const urlObj = new URL(request.url);
urlObj.searchParams.set(request.auth.apikey?.key, request.auth.apikey?.value);
axiosRequest.url = urlObj.toString();
} catch (error) {
console.error('Invalid URL:', request.url, error);
}
}
}
}
}
request.body = request.body || {};

View File

@@ -22,17 +22,17 @@ const path = require('path');
const { parseDataFromResponse } = require('../utils/common');
const { getCookieStringForUrl, saveCookies, shouldUseCookies } = require('../utils/cookies');
const { createFormData } = require('../utils/form-data');
const { getOAuth2Token } = require('./oauth2');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor } = require('@usebruno/requests');
const onConsoleLog = (type, args) => {
console[type](...args);
};
const runSingleRequest = async function (
filename,
bruJson,
item,
collectionPath,
runtimeVariables,
envVariables,
@@ -43,14 +43,12 @@ const runSingleRequest = async function (
collection,
runSingleRequestByPathname
) {
const { pathname: itemPathname } = item;
const relativeItemPathname = path.relative(collectionPath, itemPathname);
try {
let request;
let nextRequestName;
let shouldStopRunnerExecution = false;
let item = {
pathname: path.join(collectionPath, filename),
...bruJson
}
request = prepareRequest(item, collection);
request.__bruno__executionMode = 'cli';
@@ -84,7 +82,7 @@ const runSingleRequest = async function (
if (result?.skipRequest) {
return {
test: {
filename: filename
filename: relativeItemPathname
},
request: {
method: request.method,
@@ -98,7 +96,8 @@ const runSingleRequest = async function (
data: null,
responseTime: 0
},
error: 'Request has been skipped from pre-request script',
error: null,
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
@@ -305,6 +304,33 @@ const runSingleRequest = async function (
}
}
// Handle OAuth2 authentication
if (request.oauth2) {
try {
const token = await getOAuth2Token(request.oauth2);
if (token) {
const { tokenPlacement = 'header', tokenHeaderPrefix = 'Bearer', tokenQueryKey = 'access_token' } = request.oauth2;
if (tokenPlacement === 'header') {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${token}`;
} else if (tokenPlacement === 'url') {
try {
const url = new URL(request.url);
url.searchParams.set(tokenQueryKey, token);
request.url = url.toString();
} catch (error) {
console.error('Error applying OAuth2 token to URL:', error.message);
}
}
}
} catch (error) {
console.error('OAuth2 token fetch error:', error.message);
}
// Remove oauth2 config from request to prevent it from being sent
delete request.oauth2;
}
let response, responseTime;
try {
@@ -333,6 +359,11 @@ const runSingleRequest = async function (
delete request.awsv4config;
}
if (request.digestConfig) {
addDigestInterceptor(axiosInstance, request);
delete request.digestConfig;
}
/** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request);
@@ -357,10 +388,10 @@ const runSingleRequest = async function (
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
} else {
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`));
return {
test: {
filename: filename
filename: relativeItemPathname
},
request: {
method: request.method,
@@ -369,13 +400,14 @@ const runSingleRequest = async function (
data: request.data
},
response: {
status: null,
status: 'error',
statusText: null,
headers: null,
data: null,
responseTime: 0
},
error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!',
status: 'error',
assertionResults: [],
testResults: [],
nextRequestName: nextRequestName,
@@ -387,12 +419,12 @@ const runSingleRequest = async function (
response.responseTime = responseTime;
console.log(
chalk.green(stripExtension(filename)) +
chalk.green(stripExtension(relativeItemPathname)) +
chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`)
);
// run post-response vars
const postResponseVars = get(bruJson, 'request.vars.res');
const postResponseVars = get(item, 'request.vars.res');
if (postResponseVars?.length) {
const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
varsRuntime.runPostResponseVars(
@@ -433,7 +465,7 @@ const runSingleRequest = async function (
// run assertions
let assertionResults = [];
const assertions = get(bruJson, 'request.assertions');
const assertions = get(item, 'request.assertions');
if (assertions) {
const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
assertionResults = assertRuntime.runAssertions(
@@ -495,7 +527,7 @@ const runSingleRequest = async function (
return {
test: {
filename: filename
filename: relativeItemPathname
},
request: {
method: request.method,
@@ -511,16 +543,17 @@ const runSingleRequest = async function (
responseTime
},
error: null,
status: 'pass',
assertionResults,
testResults,
nextRequestName: nextRequestName,
shouldStopRunnerExecution
};
} catch (err) {
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`));
return {
test: {
filename: filename
filename: relativeItemPathname
},
request: {
method: null,
@@ -529,12 +562,13 @@ const runSingleRequest = async function (
data: null
},
response: {
status: null,
status: 'error',
statusText: null,
headers: null,
data: null,
responseTime: 0
},
status: 'error',
error: err.message,
assertionResults: [],
testResults: []

View File

@@ -0,0 +1,22 @@
// In-memory token store implementation for OAuth2 tokens
const tokenStore = {
tokens: new Map(),
// Save a token with optional expiry information
async saveToken(serviceId, account, token) {
this.tokens.set(`${serviceId}:${account}`, token);
return true;
},
// Get a token
async getToken(serviceId, account) {
return this.tokens.get(`${serviceId}:${account}`);
},
// Delete a token
async deleteToken(serviceId, account) {
return this.tokens.delete(`${serviceId}:${account}`);
}
};
module.exports = tokenStore;

View File

@@ -15,6 +15,17 @@ const collectionBruToJson = (bru) => {
}
};
// add meta if it exists
// this is only for folder bru file
// in the future, all of this will be replaced by standard bru lang
const sequence = _.get(json, 'meta.seq');
if (json?.meta) {
transformedJson.meta = {
name: json.meta.name,
seq: !isNaN(sequence) ? Number(sequence) : 1
};
}
return transformedJson;
} catch (error) {
return Promise.reject(error);

View File

@@ -1,5 +1,115 @@
const { get, each, find, compact } = require('lodash');
const os = require('os');
const fs = require('fs');
const path = require('path');
const { jsonToBruV2, envJsonToBruV2, jsonToCollectionBru } = require('@usebruno/lang');
const { sanitizeName } = require('./filesystem');
const { bruToJson, collectionBruToJson } = require('./bru');
const constants = require('../constants');
const chalk = require('chalk');
const createCollectionJsonFromPathname = (collectionPath) => {
const environmentsPath = path.join(collectionPath, `environments`);
// get the collection bruno json config [<collection-path>/bruno.json]
const brunoConfig = getCollectionBrunoJsonConfig(collectionPath);
// get the collection root [<collection-path>/collection.bru]
const collectionRoot = getCollectionRoot(collectionPath);
// get the collection items recursively
const traverse = (currentPath) => {
const filesInCurrentDir = fs.readdirSync(currentPath);
if (currentPath.includes('node_modules')) {
return;
}
const currentDirItems = [];
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (stats.isDirectory()) {
if (filePath === environmentsPath) continue;
if (filePath.startsWith('.git') || filePath.startsWith('node_modules')) continue;
// get the folder root
let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) }
const folderBruJson = getFolderRoot(filePath);
if (folderBruJson) {
folderItem.root = folderBruJson;
folderItem.seq = folderBruJson.meta.seq;
}
currentDirItems.push(folderItem);
}
else {
if (['collection.bru', 'folder.bru'].includes(file)) continue;
if (path.extname(filePath) !== '.bru') continue;
// get the request item
const bruContent = fs.readFileSync(filePath, 'utf8');
const requestItem = bruToJson(bruContent);
currentDirItems.push({
name: file,
pathname: filePath,
...requestItem
});
}
}
let currentDirFolderItems = currentDirItems?.filter((iter) => iter.type === 'folder');
let sortedFolderItems = currentDirFolderItems?.sort((a, b) => a.seq - b.seq);
let currentDirRequestItems = currentDirItems?.filter((iter) => iter.type !== 'folder');
let sortedRequestItems = currentDirRequestItems?.sort((a, b) => a.seq - b.seq);
return sortedFolderItems?.concat(sortedRequestItems);
};
let collectionItems = traverse(collectionPath);
let collection = {
brunoConfig,
root: collectionRoot,
pathname: collectionPath,
items: collectionItems
}
return collection;
};
const getCollectionBrunoJsonConfig = (dir) => {
// right now, bru must be run from the root of the collection
// will add support in the future to run it from anywhere inside the collection
const brunoJsonPath = path.join(dir, 'bruno.json');
const brunoJsonExists = fs.existsSync(brunoJsonPath);
if (!brunoJsonExists) {
console.error(chalk.red(`You can run only at the root of a collection`));
process.exit(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
}
const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
const brunoConfig = JSON.parse(brunoConfigFile);
return brunoConfig;
}
const getCollectionRoot = (dir) => {
const collectionRootPath = path.join(dir, 'collection.bru');
const exists = fs.existsSync(collectionRootPath);
if (!exists) {
return {};
}
const content = fs.readFileSync(collectionRootPath, 'utf8');
return collectionBruToJson(content);
};
const getFolderRoot = (dir) => {
const folderRootPath = path.join(dir, 'folder.bru');
const exists = fs.existsSync(folderRootPath);
if (!exists) {
return null;
}
const content = fs.readFileSync(folderRootPath, 'utf8');
return collectionBruToJson(content);
};
const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
@@ -200,10 +310,184 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
return path;
};
const mergeAuth = (collection, request, requestTreePath) => {
let collectionAuth = collection?.root?.request?.auth || { mode: 'none' };
let effectiveAuth = collectionAuth;
for (let i of requestTreePath) {
if (i.type === 'folder') {
const folderAuth = i?.root?.request?.auth;
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveAuth = folderAuth;
}
}
}
if (request.auth && request.auth.mode === 'inherit') {
request.auth = effectiveAuth;
}
}
const getAllRequestsInFolder = (folderItems = [], recursive = true) => {
let requests = [];
if (folderItems && folderItems.length) {
folderItems.forEach((item) => {
if (item.type !== 'folder') {
requests.push(item);
} else {
if (recursive) {
requests = requests.concat(getAllRequestsInFolder(item.items, recursive));
}
}
});
}
return requests;
};
const getAllRequestsAtFolderRoot = (folderItems = []) => {
return getAllRequestsInFolder(folderItems, false);
}
/**
* Safe write file implementation to handle errors
* @param {string} filePath - Path to write file
* @param {string} content - Content to write
*/
const safeWriteFileSync = (filePath, content) => {
try {
fs.writeFileSync(filePath, content, { encoding: 'utf8' });
} catch (error) {
console.error(`Error writing file ${filePath}:`, error);
}
};
/**
* Creates a Bruno collection directory structure from a Bruno collection object
*
* @param {Object} collection - The Bruno collection object
* @param {string} dirPath - The output directory path
*/
const createCollectionFromBrunoObject = async (collection, dirPath) => {
// Create bruno.json
const brunoConfig = {
version: '1',
name: collection.name,
type: 'collection',
ignore: ['node_modules', '.git']
};
fs.writeFileSync(
path.join(dirPath, 'bruno.json'),
JSON.stringify(brunoConfig, null, 2)
);
// Create collection.bru if root exists
if (collection.root) {
const collectionContent = await jsonToCollectionBru(collection.root);
fs.writeFileSync(path.join(dirPath, 'collection.bru'), collectionContent);
}
// Process environments
if (collection.environments && collection.environments.length) {
const envDirPath = path.join(dirPath, 'environments');
fs.mkdirSync(envDirPath, { recursive: true });
for (const env of collection.environments) {
const content = await envJsonToBruV2(env);
const filename = sanitizeName(`${env.name}.bru`);
fs.writeFileSync(path.join(envDirPath, filename), content);
}
}
// Process collection items
await processCollectionItems(collection.items, dirPath);
return dirPath;
};
/**
* Recursively processes collection items to create files and folders
*
* @param {Array} items - Collection items
* @param {string} currentPath - Current directory path
*/
const processCollectionItems = async (items = [], currentPath) => {
for (const item of items) {
if (item.type === 'folder') {
// Create folder
let sanitizedFolderName = sanitizeName(item?.filename || item?.name);
const folderPath = path.join(currentPath, sanitizedFolderName);
fs.mkdirSync(folderPath, { recursive: true });
// Create folder.bru file if root exists
if (item?.root?.meta?.name) {
const folderBruFilePath = path.join(folderPath, 'folder.bru');
if (item.seq) {
item.root.meta.seq = item.seq;
}
const folderContent = await jsonToCollectionBru(
item.root,
true
);
safeWriteFileSync(folderBruFilePath, folderContent);
}
// Process folder items recursively
if (item.items && item.items.length) {
await processCollectionItems(item.items, folderPath);
}
} else if (['http-request', 'graphql-request'].includes(item.type)) {
// Create request file
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.bru`);
if (!sanitizedFilename.endsWith('.bru')) {
sanitizedFilename += '.bru';
}
// Convert JSON to BRU format based on the item type
let type = item.type === 'http-request' ? 'http' : 'graphql';
const bruJson = {
meta: {
name: item.name,
type: type,
seq: typeof item.seq === 'number' ? item.seq : 1
},
http: {
method: (item.request?.method || 'GET').toLowerCase(),
url: item.request?.url || '',
auth: item.request?.auth?.mode || 'none',
body: item.request?.body?.mode || 'none'
},
params: item.request?.params || [],
headers: item.request?.headers || [],
auth: item.request?.auth || {},
body: item.request?.body || {},
script: item.request?.script || {},
vars: {
req: item.request?.vars?.req || [],
res: item.request?.vars?.res || []
},
assertions: item.request?.assertions || [],
tests: item.request?.tests || '',
docs: item.request?.docs || ''
};
// Convert to BRU format and write to file
const content = await jsonToBruV2(bruJson);
safeWriteFileSync(path.join(currentPath, sanitizedFilename), content);
}
}
};
module.exports = {
createCollectionJsonFromPathname,
mergeHeaders,
mergeVars,
mergeScripts,
findItemInCollection,
getTreePathFromCollectionToItem
getTreePathFromCollectionToItem,
createCollectionFromBrunoObject,
mergeAuth,
getAllRequestsInFolder,
getAllRequestsAtFolderRoot
}

View File

@@ -118,6 +118,46 @@ const getSubDirectories = (dir) => {
}
};
/**
* Sanitizes a filename to make it safe for filesystem operations
*
* @param {string} name - The name to sanitize
* @returns {string} - The sanitized name
*/
const sanitizeName = (name) => {
if (!name) return '';
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g;
return name
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[.\s-]+/, '') // remove leading dots, hyphens and spaces
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
};
/**
* Validates if a name is valid for the filesystem
*
* @param {string} name - The name to validate
* @returns {boolean} - True if the name is valid, false otherwise
*/
const validateName = (name) => {
if (!name) return false;
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
const lastCharacter = /[^.\s]$/; // no dot or space at end, hyphen allowed
if (name.length > 255) return false; // max name length
if (reservedDeviceNames.test(name)) return false; // windows reserved names
return (
firstCharacter.test(name) &&
middleCharacters.test(name) &&
lastCharacter.test(name)
);
};
module.exports = {
exists,
isSymbolicLink,
@@ -131,5 +171,7 @@ module.exports = {
searchForFiles,
searchForBruFiles,
stripExtension,
getSubDirectories
getSubDirectories,
sanitizeName,
validateName
};

View File

@@ -1,67 +0,0 @@
const { describe, it, expect } = require('@jest/globals');
const { printRunSummary } = require('../../src/commands/run');
describe('printRunSummary', () => {
// Suppress console.log output
jest.spyOn(console, 'log').mockImplementation(() => {});
it('should produce the correct summary for a successful run', () => {
const results = [
{
testResults: [{ status: 'pass' }, { status: 'pass' }, { status: 'pass' }],
assertionResults: [{ status: 'pass' }, { status: 'pass' }],
error: null
},
{
testResults: [{ status: 'pass' }, { status: 'pass' }],
assertionResults: [{ status: 'pass' }, { status: 'pass' }, { status: 'pass' }],
error: null
}
];
const summary = printRunSummary(results);
expect(summary.totalRequests).toBe(2);
expect(summary.passedRequests).toBe(2);
expect(summary.failedRequests).toBe(0);
expect(summary.totalAssertions).toBe(5);
expect(summary.passedAssertions).toBe(5);
expect(summary.failedAssertions).toBe(0);
expect(summary.totalTests).toBe(5);
expect(summary.passedTests).toBe(5);
expect(summary.failedTests).toBe(0);
});
it('should produce the correct summary for a failed run', () => {
const results = [
{
testResults: [{ status: 'fail' }, { status: 'pass' }, { status: 'pass' }],
assertionResults: [{ status: 'pass' }, { status: 'fail' }],
error: null
},
{
testResults: [{ status: 'pass' }, { status: 'fail' }],
assertionResults: [{ status: 'pass' }, { status: 'fail' }, { status: 'fail' }],
error: null
},
{
testResults: [],
assertionResults: [],
error: new Error('Request failed')
}
];
const summary = printRunSummary(results);
expect(summary.totalRequests).toBe(3);
expect(summary.passedRequests).toBe(2);
expect(summary.failedRequests).toBe(1);
expect(summary.totalAssertions).toBe(5);
expect(summary.passedAssertions).toBe(2);
expect(summary.failedAssertions).toBe(3);
expect(summary.totalTests).toBe(5);
expect(summary.passedTests).toBe(3);
expect(summary.failedTests).toBe(2);
});
});

View File

@@ -1,81 +0,0 @@
const { describe, it, expect } = require('@jest/globals');
const fs = require('fs');
const makeHtmlOutput = require('../../src/reporters/html');
describe('makeHtmlOutput', () => {
beforeEach(() => {
jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should produce an html report', () => {
const outputJson = {
summary: {
totalRequests: 1,
passedRequests: 1,
failedRequests: 1,
totalAssertions: 1,
passedAssertions: 1,
failedAssertions: 1,
totalTests: 1,
passedTests: 1,
failedTests: 1
},
results: [
{
description: 'description provided',
suitename: 'Tests/Suite A',
request: {
method: 'GET',
url: 'https://ima.test'
},
assertionResults: [
{
lhsExpr: 'res.status',
rhsExpr: 'eq 200',
status: 'pass'
},
{
lhsExpr: 'res.status',
rhsExpr: 'neq 200',
status: 'fail',
error: 'expected 200 to not equal 200'
}
],
runtime: 1.2345678
},
{
request: {
method: 'GET',
url: 'https://imanother.test'
},
suitename: 'Tests/Suite B',
testResults: [
{
lhsExpr: 'res.status',
rhsExpr: 'eq 200',
description: 'A test that passes',
status: 'pass'
},
{
description: 'A test that fails',
status: 'fail',
error: 'expected 200 to not equal 200',
status: 'fail'
}
],
runtime: 2.3456789
}
]
};
makeHtmlOutput(outputJson, '/tmp/testfile.html');
const htmlReport = fs.writeFileSync.mock.calls[0][1];
expect(htmlReport).toContain(JSON.stringify(outputJson, null, 2));
});
});

View File

@@ -0,0 +1,172 @@
const path = require("node:path");
const { describe, it, expect } = require('@jest/globals');
const constants = require('../../src/constants');
const { createCollectionJsonFromPathname } = require('../../src/utils/collection');
describe('create collection json from pathname', () => {
it("should throw an error when the pathname is not a valid bruno collection root", () => {
const invalidCollectionPathname = path.join(__dirname, './fixtures/collection-invalid');
jest.spyOn(console, 'error').mockImplementation(() => { });
let mockProcessExit = jest.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(code); });
try { createCollectionJsonFromPathname(invalidCollectionPathname); } catch { }
expect(mockProcessExit).toHaveBeenCalledWith(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
jest.restoreAllMocks();
})
it("creates a bruno collection json from the collection bru files", () => {
const collectionPathname = path.join(__dirname, './fixtures/collection-json-from-pathname/collection');
const outputCollectionJson = createCollectionJsonFromPathname(collectionPathname);
let c = outputCollectionJson;
expect(c).toBeDefined();
/* collection bruno.json */
expect(c).toHaveProperty('brunoConfig.version', "1");
expect(c).toHaveProperty('brunoConfig.name', 'collection');
expect(c).toHaveProperty('brunoConfig.type', 'collection');
expect(c).toHaveProperty('brunoConfig.ignore', ["node_modules", ".git"]);
expect(c).toHaveProperty('brunoConfig.proxy.enabled', false);
expect(c).toHaveProperty('brunoConfig.proxy.protocol', 'http');
expect(c).toHaveProperty('brunoConfig.proxy.hostname', '<proxy-hostname>');
expect(c).toHaveProperty('brunoConfig.proxy.port', 3000);
expect(c).toHaveProperty('brunoConfig.proxy.auth.enabled', false);
expect(c).toHaveProperty('brunoConfig.proxy.auth.username', '<user-name>');
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', []);
/* collection pathname */
expect(c).toHaveProperty('pathname', collectionPathname);
/* collection root */
// headers
expect(c).toHaveProperty('root.request.headers[0].name', 'collection_header');
expect(c).toHaveProperty('root.request.headers[0].value', 'collection_header_value');
expect(c).toHaveProperty('root.request.headers[0].enabled', true);
// auth
expect(c).toHaveProperty('root.request.auth.mode', 'basic');
expect(c).toHaveProperty('root.request.auth.basic.username', 'username');
expect(c).toHaveProperty('root.request.auth.basic.password', 'password');
// pre-request scripts
expect(c).toHaveProperty('root.request.script.req', 'const collectionPreRequestScript = true;');
// collection root - post-response scripts
expect(c).toHaveProperty('root.request.script.res', 'const collectionPostResponseScript = true;');
// pre-request vars
expect(c).toHaveProperty('root.request.vars.req[0].name', 'collection_pre_var');
expect(c).toHaveProperty('root.request.vars.req[0].value', 'collection_pre_var_value');
expect(c).toHaveProperty('root.request.vars.req[0].enabled', true);
// post-response vars
expect(c).toHaveProperty('root.request.vars.res[0].name', 'collection_post_var');
expect(c).toHaveProperty('root.request.vars.res[0].value', 'collection_post_var_value');
expect(c).toHaveProperty('root.request.vars.res[0].enabled', true);
// tests
expect(c).toHaveProperty('root.request.tests', 'test(\"collection level script\", function() {\n expect(\"test\").to.equal(\"test\");\n});');
/* collection items names and sequences */
// <collection-root>/folder_2
expect(c).toHaveProperty('items[0].type', 'folder');
expect(c).toHaveProperty('items[0].name', 'folder_2');
expect(c).toHaveProperty('items[0].seq', 1);
// <collection-root>/folder_2/request_1
expect(c).toHaveProperty('items[0].items[0].name', 'request_1');
expect(c).toHaveProperty('items[0].items[0].seq', 1);
// <collection-root>/folder_2/request_3
expect(c).toHaveProperty('items[0].items[1].name', 'request_3');
expect(c).toHaveProperty('items[0].items[1].seq', 2);
// <collection-root>/folder_2/request_2
expect(c).toHaveProperty('items[0].items[2].name', 'request_2');
expect(c).toHaveProperty('items[0].items[2].seq', 3);
// <collection-root>/folder_1
expect(c).toHaveProperty('items[1].type', 'folder');
expect(c).toHaveProperty('items[1].name', 'folder_1');
expect(c).toHaveProperty('items[1].seq', 5);
// <collection-root>/folder_1/folder_2
expect(c).toHaveProperty('items[1].items[0].name', 'folder_2');
expect(c).toHaveProperty('items[1].items[0].seq', 1);
// <collection-root>/folder_1/folder_2/request_3
expect(c).toHaveProperty('items[1].items[0].items[0].name', 'request_3');
expect(c).toHaveProperty('items[1].items[0].items[0].seq', 1);
// <collection-root>/folder_1/folder_2/request_1
expect(c).toHaveProperty('items[1].items[0].items[1].name', 'request_1');
expect(c).toHaveProperty('items[1].items[0].items[1].seq', 2);
// <collection-root>/folder_1/folder_2/request_2
expect(c).toHaveProperty('items[1].items[0].items[2].name', 'request_2');
expect(c).toHaveProperty('items[1].items[0].items[2].seq', 3);
// <collection-root>/folder_1/folder_1
expect(c).toHaveProperty('items[1].items[1].name', 'folder_1');
expect(c).toHaveProperty('items[1].items[1].seq', 2);
// <collection-root>/folder_1/folder_1/request_3
expect(c).toHaveProperty('items[1].items[1].items[0].name', 'request_3');
expect(c).toHaveProperty('items[1].items[1].items[0].seq', 1);
// <collection-root>/folder_1/folder_1/request_2
expect(c).toHaveProperty('items[1].items[1].items[1].name', 'request_2');
expect(c).toHaveProperty('items[1].items[1].items[1].seq', 2);
// <collection-root>/folder_1/folder_1/request_1
expect(c).toHaveProperty('items[1].items[1].items[2].name', 'request_1');
expect(c).toHaveProperty('items[1].items[1].items[2].seq', 3);
// <collection-root>/folder_1/request_1
expect(c).toHaveProperty('items[1].items[2].name', 'request_1');
expect(c).toHaveProperty('items[1].items[2].seq', 3);
// <collection-root>/folder_1/request_3
expect(c).toHaveProperty('items[1].items[3].name', 'request_3');
expect(c).toHaveProperty('items[1].items[3].seq', 4);
// <collection-root>/folder_1/request_2
expect(c).toHaveProperty('items[1].items[4].name', 'request_2');
expect(c).toHaveProperty('items[1].items[4].seq', 5);
// <collection-root>/request_2
expect(c).toHaveProperty('items[2].name', 'request_3');
expect(c).toHaveProperty('items[2].seq', 2);
// <collection-root>/request_3
expect(c).toHaveProperty('items[3].name', 'request_1');
expect(c).toHaveProperty('items[3].seq', 3);
// <collection-root>/request_4
expect(c).toHaveProperty('items[4].name', 'request_2');
expect(c).toHaveProperty('items[4].seq', 4);
/* collection request item - <collection-root>/request_4 */
// <collection-root>/request_4
// headers
expect(c).toHaveProperty('items[4].request.headers[0].name', 'request_header');
expect(c).toHaveProperty('items[4].request.headers[0].value', 'request_header_value');
expect(c).toHaveProperty('items[4].request.headers[0].enabled', true);
// auth
expect(c).toHaveProperty('items[4].request.auth.mode', 'basic');
expect(c).toHaveProperty('items[4].request.auth.basic.username', 'username');
expect(c).toHaveProperty('items[4].request.auth.basic.password', 'password');
// pre-request scripts
expect(c).toHaveProperty('items[4].request.script.req', 'const requestPreRequestScript = true;');
// request items[4] - post-response scripts
expect(c).toHaveProperty('items[4].request.script.res', 'const requestPostResponseScript = true;');
// pre-request vars
expect(c).toHaveProperty('items[4].request.vars.req[0].name', 'request_pre_var');
expect(c).toHaveProperty('items[4].request.vars.req[0].value', 'request_pre_var_value');
expect(c).toHaveProperty('items[4].request.vars.req[0].enabled', true);
// post-response vars
expect(c).toHaveProperty('items[4].request.vars.res[0].name', 'request_post_var');
expect(c).toHaveProperty('items[4].request.vars.res[0].value', 'request_post_var_value');
expect(c).toHaveProperty('items[4].request.vars.res[0].enabled', true);
// tests
expect(c).toHaveProperty('items[4].request.tests', 'test(\"request level script\", function() {\n expect(\"test\").to.equal(\"test\");\n});');
});
});

View File

@@ -0,0 +1,31 @@
{
"version": "1",
"name": "collection",
"type": "collection",
"ignore": [
"node_modules",
".git"
],
"proxy": {
"enabled": false,
"protocol": "http",
"hostname": "<proxy-hostname>",
"port": 3000,
"auth": {
"enabled": false,
"username": "<user-name>",
"password": "<password>"
},
"bypassProxy": ""
},
"scripts": {
"moduleWhitelist": ["crypto", "buffer"],
"filesystemAccess": {
"allow": true
}
},
"clientCertificates": {
"enabled": true,
"certs": []
}
}

View File

@@ -0,0 +1,38 @@
headers {
collection_header: collection_header_value
}
auth {
mode: basic
}
auth:basic {
username: username
password: password
}
vars:pre-request {
collection_pre_var: collection_pre_var_value
}
vars:post-response {
collection_post_var: collection_post_var_value
}
script:pre-request {
const collectionPreRequestScript = true;
}
script:post-response {
const collectionPostResponseScript = true;
}
tests {
test("collection level script", function() {
expect("test").to.equal("test");
});
}
docs {
# docs
}

View File

@@ -0,0 +1,4 @@
meta {
name: folder_1
seq: 5
}

View File

@@ -0,0 +1,4 @@
meta {
name: folder_1
seq: 2
}

View File

@@ -0,0 +1,11 @@
meta {
name: request_1
type: http
seq: 3
}
get {
url: https://echo.usebruno.com
body: text
auth: none
}

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