Compare commits

...

138 Commits

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

---------

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

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

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

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

* sendRequest callback errors handling

* updated tests and added await for the callbacks

---------

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

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

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

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

Co-authored-by: lohit <lohit@usebruno.com>
2025-06-06 01:54:01 +05:30
lohit
9dde2df475 Merge pull request #4661 from devendra-bruno/fix/gql-introspection-variable-interpolation
Added combined Vars for prepareGqlIntrospectionRequest for all interp…
2025-06-05 18:05:45 +05:30
Maintainer Bruno
1cc94e8ffe feat(dev): enhance hot reload development setup 2025-06-04 16:56:22 +05:30
lohit
223f79a3e2 Merge pull request #4694 from usebruno/feature/playwright
Improvements in Playwright setup and added tests for running bruno-testbench
2025-06-04 15:18:34 +05:30
lohit
5dc6f6757d Merge pull request #4765 from lohxt1/single_line_editor_onedit
fix: single line editor component onChange validations update
2025-06-04 14:48:48 +05:30
lohit
e20fe790a6 Merge pull request #4782 from ramki-bruno/fix/proxy-pass-encoding
Fix: Special URI characters in proxy username/password is giving error
2025-06-04 14:48:26 +05:30
ramki-bruno
a006fe8230 Move Playwright tests to Tests Workflow itself
Currently the test-results and annotations form jobs are getting added
to random Workflow and there is no fix for it right now
Ref: https://github.com/EnricoMi/publish-unit-test-result-action/issues/12
Also Playwright tests have the same triggers as Tests, so no need to
keep it separate.
2025-05-30 13:57:44 +05:30
ramki-bruno
577d54b432 Added Playwright test for bruno-testbench, few sanity tests and improvements
- Trace will capture snapshots now
- Added ability to add init Electron user-data, preferences and other
  app settings.
- Improved test Fixtures
  - Use tempdir for Electron user-data
  - Ability to reuse app instance for a given init user-data by placing
    them in a folder(`pageWithUserData` Fixture)
  - Ability to create tests with fresh user-data(`newPage` Fixture)
- Improved logging
- Improved the env vars to customize the Electron user-data-path
2025-05-30 13:57:44 +05:30
maintainer-bruno
afaebf6b3d Merge pull request #4796 from lohxt1/collection_auth_default_values_issue
fix: collection auth default value access fix and validations
2025-05-29 18:28:57 +05:30
lohit
6e89001825 fix: collection auth default value access fix and validations 2025-05-29 17:45:42 +05:30
ramki-bruno
cb611c6510 Fix: Special URI characters in proxy username/password is giving error
URI-encoding the _username_ and _password_ before creating the proxy URI
which then gets passed to `HttpsProxyAgent` and `HttpProxyAgent`
respectively.
2025-05-28 14:45:21 +05:30
lohit
e7dd78ea53 Merge pull request #4775 from lohxt1/axios_instance_redirect_error_fix
fix: return the actual axios error in bruno-cli' axios-instance for url-redirect related errors
2025-05-27 20:27:48 +05:30
lohit
9ad0f2d169 revert custom error messages 2025-05-27 19:40:51 +05:30
lohit
bf19645282 revert test update 2025-05-27 19:40:22 +05:30
lohit
bb01199877 updates 2025-05-27 19:17:05 +05:30
lohit
5627c5624f updates 2025-05-27 19:16:29 +05:30
lohit
8e23a7054f Merge pull request #4770 from lohxt1/error_requests_cli_tests_issue
fix: consider request execution errors as `CLI Tests` workflow failure
2025-05-27 18:49:02 +05:30
lohit
d820069371 return the actual axios error with the custom error message in bruno-cli axios-instance 2025-05-27 18:41:47 +05:30
devendra-bruno
6f9daadcfb Update index.js Removed duplicate variable 2025-05-27 15:44:07 +05:30
lohit
2de9b87c6f consider errored request as a collection run fail 2025-05-27 15:30:54 +05:30
devendra-bruno
8d5d952026 Added runtimeVars in prepareGqlIntrospectionRequest 2025-05-27 14:38:48 +05:30
lohit
178773d63a Merge pull request #4173 from Pragadesh-45/feat/custom-installation-path
Feat/ Custom installation path for GUI installer on Windows
2025-05-27 11:49:29 +05:30
lohit
7994946c85 Merge pull request #4764 from lohxt1/shortcut_key_new_request_issue
fix: new request shortcut key
2025-05-27 11:49:15 +05:30
lohit
b020255269 Merge pull request #4662 from sanjaikumar-bruno/fix/cli-not-following-redirects
feat: enhance axios instance with redirect handling and cookie management in CLI
2025-05-27 11:48:43 +05:30
devendra-bruno
afb2d3dffd Updated resolved variable assignment and testcases 2025-05-26 22:52:37 +05:30
lohit
73b0f0919d Merge pull request #4679 from anusree-bruno/bugfix/timestamp-current-time
fix: ensure timestamp and isoTimestamp return current time instead of random values
2025-05-26 22:29:53 +05:30
Pragadesh-45
8975b9eef6 fix: update Windows build configuration for icon and publisher name 2025-05-26 21:06:01 +05:45
lohit
865e813b42 revert test bru file 2025-05-26 20:45:33 +05:30
lohit
51f36b1903 Merge pull request #4038 from Chriss4123/feature/localhost-secure-context
feat: Add RFC 6761–compliant localhost loopback checks so `secure` cookies work on localhost (fixes: #1676)
2025-05-26 17:16:33 +05:30
Clay Powers
6b122d7262 Switch GraphQL variables code editor to json linting (#4756) 2025-05-26 16:55:11 +05:30
devendra-bruno
9f1aed3209 Refactored fetch-gql-schema-handler.spec.js 2025-05-26 16:42:18 +05:30
devendra-bruno
ce1110bdd4 Added interpolate for header values 2025-05-26 16:39:40 +05:30
devendra-bruno
788569a5f4 Added testcases for prepare-gql-introspection-request.spec.js 2025-05-26 16:39:07 +05:30
devendra-bruno
91397eaf57 Renamed fetchGqlSchema to fetchGqlSchemaHandler 2025-05-26 16:38:09 +05:30
devendra-bruno
c293ceefcf Refactored fetch-gql-schema-handler.spec.js 2025-05-26 16:37:28 +05:30
lohit
a8e5ce9c13 fix: new request shortcut key 2025-05-26 14:58:25 +05:30
anusree-bruno
8ac916b0ff removed unwanted tests 2025-05-26 14:49:21 +05:30
anusree-bruno
8d860a051c replace real time with mocked time in faker tests 2025-05-26 14:43:23 +05:30
lohit
256f63dd38 single line editor comp onChange validations 2025-05-26 10:20:22 +05:30
devendra-bruno
0948964677 Revert changes to common.spec.js 2025-05-26 09:47:43 +05:30
Shruti Shahi
1b52bb27f7 Added Hindi translation of Readme file 2025-05-24 01:52:54 +05:30
Anoop M D
4ac2c4ac34 Merge pull request #4706 from ved-bruno/e2e_support
Playwright: Support Element Verification
2025-05-23 21:11:27 +05:30
maintainer-bruno
7c27193983 chore: add CODEOWNERS file for repository maintenance 2025-05-23 16:57:48 +05:30
ramki-bruno
2c3d2ff6a7 Make Secure-local-cookies work in CLI as well 2025-05-23 13:49:56 +05:30
Chriss4123
a4fff01647 Support Secure cookies for localhost and loopback addresses 2025-05-23 12:35:04 +05:30
sanjai0py
2cd985faf7 Remove test file for redirects with cookies 2025-05-23 08:58:28 +05:30
sanish chirayath
9a35302d4b Feature: implemented bru.interpolate (#4122)
* feat: enhance variable highlighting in CodeMirror and update interpolation method

* feat: add interpolate function to bru shim and corresponding tests

- Implemented the `interpolate` function in the bru shim to handle variable interpolation.
- Added a new test case for the `interpolate` function to verify its functionality with mock variables.

* feat: enhance interpolate function to support object interpolation

* feat: add translation support for pm.variables.replaceIn to bru.interpolate

* revert: eslint config changes

* revert: eslint config changes

* fix: update method call to use correct interpolation function in Bru class

* refactor: added jsdoc to codemirror highlighting code

* fix: higlighting for multiline editor
2025-05-22 15:37:15 +05:30
Pooja
553f7675f2 fix: request timer reset while switching tabs (#4165)
* fix" request timer reset while switching tabs

* fix

* rm: extraReducers

* improve: logic

* fix: pass startTime as prop

* fix

* fix: directly use collection in setRequestStartTime

* rm: reseting start time null
2025-05-22 15:36:26 +05:30
devendra-bruno
3e714ab9f8 Updated handler fetch-gql-schema 2025-05-21 17:54:53 +05:30
devendra-bruno
f2e9a6a502 Added folder level variable support 2025-05-21 17:39:10 +05:30
devendra-bruno
b924e15afa Added testcases for fetch-gql-schema-handler 2025-05-21 17:35:47 +05:30
devendra-bruno
b0c74909ba Updated argument request object for useGraphqlSchema hook 2025-05-21 17:35:17 +05:30
devendra-bruno
548a6b4319 Rename combinedVars to resolvedVars 2025-05-21 17:34:36 +05:30
sanjai0py
b299879b82 Refactor saveCookies function to remove disableCookies parameter and streamline cookie handling in response interceptors 2025-05-21 17:00:22 +05:30
Pooja
3696562414 fix: circular recursion for openapi import (#4729) 2025-05-21 15:10:35 +05:30
devendra-bruno
e02c6c274b Fix/svg render respone panel (#4655)
* Refactor getContentType in utils

* Add testcases for getContentType

* Refactor getContent

* Refactor getContent

* Refactor getContent

* Added testcase of case insensitivity

* Added content-type case in sensitive

* Refactor testcase spec

* Added testcases for empty content-type
2025-05-21 13:45:06 +05:30
devendra-bruno
9c9afaf78f Extracted fetchGqlSchema handler seperate from ipc handler 2025-05-21 06:42:19 +05:30
devendra-bruno
6cde453032 Added test for prepareGqlIntrospectionRequest 2025-05-21 06:41:18 +05:30
devendra-bruno
8f06889996 Remove mergeEnvironmnetVariable method from spec file 2025-05-21 06:40:21 +05:30
devendra-bruno
52662f0766 Updated testcases in prepare-gql-introspection spec 2025-05-19 17:39:39 +05:30
sanjai0py
ab0a4b8140 Add disableCookies option to axios instance and saveCookies function 2025-05-19 15:08:12 +05:30
sanjai0py
1b268ae9db Merge branch 'main' into fix/cli-not-following-redirects 2025-05-19 14:36:54 +05:30
ved-bruno
8debb9fd11 made suggested changes for support element verification 2025-05-19 14:02:34 +05:30
naman-bruno
7c07488e16 Merge pull request #4697 from lohxt1/req_get_name_test
fix: bruno converters test for reg.getName()
2025-05-16 21:52:30 +05:30
lohit
6073a9e2c3 fix bruno converters test for reg.getName() 2025-05-16 21:27:00 +05:30
lohit
9c652f86c9 Merge pull request #4696 from naman-bruno/bugfix/noproxy-option
add: noproxy flag in options
2025-05-16 21:06:29 +05:30
naman-bruno
3c0090d86f fix: runSingleRequest function 2025-05-16 21:02:24 +05:30
naman-bruno
9132755d49 add: noproxy in options 2025-05-16 20:52:15 +05:30
lohit
2a44691cb3 Merge pull request #4590 from poojabela/feat/add-getName-api-for-script
feat: add `req.getName` & `bru.getCollectionName` api
2025-05-16 20:21:35 +05:30
lohit
0d8a696498 Merge pull request #4609 from pooja-bruno/feat/extend-support-for-more-auth-for-folder-level
feat: extend support for more auth in folder level
2025-05-16 20:20:33 +05:30
lohit
bfa2706598 Merge pull request #4366 from Pragadesh-45/fix/import-curl
Feat: Enhance curl parsing for multipart form data
2025-05-16 20:20:18 +05:30
ved-bruno
5fdb52388a support element verification 2025-05-16 19:34:39 +05:30
Pooja
799dc9a1ca feat: add function in bruno converters package (#4669)
* feat: add  function in bruno converters package

* add: example for openapi yaml to bruno conversion

* add: converting json to yaml in converters

* fix
2025-05-16 17:26:40 +05:30
naman-bruno
2bb56e8a4b Fix: properly handling redirects with status code (#4561)
* Fix: properly handling redirects with status code

* fix: updated redirect logic for method change
2025-05-16 17:14:37 +05:30
Pragadesh-45
084d2bf692 test: add unit tests for basic functionality, headers, auth, and form data handling in parseCurlCommand 2025-05-16 14:32:30 +05:45
Pragadesh-45
10640c7561 feat: enhance curl parsing for multipart form data
- Updated `parseCurlCommand` to handle `-F` and `--form` flags, allowing for multiple form fields with file uploads.
- Adjusted `curlToJson` to set `Content-Type` for multipart data and handle binary data correctly.
2025-05-16 14:32:05 +05:45
naman-bruno
9f044c48fe Added proxy flag for cli (#3963)
* system level proxy depends on proxy flag

* added proxy flag

* fix: proxy flag behaviour

* fix: noproxy flag logic
2025-05-16 14:02:11 +05:30
devendra-bruno
5567e1b7f2 Fixed typo in prepareGqlIntrospectionRequest 2025-05-16 00:47:49 +05:30
devendra-bruno
3cd18d1e16 Added testcases for prepare-gql-introspection-request 2025-05-16 00:43:58 +05:30
devendra-bruno
9d3e42b5d4 Update prepareGqlIntrospectionRequest change assignment sequence 2025-05-16 00:43:27 +05:30
devendra-bruno
0f318c26c2 Updated precedence in combinedVars object 2025-05-16 00:42:27 +05:30
Anoop M D
79f4e69a05 Merge pull request #4160 from usebruno/feature/custom-user-data-path-for-dev 2025-05-15 16:18:28 +05:30
devendra-bruno
6598d23ff0 Removed mergeEnvrionmentVariables tests from common.spec.js 2025-05-15 15:57:43 +05:30
devendra-bruno
c83436655c Remove mergeEnvironmnetVariables from common utils 2025-05-15 15:57:00 +05:30
devendra-bruno
62595c519c Added lodash merge for combining vars before interpolateVars 2025-05-15 15:56:30 +05:30
anusree-bruno
d2eb2d2941 fix: ensure timestamp and isoTimestamp return current time instead of random values 2025-05-15 14:11:53 +05:30
pooja-bruno
fbd3a38587 fix 2025-05-14 17:55:50 +05:30
pooja-bruno
45b660985e fix: ui 2025-05-14 17:45:03 +05:30
pooja-bruno
0888125899 add: default auth mode inherit in folder 2025-05-14 16:12:48 +05:30
pooja-bruno
c85d9bcd84 fix: folder inherit auth 2025-05-14 16:01:42 +05:30
devendra-bruno
8e91640084 Added mergeEnvironmentVariables method for gql prep method 2025-05-14 12:25:41 +05:30
devendra-bruno
0ca2891166 Added mergeEnvironmentVariables method in electron common utils export 2025-05-14 12:24:09 +05:30
devendra-bruno
5000bb8db3 Added testcases for mergeEnvironmentVariables method 2025-05-14 12:23:32 +05:30
devendra-bruno
9927424826 Added mergeEnvironmentVariables method in electron common utils 2025-05-14 12:22:39 +05:30
sanjai0py
c14d3f4274 feat: add test case for redirects with cookie authentication 2025-05-14 10:46:14 +05:30
sanjai0py
5a4e33e503 Merge branch 'main' into fix/cli-not-following-redirects 2025-05-13 20:07:29 +05:30
sanjai0py
5649799167 feat: add maxRedirects configuration to runSingleRequest 2025-05-13 20:02:29 +05:30
sanjai0py
0f6da35c0b feat: enhance axios instance with redirect handling and cookie management 2025-05-13 17:27:55 +05:30
devendra-bruno
ad3f5de99a Added combined variable object for gqlIntrospectionRequest 2025-05-13 17:05:37 +05:30
devendra-bruno
2de7ba0d0c Added combined Vars for prepareGqlIntrospectionRequest for all interpolate 2025-05-13 16:06:20 +05:30
pooja-bruno
0d7c94e7e9 add: auth for other 2025-05-06 18:41:40 +05:30
pooja-bruno
9e29821012 feat: extend support for more auth in folder level 2025-05-06 17:56:34 +05:30
poojabela
e0fb379511 add: bru.collectionName api 2025-04-30 17:25:42 +05:30
poojabela
ba9362ccb2 add: getName in collection 2025-04-30 15:36:44 +05:30
poojabela
261a36c435 add: getName in hint 2025-04-30 15:29:10 +05:30
poojabela
cb92e46f8d feat: add req.getName api 2025-04-30 15:14:36 +05:30
Jonathan Perlman
b5861dae39 Fix Digest auth header field key value extraction 2025-04-15 14:31:08 -04:00
Pragadesh-45
f6ab59ceda feat: update Windows build configuration to support custom installation path from GUI installer 2025-03-06 17:40:15 +05:30
Pragadesh-45
f1004e2e36 Merge branch 'main' of https://github.com/Pragadesh-45/bruno 2025-03-06 00:27:18 +05:30
Pragadesh-45
26eaec4c72 Merge branch 'usebruno:main' into main 2025-02-07 10:01:15 +05:30
Pragadesh-45
d0419edb92 fix: correct variable used in collection name update 2025-02-04 17:49:40 +05:30
141 changed files with 8922 additions and 950 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @helloanoop @maintainer-bruno @lohit-bruno @naman-bruno

View File

@@ -1,44 +0,0 @@
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

@@ -91,5 +91,47 @@ jobs:
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
check_name: CLI Test Results
files: packages/bruno-tests/collection/junit.xml
comment_mode: always
e2e-test:
name: Playwright E2E Tests
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: Install dependencies for test collection environment
run: |
npm ci --prefix packages/bruno-tests/collection
- 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

@@ -99,14 +99,13 @@ npm run dev
```
#### Customize Electron `userData` path
If `ELECTRON_APP_NAME` env-variable is present and its development mode, then the `appName` and `userData` path is modified accordingly.
If `ELECTRON_USER_DATA_PATH` env-variable is present and its development mode, then `userData` path is modified accordingly.
e.g.
```sh
ELECTRON_APP_NAME=bruno-dev npm run dev:electron
ELECTRON_USER_DATA_PATH=$(realpath ~/Desktop/bruno-test) npm run dev:electron
```
> This doesn't change the name of the window or the names in lot of other places, only the name used by Electron internally.
This will create a `bruno-test` folder on your Desktop and use it as the `userData` path.
### Troubleshooting

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

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

View File

@@ -0,0 +1,5 @@
import { test, expect } from '../../playwright';
test('Check if the logo on top left is visible', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});

View File

@@ -0,0 +1,31 @@
import { test, expect } from '../../playwright';
test('Create new collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Name').press('Tab');
await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByText('test-collection').click();
await page.getByLabel('Safe ModeBETA').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('r1');
await page.getByPlaceholder('Request URL').click();
await page.getByPlaceholder('Request URL').fill('http://localhost:8081');
await page.getByRole('button', { name: 'Create' }).click();
await page.locator('pre').filter({ hasText: 'http://localhost:' }).click();
await page.locator('textarea').fill('/ping');
await page.locator('#send-request').getByRole('img').nth(2).click();
await expect(page.getByRole('main')).toContainText('200 OK');
await page.getByRole('tab', { name: 'GET r1' }).locator('circle').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByText('GETr1').click();
await page.getByRole('button', { name: 'Clear response' }).click();
await page.locator('body').press('ControlOrMeta+Enter');
await expect(page.getByRole('main')).toContainText('200 OK');
});

View File

@@ -0,0 +1,4 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/packages/bruno-tests/collection"]
}

View File

@@ -0,0 +1,49 @@
import { test, expect } from '../../playwright';
test.describe.parallel('Run Testbench Requests', () => {
test('Run bruno-testbench in Developer Mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await page.getByText('bruno-testbench').click();
await page.getByLabel('Developer Mode(use only if').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const [totalRequests, passed, failed, skipped] = result
.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/)
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
test.fixme('Run bruno-testbench in Safe Mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await page.getByText('bruno-testbench').click();
await page.getByLabel('Safe ModeBETA').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const [totalRequests, passed, failed, skipped] = result
.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/)
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
});

View File

@@ -0,0 +1,27 @@
import { test, expect } from '../../playwright';
test('Should verify all support links with correct URL in preference > Support tab', async ({ page }) => {
// Open Preferences
await page.getByLabel('Open Preferences').click();
// Verify Support tab
await page.getByRole('tab', { name: 'Support' }).click();
const locator_twitter = page.getByRole('link', { name: 'Twitter' });
expect(await locator_twitter.getAttribute('href')).toEqual('https://twitter.com/use_bruno');
const locator_github = page.getByRole('link', { name: 'GitHub', exact: true });
expect(await locator_github.getAttribute('href')).toEqual('https://github.com/usebruno/bruno');
const locator_discord = page.getByRole('link', { name: 'Discord', exact: true });
expect(await locator_discord.getAttribute('href')).toEqual('https://discord.com/invite/KgcZUncpjq');
const locator_reportissues = page.getByRole('link', { name: 'Report Issues', exact: true });
expect(await locator_reportissues.getAttribute('href')).toEqual('https://github.com/usebruno/bruno/issues');
const locator_documentation = page.getByRole('link', { name: 'Documentation', exact: true });
expect(await locator_documentation.getAttribute('href')).toEqual('https://docs.usebruno.com');
});

View File

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

View File

@@ -38,4 +38,4 @@ module.exports = defineConfig([
"no-undef": "error",
},
}
]);
]);

3004
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@
"setup": "node ./scripts/setup.js",
"watch:converters": "npm run watch --workspace=packages/bruno-converters",
"dev": "concurrently --kill-others \"npm run dev:web\" \"npm run dev:electron\"",
"dev:watch": "node ./scripts/dev-hot-reload.js",
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",
@@ -71,4 +72,4 @@
}
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -58,6 +58,7 @@ if (!SERVER_RENDERED) {
'req.getTimeout()',
'req.setTimeout(timeout)',
'req.getExecutionMode()',
'req.getName()',
'bru',
'bru.cwd()',
'bru.getEnvName()',
@@ -80,12 +81,14 @@ if (!SERVER_RENDERED) {
'bru.getAssertionResults()',
'bru.getTestResults()',
'bru.sleep(ms)',
'bru.getCollectionName()',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)',
'bru.runner',
'bru.runner.setNextRequest(requestName)',
'bru.runner.skipRequest()',
'bru.runner.stopExecution()'
'bru.runner.stopExecution()',
'bru.interpolate(str)'
];
CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
const cursor = editor.getCursor();
@@ -363,7 +366,7 @@ export default class CodeEditor extends React.Component {
let variables = getAllVariables(this.props.collection, this.props.item);
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, mode);
defineCodeMirrorBrunoVariablesMode(variables, mode, false, this.props.enableVariableHighlighting);
this.editor.setOption('mode', 'brunovariables');
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ const CollectionSettings = ({ collection }) => {
const requestVars = get(collection, 'root.request.vars.req', []);
const responseVars = get(collection, 'root.request.vars.res', []);
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const auth = get(collection, 'root.request.auth', {}).mode;
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
@@ -155,7 +155,7 @@ const CollectionSettings = ({ collection }) => {
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{auth !== 'none' && <ContentIndicator />}
{authMode !== 'none' && <ContentIndicator />}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script

View File

@@ -11,6 +11,12 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.inherit-mode-text {
color: ${(props) => props.theme.colors.text.yellow};
}
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default Wrapper;

View File

@@ -9,6 +9,14 @@ import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/Passwo
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
import AuthMode from '../AuthMode';
import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index';
const GrantTypeComponentMap = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -37,12 +45,132 @@ const Auth = ({ collection, folder }) => {
let request = get(folder, 'root.request', {});
const authMode = get(folder, 'root.request.auth.mode');
const getTreePathFromCollectionToFolder = (collection, _folder) => {
let path = [];
let item = findItemInCollection(collection, _folder?.uid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Get path from collection to current folder
const folderTreePath = getTreePathFromCollectionToFolder(collection, folder);
// Check parent folders to find closest auth configuration
// Skip the last item which is the current folder
for (let i = 0; i < folderTreePath.length - 1; i++) {
const parentFolder = folderTreePath[i];
if (parentFolder.type === 'folder') {
const folderAuth = get(parentFolder, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: parentFolder.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const handleSave = () => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const getAuthView = () => {
switch (authMode) {
case 'basic': {
return (
<BasicAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'bearer': {
return (
<BearerAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'digest': {
return (
<DigestAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'ntlm': {
return (
<NTLMAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'wsse': {
return (
<WsseAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'apikey': {
return (
<ApiKeyAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'awsv4': {
return (
<AwsV4Auth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'oauth2': {
return (
<>
@@ -56,6 +184,17 @@ const Auth = ({ collection, folder }) => {
</>
);
}
case 'inherit': {
const source = getEffectiveAuthSource();
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
</>
);
}
case 'none': {
return null;
}
@@ -64,6 +203,7 @@ const Auth = ({ collection, folder }) => {
}
};
return (
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">

View File

@@ -35,6 +35,51 @@ const AuthMode = ({ collection, folder }) => {
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('awsv4');
}}
>
AWS Sig v4
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('basic');
}}
>
Basic Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('bearer');
}}
>
Bearer Token
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('digest');
}}
>
Digest Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('ntlm');
}}
>
NTLM Auth
</div>
<div
className="dropdown-item"
onClick={() => {
@@ -44,6 +89,33 @@ const AuthMode = ({ collection, folder }) => {
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('inherit');
}}
>
Inherit
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@@ -130,7 +130,7 @@ class MultiLineEditor extends Component {
addOverlay = (variables) => {
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain');
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', false, true);
this.editor.setOption('mode', 'brunovariables');
};

View File

@@ -5,21 +5,23 @@ import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useTheme } from 'providers/Theme';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
const ApiKeyAuth = ({ item, collection }) => {
const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const apikeyAuth = item.draft ? get(item, 'draft.request.auth.apikey', {}) : get(item, 'request.auth.apikey', {});
const apikeyAuth = get(request, 'auth.apikey', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const Icon = forwardRef((props, ref) => {
return (
@@ -90,7 +92,7 @@ const ApiKeyAuth = ({ item, collection }) => {
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
dropdownTippyRef?.current?.hide();
handleAuthChange('placement', 'header');
}}
>
@@ -99,11 +101,11 @@ const ApiKeyAuth = ({ item, collection }) => {
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
dropdownTippyRef?.current?.hide();
handleAuthChange('placement', 'queryparams');
}}
>
Query Params
Query Param
</div>
</Dropdown>
</div>

View File

@@ -8,14 +8,17 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import { update } from 'lodash';
const AwsV4Auth = ({ onTokenChange, item, collection }) => {
const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const awsv4Auth = item.draft ? get(item, 'draft.request.auth.awsv4', {}) : get(item, 'request.auth.awsv4', {});
const awsv4Auth = get(request, 'auth.awsv4', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleAccessKeyIdChange = (accessKeyId) => {
dispatch(

View File

@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BasicAuth = ({ item, collection }) => {
const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const basicAuth = item.draft ? get(item, 'draft.request.auth.basic', {}) : get(item, 'request.auth.basic', {});
const basicAuth = get(request, 'auth.basic', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(

View File

@@ -7,16 +7,18 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BearerAuth = ({ item, collection }) => {
const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const bearerToken = item.draft
? get(item, 'draft.request.auth.bearer.token', '')
: get(item, 'request.auth.bearer.token', '');
// Use the request prop directly like OAuth2ClientCredentials does
const bearerToken = get(request, 'auth.bearer.token', '');
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleTokenChange = (token) => {
dispatch(

View File

@@ -3,18 +3,20 @@ import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const DigestAuth = ({ item, collection }) => {
const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const digestAuth = item.draft ? get(item, 'draft.request.auth.digest', {}) : get(item, 'request.auth.digest', {});
const digestAuth = get(request, 'auth.digest', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(

View File

@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const NTLMAuth = ({ item, collection }) => {
const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const ntlmAuth = item.draft ? get(item, 'draft.request.auth.ntlm', {}) : get(item, 'request.auth.ntlm', {});
const ntlmAuth = get(request, 'auth.ntlm', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(
@@ -26,7 +29,6 @@ const NTLMAuth = ({ item, collection }) => {
username: username,
password: ntlmAuth.password,
domain: ntlmAuth.domain
}
})
);

View File

@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const WsseAuth = ({ item, collection }) => {
const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wsseAuth = item.draft ? get(item, 'draft.request.auth.wsse', {}) : get(item, 'request.auth.wsse', {});
const wsseAuth = get(request, 'auth.wsse', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUserChange = (username) => {
dispatch(
@@ -55,6 +58,7 @@ const WsseAuth = ({ item, collection }) => {
onChange={(val) => handleUserChange(val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
@@ -67,6 +71,8 @@ const WsseAuth = ({ item, collection }) => {
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
/>
</div>
</StyledWrapper>

View File

@@ -7,6 +7,8 @@ import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import NTLMAuth from './NTLMAuth';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
@@ -27,6 +29,16 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
const Auth = ({ item, collection }) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
// Create a request object to pass to the auth components
const request = item.draft
? get(item, 'draft.request', {})
: get(item, 'request', {});
// Save function for request level
const save = () => {
return saveRequest(item.uid, collection.uid);
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
@@ -59,28 +71,28 @@ const Auth = ({ item, collection }) => {
const getAuthView = () => {
switch (authMode) {
case 'awsv4': {
return <AwsV4Auth collection={collection} item={item} />;
return <AwsV4Auth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'basic': {
return <BasicAuth collection={collection} item={item} />;
return <BasicAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'bearer': {
return <BearerAuth collection={collection} item={item} />;
return <BearerAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'digest': {
return <DigestAuth collection={collection} item={item} />;
return <DigestAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'ntlm': {
return <NTLMAuth collection={collection} item={item} />;
return <NTLMAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} item={item} />;
return <OAuth2 collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'wsse': {
return <WsseAuth collection={collection} item={item} />;
return <WsseAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} />;
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();

View File

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

View File

@@ -64,9 +64,10 @@ const GraphQLVariables = ({ variables, item, collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="javascript"
mode="application/json"
onRun={onRun}
onSave={onSave}
enableVariableHighlighting={true}
/>
</>
);

View File

@@ -49,7 +49,7 @@ const RequestBody = ({ item, collection }) => {
<StyledWrapper className="w-full">
<CodeEditor
collection={collection}
item={item}
item={item}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
@@ -58,13 +58,14 @@ const RequestBody = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
mode={codeMirrorMode[bodyMode]}
enableVariableHighlighting={true}
/>
</StyledWrapper>
);
}
if (bodyMode === 'file') {
return <FileBody item={item} collection={collection}/>
return <FileBody item={item} collection={collection} />;
}
if (bodyMode === 'formUrlEncoded') {
@@ -77,4 +78,4 @@ const RequestBody = ({ item, collection }) => {
return <StyledWrapper className="w-full">No Body</StyledWrapper>;
};
export default RequestBody;
export default RequestBody;

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ const ResponseLoadingOverlay = ({ item, collection }) => {
<div className="overlay">
<div style={{ marginBottom: 15, fontSize: 26 }}>
<div style={{ display: 'inline-block', fontSize: 20, marginLeft: 5, marginRight: 5 }}>
<StopWatch requestTimestamp={item?.requestSent?.timestamp} />
<StopWatch startTime={item?.requestStartTime} />
</div>
</div>
<IconRefresh size={24} className="loading-icon" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,12 +4,60 @@ import CodeView from './CodeView';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url';
import { get } from 'lodash';
import { findEnvironmentInCollection } from 'utils/collections';
import { findEnvironmentInCollection, findItemInCollection, findParentItemInCollection } from 'utils/collections';
import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
const getTreePathFromCollectionToItem = (collection, _itemUid) => {
let path = [];
let item = findItemInCollection(collection, _itemUid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
// Function to resolve inherited auth
const resolveInheritedAuth = (item, collection) => {
const request = item.draft?.request || item.request;
const authMode = request?.auth?.mode;
// If auth is not inherit or no auth defined, return the request as is
if (!authMode || authMode !== 'inherit') {
return {
...request
};
}
// Get the tree path from collection to item
const requestTreePath = getTreePathFromCollectionToItem(collection, item.uid);
// Default to collection auth
const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' });
let effectiveAuth = collectionAuth;
let source = 'collection';
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveAuth = folderAuth;
source = 'folder';
break;
}
}
}
return {
...request,
auth: effectiveAuth
};
};
const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
const languages = getLanguages();
@@ -46,6 +94,9 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params')
);
// Resolve auth inheritance
const resolvedRequest = resolveInheritedAuth(item, collection);
const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
return (
<Modal size="lg" title="Generate Code" handleCancel={onClose} hideFooter={true}>
@@ -94,16 +145,10 @@ const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
language={selectedLanguage}
item={{
...item,
request:
item.request.url !== ''
? {
...item.request,
url: finalUrl
}
: {
...item.draft.request,
url: finalUrl
}
request: {
...resolvedRequest,
url: finalUrl
}
}}
/>
) : (

View File

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

View File

@@ -1,27 +1,24 @@
import React, { useState, useEffect } from 'react';
const StopWatch = () => {
const [milliseconds, setMilliseconds] = useState(0);
const tickInterval = 100;
const tick = () => {
setMilliseconds(_milliseconds => _milliseconds + tickInterval);
};
const StopWatch = ({ startTime }) => {
const [currentTime, setCurrentTime] = useState(Date.now());
useEffect(() => {
let timerID = setInterval(() => {
tick()
}, tickInterval);
return () => {
clearTimeout(timerID);
};
}, []);
if (milliseconds < 250) {
return 'Loading...';
}
let seconds = milliseconds / 1000;
if (!startTime) return;
const intervalId = setInterval(() => {
setCurrentTime(Date.now());
}, 100);
return () => clearInterval(intervalId);
}, [startTime]);
if (!startTime) return <span>Loading...</span>;
const elapsedTime = currentTime - startTime;
if (elapsedTime < 250) return <span>Loading...</span>;
const seconds = elapsedTime / 1000;
return <span>{seconds.toFixed(1)}s</span>;
};

View File

@@ -211,13 +211,15 @@ export const HotkeysProvider = (props) => {
};
}, [activeTabUid, tabs, collections, dispatch]);
const currentCollection = getCurrentCollection();
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showEnvSettingsModal && (
<EnvironmentSettings collection={getCurrentCollection()} onClose={() => setShowEnvSettingsModal(false)} />
<EnvironmentSettings collection={currentCollection} onClose={() => setShowEnvSettingsModal(false)} />
)}
{showNewRequestModal && (
<NewRequest collection={getCurrentCollection()} onClose={() => setShowNewRequestModal(false)} />
<NewRequest collectionUid={currentCollection?.uid} onClose={() => setShowNewRequestModal(false)} />
)}
<div>{props.children}</div>
</HotkeysContext.Provider>

View File

@@ -36,7 +36,8 @@ import {
updateLastAction,
setCollectionSecurityConfig,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl
collectionClearOauth2CredentialsByUrl,
initRunRequestEvent
} from './index';
import { each } from 'lodash';
@@ -220,15 +221,26 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const itemUid = item?.uid;
return new Promise((resolve, reject) => {
return new Promise(async (resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const itemCopy = cloneDeep(item || {});
let collectionCopy = cloneDeep(collection);
const itemCopy = cloneDeep(item);
const requestUid = uuid();
itemCopy.requestUid = requestUid;
await dispatch(initRunRequestEvent({
requestUid,
itemUid,
collectionUid
}));
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
@@ -247,8 +259,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
return dispatch(
responseReceived({
itemUid: item.uid,
collectionUid: collectionUid,
itemUid,
collectionUid,
response: serializedResponse
})
);
@@ -259,8 +271,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
console.log('>> request cancelled');
dispatch(
responseReceived({
itemUid: item.uid,
collectionUid: collectionUid,
itemUid,
collectionUid,
response: null
})
);
@@ -277,8 +289,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
dispatch(
responseReceived({
itemUid: item.uid,
collectionUid: collectionUid,
itemUid,
collectionUid,
response: errorResponse
})
);
@@ -381,7 +393,12 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
meta: {
name: folderName,
seq: items?.length + 1
}
},
request: {
auth: {
mode: 'inherit'
}
}
}
};
ipcRenderer
@@ -417,7 +434,12 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
meta: {
name: folderName,
seq: items?.length + 1
}
},
request: {
auth: {
mode: 'inherit'
}
}
}
};
ipcRenderer

View File

@@ -276,6 +276,8 @@ export const collectionsSlice = createSlice({
if (item) {
item.response = null;
item.cancelTokenUid = null;
item.requestUid = null;
item.requestStartTime = null;
}
}
},
@@ -288,6 +290,7 @@ export const collectionsSlice = createSlice({
item.requestState = 'received';
item.response = action.payload.response;
item.cancelTokenUid = null;
item.requestStartTime = null;
if (!collection.timeline) {
collection.timeline = [];
@@ -1593,6 +1596,27 @@ export const collectionsSlice = createSlice({
case 'oauth2':
set(folder, 'root.request.auth.oauth2', action.payload.content);
break;
case 'basic':
set(folder, 'root.request.auth.basic', action.payload.content);
break;
case 'bearer':
set(folder, 'root.request.auth.bearer', action.payload.content);
break;
case 'digest':
set(folder, 'root.request.auth.digest', action.payload.content);
break;
case 'ntlm':
set(folder, 'root.request.auth.ntlm', action.payload.content);
break;
case 'apikey':
set(folder, 'root.request.auth.apikey', action.payload.content);
break;
case 'awsv4':
set(folder, 'root.request.auth.awsv4', action.payload.content);
break;
case 'wsse':
set(folder, 'root.request.auth.wsse', action.payload.content);
break;
}
}
},
@@ -1933,26 +1957,44 @@ export const collectionsSlice = createSlice({
collection.runnerResult = null;
}
},
initRunRequestEvent: (state, action) => {
const { requestUid, itemUid, collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, itemUid);
if (!item) return;
item.requestState = null;
item.requestUid = requestUid;
item.requestStartTime = Date.now();
},
runRequestEvent: (state, action) => {
const { itemUid, collectionUid, type, requestUid, hasError } = action.payload;
const { itemUid, collectionUid, type, requestUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
// ignore outdated updates in case multiple requests are fired rapidly to avoid state inconsistency
if (item.requestUid !== requestUid) return;
if (type === 'pre-request-script-execution') {
item.requestUid = requestUid;
item.preRequestScriptErrorMessage = action.payload.errorMessage;
}
if(type === 'post-response-script-execution') {
item.requestUid = requestUid;
item.postResponseScriptErrorMessage = action.payload.errorMessage;
}
if(type === 'test-script-execution') {
item.testScriptErrorMessage = action.payload.errorMessage;
}
if (type === 'request-queued') {
const { cancelTokenUid } = action.payload;
item.requestUid = requestUid;
// ignore if request is already in progress or completed
if (['sending', 'received'].includes(item.requestState)) return;
item.requestState = 'queued';
item.cancelTokenUid = cancelTokenUid;
}
@@ -1960,10 +2002,9 @@ export const collectionsSlice = createSlice({
if (type === 'request-sent') {
const { cancelTokenUid, requestSent } = action.payload;
item.requestSent = requestSent;
// sometimes the response is received before the request-sent event arrives
if (item.requestUid === requestUid && item.requestState === 'queued') {
item.requestUid = requestUid;
if (item.requestState === 'queued') {
item.requestState = 'sending';
item.cancelTokenUid = cancelTokenUid;
}
@@ -1978,6 +2019,16 @@ export const collectionsSlice = createSlice({
const { results } = action.payload;
item.testResults = results;
}
if (type === 'test-results-pre-request') {
const { results } = action.payload;
item.preRequestTestResults = results;
}
if (type === 'test-results-post-response') {
const { results } = action.payload;
item.postResponseTestResults = results;
}
}
}
},
@@ -2034,6 +2085,16 @@ export const collectionsSlice = createSlice({
item.testResults = action.payload.testResults;
}
if (type === 'test-results-pre-request') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.preRequestTestResults = action.payload.preRequestTestResults;
}
if (type === 'test-results-post-response') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.postResponseTestResults = action.payload.postResponseTestResults;
}
if (type === 'assertion-results') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.assertionResults = action.payload.assertionResults;
@@ -2165,6 +2226,7 @@ export const collectionsSlice = createSlice({
);
return oauth2Credential;
},
updateFolderAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
@@ -2173,8 +2235,9 @@ export const collectionsSlice = createSlice({
set(folder, 'root.request.auth', {});
set(folder, 'root.request.auth.mode', action.payload.mode);
}
},
}
}
},
});
export const {
@@ -2275,17 +2338,18 @@ export const {
collectionAddEnvFileEvent,
collectionRenamedEvent,
resetRunResults,
initRunRequestEvent,
runRequestEvent,
runFolderEvent,
resetCollectionRunner,
updateRequestDocs,
updateFolderDocs,
moveCollection,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl,
collectionGetOauth2CredentialsByUrl,
updateFolderAuth,
updateFolderAuthMode,
moveCollection
} = collectionsSlice.actions;
export default collectionsSlice.reducer;

View File

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

View File

@@ -74,11 +74,11 @@ export class MaskedEditor {
} else {
for (let line = 0; line < lineCount; line++) {
const lineLength = this.editor.getLine(line).length;
const maskedNode = document.createTextNode('*'.repeat(lineLength));
const maskedNode = document.createTextNode('*'.repeat(lineLength));
this.editor.markText(
{ line, ch: 0 },
{ line, ch: lineLength },
{ replacedWith: maskedNode, handleMouseEvents: false }
{ replacedWith: maskedNode, handleMouseEvents: false }
);
}
}
@@ -86,7 +86,18 @@ export class MaskedEditor {
};
}
export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams) => {
/**
* Defines a custom CodeMirror mode for Bruno variables highlighting.
* This function creates a specialized mode that can highlight both Bruno template
* variables (in the format {{variable}}) and URL path parameters (in the format /:param).
*
* @param {Object} _variables - The variables object containing data to validate against
* @param {string} mode - The base CodeMirror mode to extend (e.g., 'javascript', 'application/json')
* @param {boolean} highlightPathParams - Whether to highlight URL path parameters
* @param {boolean} highlightVariables - Whether to highlight template variables
* @returns {void} - Registers the mode with CodeMirror for later use
*/
export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams, highlightVariables) => {
CodeMirror.defineMode('brunovariables', function (config, parserConfig) {
const { pathParams = {}, ...variables } = _variables || {};
const variablesOverlay = {
@@ -139,13 +150,15 @@ export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPa
}
};
let baseMode = CodeMirror.overlayMode(CodeMirror.getMode(config, parserConfig.backdrop || mode), variablesOverlay);
let baseMode = CodeMirror.getMode(config, parserConfig.backdrop || mode);
if (highlightPathParams) {
return CodeMirror.overlayMode(baseMode, urlPathParamsOverlay);
} else {
return baseMode;
if (highlightVariables) {
baseMode = CodeMirror.overlayMode(baseMode, variablesOverlay);
}
if (highlightPathParams) {
baseMode = CodeMirror.overlayMode(baseMode, urlPathParamsOverlay);
}
return baseMode;
});
};

View File

@@ -83,29 +83,40 @@ export const normalizeFileName = (name) => {
};
export const getContentType = (headers) => {
const headersArray = typeof headers === 'object' ? Object.entries(headers) : [];
if (headersArray.length > 0) {
let contentType = headersArray
.filter((header) => header[0].toLowerCase() === 'content-type')
.map((header) => {
return header[1];
});
if (contentType && contentType.length) {
if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(contentType[0])) {
return 'application/ld+json';
} else if (typeof contentType[0] === 'string' && /^image\/svg\+xml/i.test(contentType[0])) {
return 'image/svg+xml';
} else if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(contentType[0])) {
return 'application/xml';
}
return contentType[0];
}
// Return empty string for invalid headers
if (!headers || typeof headers !== 'object' || Object.keys(headers).length === 0) {
return '';
}
return '';
};
// Get content-type header value
const contentTypeHeader = Object.entries(headers)
.find(([key]) => key.toLowerCase() === 'content-type');
const contentType = contentTypeHeader && contentTypeHeader[1];
// Return empty string if no content-type or not a string
if (!contentType || typeof contentType !== 'string') {
return '';
}
// This pattern matches content types like application/json, application/ld+json, text/json, etc.
const JSON_PATTERN = /^[\w\-]+\/([\w\-]+\+)?json/;
// This pattern matches content types like image/svg.
const SVG_PATTERN = /^image\/svg/i;
// This pattern matches content types like application/xml, text/xml, application/atom+xml, etc.
const XML_PATTERN = /^[\w\-]+\/([\w\-]+\+)?xml/;
if (JSON_PATTERN.test(contentType)) {
return 'application/ld+json';
} else if (SVG_PATTERN.test(contentType)) {
return 'image/svg+xml';
} else if (XML_PATTERN.test(contentType)) {
return 'application/xml';
}
return contentType;
}
export const startsWith = (str, search) => {
if (!str || !str.length || typeof str !== 'string') {
@@ -185,4 +196,23 @@ export const getEncoding = (headers) => {
export const multiLineMsg = (...messages) => {
return messages.filter(m => m !== undefined && m !== null && m !== '').join('\n');
}
export const formatSize = (bytes) => {
// Handle invalid inputs
if (isNaN(bytes) || typeof bytes !== 'number') {
return '0B';
}
if (bytes < 1024) {
return bytes + 'B';
}
if (bytes < 1024 * 1024) {
return (bytes / 1024).toFixed(1) + 'KB';
}
if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + 'MB';
}
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + 'GB';
}

View File

@@ -1,6 +1,6 @@
const { describe, it, expect } = require('@jest/globals');
import { normalizeFileName, startsWith, humanizeDate, relativeDate } from './index';
import { normalizeFileName, startsWith, humanizeDate, relativeDate, getContentType, formatSize } from './index';
describe('common utils', () => {
describe('normalizeFileName', () => {
@@ -107,4 +107,81 @@ describe('common utils', () => {
expect(relativeDate(date)).toBe('2 months ago');
});
});
describe('getContentType', () => {
it('should handle JSON content types correctly', () => {
expect(getContentType({ 'content-type': 'application/json' })).toBe('application/ld+json');
expect(getContentType({ 'content-type': 'text/json' })).toBe('application/ld+json');
expect(getContentType({ 'content-type': 'application/ld+json' })).toBe('application/ld+json');
});
it('should handle XML content types correctly', () => {
expect(getContentType({ 'content-type': 'text/xml' })).toBe('application/xml');
expect(getContentType({ 'content-type': 'application/xml' })).toBe('application/xml');
expect(getContentType({ 'content-type': 'application/atom+xml' })).toBe('application/xml');
});
it('should handle image content types correctly', () => {
expect(getContentType({ 'content-type': 'image/svg+xml;charset=utf-8' })).toBe('image/svg+xml');
expect(getContentType({ 'content-type': 'IMAGE/SVG+xml' })).toBe('image/svg+xml');
});
it('should return original content type when no pattern matches', () => {
expect(getContentType({ 'content-type': 'image/jpeg' })).toBe('image/jpeg');
expect(getContentType({ 'content-type': 'application/pdf' })).toBe('application/pdf');
});
it('should not be case sensitive', () => {
expect(getContentType({ 'content-type': 'text/json' })).toBe('application/ld+json');
expect(getContentType({ 'Content-Type': 'text/json' })).toBe('application/ld+json');
});
it('should handle empty content type', () => {
expect(getContentType({ 'content-type': '' })).toBe('');
expect(getContentType({ 'content-type': null })).toBe('');
expect(getContentType({ 'content-type': undefined })).toBe('');
});
it('should handle empty or invalid inputs', () => {
expect(getContentType({})).toBe('');
expect(getContentType(null)).toBe('');
expect(getContentType(undefined)).toBe('');
});
});
describe('formatSize', () => {
it('should format bytes', () => {
expect(formatSize(0)).toBe('0B');
expect(formatSize(1023)).toBe('1023B');
});
it('should format kilobytes', () => {
expect(formatSize(1024)).toBe('1.0KB');
expect(formatSize(1048575)).toBe('1024.0KB');
});
it('should format megabytes', () => {
expect(formatSize(1048576)).toBe('1.0MB');
expect(formatSize(1073741823)).toBe('1024.0MB');
});
it('should format gigabytes', () => {
expect(formatSize(1073741824)).toBe('1.0GB');
expect(formatSize(1099511627776)).toBe('1024.0GB');
});
it('should format decimal values', () => {
expect(formatSize(1126.5)).toBe('1.1KB');
expect(formatSize(1153433.6)).toBe('1.1MB');
expect(formatSize(1153433600)).toBe('1.1GB');
expect(formatSize(1024.1)).toBe('1.0KB');
expect(formatSize(1048576.1)).toBe('1.0MB');
});
it('should format invalid inputs', () => {
expect(formatSize(null)).toBe('0B');
expect(formatSize(undefined)).toBe('0B');
expect(formatSize(NaN)).toBe('0B');
});
});
});

View File

@@ -183,7 +183,13 @@ const curlToJson = (curlCommand) => {
if (request.query) {
requestJson.queries = getQueries(request);
} else if (request.multipartUploads || request.isDataBinary) {
} else if (request.multipartUploads) {
requestJson.data = request.multipartUploads;
if (!requestJson.headers) {
requestJson.headers = {};
}
requestJson.headers['Content-Type'] = 'multipart/form-data';
} else if (request.isDataBinary) {
Object.assign(requestJson, getFilesString(request));
} else if (typeof request.data === 'string' || typeof request.data === 'number') {
Object.assign(requestJson, getDataString(request));

View File

@@ -37,7 +37,8 @@ const parseCurlCommand = (curlCommand) => {
alias: {
H: 'header',
A: 'user-agent',
u: 'user'
u: 'user',
F: 'form'
}
});
@@ -95,17 +96,31 @@ const parseCurlCommand = (curlCommand) => {
cookieString = parsedArguments.cookie;
}
let multipartUploads;
if (parsedArguments.F) {
multipartUploads = {};
if (!Array.isArray(parsedArguments.F)) {
parsedArguments.F = [parsedArguments.F];
}
parsedArguments.F.forEach((multipartArgument) => {
// input looks like key=value. value could be json or a file path prepended with an @
const splitArguments = multipartArgument.split('=', 2);
const key = splitArguments[0];
const value = splitArguments[1];
multipartUploads[key] = value;
// Handle multipart form data specified via -F or --form flags
// Example: curl -F 'id=123' -F 'file=@/path/to/file.txt'
if (parsedArguments.F || parsedArguments.form) {
multipartUploads = [];
const formArgs = parsedArguments.F || parsedArguments.form;
const formArray = Array.isArray(formArgs) ? formArgs : [formArgs];
formArray.forEach((multipartArgument) => {
// Parse each form field using regex:
// - Group 1: Field name before =
// - Group 2: Value in quotes after = (for text fields)
// - Group 3: Value after @ (for file fields)
const match = multipartArgument.match(/^([^=]+)=(?:@?"([^"]*)"|([^@]*))?$/);
if (match) {
const key = match[1];
const value = match[2] || match[3] || '';
const isFile = multipartArgument.includes('@');
multipartUploads.push({
name: key,
value: value,
type: isFile ? 'file' : 'text',
enabled: true
});
}
});
}
if (cookieString) {

View File

@@ -0,0 +1,145 @@
const { describe, it, expect } = require('@jest/globals');
import parseCurlCommand from './parse-curl';
describe('parseCurlCommand', () => {
describe('basic functionality', () => {
it('should handle basic GET request', () => {
const result = parseCurlCommand('curl https://api.example.com/users');
expect(result).toEqual({
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users',
method: 'get'
});
});
it('should parse explicit POST method', () => {
const result = parseCurlCommand('curl -X POST https://api.example.com/users');
expect(result).toEqual({
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users',
method: 'post'
});
});
});
describe('headers handling', () => {
it('should parse multiple headers', () => {
const result = parseCurlCommand(
`curl -H 'Content-Type: application/json' -H 'Authorization: Bearer token' https://api.example.com`
);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'get',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer token'
}
});
});
it('should parse user-agent', () => {
const result = parseCurlCommand(`curl -A 'Custom Agent' https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'get',
headers: {
'User-Agent': 'Custom Agent'
}
});
});
});
describe('auth handling', () => {
it('should parse basic auth', () => {
const result = parseCurlCommand(`curl -u user:pass https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'get',
auth: {
mode: 'basic',
basic: {
username: 'user',
password: 'pass'
}
}
});
});
});
describe('data handling', () => {
it('should parse POST data', () => {
const result = parseCurlCommand(`curl -d 'foo=bar&baz=qux' https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'post',
data: 'foo=bar&baz=qux'
});
});
it('should handle data-binary', () => {
const result = parseCurlCommand(`curl --data-binary '@file.json' https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'post',
data: '@file.json',
isDataBinary: true
});
});
});
describe('form data handling', () => {
it('should parse complex form data with multiple fields and file upload', () => {
const curlCommand = `curl --location 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d' \
--form 'id="1"' \
--form 'documentid="ADMINN_ID"' \
--form 'appoinID="12376"' \
--form 'autoclose="false"' \
--form 'fileData=@"/path/to/file"'`;
const result = parseCurlCommand(curlCommand);
expect(result).toEqual({
url: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d',
urlWithoutQuery: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d',
method: 'post',
multipartUploads: [
{
name: 'id',
value: '1',
type: 'text',
enabled: true
},
{
name: 'documentid',
value: 'ADMINN_ID',
type: 'text',
enabled: true
},
{
name: 'appoinID',
value: '12376',
type: 'text',
enabled: true
},
{
name: 'autoclose',
value: 'false',
type: 'text',
enabled: true
},
{
name: 'fileData',
value: '/path/to/file',
type: 'file',
enabled: true
}
]
});
});
});
});

View File

@@ -12,9 +12,23 @@ const { rpad } = require('../utils/common');
const { bruToJson, getOptions, collectionBruToJson } = require('../utils/bru');
const { dotenvToJson } = require('@usebruno/lang');
const constants = require('../constants');
const { findItemInCollection, getAllRequestsInFolder, createCollectionJsonFromPathname } = require('../utils/collection');
const command = 'run [filename]';
const desc = 'Run a request';
const { findItemInCollection, getAllRequestsInFolder, createCollectionJsonFromPathname, getCallStack } = require('../utils/collection');
const command = 'run [paths...]';
const desc = 'Run one or more requests/folders';
const formatTestSummary = (label, maxLength, passed, failed, total, errorCount = 0, skippedCount = 0) => {
const parts = [
`${rpad(label, maxLength)} ${chalk.green(`${passed} passed`)}`
];
if (failed > 0) parts.push(chalk.red(`${failed} failed`));
if (errorCount > 0) parts.push(chalk.red(`${errorCount} error`));
if (skippedCount > 0) parts.push(chalk.magenta(`${skippedCount} skipped`));
parts.push(`${total} total`);
return parts.join(', ');
};
const printRunSummary = (results) => {
const {
@@ -28,38 +42,40 @@ const printRunSummary = (results) => {
failedAssertions,
totalTests,
passedTests,
failedTests
failedTests,
totalPreRequestTests,
passedPreRequestTests,
failedPreRequestTests,
totalPostResponseTests,
passedPostResponseTests,
failedPostResponseTests
} = getRunnerSummary(results);
const maxLength = 12;
let requestSummary = `${rpad('Requests:', maxLength)} ${chalk.green(`${passedRequests} passed`)}`;
if (failedRequests > 0) {
requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`;
}
if (errorRequests > 0) {
requestSummary += `, ${chalk.red(`${errorRequests} error`)}`;
}
if (skippedRequests > 0) {
requestSummary += `, ${chalk.magenta(`${skippedRequests} skipped`)}`;
}
requestSummary += `, ${totalRequests} total`;
const requestSummary = formatTestSummary('Requests:', maxLength, passedRequests, failedRequests, totalRequests, errorRequests, skippedRequests);
const testSummary = formatTestSummary('Tests:', maxLength, passedTests, failedTests, totalTests);
const assertSummary = formatTestSummary('Assertions:', maxLength, passedAssertions, failedAssertions, totalAssertions);
let assertSummary = `${rpad('Tests:', maxLength)} ${chalk.green(`${passedTests} passed`)}`;
if (failedTests > 0) {
assertSummary += `, ${chalk.red(`${failedTests} failed`)}`;
let preRequestTestSummary = '';
if (totalPreRequestTests > 0) {
preRequestTestSummary = formatTestSummary('Pre-Request Tests:', maxLength, passedPreRequestTests, failedPreRequestTests, totalPreRequestTests);
}
assertSummary += `, ${totalTests} total`;
let testSummary = `${rpad('Assertions:', maxLength)} ${chalk.green(`${passedAssertions} passed`)}`;
if (failedAssertions > 0) {
testSummary += `, ${chalk.red(`${failedAssertions} failed`)}`;
let postResponseTestSummary = '';
if (totalPostResponseTests > 0) {
postResponseTestSummary = formatTestSummary('Post-Response Tests:', maxLength, passedPostResponseTests, failedPostResponseTests, totalPostResponseTests);
}
testSummary += `, ${totalAssertions} total`;
console.log('\n' + chalk.bold(requestSummary));
console.log(chalk.bold(assertSummary));
if (preRequestTestSummary) {
console.log(chalk.bold(preRequestTestSummary));
}
if (postResponseTestSummary) {
console.log(chalk.bold(postResponseTestSummary));
}
console.log(chalk.bold(testSummary));
console.log(chalk.bold(assertSummary));
return {
totalRequests,
@@ -72,7 +88,13 @@ const printRunSummary = (results) => {
failedAssertions,
totalTests,
passedTests,
failedTests
failedTests,
totalPreRequestTests,
passedPreRequestTests,
failedPreRequestTests,
totalPostResponseTests,
passedPostResponseTests,
failedPostResponseTests
}
};
@@ -106,6 +128,10 @@ const builder = async (yargs) => {
describe: 'Environment variables',
type: 'string'
})
.option('env-file', {
describe: 'Path to environment file (.bru) - can be absolute or relative path',
type: 'string'
})
.option('env-var', {
describe: 'Overwrite a single environment variable, multiple usages possible',
type: 'string'
@@ -164,14 +190,21 @@ const builder = async (yargs) => {
type: 'string',
description: 'Path to the Client certificate config file used for securing the connection in the request'
})
.option('--noproxy', {
type: 'boolean',
description: 'Disable all proxy settings (both collection-defined and system proxies)',
default: false
})
.option('delay', {
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 request.bru --env-file env.bru', 'Run a request with the environment from env.bru file')
.example('$0 run folder', 'Run all requests in a folder')
.example('$0 run folder -r', 'Run all requests in a folder recursively')
.example('$0 run request.bru folder', 'Run a request and all requests in a folder')
.example('$0 run --reporter-skip-all-headers', 'Run all requests in a folder recursively with omitted headers from the reporter output')
.example(
'$0 run --reporter-skip-headers "Authorization"',
@@ -197,7 +230,6 @@ const builder = async (yargs) => {
'$0 run request.bru --reporter-junit results.xml --reporter-html results.html',
'Run a request and write the results to results.html in html format and results.xml in junit format in the current directory'
)
.example('$0 run request.bru --tests-only', 'Run all requests that have a test')
.example(
'$0 run request.bru --cacert myCustomCA.pem',
@@ -208,17 +240,19 @@ const builder = async (yargs) => {
'Use a custom CA certificate exclusively when validating the peers of the requests in the specified folder.'
)
.example('$0 run --client-cert-config client-cert-config.json', 'Run a request with Client certificate configurations')
.example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.');
.example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.')
.example('$0 run --noproxy', 'Run requests with system proxy disabled');
};
const handler = async function (argv) {
try {
let {
filename,
paths,
cacert,
ignoreTruststore,
disableCookies,
env,
envFile,
envVar,
insecure,
r: recursive,
@@ -233,6 +267,7 @@ const handler = async function (argv) {
reporterSkipAllHeaders,
reporterSkipHeaders,
clientCertConfig,
noproxy,
delay
} = argv;
const collectionPath = process.cwd();
@@ -274,33 +309,31 @@ const handler = async function (argv) {
}
}
if (filename && filename.length) {
const pathExists = await exists(filename);
if (!pathExists) {
console.error(chalk.red(`File or directory ${filename} does not exist`));
process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND);
}
} else {
filename = './';
recursive = true;
}
const runtimeVariables = {};
let envVars = {};
if (env) {
const envFile = path.join(collectionPath, 'environments', `${env}.bru`);
const envPathExists = await exists(envFile);
if (env && envFile) {
console.error(chalk.red(`Cannot use both --env and --env-file options together`));
process.exit(constants.EXIT_STATUS.ERROR_MALFORMED_ENV_OVERRIDE);
}
if (envFile || env) {
const envFilePath = envFile
? path.resolve(collectionPath, envFile)
: path.join(collectionPath, 'environments', `${env}.bru`);
const envFileExists = await exists(envFilePath);
if (!envFileExists) {
const errorPath = envFile || `environments/${env}.bru`;
console.error(chalk.red(`Environment file not found: `) + chalk.dim(errorPath));
if (!envPathExists) {
console.error(chalk.red(`Environment file not found: `) + chalk.dim(`environments/${env}.bru`));
process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND);
}
const envBruContent = fs.readFileSync(envFile, 'utf8');
const envBruContent = fs.readFileSync(envFilePath, 'utf8').replace(/\r\n/g, '\n');
const envJson = bruToEnvJson(envBruContent);
envVars = getEnvVars(envJson);
envVars.__name__ = env;
envVars.__name__ = envFile ? path.basename(envFilePath, '.bru') : env;
}
if (envVar) {
@@ -339,6 +372,9 @@ const handler = async function (argv) {
if (disableCookies) {
options['disableCookies'] = true;
}
if (noproxy) {
options['noproxy'] = true;
}
if (cacert && cacert.length) {
if (insecure) {
console.error(chalk.red(`Ignoring the cacert option since insecure connections are enabled`));
@@ -392,45 +428,34 @@ const handler = async function (argv) {
});
}
const _isFile = isFile(filename);
let requestItems = [];
let results = [];
let requestItems = [];
if (_isFile) {
console.log(chalk.yellow('Running Request \n'));
const bruContent = fs.readFileSync(filename, 'utf8');
const requestItem = bruToJson(bruContent);
requestItem.pathname = path.resolve(collectionPath, filename);
requestItems.push(requestItem);
if (!paths || !paths.length) {
paths = ['./'];
recursive = true;
}
const _isDirectory = isDirectory(filename);
if (_isDirectory) {
if (!recursive) {
console.log(chalk.yellow('Running Folder \n'));
} else {
console.log(chalk.yellow('Running Folder Recursively \n'));
}
const resolvedFilepath = path.resolve(filename);
if (resolvedFilepath === collectionPath) {
requestItems = getAllRequestsInFolder(collection?.items, recursive);
} else {
const folderItem = findItemInCollection(collection, resolvedFilepath);
if (folderItem) {
requestItems = getAllRequestsInFolder(folderItem.items, recursive);
}
}
const resolvedPaths = paths.map(p => path.resolve(process.cwd(), p));
if (testsOnly) {
requestItems = requestItems.filter((iter) => {
const requestHasTests = iter.request?.tests;
const requestHasActiveAsserts = iter.request?.assertions.some((x) => x.enabled) || false;
return requestHasTests || requestHasActiveAsserts;
});
for (const resolvedPath of resolvedPaths) {
const pathExists = await exists(resolvedPath);
if (!pathExists) {
console.error(chalk.red(`Path not found: ${resolvedPath}`));
process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND);
}
}
requestItems = getCallStack(resolvedPaths, collection, { recursive });
if (testsOnly) {
requestItems = requestItems.filter((iter) => {
const requestHasTests = iter.request?.tests;
const requestHasActiveAsserts = iter.request?.assertions.some((x) => x.enabled) || false;
return requestHasTests || requestHasActiveAsserts;
});
}
const runtime = getJsSandboxRuntime(sandbox);
const runSingleRequestByPathname = async (relativeItemPathname) => {
@@ -489,7 +514,7 @@ const handler = async function (argv) {
if(Number.isNaN(delay) && !isLastRun){
console.log(chalk.red(`Ignoring delay because it's not a valid number.`));
}
results.push({
...result,
runtime: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
@@ -530,7 +555,9 @@ const handler = async function (argv) {
const requestFailure = result?.error && !result?.skipped;
const testFailure = result?.testResults?.find((iter) => iter.status === 'fail');
const assertionFailure = result?.assertionResults?.find((iter) => iter.status === 'fail');
if (requestFailure || testFailure || assertionFailure) {
const preRequestTestFailure = result?.preRequestTestResults?.find((iter) => iter.status === 'fail');
const postResponseTestFailure = result?.postResponseTestResults?.find((iter) => iter.status === 'fail');
if (requestFailure || testFailure || assertionFailure || preRequestTestFailure || postResponseTestFailure) {
break;
}
}
@@ -541,7 +568,7 @@ const handler = async function (argv) {
if (result?.shouldStopRunnerExecution) {
break;
}
if (nextRequestName !== undefined) {
nJumps++;
if (nJumps > 10000) {
@@ -608,7 +635,7 @@ const handler = async function (argv) {
}
}
if (summary.failedAssertions + summary.failedTests + summary.failedRequests > 0) {
if ((summary.failedAssertions + summary.failedTests + summary.failedPreRequestTests + summary.failedPostResponseTests + summary.failedRequests > 0) || (summary?.errorRequests > 0)) {
process.exit(constants.EXIT_STATUS.ERROR_FAILED_COLLECTION);
}
} catch (err) {

View File

@@ -32,6 +32,7 @@ const prepareRequest = (item = {}, collection = {}) => {
method: request.method,
url: request.url,
headers: headers,
name: item.name,
pathParams: request?.params?.filter((param) => param.type === 'path'),
responseType: 'arraybuffer'
};
@@ -46,7 +47,7 @@ const prepareRequest = (item = {}, collection = {}) => {
}
if (collectionAuth.mode === 'bearer') {
axiosRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`;
axiosRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token', '')}`;
}
if (collectionAuth.mode === 'apikey') {
@@ -173,7 +174,7 @@ const prepareRequest = (item = {}, collection = {}) => {
}
if (request.auth.mode === 'bearer') {
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token', '')}`;
}
if (request.auth.mode === 'wsse') {

View File

@@ -45,10 +45,33 @@ const runSingleRequest = async function (
) {
const { pathname: itemPathname } = item;
const relativeItemPathname = path.relative(collectionPath, itemPathname);
const logResults = (results, title) => {
if (results?.length) {
if (title) {
console.log(chalk.dim(title));
}
each(results, (r) => {
const message = r.description || `${r.lhsExpr}: ${r.rhsExpr}`;
if (r.status === 'pass') {
console.log(chalk.green(``) + chalk.dim(message));
} else {
console.log(chalk.red(``) + chalk.red(message));
if (r.error) {
console.log(chalk.red(` ${r.error}`));
}
}
});
}
};
try {
let request;
let nextRequestName;
let shouldStopRunnerExecution = false;
let preRequestTestResults = [];
let postResponseTestResults = [];
request = prepareRequest(item, collection);
request.__bruno__executionMode = 'cli';
@@ -58,6 +81,7 @@ const runSingleRequest = async function (
// run pre request script
const requestScriptFile = get(request, 'script.req');
const collectionName = collection?.brunoConfig?.name
if (requestScriptFile?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
const result = await scriptRuntime.runRequestScript(
@@ -69,7 +93,8 @@ const runSingleRequest = async function (
onConsoleLog,
processEnvVars,
scriptingConfig,
runSingleRequestByPathname
runSingleRequestByPathname,
collectionName
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
@@ -101,9 +126,13 @@ const runSingleRequest = async function (
skipped: true,
assertionResults: [],
testResults: [],
preRequestTestResults: result?.results || [],
postResponseTestResults: [],
shouldStopRunnerExecution
};
}
preRequestTestResults = result?.results || [];
}
// interpolate variables inside request
@@ -115,6 +144,7 @@ const runSingleRequest = async function (
const options = getOptions();
const insecure = get(options, 'insecure', false);
const noproxy = get(options, 'noproxy', false);
const httpsAgentRequestFields = {};
if (insecure) {
httpsAgentRequestFields['rejectUnauthorized'] = false;
@@ -179,15 +209,22 @@ const runSingleRequest = async function (
const collectionProxyConfig = get(brunoConfig, 'proxy', {});
const collectionProxyEnabled = get(collectionProxyConfig, 'enabled', false);
if (collectionProxyEnabled === true) {
if (noproxy) {
// If noproxy flag is set, don't use any proxy
proxyMode = 'off';
} else if (collectionProxyEnabled === true) {
// If collection proxy is enabled, use it
proxyConfig = collectionProxyConfig;
proxyMode = 'on';
} else {
// if the collection level proxy is not set, pick the system level proxy by default, to maintain backward compatibility
} else if (collectionProxyEnabled === 'global') {
// If collection proxy is set to 'global', use system proxy
const { http_proxy, https_proxy } = getSystemProxyEnvVariables();
if (http_proxy?.length || https_proxy?.length) {
proxyMode = 'system';
}
} else {
proxyMode = 'off';
}
if (proxyMode === 'on') {
@@ -201,8 +238,8 @@ const runSingleRequest = async function (
let uriPort = isUndefined(proxyPort) || isNull(proxyPort) ? '' : `:${proxyPort}`;
let proxyUri;
if (proxyAuthEnabled) {
const proxyAuthUsername = interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions);
const proxyAuthPassword = interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions);
const proxyAuthUsername = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions));
const proxyAuthPassword = encodeURIComponent(interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions));
proxyUri = `${proxyProtocol}://${proxyAuthUsername}:${proxyAuthPassword}@${proxyHostname}${uriPort}`;
} else {
@@ -304,6 +341,14 @@ const runSingleRequest = async function (
}
}
let requestMaxRedirects = request.maxRedirects
request.maxRedirects = 0
// Set default value for requestMaxRedirects if not explicitly set
if (requestMaxRedirects === undefined) {
requestMaxRedirects = 5; // Default to 5 redirects
}
// Handle OAuth2 authentication
if (request.oauth2) {
try {
@@ -334,7 +379,7 @@ const runSingleRequest = async function (
let response, responseTime;
try {
let axiosInstance = makeAxiosInstance();
let axiosInstance = makeAxiosInstance({ requestMaxRedirects: requestMaxRedirects, disableCookies: options.disableCookies });
if (request.ntlmConfig) {
axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults)
delete request.ntlmConfig;
@@ -410,6 +455,8 @@ const runSingleRequest = async function (
status: 'error',
assertionResults: [],
testResults: [],
preRequestTestResults,
postResponseTestResults,
nextRequestName: nextRequestName,
shouldStopRunnerExecution
};
@@ -423,6 +470,9 @@ const runSingleRequest = async function (
chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`)
);
// Log pre-request test results
logResults(preRequestTestResults, 'Pre-Request Tests');
// run post-response vars
const postResponseVars = get(item, 'request.vars.res');
if (postResponseVars?.length) {
@@ -452,7 +502,8 @@ const runSingleRequest = async function (
null,
processEnvVars,
scriptingConfig,
runSingleRequestByPathname
runSingleRequestByPathname,
collectionName
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
@@ -461,9 +512,11 @@ const runSingleRequest = async function (
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
postResponseTestResults = result?.results || [];
logResults(postResponseTestResults, 'Post-Response Tests');
}
// run assertions
let assertionResults = [];
const assertions = get(item, 'request.assertions');
if (assertions) {
@@ -476,15 +529,6 @@ const runSingleRequest = async function (
runtimeVariables,
processEnvVars
);
each(assertionResults, (r) => {
if (r.status === 'pass') {
console.log(chalk.green(``) + chalk.dim(`assert: ${r.lhsExpr}: ${r.rhsExpr}`));
} else {
console.log(chalk.red(``) + chalk.red(`assert: ${r.lhsExpr}: ${r.rhsExpr}`));
console.log(chalk.red(` ${r.error}`));
}
});
}
// run tests
@@ -502,7 +546,8 @@ const runSingleRequest = async function (
null,
processEnvVars,
scriptingConfig,
runSingleRequestByPathname
runSingleRequestByPathname,
collectionName
);
testResults = get(result, 'results', []);
@@ -513,17 +558,12 @@ const runSingleRequest = async function (
if (result?.stopExecution) {
shouldStopRunnerExecution = true;
}
logResults(testResults, 'Tests');
}
if (testResults?.length) {
each(testResults, (testResult) => {
if (testResult.status === 'pass') {
console.log(chalk.green(``) + chalk.dim(testResult.description));
} else {
console.log(chalk.red(``) + chalk.red(testResult.description));
}
});
}
logResults(assertionResults, 'Assertions');
return {
test: {
@@ -546,6 +586,8 @@ const runSingleRequest = async function (
status: 'pass',
assertionResults,
testResults,
preRequestTestResults,
postResponseTestResults,
nextRequestName: nextRequestName,
shouldStopRunnerExecution
};
@@ -571,7 +613,9 @@ const runSingleRequest = async function (
status: 'error',
error: err.message,
assertionResults: [],
testResults: []
testResults: [],
preRequestTestResults: [],
postResponseTestResults: []
};
}
};

View File

@@ -1,5 +1,47 @@
const axios = require('axios');
const { CLI_VERSION } = require('../constants');
const { addCookieToJar, getCookieStringForUrl } = require('./cookies');
const redirectResponseCodes = [301, 302, 303, 307, 308];
const METHOD_CHANGING_REDIRECTS = [301, 302, 303];
const saveCookies = (url, headers) => {
if (headers['set-cookie']) {
let setCookieHeaders = Array.isArray(headers['set-cookie'])
? headers['set-cookie']
: [headers['set-cookie']];
for (let setCookieHeader of setCookieHeaders) {
if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
addCookieToJar(setCookieHeader, url);
}
}
}
};
const createRedirectConfig = (error, redirectUrl) => {
const requestConfig = {
...error.config,
url: redirectUrl,
headers: { ...error.config.headers }
};
const statusCode = error.response.status;
const originalMethod = (error.config.method || 'get').toLowerCase();
// For 301, 302, 303: change method to GET unless it was HEAD
if (METHOD_CHANGING_REDIRECTS.includes(statusCode) && originalMethod !== 'head') {
requestConfig.method = 'get';
requestConfig.data = undefined;
// Clean up headers that are no longer relevant
delete requestConfig.headers['content-length'];
delete requestConfig.headers['Content-Length'];
delete requestConfig.headers['content-type'];
delete requestConfig.headers['Content-Type'];
}
return requestConfig;
};
/**
* Function that configures axios with timing interceptors
@@ -7,10 +49,13 @@ const { CLI_VERSION } = require('../constants');
* @see https://github.com/axios/axios/issues/695
* @returns {axios.AxiosInstance}
*/
function makeAxiosInstance() {
function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies } = {}) {
let redirectCount = 0;
/** @type {axios.AxiosInstance} */
const instance = axios.create({
proxy: false,
maxRedirects: 0,
headers: {
"User-Agent": `bruno-runtime/${CLI_VERSION}`
}
@@ -18,6 +63,15 @@ function makeAxiosInstance() {
instance.interceptors.request.use((config) => {
config.headers['request-start-time'] = Date.now();
// Add cookies to request if available and not disabled
if (!disableCookies) {
const cookieString = getCookieStringForUrl(config.url);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
config.headers['cookie'] = cookieString;
}
}
return config;
});
@@ -26,6 +80,8 @@ function makeAxiosInstance() {
const end = Date.now();
const start = response.config.headers['request-start-time'];
response.headers['request-duration'] = end - start;
redirectCount = 0;
return response;
},
(error) => {
@@ -33,6 +89,42 @@ function makeAxiosInstance() {
const end = Date.now();
const start = error.config.headers['request-start-time'];
error.response.headers['request-duration'] = end - start;
if (redirectResponseCodes.includes(error.response.status)) {
if (redirectCount >= requestMaxRedirects) {
// todo: needs to be discussed whether the original error response message should be modified or not
return Promise.reject(error);
}
const locationHeader = error.response.headers.location;
if (!locationHeader) {
// todo: needs to be discussed whether the original error response message should be modified or not
return Promise.reject(error);
}
redirectCount++;
let redirectUrl = locationHeader;
if (!locationHeader.match(/^https?:\/\//i)) {
const URL = require('url');
redirectUrl = URL.resolve(error.config.url, locationHeader);
}
if (!disableCookies){
saveCookies(redirectUrl, error.response.headers);
}
const requestConfig = createRedirectConfig(error, redirectUrl);
if (!disableCookies) {
const cookieString = getCookieStringForUrl(redirectUrl);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
requestConfig.headers['cookie'] = cookieString;
}
}
return instance(requestConfig);
}
}
return Promise.reject(error);
}

View File

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

View File

@@ -1,5 +1,6 @@
const { Cookie, CookieJar } = require('tough-cookie');
const each = require('lodash/each');
const { isPotentiallyTrustworthyOrigin } = require('@usebruno/requests').utils;
const cookieJar = new CookieJar();
@@ -11,7 +12,9 @@ const addCookieToJar = (setCookieHeader, requestUrl) => {
};
const getCookiesForUrl = (url) => {
return cookieJar.getCookiesSync(url);
return cookieJar.getCookiesSync(url, {
secure: isPotentiallyTrustworthyOrigin(url)
});
};
const getCookieStringForUrl = (url) => {

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,26 @@
import { mockDataFunctions } from "./faker-functions";
describe("mockDataFunctions Regex Validation", () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
});
afterAll(() => {
jest.useRealTimers();
});
test("timestamp and isoTimestamp should return mocked time values", () => {
const expectedTimestamp = '1704067200';
const expectedIsoTimestamp = '2024-01-01T00:00:00.000Z';
expect(mockDataFunctions.timestamp()).toBe(expectedTimestamp);
expect(mockDataFunctions.isoTimestamp()).toBe(expectedIsoTimestamp);
});
test("all values should match their expected patterns", () => {
const patterns: Record<string, RegExp> = {
guid: /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/,
timestamp: /^\d{13,}$/,
isoTimestamp: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
randomUUID: /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/,
randomAlphaNumeric: /^[\w]$/,
randomBoolean: /^(true|false)$/,

View File

@@ -2,8 +2,8 @@ import { faker } from '@faker-js/faker';
export const mockDataFunctions = {
guid: () => faker.string.uuid(),
timestamp: () => faker.date.anytime().getTime().toString(),
isoTimestamp: () => faker.date.anytime().toISOString(),
timestamp: () => Math.floor(Date.now() / 1000).toString(),
isoTimestamp: () => new Date().toISOString(),
randomUUID: () => faker.string.uuid(),
randomAlphaNumeric: () => faker.string.alphanumeric(),
randomBoolean: () => faker.datatype.boolean(),

View File

@@ -1,5 +1,6 @@
import each from 'lodash/each';
import get from 'lodash/get';
import jsyaml from 'js-yaml';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
const parseGraphQL = (text) => {
@@ -288,6 +289,9 @@ const parseInsomniaCollection = (data) => {
export const insomniaToBruno = (insomniaCollection) => {
try {
if(typeof insomniaCollection !== 'object') {
insomniaCollection = jsyaml.load(insomniaCollection);
}
let collection;
if (isInsomniaV5Export(insomniaCollection)) {
collection = parseInsomniaV5Collection(insomniaCollection);

View File

@@ -1,5 +1,6 @@
import each from 'lodash/each';
import get from 'lodash/get';
import jsyaml from 'js-yaml';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common';
const ensureUrl = (url) => {
@@ -7,14 +8,22 @@ const ensureUrl = (url) => {
return url.replace(/([^:])\/{2,}/g, '$1/');
};
const buildEmptyJsonBody = (bodySchema) => {
const buildEmptyJsonBody = (bodySchema, visited = new Map()) => {
// Check for circular references
if (visited.has(bodySchema)) {
return {};
}
// Add this schema to visited map
visited.set(bodySchema, true);
let _jsonBody = {};
each(bodySchema.properties || {}, (prop, name) => {
if (prop.type === 'object') {
_jsonBody[name] = buildEmptyJsonBody(prop);
_jsonBody[name] = buildEmptyJsonBody(prop, visited);
} else if (prop.type === 'array') {
if (prop.items && prop.items.type === 'object') {
_jsonBody[name] = [buildEmptyJsonBody(prop.items)];
_jsonBody[name] = [buildEmptyJsonBody(prop.items, visited)];
} else {
_jsonBody[name] = [];
}
@@ -422,6 +431,10 @@ export const parseOpenApiCollection = (data) => {
export const openApiToBruno = (openApiSpecification) => {
try {
if(typeof openApiSpecification !== 'object') {
openApiSpecification = jsyaml.load(openApiSpecification);
}
const collection = parseOpenApiCollection(openApiSpecification);
const transformedCollection = transformItemsInCollection(collection);
const hydratedCollection = hydrateSeqInCollection(transformedCollection);

View File

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

View File

@@ -5,6 +5,7 @@ const replacements = {
'pm\\.environment\\.set\\(': 'bru.setEnvVar(',
'pm\\.variables\\.get\\(': 'bru.getVar(',
'pm\\.variables\\.set\\(': 'bru.setVar(',
'pm\\.variables\\.replaceIn\\(': 'bru.interpolate(',
'pm\\.collectionVariables\\.get\\(': 'bru.getVar(',
'pm\\.collectionVariables\\.set\\(': 'bru.setVar(',
'pm\\.collectionVariables\\.has\\(': 'bru.hasVar(',
@@ -33,6 +34,7 @@ const replacements = {
'pm\\.request\\.method': 'req.getMethod()',
'pm\\.request\\.headers': 'req.getHeaders()',
'pm\\.request\\.body': 'req.getBody()',
'pm\\.info\\.requestName': 'req.getName()',
// deprecated translations
'postman\\.setEnvironmentVariable\\(': 'bru.setEnvVar(',
'postman\\.getEnvironmentVariable\\(': 'bru.getEnvVar(',

View File

@@ -1,3 +1,4 @@
import sendRequestTransformer from './send-request-transformer';
const j = require('jscodeshift');
const cloneDeep = require('lodash/cloneDeep');
@@ -52,7 +53,7 @@ const simpleTranslations = {
'pm.variables.get': 'bru.getVar',
'pm.variables.set': 'bru.setVar',
'pm.variables.has': 'bru.hasVar',
'pm.variables.replaceIn': 'bru.interpolate',
// Collection variables
'pm.collectionVariables.get': 'bru.getVar',
'pm.collectionVariables.set': 'bru.setVar',
@@ -67,6 +68,9 @@ const simpleTranslations = {
'pm.expect': 'expect',
'pm.expect.fail': 'expect.fail',
// Info
'pm.info.requestName': 'req.getName()',
// Request properties
'pm.request.url': 'req.getUrl()',
'pm.request.method': 'req.getMethod()',
@@ -96,7 +100,14 @@ const simpleTranslations = {
* as a separate statement, which allows a single Postman expression to be
* transformed into multiple Bruno statements (e.g. for complex assertions).
*/
const complexTransformations = [
// pm.sendRequest transformation
{
pattern: 'pm.sendRequest',
transform: sendRequestTransformer
},
// pm.environment.has requires special handling
{
pattern: 'pm.environment.has',

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
import { describe, it, expect } from '@jest/globals';
import insomniaToBruno from '../../src/insomnia/insomnia-to-bruno';
import jsyaml from 'js-yaml';
describe('insomnia-collection', () => {
it('should correctly import a valid Insomnia v5 collection file', async () => {
const brunoCollection = insomniaToBruno(jsyaml.load(insomniaCollection));
const brunoCollection = insomniaToBruno(insomniaCollection);
expect(brunoCollection).toMatchObject(expectedOutput)
});

View File

@@ -0,0 +1,248 @@
import { describe, it, expect } from '@jest/globals';
import openApiToBruno from '../../../src/openapi/openapi-to-bruno';
describe('openapi-circular-references', () => {
it('should handle simple circular references in schema correctly', async () => {
const brunoCollection = openApiToBruno(circularRefsData);
expect(brunoCollection).toMatchObject(circularRefsOutput);
});
it('should handle complex circular reference chains correctly', async () => {
const brunoCollection = openApiToBruno(complexCircularRefsData);
expect(brunoCollection).toMatchObject(circularRefsOutput);
});
});
const circularRefsData = {
"components": {
"schemas": {
"schema_1": {
"additionalProperties": false,
"description": "schema_1",
"properties": {
"conditions": {
"$ref": "#/components/schemas/schema_1"
}
},
"type": "object"
},
"schema_2": {
"additionalProperties": false,
"description": "schema_2",
"properties": {
"conditionGroup": {
"description": "nested schema_1",
"items": { "$ref": "#/components/schemas/schema_1" },
"type": "array"
},
"operation": {
"description": "operation",
"enum": ["ANY", "ALL"],
"type": "string"
}
},
"type": "object"
}
}
},
"info": {
"description": "circular reference openapi sample json spec",
"title": "circular reference openapi sample json spec",
"version": "0.1"
},
"openapi": "3.0.1",
"paths": {
"/": {
"post": {
"deprecated": false,
"description": "echo ping api",
"operationId": "echo ping",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/schema_1"
}
}
},
"description": "echo ping api",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"example": "ping"
}
},
"description": "Returned if the request is successful."
}
}
}
}
},
"servers": [{ "url": "https://echo.usebruno.com" }]
};
// More complex circular reference test with a longer chain
const complexCircularRefsData = {
"components": {
"schemas": {
"schema_1": {
"additionalProperties": false,
"description": "schema_1",
"properties": {
"conditionGroup": {
"description": "nested schema_1",
"items": { "$ref": "#/components/schemas/schema_2" },
"type": "array"
}
},
"type": "object"
},
"schema_2": {
"additionalProperties": false,
"description": "schema_2",
"properties": {
"conditionGroup": {
"description": "nested schema_2",
"items": { "$ref": "#/components/schemas/schema_3" },
"type": "array"
}
},
"type": "object"
},
"schema_3": {
"additionalProperties": false,
"description": "schema_3",
"properties": {
"conditionGroup": {
"description": "nested schema_3",
"items": { "$ref": "#/components/schemas/schema_4" },
"type": "array"
}
},
"type": "object"
},
"schema_4": {
"additionalProperties": false,
"description": "schema_4",
"properties": {
"conditionGroup": {
"description": "nested schema_4",
"items": { "$ref": "#/components/schemas/schema_5" },
"type": "array"
}
},
"type": "object"
},
"schema_5": {
"additionalProperties": false,
"description": "schema_4",
"properties": {
"conditionGroup": {
"description": "nested schema_5",
"items": { "$ref": "#/components/schemas/schema_1" },
"type": "array"
}
},
"type": "object"
},
"schema_6": {
"additionalProperties": false,
"description": "schema_3",
"properties": {
"conditionGroup": {
"description": "nested schema_3",
"items": { "$ref": "#/components/schemas/schema_1" },
"type": "array"
},
"operation": {
"description": "operation",
"enum": ["ANY", "ALL"],
"type": "string"
}
},
"type": "object"
}
}
},
"info": {
"description": "circular reference openapi sample json spec",
"title": "circular reference openapi sample json spec",
"version": "0.1"
},
"openapi": "3.0.1",
"paths": {
"/": {
"post": {
"deprecated": false,
"description": "echo ping api",
"operationId": "echo ping",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/schema_1"
}
}
},
"description": "echo ping api",
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"example": "ping"
}
},
"description": "Returned if the request is successful."
}
}
}
}
},
"servers": [{ "url": "https://echo.usebruno.com" }]
};
const circularRefsOutput = {
"environments": [
{
"name": "Environment 1",
"variables": [
{
"enabled": true,
"name": "baseUrl",
"secret": false,
"type": "text",
"value": "https://echo.usebruno.com",
},
],
},
],
"items": [
{
"name": "echo ping",
"type": "http-request",
"request": {
"url": "{{baseUrl}}/",
"method": "POST",
"auth": {
"mode": "none",
},
"headers": [],
"params": [],
"body": {
"mode": "json",
}
},
},
],
"name": "circular reference openapi sample json spec",
"version": "1",
};

View File

@@ -1,11 +1,9 @@
import jsyaml from 'js-yaml';
import { describe, it, expect } from '@jest/globals';
import openApiToBruno from '../../src/openapi/openapi-to-bruno';
import openApiToBruno from '../../../src/openapi/openapi-to-bruno';
describe('openapi-collection', () => {
it('should correctly import a valid OpenAPI file', async () => {
const openApiSpecification = jsyaml.load(openApiCollectionString);
const brunoCollection = openApiToBruno(openApiSpecification);
const brunoCollection = openApiToBruno(openApiCollectionString);
expect(brunoCollection).toMatchObject(expectedOutput);
});

View File

@@ -7,6 +7,7 @@ describe('postmanTranslations - request commands', () => {
const requestMethod = pm.request.method;
const requestHeaders = pm.request.headers;
const requestBody = pm.request.body;
const requestName = pm.info.requestName;
pm.test('Request method is POST', function() {
pm.expect(pm.request.method).to.equal('POST');
@@ -17,6 +18,7 @@ describe('postmanTranslations - request commands', () => {
const requestMethod = req.getMethod();
const requestHeaders = req.getHeaders();
const requestBody = req.getBody();
const requestName = req.getName();
test('Request method is POST', function() {
expect(req.getMethod()).to.equal('POST');

View File

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

View File

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

View File

@@ -5,55 +5,104 @@ describe('Variables Translation', () => {
it('should translate pm.variables.get', () => {
const code = 'pm.variables.get("test");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.getVar("test");');
});
it('should translate pm.variables.set', () => {
const code = 'pm.variables.set("test", "value");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setVar("test", "value");');
});
it('should translate pm.variables.has', () => {
const code = 'pm.variables.has("userId");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.hasVar("userId");');
});
it('should translate pm.variables.replaceIn', () => {
const code = 'pm.variables.replaceIn("Hello {{name}}");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.interpolate("Hello {{name}}");');
});
it('should translate pm.variables.replaceIn with variables and expressions', () => {
const code = 'const greeting = pm.variables.replaceIn("Hello {{name}}, your user id is {{userId}}");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const greeting = bru.interpolate("Hello {{name}}, your user id is {{userId}}");');
});
it('should translate pm.variables.replaceIn within complex expressions', () => {
const code = 'const url = baseUrl + pm.variables.replaceIn("/users/{{userId}}/profile");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const url = baseUrl + bru.interpolate("/users/{{userId}}/profile");');
});
it('should translate pm.variables.replaceIn with multiple nested variable references', () => {
const code = 'const template = pm.variables.replaceIn("{{prefix}}-{{env}}-{{suffix}}");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const template = bru.interpolate("{{prefix}}-{{env}}-{{suffix}}");');
});
it('should translate aliased variables.replaceIn', () => {
const code = `
const variables = pm.variables;
const message = variables.replaceIn("Welcome, {{username}}!");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const message = bru.interpolate("Welcome, {{username}}!");
`);
});
// Collection variables tests
it('should translate pm.collectionVariables.get', () => {
const code = 'pm.collectionVariables.get("apiUrl");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.getVar("apiUrl");');
});
it('should translate pm.collectionVariables.set', () => {
const code = 'pm.collectionVariables.set("token", jsonData.token);';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setVar("token", jsonData.token);');
});
it('should translate pm.collectionVariables.has', () => {
const code = 'pm.collectionVariables.has("authToken");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.hasVar("authToken");');
});
it('should translate pm.collectionVariables.unset', () => {
const code = 'pm.collectionVariables.unset("tempVar");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.deleteVar("tempVar");');
});
it('should handle pm.globals.get', () => {
const code = 'pm.globals.get("test");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.getGlobalEnvVar("test");');
});
it('should handle pm.globals.set', () => {
const code = 'pm.globals.set("test", "value");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setGlobalEnvVar("test", "value");');
});
@@ -66,6 +115,7 @@ describe('Variables Translation', () => {
const get = vars.get("test");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const has = bru.hasVar("test");
const set = bru.setVar("test", "value");
@@ -83,6 +133,7 @@ describe('Variables Translation', () => {
const unset = collVars.unset("test");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const has = bru.hasVar("test");
const set = bru.setVar("test", "value");
@@ -98,6 +149,7 @@ describe('Variables Translation', () => {
const set = globals.set("test", "value");
`;
const translatedCode = translateCode(code);
expect(translatedCode).toBe(`
const get = bru.getGlobalEnvVar("test");
const set = bru.setGlobalEnvVar("test", "value");
@@ -108,6 +160,7 @@ describe('Variables Translation', () => {
it('should handle conditional expressions with variable calls', () => {
const code = 'const userStatus = pm.variables.has("userId") ? "logged-in" : "guest";';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const userStatus = bru.hasVar("userId") ? "logged-in" : "guest";');
});
@@ -148,6 +201,7 @@ describe('Variables Translation', () => {
it('should handle more complex nested expressions with variables', () => {
const code = 'pm.collectionVariables.set("fullPath", pm.environment.get("baseUrl") + pm.variables.get("endpoint"));';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.setVar("fullPath", bru.getEnvVar("baseUrl") + bru.getVar("endpoint"));');
});
});

View File

@@ -36,9 +36,22 @@ const config = {
},
win: {
artifactName: '${name}_${version}_${arch}_win.${ext}',
icon: 'resources/icons/png',
certificateFile: `${process.env.WIN_CERT_FILEPATH}`,
certificatePassword: `${process.env.WIN_CERT_PASSWORD}`
icon: 'resources/icons/win/icon.ico',
target: [
{
target: 'nsis',
arch: ['x64']
}
],
sign: null,
publisherName: 'Bruno Software Inc'
},
nsis: {
oneClick: false,
allowToChangeInstallationDirectory: true,
allowElevation: true,
createDesktopShortcut: true,
createStartMenuShortcut: true
}
};

View File

@@ -3,6 +3,10 @@
"name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com",
"repository": {
"type": "git",
"url": "https://github.com/usebruno/bruno.git"
},
"private": true,
"main": "src/index.js",
"author": "Anoop M D <anoop.md1421@gmail.com> (https://helloanoop.com/)",

View File

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

View File

@@ -14,16 +14,11 @@ const { format } = require('url');
const { BrowserWindow, app, session, Menu, ipcMain } = require('electron');
const { setContentSecurityPolicy } = require('electron-util');
if (isDev && process.env.ELECTRON_APP_NAME) {
const appName = process.env.ELECTRON_APP_NAME;
const userDataPath = path.join(app.getPath("appData"), appName);
if (isDev && process.env.ELECTRON_USER_DATA_PATH) {
console.debug("`ELECTRON_USER_DATA_PATH` found, modifying `userData` path: \n"
+ `\t${app.getPath("userData")} -> ${process.env.ELECTRON_USER_DATA_PATH}`);
console.log("`ELECTRON_APP_NAME` found, overriding `appName` and `userData` path: \n"
+ `\t${app.getName()} -> ${appName}\n`
+ `\t${app.getPath("userData")} -> ${userDataPath}`);
app.setName(appName);
app.setPath("userData", userDataPath);
app.setPath('userData', process.env.ELECTRON_USER_DATA_PATH);
}
const menuTemplate = require('./app/menu-template');

View File

@@ -309,6 +309,27 @@ function makeAxiosInstance({
},
};
// Apply proper HTTP redirect behavior based on status code
const statusCode = error.response.status;
const originalMethod = (error.config.method || 'get').toLowerCase();
// For 301, 302, 303: change method to GET unless it was HEAD
if ([301, 302, 303].includes(statusCode) && originalMethod !== 'head') {
requestConfig.method = 'get';
requestConfig.data = undefined;
delete requestConfig.headers['content-length'];
delete requestConfig.headers['Content-Length'];
delete requestConfig.headers['content-type'];
delete requestConfig.headers['Content-Type'];
timeline.push({
timestamp: new Date(),
type: 'info',
message: `Changed method from ${originalMethod.toUpperCase()} to GET for ${statusCode} redirect and removed request body`,
});
}
if (preferencesUtil.shouldSendCookies()) {
const cookieString = getCookieStringForUrl(redirectUrl);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
@@ -316,7 +337,7 @@ function makeAxiosInstance({
}
}
try {
try {
setupProxyAgents({
requestConfig,
proxyMode,

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
};
break;
case 'bearer':
axiosRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token')}`;
axiosRequest.headers['Authorization'] = `Bearer ${get(collectionAuth, 'bearer.token', '')}`;
break;
case 'digest':
axiosRequest.digestConfig = {
@@ -152,7 +152,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
};
break;
case 'bearer':
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token', '')}`;
break;
case 'digest':
axiosRequest.digestConfig = {
@@ -301,6 +301,7 @@ const prepareRequest = async (item, collection = {}, abortController) => {
method: request.method,
url,
headers,
name: item.name,
pathParams: request?.params?.filter((param) => param.type === 'path'),
responseType: 'arraybuffer'
};

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
const { Cookie, CookieJar } = require('tough-cookie');
const each = require('lodash/each');
const moment = require('moment');
const { isPotentiallyTrustworthyOrigin } = require('@usebruno/requests').utils;
const cookieJar = new CookieJar();
@@ -12,7 +13,9 @@ const addCookieToJar = (setCookieHeader, requestUrl) => {
};
const getCookiesForUrl = (url) => {
return cookieJar.getCookiesSync(url);
return cookieJar.getCookiesSync(url, {
secure: isPotentiallyTrustworthyOrigin(url)
});
};
const getCookieStringForUrl = (url) => {

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